// Coloring engine — pointer-events based for Apple Pencil + touch + mouse.
// A "stroke" with the active color paints any region the pointer touches.
// Drag-from-palette: long press OR drag a swatch onto canvas.

const DragCtx = React.createContext(null);

function DragProvider({ children }) {
  const [dragColor, setDragColor] = React.useState(null);
  const [dragPos, setDragPos] = React.useState({ x: 0, y: 0 });
  const [active, setActive] = React.useState(false);
  // Track which pointer started the drag so we can ignore unrelated pointerups
  // (e.g. fingers lifting off a pinch on the canvas while a swatch is mid-drag).
  // Without this, lifting a pinch finger could drop the swatch into a region (Fix #18).
  const [dragPointerId, setDragPointerId] = React.useState(null);

  const startDrag = (color, e) => {
    setDragColor(color);
    setActive(true);
    setDragPointerId(e.pointerId ?? null);
    const x = e.clientX ?? (e.touches && e.touches[0].clientX) ?? 0;
    const y = e.clientY ?? (e.touches && e.touches[0].clientY) ?? 0;
    setDragPos({ x, y });
  };

  React.useEffect(() => {
    if (!active) return;
    const move = (e) => {
      const x = e.clientX ?? (e.touches && e.touches[0]?.clientX);
      const y = e.clientY ?? (e.touches && e.touches[0]?.clientY);
      if (x != null) setDragPos({ x, y });
    };
    const end = (e) => {
      // Only end the drag when the pointer that started it lifts (or any
      // pointer if we never recorded an id). Stray ups from other pointers
      // shouldn't drop the swatch.
      if (dragPointerId != null && e.pointerId !== dragPointerId) return;
      setActive(false);
      setDragColor(null);
      setDragPointerId(null);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', end);
    window.addEventListener('pointercancel', end);
    return () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', end);
      window.removeEventListener('pointercancel', end);
    };
  }, [active, dragPointerId]);

  return (
    <DragCtx.Provider value={{ dragColor, dragPos, active, dragPointerId, startDrag, setDragColor, setActive }}>
      {children}
    </DragCtx.Provider>
  );
}

function useDrag() { return React.useContext(DragCtx); }

function useColoring(initialKind = 'mandala') {
  const [kind, setKind] = React.useState(initialKind);
  const [allFills, setAllFills] = React.useState({});
  const fills = allFills[kind] || {};
  const paint = (region, color) => {
    setAllFills((s) => {
      const cur = s[kind] || {};
      // Pass color=null to UNPAINT a region. Used by brush undo when the
      // last stroke for a region is rolled back so the progress meter
      // stops counting that region.
      if (color == null) {
        if (!(region in cur)) return s;
        const next = { ...cur };
        delete next[region];
        return { ...s, [kind]: next };
      }
      if (cur[region] === color) return s;
      return { ...s, [kind]: { ...cur, [region]: color } };
    });
  };
  const reset = () => setAllFills((s) => ({ ...s, [kind]: {} }));
  const undo = () => {
    setAllFills((s) => {
      const cur = { ...(s[kind] || {}) };
      const keys = Object.keys(cur);
      if (!keys.length) return s;
      delete cur[keys[keys.length - 1]];
      return { ...s, [kind]: cur };
    });
  };
  // setKindFills replaces the full fills object for a given kind (used to
  // restore a saved painting). setAllFills is also exposed for advanced use.
  const setKindFills = (k, kindFills) => {
    setAllFills((s) => ({ ...s, [k]: kindFills || {} }));
  };
  return { kind, setKind, fills, allFills, setAllFills, setKindFills, paint, reset, undo };
}

// ─── Palette swatch — pointerdown starts a drag immediately ───
function PaletteSwatch({ color, selected, onSelect, size = 28, shape = 'circle', ink = '#1a1a1a' }) {
  const drag = useDrag();
  const onDown = (e) => {
    e.preventDefault();
    drag.startDrag(color, e);
    onSelect && onSelect(color);
  };
  return (
    <button
      onPointerDown={onDown}
      aria-label={`Color ${color}`}
      style={{
        width: size, height: size,
        borderRadius: shape === 'circle' ? '50%' : 4,
        background: color,
        border: selected ? `1.5px solid ${ink}` : `1px solid ${ink}22`,
        outline: 'none',
        cursor: 'grab',
        padding: 0,
        boxShadow: selected ? `0 0 0 3px ${ink}10` : 'none',
        transition: 'transform 200ms, box-shadow 200ms',
        transform: selected ? 'scale(1.08)' : 'scale(1)',
        flexShrink: 0,
        touchAction: 'none',
      }}
    />
  );
}

function Palette({ colors, selected, onSelect, layout = 'row', size = 28, ink = '#1a1a1a', shape = 'circle', gap = 10 }) {
  return (
    <div style={{
      display: 'flex',
      flexDirection: layout === 'row' ? 'row' : 'column',
      gap, alignItems: 'center', flexWrap: layout === 'row' ? 'wrap' : 'nowrap',
    }}>
      {colors.map((c) => (
        <PaletteSwatch key={c} color={c} selected={selected === c} onSelect={onSelect} size={size} ink={ink} shape={shape} />
      ))}
    </div>
  );
}

// ─── Drag ghost ───
function DragGhost() {
  const drag = useDrag();
  if (!drag.active || !drag.dragColor) return null;
  return (
    <div style={{
      position: 'fixed',
      left: drag.dragPos.x, top: drag.dragPos.y,
      transform: 'translate(-50%, -50%)',
      width: 36, height: 36, borderRadius: '50%',
      background: drag.dragColor,
      boxShadow: '0 8px 24px rgba(0,0,0,0.18), 0 0 0 2px rgba(255,255,255,0.6)',
      pointerEvents: 'none',
      zIndex: 9999,
    }} />
  );
}

// ─── Color canvas ───
// Two ways to paint:
//   1. Tap/click a region → fills with `selected` color
//   2. Drag a swatch from the palette → drop on a region
//   3. With Apple Pencil, dragging across regions paints each (stroke mode)
function ColorCanvas({ kind, fills, onPaint, anim = 'bleed', ink = '#1a1a1a', size = 400, illust, selectedColor, selectedNum = null, strictMatch = false, showNumbers = false, numberMap = {}, highlightIdx = null, lineWeight = 1.0, previewMode = false, regionMeta = null, brushMode = false, brushSize = 24, colorableSize = null }) {
  const ref = React.useRef(null);
  const brushCanvasRef = React.useRef(null);
  // Active brush stroke. Captured at pointerdown and reused for every move
  // so the path2d clip is locked to the start region (boundary behavior A).
  const brushStrokeRef = React.useRef(null);
  // Path2D cache so we don't reparse SVG strings on every stroke. Keyed by
  // region id; entries created lazily on first stroke that touches a region.
  const path2dCacheRef = React.useRef(new Map());
  // Tracks which regions have already been onPaint-flagged for progress
  // tracking, so we only fire onPaint once per stroke (not per pointermove).
  const brushFlaggedRef = React.useRef(new Set());
  // Full per-stroke history for brush mode. Each entry:
  //   { regionId, color, brushSize, path2d, points: [[x,y], ...] }
  // Undo pops the last stroke, clears the canvas, replays the rest.
  const brushHistoryRef = React.useRef([]);
  const drag = useDrag();
  const [hoverRegion, setHoverRegion] = React.useState(null);
  const [rejectedFlash, setRejectedFlash] = React.useState(null); // { regionId, expected }
  const rejectTimerRef = React.useRef(null);
  const strokingRef = React.useRef(false);
  const lastRegionRef = React.useRef(null);
  // Last sampled pointer position — used to interpolate between pointermove
  // events so fast brush strokes don't skip regions between samples.
  const lastPosRef = React.useRef(null);
  // Pointer ID of the active brush stroke. We use this to scope window-level
  // cleanup so an unrelated pointerup (e.g. swatch lift) doesn't kill an
  // in-flight Pencil stroke.
  const strokePointerIdRef = React.useRef(null);
  // Counts brush-strokes that crossed regions but couldn't paint them due to
  // strict CBN. After a threshold we surface a hint so the user knows
  // brushing IS firing — strict mode is just filtering.
  const strokeRejectCountRef = React.useRef(0);
  const rejectHintShownRef = React.useRef(false);
  const Comp = (illust || ILLUSTRATIONS[kind])?.Comp;

  // Full reset on canvas swap (new generation / save loaded). Everything is
  // stale: completed strokes belong to the old image, in-flight pointer state
  // is meaningless, region path cache is keyed on the old region ids.
  React.useEffect(() => {
    strokingRef.current = false;
    lastRegionRef.current = null;
    lastPosRef.current = null;
    brushStrokeRef.current = null;
    path2dCacheRef.current = new Map();
    brushFlaggedRef.current = new Set();
    brushHistoryRef.current = [];
  }, [kind]);

  // Zoom (size) changed — cached lastPosRef is in screen coords and would
  // interpolate across an unrelated screen path. ONLY invalidate the
  // in-flight pointer state; completed strokes are in source-image coords
  // (canvas-internal pixels) and remain valid across zoom. Wiping them here
  // was the v1.1 save-empty bug (every pinch on mobile cleared history).
  React.useEffect(() => {
    lastPosRef.current = null;
  }, [size]);

  // Sync the brush canvas's internal pixel buffer to the source-image
  // resolution. The canvas only mounts when brushMode is on, so brushMode
  // is in the deps — flipping it ON triggers this effect to size the
  // freshly-mounted canvas. The on-screen CSS size is controlled separately
  // by `size` (canvasSize × zoom).
  React.useEffect(() => {
    if (!brushMode) return;
    const c = brushCanvasRef.current;
    if (!c || !colorableSize) return;
    if (c.width !== colorableSize.width)  c.width  = colorableSize.width;
    if (c.height !== colorableSize.height) c.height = colorableSize.height;
    // After (re-)mount and resize, replay any strokes preserved in the ref
    // so toggling brushMode off→on doesn't lose visible work.
    replayBrushHistory();
  }, [brushMode, colorableSize?.width, colorableSize?.height, kind, replayBrushHistory]);

  // Clear the brush canvas whenever fills resets to {} (i.e. user clicked
  // Clear) or the canvas swaps. v1 limitation: undo doesn't clear individual
  // strokes — it just removes from the fills map; canvas pixels stay until
  // a full reset.
  React.useEffect(() => {
    const c = brushCanvasRef.current;
    if (!c) return;
    if (Object.keys(fills || {}).length === 0) {
      const ctx = c.getContext('2d');
      if (ctx) ctx.clearRect(0, 0, c.width, c.height);
      brushFlaggedRef.current = new Set();
      brushHistoryRef.current = [];
    }
  }, [fills, kind]);

  // Render all strokes in brushHistoryRef onto the brush canvas. Reused by
  // (a) brushMode mount, (b) brush undo, (c) any time we need to rebuild the
  // visual from history.
  const replayBrushHistory = React.useCallback(() => {
    const c = brushCanvasRef.current;
    if (!c) return;
    const ctx = c.getContext('2d');
    ctx.clearRect(0, 0, c.width, c.height);
    for (const s of brushHistoryRef.current) {
      ctx.save();
      ctx.clip(s.path2d, 'evenodd');
      ctx.fillStyle = s.color;
      ctx.beginPath();
      ctx.arc(s.points[0][0], s.points[0][1], s.brushSize / 2, 0, Math.PI * 2);
      ctx.fill();
      if (s.points.length > 1) {
        ctx.strokeStyle = s.color;
        ctx.lineWidth = s.brushSize;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.beginPath();
        ctx.moveTo(s.points[0][0], s.points[0][1]);
        for (let i = 1; i < s.points.length; i++) {
          ctx.lineTo(s.points[i][0], s.points[i][1]);
        }
        ctx.stroke();
      }
      ctx.restore();
    }
  }, []);

  // Brush undo: pop the last stroke, redraw remaining history.
  React.useEffect(() => {
    const onBrushUndo = () => {
      if (brushHistoryRef.current.length === 0) return;
      brushHistoryRef.current.pop();
      replayBrushHistory();
    };
    window.addEventListener('zen:brush-undo', onBrushUndo);
    return () => window.removeEventListener('zen:brush-undo', onBrushUndo);
  }, [replayBrushHistory]);

  // Brush clear: triggered explicitly by Atelier's Clear button (since brush
  // strokes don't update `fills`, the fills-empty effect alone wouldn't fire
  // when the user has brushed but not tapped).
  React.useEffect(() => {
    const onBrushClear = () => {
      const c = brushCanvasRef.current;
      if (c) c.getContext('2d').clearRect(0, 0, c.width, c.height);
      brushHistoryRef.current = [];
      brushFlaggedRef.current = new Set();
    };
    window.addEventListener('zen:brush-clear', onBrushClear);
    return () => window.removeEventListener('zen:brush-clear', onBrushClear);
  }, []);

  // Expose brush-history export / import so Atelier's Save button can read
  // strokes and the load flow can rehydrate them. Path2D isn't serializable,
  // so we strip it on export and rebuild from regionMeta on import.
  React.useEffect(() => {
    if (typeof window === 'undefined') return;
    window.__zenGetBrushHistory = () => brushHistoryRef.current.map((s) => ({
      regionId: s.regionId,
      color: s.color,
      brushSize: s.brushSize,
      points: s.points,
    }));
    const onImport = (e) => {
      const strokes = e.detail?.strokes;
      if (!Array.isArray(strokes)) return;
      // Rebuild Path2D for each stroke from current regionMeta. Skip strokes
      // whose region no longer exists (shouldn't happen unless the
      // generation/save mapping is broken).
      const rebuilt = [];
      for (const s of strokes) {
        const path2d = getRegionPath2d(s.regionId);
        if (!path2d) continue;
        rebuilt.push({
          regionId: s.regionId,
          color: s.color,
          brushSize: s.brushSize,
          points: s.points,
          path2d,
          lastX: s.points[s.points.length - 1][0],
          lastY: s.points[s.points.length - 1][1],
        });
      }
      brushHistoryRef.current = rebuilt;
      // Defer replay so the canvas has time to resize / mount.
      setTimeout(() => replayBrushHistory(), 0);
    };
    window.addEventListener('zen:import-brush-history', onImport);
    return () => {
      window.removeEventListener('zen:import-brush-history', onImport);
      if (window.__zenGetBrushHistory) delete window.__zenGetBrushHistory;
    };
  }, [replayBrushHistory, metaById]);

  // Window-level safety net: clear stroke state ONLY when the up event is for
  // our active stroke pointer, OR on blur/visibility change, OR when the
  // parent dispatches a 'zen:cancel-stroke' (e.g. pinch started mid-paint).
  React.useEffect(() => {
    const fullReset = () => {
      // Don't lose a partial stroke on blur / visibilitychange / cancel: commit
      // whatever points we have so the user's brushwork is recoverable in the
      // save. The component-level onPointerUp does the same thing, but for
      // ambiguous gestures (iOS pointercancel from gesture recognition, tab
      // backgrounding, pinch starting mid-stroke) only this handler fires.
      if (brushStrokeRef.current && brushStrokeRef.current.points && brushStrokeRef.current.points.length > 0) {
        brushHistoryRef.current.push(brushStrokeRef.current);
      }
      strokingRef.current = false;
      strokePointerIdRef.current = null;
      lastRegionRef.current = null;
      lastPosRef.current = null;
      brushStrokeRef.current = null;
      setHoverRegion(null);
    };
    const onPointerEnd = (e) => {
      if (strokePointerIdRef.current == null) return;
      if (e.pointerId !== strokePointerIdRef.current) return;
      fullReset();
    };
    window.addEventListener('pointerup', onPointerEnd);
    window.addEventListener('pointercancel', onPointerEnd);
    window.addEventListener('blur', fullReset);
    document.addEventListener('visibilitychange', fullReset);
    window.addEventListener('zen:cancel-stroke', fullReset);
    return () => {
      window.removeEventListener('pointerup', onPointerEnd);
      window.removeEventListener('pointercancel', onPointerEnd);
      window.removeEventListener('blur', fullReset);
      document.removeEventListener('visibilitychange', fullReset);
      window.removeEventListener('zen:cancel-stroke', fullReset);
    };
  }, []);

  const flashRejection = (regionEl) => {
    const id = regionEl.getAttribute('data-region');
    const expected = parseInt(regionEl.getAttribute('data-num') || '0', 10);
    setRejectedFlash({ regionId: id, expected });
    if (rejectTimerRef.current) clearTimeout(rejectTimerRef.current);
    rejectTimerRef.current = setTimeout(() => setRejectedFlash(null), 700);
  };

  const regionAtPoint = (x, y) => {
    const el = document.elementFromPoint(x, y);
    return el && el.closest && el.closest('[data-region]');
  };

  // Build a fast id→meta lookup for fill-color resolution at paint time.
  const metaById = React.useMemo(() => {
    if (!regionMeta) return null;
    const m = new Map();
    for (const r of regionMeta) m.set(r.id, r);
    return m;
  }, [regionMeta]);

  // Strict color-by-numbers: paint only fires if the active swatch's number matches
  // the region's assigned number. Wrong color → silent no-op (meditative experience).
  const canPaintRegion = (regionEl) => {
    if (!strictMatch) return true;
    if (!selectedNum) return true; // no number selected = allow any
    const expected = parseInt(regionEl.getAttribute('data-num') || '0', 10);
    return !expected || expected === selectedNum;
  };

  // ─── Brush mode helpers ───────────────────────────────────────────
  // Convert a client (CSS) coord to canvas-internal pixel coords. The canvas
  // buffer is at source resolution; CSS scales it. getBoundingClientRect on
  // the canvas gives current displayed size in CSS px.
  const toCanvasCoords = (cx, cy) => {
    const c = brushCanvasRef.current;
    if (!c) return null;
    const r = c.getBoundingClientRect();
    if (r.width === 0 || r.height === 0) return null;
    return {
      x: (cx - r.left) * c.width / r.width,
      y: (cy - r.top) * c.height / r.height,
    };
  };
  // Build/cache a Path2D for a region's `d` attribute. metaById is keyed by
  // region id; lookup is constant-time after the first hit. If the region
  // has topologically-nested children (`holes` from server), append each
  // child's d to the path. The clip in pointerdown/move uses 'evenodd' so
  // these holes carve out properly — without this, a brush in the parent
  // donut leaks into the nested child's pixel area.
  const getRegionPath2d = (regionId) => {
    const cache = path2dCacheRef.current;
    if (cache.has(regionId)) return cache.get(regionId);
    const meta = metaById && metaById.get(regionId);
    if (!meta || !meta.d) return null;
    const p = new Path2D(meta.d);
    if (Array.isArray(meta.holes)) {
      for (const holeD of meta.holes) {
        if (holeD) p.addPath(new Path2D(holeD));
      }
    }
    cache.set(regionId, p);
    return p;
  };

  // Resolve fill at paint time. In strict mode, fill = the region's true mean
  // source color (so the painted result matches the gradient preview); in free
  // mode, fill = whatever swatch the user picked.
  // BUT: if the mean color is too close to the canvas background (#fafaf5 ≈
  // luminance 0.97), painting with it produces white-on-white invisibility.
  // In that case, fall back to the swatch color so the user can see the fill.
  const NEAR_BG_LUM = 0.88;
  const fillFor = (regionId) => {
    if (!strictMatch || !metaById) return selectedColor;
    const meta = metaById.get(regionId);
    if (!meta || !meta.meanHex) return selectedColor;
    const m = /^#?([0-9a-f]{6})$/i.exec(meta.meanHex);
    if (!m) return selectedColor;
    const n = parseInt(m[1], 16);
    const r = (n >> 16) & 0xff, g = (n >> 8) & 0xff, b = n & 0xff;
    const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
    if (lum > NEAR_BG_LUM) return selectedColor; // would be invisible — use swatch
    return meta.meanHex;
  };

  const onPointerDown = (e) => {
    const debugSkip = (reason) => {
      if (typeof window !== 'undefined') {
        window.dispatchEvent(new CustomEvent('zen:debug-paint', { detail: { region: '-', source: `skip-${reason}` } }));
      }
    };
    // Only block paint if a swatch is ACTIVELY being dragged (has a color).
    // `drag.active` can stick true on iPad Chrome if a tap-and-release
    // doesn't get the cleanup listener attached in time — but `dragColor`
    // is set/cleared synchronously alongside, so it's a more reliable gate.
    if (drag.active && drag.dragColor) { debugSkip('drag-active'); return; }
    // Defensive cleanup: if drag.active is stuck true with no color (race),
    // clear it now so subsequent moves don't enter the hover-only branch.
    if (drag.active && !drag.dragColor && drag.setActive) {
      drag.setActive(false);
    }
    if (!selectedColor) { debugSkip('no-color'); return; }
    // Pinch in progress (2+ pointers down on parent) — don't start a paint stroke.
    if (typeof window !== 'undefined' && window.__zenPinching) { debugSkip('pinching'); return; }
    e.preventDefault();
    strokingRef.current = true;
    strokePointerIdRef.current = e.pointerId;
    lastPosRef.current = { x: e.clientX, y: e.clientY };
    lastRegionRef.current = null;
    strokeRejectCountRef.current = 0;
    rejectHintShownRef.current = false;
    // Capture on the OUTER wrapper (a stable parent) instead of e.target
    // (an SVG path). iPad WebKit can drop capture on SVG path elements when
    // the pointer moves off the captured path's geometry — the wrapper is
    // always under the pointer for the whole stroke, so events keep firing.
    try {
      ref.current && ref.current.setPointerCapture && ref.current.setPointerCapture(e.pointerId);
    } catch {}
    // brushMode: pixel paint on the canvas overlay, clipped to the region the
    // stroke STARTED in. The path2d is captured ONCE at pointerdown — moving
    // out of the start region → ctx.clip excludes those pixels → no paint.
    if (brushMode) {
      const t = regionAtPoint(e.clientX, e.clientY);
      if (!t || !ref.current || !ref.current.contains(t)) {
        // Pointer not over a region (e.g. on the canvas-wrap padding) — bail.
        strokingRef.current = false;
        return;
      }
      if (!canPaintRegion(t)) {
        flashRejection(t);
        strokingRef.current = false;
        if (typeof window !== 'undefined') {
          window.dispatchEvent(new CustomEvent('zen:debug-paint', { detail: { region: t.getAttribute('data-region'), source: 'brush-rejected' } }));
        }
        return;
      }
      const regionId = t.getAttribute('data-region');
      const path2d = getRegionPath2d(regionId);
      const canvas = brushCanvasRef.current;
      const cc = toCanvasCoords(e.clientX, e.clientY);
      if (!path2d || !canvas || !cc) {
        strokingRef.current = false;
        return;
      }
      const color = fillFor(regionId);
      const ctx = canvas.getContext('2d');
      // Initial dot. Keep brush size in source-image px so it stays consistent
      // regardless of zoom (the canvas internal buffer is always native res).
      ctx.save();
      ctx.clip(path2d, 'evenodd');
      ctx.fillStyle = color;
      ctx.beginPath();
      ctx.arc(cc.x, cc.y, brushSize / 2, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();
      brushStrokeRef.current = {
        regionId, path2d, color,
        brushSize, // snapshot at stroke start so a mid-stroke slider change doesn't retroactively scale
        points: [[cc.x, cc.y]],
        lastX: cc.x, lastY: cc.y,
      };
      // NOTE: deliberately NOT calling onPaint here. Auto-filling the SVG path
      // would make the brush stroke invisible (same-color-on-same-color blend),
      // since brush strokes go on the canvas overlay above the SVG. Brushed
      // regions live in canvas-only space; tap-filled regions live in
      // SVG-fill space; both layer correctly. Progress meter counts tap-fills
      // only in v1 — brush-only progress is a follow-up.
      if (typeof window !== 'undefined') {
        window.dispatchEvent(new CustomEvent('zen:debug-paint', { detail: { region: regionId, source: 'brush-down' } }));
      }
      return;
    }
    const t = regionAtPoint(e.clientX, e.clientY);
    if (t && ref.current && ref.current.contains(t)) {
      if (canPaintRegion(t)) {
        const region = t.getAttribute('data-region');
        onPaint(region, fillFor(region));
        lastRegionRef.current = region;
        if (typeof window !== 'undefined') {
          window.dispatchEvent(new CustomEvent('zen:debug-paint', { detail: { region, source: 'tap' } }));
        }
      } else {
        flashRejection(t);
        if (typeof window !== 'undefined') {
          window.dispatchEvent(new CustomEvent('zen:debug-paint', { detail: { region: t.getAttribute('data-region'), source: 'tap-rejected' } }));
        }
      }
    } else {
      if (typeof window !== 'undefined') {
        window.dispatchEvent(new CustomEvent('zen:debug-paint', { detail: { region: null, source: 'no-region' } }));
      }
    }
  };

  const onPointerMove = (e) => {
    // Drag-from-swatch hover preview — only when a swatch is REALLY being
    // dragged (dragColor set). Without this gate, a stuck drag.active flag
    // would route every brush move into hover-only mode and silently kill
    // brushing. Sketch mode works because it doesn't have this branch.
    if (drag.active && drag.dragColor) {
      const t = regionAtPoint(e.clientX, e.clientY);
      if (t && ref.current && ref.current.contains(t)) {
        setHoverRegion(t.getAttribute('data-region'));
      } else {
        setHoverRegion(null);
      }
      return;
    }
    if (!strokingRef.current) {
      // Once per stroke window: log so user sees why move events aren't painting.
      if (typeof window !== 'undefined' && !window.__zenLastNotStrokingTs || Date.now() - (window.__zenLastNotStrokingTs || 0) > 1000) {
        window.__zenLastNotStrokingTs = Date.now();
        window.dispatchEvent(new CustomEvent('zen:debug-paint', { detail: { region: '-', source: 'mv-no-stroke' } }));
      }
      return;
    }
    if (!selectedColor) return;
    // Cancel paint if a pinch started after our pointerdown.
    if (typeof window !== 'undefined' && window.__zenPinching) {
      strokingRef.current = false;
      strokePointerIdRef.current = null;
      lastPosRef.current = null;
      lastRegionRef.current = null;
      brushStrokeRef.current = null;
      return;
    }
    // brushMode: draw a clipped line segment from lastX/Y to current cursor
    // position. The path2d clip is the START region's geometry, captured at
    // pointerdown — leaving the region clips to nothing (boundary contains
    // the paint, Option A).
    if (brushMode) {
      const stroke = brushStrokeRef.current;
      const canvas = brushCanvasRef.current;
      if (!stroke || !canvas) return;
      const cc = toCanvasCoords(e.clientX, e.clientY);
      if (!cc) return;
      const ctx = canvas.getContext('2d');
      ctx.save();
      ctx.clip(stroke.path2d, 'evenodd');
      ctx.strokeStyle = stroke.color;
      ctx.lineWidth = stroke.brushSize;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';
      ctx.beginPath();
      ctx.moveTo(stroke.lastX, stroke.lastY);
      ctx.lineTo(cc.x, cc.y);
      ctx.stroke();
      ctx.restore();
      stroke.lastX = cc.x;
      stroke.lastY = cc.y;
      stroke.points.push([cc.x, cc.y]);
      return;
    }
    // Brush-stroke interpolation: between two pointermove events the cursor
    // can travel many pixels in one frame, especially with Apple Pencil
    // which can deliver 240Hz events but not always evenly. Sample every
    // ~6 CSS pixels along the line so a fast stroke paints every region
    // it crosses, not just the ones at sample timestamps.
    const last = lastPosRef.current;
    const x1 = e.clientX, y1 = e.clientY;
    const x0 = last ? last.x : x1;
    const y0 = last ? last.y : y1;
    const dx = x1 - x0, dy = y1 - y0;
    const dist = Math.hypot(dx, dy);
    const STEP = 6;
    const STALE_DIST = 150;
    let steps;
    if (dist > STALE_DIST) {
      steps = 1;
    } else {
      steps = Math.min(64, Math.max(1, Math.ceil(dist / STEP)));
    }
    let rejectedCountThisMove = 0;
    let lastRejectedEl = null;
    for (let i = 1; i <= steps; i++) {
      const t = i / steps;
      const px = x0 + dx * t;
      const py = y0 + dy * t;
      const target = regionAtPoint(px, py);
      if (!target || !ref.current || !ref.current.contains(target)) continue;
      if (!canPaintRegion(target)) {
        // Visual feedback so the user can see the brush IS firing — strict
        // CBN is just filtering it. Add a transient class via DOM (avoids
        // React re-renders on every brush sample).
        if (target !== lastRejectedEl) {
          target.classList.add('zen-brush-reject');
          setTimeout(() => target.classList.remove('zen-brush-reject'), 500);
          lastRejectedEl = target;
          rejectedCountThisMove++;
        }
        continue;
      }
      const region = target.getAttribute('data-region');
      if (region !== lastRegionRef.current) {
        onPaint(region, fillFor(region));
        lastRegionRef.current = region;
        if (typeof window !== 'undefined') {
          window.dispatchEvent(new CustomEvent('zen:debug-paint', { detail: { region, source: 'brush' } }));
        }
      }
    }
    // Track rolling rejected count across the stroke; surface a hint if many.
    if (rejectedCountThisMove > 0) {
      strokeRejectCountRef.current = (strokeRejectCountRef.current || 0) + rejectedCountThisMove;
      if (strictMatch && strokeRejectCountRef.current >= 5 && !rejectHintShownRef.current) {
        rejectHintShownRef.current = true;
        if (typeof window !== 'undefined') {
          window.dispatchEvent(new CustomEvent('zen:brush-hint', {
            detail: { selectedNum, rejectedCount: strokeRejectCountRef.current },
          }));
        }
      }
    }
    lastPosRef.current = { x: x1, y: y1 };
  };

  const onPointerUp = (e) => {
    strokingRef.current = false;
    strokePointerIdRef.current = null;
    lastRegionRef.current = null;
    lastPosRef.current = null;
    // Commit the in-flight brush stroke to history before clearing it.
    // No commit if there was no stroke (e.g. tap-mode pointerup, or rejected).
    if (brushStrokeRef.current) {
      brushHistoryRef.current.push(brushStrokeRef.current);
    }
    brushStrokeRef.current = null;
    setHoverRegion(null);
  };

  // When a swatch-drag ends over the canvas, paint. Gate by dragPointerId so
  // an unrelated pointerup (e.g. lifting a pinch finger while a swatch is mid-drag)
  // doesn't accidentally drop the swatch into a region (Fix #18).
  React.useEffect(() => {
    if (!drag.active) return;
    const onUp = (e) => {
      if (drag.dragPointerId != null && e.pointerId !== drag.dragPointerId) return;
      const x = e.clientX ?? (e.changedTouches && e.changedTouches[0]?.clientX);
      const y = e.clientY ?? (e.changedTouches && e.changedTouches[0]?.clientY);
      if (x == null) return;
      const t = regionAtPoint(x, y);
      if (t && ref.current && ref.current.contains(t)) {
        const region = t.getAttribute('data-region');
        onPaint(region, drag.dragColor);
      }
      setHoverRegion(null);
    };
    window.addEventListener('pointerup', onUp);
    return () => window.removeEventListener('pointerup', onUp);
  }, [drag.active, drag.dragColor, drag.dragPointerId, onPaint]);

  return (
    <div
      ref={ref}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerUp}
      style={{
        position: 'relative',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        touchAction: 'none',
        userSelect: 'none',
      }}
    >
      {brushMode && colorableSize && (
        // Canvas overlay is rendered BEFORE the SVG so it stacks BENEATH the
        // SVG paths. Region paths use transparent fill, so the canvas paint
        // shows through where the user has brushed; the SVG path STROKES
        // (region boundaries) render on top, so adjacent brushed regions
        // still show their dividing line — without this, brushing two
        // adjacent sibling chunks of the same color produces a borderless
        // blob and the user thinks the brush is bleeding (v1.2 unusable bug).
        // pointerEvents: none routes hit-testing to the SVG paths above.
        <canvas
          ref={brushCanvasRef}
          style={{
            position: 'absolute',
            top: '50%', left: '50%',
            transform: 'translate(-50%, -50%)',
            width: size, height: size,
            pointerEvents: 'none',
          }}
        />
      )}
      {Comp ? <Comp fills={fills} ink={ink} anim={anim} size={size} showNumbers={showNumbers} numberMap={numberMap} lineWeight={lineWeight} previewMode={previewMode} brushMode={brushMode} /> : null}
      {/* No animation — keep regions visible */}
      {highlightIdx != null && (
        <style>{`[data-region]{opacity:0.35}` + Object.entries(numberMap).filter(([,n]) => n === highlightIdx).map(([r]) => `[data-region="${r}"]{opacity:1;filter:brightness(1.15)}`).join('')}</style>
      )}
      {hoverRegion && drag.dragColor && (
        <style>{`
          [data-region="${hoverRegion}"] { filter: brightness(1.05); }
        `}</style>
      )}
      {rejectedFlash && (
        <style>{`
          [data-region="${rejectedFlash.regionId}"] {
            stroke: #c84a32 !important;
            stroke-width: 2 !important;
            animation: zen-flash 700ms ease-out;
          }
          @keyframes zen-flash {
            0%   { fill: rgba(200,74,50,0.35); }
            100% { fill: rgba(200,74,50,0); }
          }
        `}</style>
      )}
    </div>
  );
}

const PALETTES = {
  ink:    { name: 'Ink Wash',     colors: ['#1a1a1a', '#3d3d3d', '#7a7a7a', '#b0b0b0', '#dcdcdc', '#f0ebe2'] },
  earth:  { name: 'Earthen',      colors: ['#8b5a3c', '#c89968', '#e8c39e', '#a8956b', '#6b5840', '#3d3225'] },
  sage:   { name: 'Sage Garden',  colors: ['#7a9b8e', '#a8c4a8', '#c8d6b9', '#5d7a6e', '#3d5247', '#e8efe2'] },
  petal:  { name: 'Petal',        colors: ['#d4a5a5', '#e8c4c4', '#f0d8d8', '#b87d7d', '#7d5454', '#fae8e8'] },
  dusk:   { name: 'Dusk',         colors: ['#4a5d8a', '#7a8cb0', '#a8b5d0', '#d4a574', '#e8c39e', '#2a3550'] },
  citrus: { name: 'Citrus',       colors: ['#e8b04a', '#f0c870', '#d97757', '#c84a32', '#a8956b', '#3d3225'] },
};

Object.assign(window, {
  DragProvider, useDrag, useColoring, Palette, PaletteSwatch, DragGhost, ColorCanvas, PALETTES,
});
