import { Stage, Layer, Line, Rect, Transformer } from 'react-konva';
import { TShirtSVG } from './TShirtSVG';
import { ImageElement } from './ImageElement';
import { TextElement } from './TextElement';
import { TemplateLayer } from './TemplateLayer';
import { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
import { CanvasHint } from './CanvasHint';
import { CropOverlay } from './CropOverlay';
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { getActiveProduct } from '../../constants/products';
import { isElementOutOfBounds } from '../../utils/elementBounds';
import {
  ROTATION_SNAPS_15,
  ROTATION_SNAP_TOLERANCE,
  CORNER_ANCHORS,
  TRANSFORMER_VISUAL_STYLE,
} from '../../constants/transformer';
import { MIN_ELEMENT_SIZE } from '../../constants/elements';
import '../../styles/DesignCanvas.css';

const CANVAS_SIZE = 300;
// HANDLE_PADDING is the slop past the wrapper edges where transformer
// handles can still render without being clipped by the Konva stage.
// (Note: it used to also be the stage's only "extra room" past the
// print zone, which is why elements couldn't be dragged past the print
// area. The stage now covers the full wrapper plus this slop; the
// constant only controls the handle margin.)
const HANDLE_PADDING = 40;

// Tolerance used when checking if a rotated element fits within the canvas.
// Konva's transformer can produce sub-pixel rounding artifacts during continuous
// transforms; a small tolerance prevents the bounds check from rejecting boxes
// that are visually inside but numerically a hair outside.
const BOUNDS_EPSILON = 0.5;

// Snap-guide tuning. Threshold is in design-coord pixels (i.e. the same
// 0..300 space as element x/y), so it scales naturally with the visual
// scale of the canvas. CANVAS_CENTER_DESIGN is the snap target.
//
// Why 8 and not the original 4: the snap zone width is 2× threshold,
// so 8 yields a 16-design-unit wide capture zone (~22 rendered pixels
// at a typical 1.4 px/design-unit visual scale). The smaller value
// missed two real-world cases for text in particular:
//
//   1. Drag velocity. Browser pointermove events deliver per-frame
//      position deltas that routinely exceed 5 design units on quick
//      drags. With an 8-unit zone, the user could pass through the
//      target without a single frame landing inside it. Doubling the
//      zone gives at least 2-3 frames inside even on fast moves.
//
//   2. Font side-bearing. Konva.Text reports width as the rendered
//      glyph extent measured via canvas.measureText. For script fonts
//      with significant side-bearings or flourishes (Pacifico — the
//      default text font here — being a clear example), the visually
//      leftmost ink can sit 3-5 design units inside the reported
//      x=0 edge. The user moves text until its VISUAL center aligns
//      at design x=150, but Konva sees the bbox center several units
//      off and rejects the snap. Image elements don't have this drift
//      because their bbox is pixel-exact. Widening absorbs the gap.
//
// 8 was chosen to handle both cases without crossing into
// "too magnetic" territory where fine positioning feels sticky.
const SNAP_THRESHOLD = 8;
const CANVAS_CENTER_DESIGN = CANVAS_SIZE / 2;

// Inset between the design-canvas-frame's edge and the wrapper inside it.
// Gives the canvas a bit of breathing room from the pink frame's edge so
// transformer handles can extend past the printable area without being
// clipped, and so the canvas doesn't read as flush against the frame.
const FRAME_INSET_PX = 20;

// Floor for wrapper dimensions. Below this, transformer handles and font
// chrome get unusable. Almost never hit — a 120px frame would mean the
// browser is sub-tablet — but it keeps the math defensive.
const MIN_WRAPPER_PX = 120;

// Marquee drag-select tuning.
//
// MARQUEE_THRESHOLD_PX is the distance (in stage internal coords) the
// pointer must travel from mousedown before we start rendering the
// marquee rect. Below this, a mousedown→mouseup pair behaves as a plain
// click — deselects empty canvas, selects an element, etc. — with no
// visible marquee. Threshold should be small enough that intentional
// drags activate immediately (no "why isn't this working" delay) but
// large enough that a click with a few pixels of hand-jitter doesn't
// flash a tiny marquee rect that selects something the user didn't mean
// to select. 4 stage units balances both — imperceptible to deliberate
// drag, comfortably above jitter.
const MARQUEE_THRESHOLD_PX = 4;

// Marquee visual style. Translucent brand-pink fill so the user sees
// what they're enclosing without the underlying elements being hidden,
// dashed pink stroke matching the rest of the canvas's selection
// affordances (snap guides, transformer border).
const MARQUEE_FILL = 'rgba(236, 72, 153, 0.08)';
const MARQUEE_STROKE = '#ec4899';

// ────────────────────────────────────────────────────────────────────────
// Stage / wrapper layout in design-coord space
// ────────────────────────────────────────────────────────────────────────
//
// The Konva stage used to cover only the print zone plus HANDLE_PADDING.
// That clipped any element dragged past the print zone, so we had to
// clamp positions to [0, CANVAS_SIZE]². To support free positioning
// across the entire shirt, the stage now covers the FULL wrapper (the
// visible product mockup) plus HANDLE_PADDING on each side for handles.
// Design coord (0, 0) still maps to the print zone's top-left — we just
// shift the layer offset so the wrapper extends in the negative direction
// too. The print-area warning in `countOutOfBounds` keys on
// [0, CANVAS_SIZE]² and continues to fire whenever an element strays
// outside the print zone — only the positional clamp has loosened.
//
// Read product config once at module load. The editor doesn't support
// runtime product swapping (see constants/products.js), so it's safe
// to derive layout constants from it here.
const _PRODUCT = getActiveProduct();
const _PZ = _PRODUCT.printZone;
const _SVG_W = _PRODUCT.productSvg.width;
const _SVG_H = _PRODUCT.productSvg.height;

// Print-zone top-left in DESIGN coords. The print zone has size
// CANVAS_SIZE × CANVAS_SIZE in design space and sits some offset into
// the wrapper; these are the design-space x/y of its top-left corner
// relative to the wrapper top-left.
const PRINT_ZONE_X_DESIGN = (_PZ.x * CANVAS_SIZE) / _PZ.width;
const PRINT_ZONE_Y_DESIGN = (_PZ.y * CANVAS_SIZE) / _PZ.height;

// Wrapper extent in DESIGN coords. CANVAS_SIZE design units span
// printZone.width SVG units, so wrapper_design = svg_units * CANVAS_SIZE / printZone_units.
const WRAPPER_W_DESIGN = (_SVG_W * CANVAS_SIZE) / _PZ.width;
const WRAPPER_H_DESIGN = (_SVG_H * CANVAS_SIZE) / _PZ.height;

// Stage internal size and layer offset. The stage covers the wrapper
// plus HANDLE_PADDING worth of slop on each side. Design (0, 0) — the
// print-zone top-left — lives at stage internal (LAYER_OFFSET_X,
// LAYER_OFFSET_Y).
const LAYER_OFFSET_X = HANDLE_PADDING + PRINT_ZONE_X_DESIGN;
const LAYER_OFFSET_Y = HANDLE_PADDING + PRINT_ZONE_Y_DESIGN;
const STAGE_W = Math.ceil(WRAPPER_W_DESIGN + HANDLE_PADDING * 2);
const STAGE_H = Math.ceil(WRAPPER_H_DESIGN + HANDLE_PADDING * 2);

// Wrapper bounds in DESIGN coords. Drag and transform clamp to these
// (not the print-zone bounds [0, CANVAS_SIZE]²), so the user can place
// elements anywhere on the visible shirt without being able to lose
// them off it entirely.
const WRAPPER_LEFT = -PRINT_ZONE_X_DESIGN;
const WRAPPER_RIGHT = WRAPPER_W_DESIGN - PRINT_ZONE_X_DESIGN;
const WRAPPER_TOP = -PRINT_ZONE_Y_DESIGN;
const WRAPPER_BOTTOM = WRAPPER_H_DESIGN - PRINT_ZONE_Y_DESIGN;

export const DesignCanvas = memo(function DesignCanvas({
  elements, selectedId, onSelect, onDeselect, onUpdate, onCommit,
  currentTemplate,
  shirtColor = '#ffffff',
  zoom = 1,
  showHint = true,
  outOfBoundsCount = 0,
  stageContainerRef,
  shiftHeld = false,
  // Multi-select set (S3). When selectedIds.size > 1, a shared
  // Transformer wraps all selected nodes and per-element Transformers
  // are suppressed (each element's isSelected becomes false). When
  // selectedIds.size <= 1, falls back to the historical single-select
  // behavior driven by `selectedId`. Default to an empty Set so a
  // caller that hasn't been updated for multi-select still works (the
  // shared Transformer is just dormant).
  selectedIds = new Set(),
  // Called by the marquee drag with the array of element ids enclosed
  // by the selection rectangle. Replaces the selection wholesale
  // (vs. the toggle-style toggleInSelection used by shift-click in the
  // LayersPanel). Optional; when omitted, marquee drag becomes inert.
  setSelection,
  // Called when the user shift-clicks an element on the canvas —
  // toggles that element's membership in the selection. The marquee
  // and the LayersPanel use the same underlying primitive; this
  // surface just routes the canvas's click events to it. Optional;
  // when omitted, shift-click on canvas degrades to a normal click.
  onToggleSelect,
  // Disables marquee drag on touch devices. Mobile uses tap-to-select
  // (single only); the marquee gesture conflicts with scroll/pan and
  // there's no good way to select multiple elements with a single
  // touch contact. Multi-select on mobile remains reachable via the
  // LayersPanel's shift/long-press toggle. Default false so existing
  // callers (and SSR) get desktop behavior.
  isMobile = false,
  // When false, the drag handler skips the center-snap pull and the
  // dashed guide lines never appear. Lifted to App so the preference
  // persists across reloads and is surfaced via the ElementToolbar's
  // Snap toggle button. Default true so existing callers (and the
  // initial render before App's first state propagation) get the
  // historical behavior.
  snapEnabled = true,
  // Optional slot upload callback. When the user clicks an empty slot
  // placeholder we open a file picker; the picked image flows back
  // through this callback (which lives in App.jsx and routes through
  // useTemplate.assignImageToSlot for the auto-crop/aspect-ratio fit).
  // Passing nothing makes slot placeholders inert, same as before.
  onSlotImageUpload,
  // S2 — fired by TextElement on double-click. App.jsx tracks the
  // editing state and renders an InlineTextEditor overlay. Optional;
  // when omitted, double-click is a no-op (text stays editable via
  // the side panel).
  onStartTextEdit,
  // Crop mode (image-only). When `cropping` is non-null, DesignCanvas
  // suppresses normal selection chrome for the targeted element and
  // mounts <CropOverlay>. Shape:
  //   {
  //     elementId: string,
  //     rect: { x, y, width, height },   // in canvas/design coords
  //     onRectChange: (newRect) => void  // called on drag/transform end
  //   }
  // App.jsx owns the rect state and the Apply / Cancel flow; this
  // surface only renders the overlay UI.
  cropping = null,
}) {
  const slots = currentTemplate?.slots || [];

  // Active product type (shirt by default). Drives where the Konva Stage is
  // positioned within the product mockup, and which mockup gets rendered.
  // Reading this each render is cheap — it's a synchronous lookup against a
  // static module-level object — and keeps the component reactive if the
  // active product is ever changed at runtime.
  const product = getActiveProduct();
  const printZone = product.printZone;
  const svgW = product.productSvg.width;
  const svgH = product.productSvg.height;

  // Out-of-bounds bookkeeping (Change 3). The chip at the bottom of the
  // canvas frame tells the user HOW MANY elements are out of bounds, but
  // doesn't tell them WHICH. Computing the set here lets each element
  // render a warning-yellow background when it's the offender, so the
  // user can see the violation on the element itself.
  //
  // Recomputed only when the elements array changes — the print zone is
  // a module-level constant. Each lookup inside the elements.map below
  // is O(1) against the Set.
  const outOfBoundsIds = useMemo(() => {
    const set = new Set();
    for (const el of elements) {
      if (isElementOutOfBounds(el, CANVAS_SIZE)) set.add(el.id);
    }
    return set;
  }, [elements]);

  // Single source of truth for slot occupancy: the elements array. Whenever a
  // slot-bound element is deleted, this Set reflects it next render.
  // Recomputed only when `elements` changes — stable during drag/transform.
  const occupiedSlotIds = useMemo(() => {
    const set = new Set();
    for (const el of elements) if (el.slotId) set.add(el.slotId);
    return set;
  }, [elements]);

  // ─── Visual scale (Stage CSS-scale to print zone size) ─────────────────
  //
  // The Stage's internal coordinate system stays at 0..CANVAS_SIZE design
  // units. To make it overlay the print zone of the product mockup at any
  // viewport size, we apply a CSS `transform: scale(s)` to the Stage where
  //
  //     s = (rendered_print_zone_width_in_DOM_px) / CANVAS_SIZE
  //
  // The wrapper's pixel dimensions are computed in the useLayoutEffect
  // below from a ResizeObserver on the parent frame, fitting the wrapper
  // to the available area while preserving the product's aspect ratio.
  // CSS scale does NOT change Konva's internal coordinates — bound
  // functions, drag positions, and the export pipeline all operate in
  // the canonical 300×300 space. Pointer hit-testing still works because
  // Konva uses getBoundingClientRect (which returns post-transform DOM
  // rect) to convert mouse coords to internal coords.
  const wrapperRef = useRef(null);
  const frameRef = useRef(null);

  const [layout, setLayout] = useState(() => {
    // Reasonable default that matches typical desktop — used only for the
    // very first render before useLayoutEffect runs. The layout effect
    // overrides this synchronously before the browser paints, so the
    // user never actually sees these numbers.
    const aspect = svgW / svgH;
    const w = 460;
    const h = w / aspect;
    return {
      width: w,
      height: h,
      stageVisualScale: (w * (printZone.width / svgW)) / CANVAS_SIZE,
    };
  });

  // "Ready" gate for CSS transitions. The very first measurement (initial
  // default → actual frame size) shouldn't animate — the user would see a
  // jarring grow-from-460 transition on every page load. We add this class
  // only after the first paint, so subsequent resize updates animate but
  // the initial sizing is silent. (See CSS: .design-canvas-frame--ready.)
  const [isReady, setIsReady] = useState(false);

  // Sizing effect: measure the frame's available area, fit the wrapper
  // inside it preserving aspect ratio, derive the matching Konva-stage
  // visual scale, and bundle them into one state update. Runs in
  // useLayoutEffect so the initial measurement happens before paint —
  // otherwise the user briefly sees the default-state size flash on the
  // first frame. The ResizeObserver inside is rAF-throttled so a window
  // drag (which fires the observer dozens of times per second) doesn't
  // overwhelm React's reconciler.
  useLayoutEffect(() => {
    const el = frameRef.current;
    if (!el) return;

    const compute = (rect) => {
      if (!rect || rect.width <= 0 || rect.height <= 0) return;

      const aspect = svgW / svgH;
      const availW = Math.max(MIN_WRAPPER_PX, rect.width - FRAME_INSET_PX * 2);
      const availH = Math.max(MIN_WRAPPER_PX, rect.height - FRAME_INSET_PX * 2);

      // Largest size that fits inside (availW, availH) preserving aspect
      // ratio. Width-bound first; if the resulting height overflows, flip
      // to height-bound and recompute.
      let w = availW;
      let h = w / aspect;
      if (h > availH) {
        h = availH;
        w = h * aspect;
      }

      const stageVisualScale = (w * (printZone.width / svgW)) / CANVAS_SIZE;

      setLayout((prev) => {
        // Sub-pixel deduplication: a wobble of <0.5px in either dimension
        // (or <1e-4 in scale) isn't perceptible and isn't worth a render.
        if (
          Math.abs(prev.width - w) < 0.5
          && Math.abs(prev.height - h) < 0.5
          && Math.abs(prev.stageVisualScale - stageVisualScale) < 1e-4
        ) return prev;
        return { width: w, height: h, stageVisualScale };
      });
    };

    // Synchronous first measurement, before the browser paints. Without
    // this the user briefly sees the wrapper at its default-state size
    // (460px) on the first frame, then it snaps to the real fitted size
    // on the next — looks broken even though it's gone in 16ms.
    compute(el.getBoundingClientRect());

    if (typeof ResizeObserver === 'undefined') return;

    // rAF-throttled observer. Coalesces multiple fires within one frame
    // into a single compute(), always using the latest rect. Without
    // this, a continuous window-drag fires the observer ~60-120x/sec
    // and each fire schedules a React re-render — reconciler thrash.
    let rafId = null;
    let latestRect = null;

    const flush = () => {
      rafId = null;
      if (latestRect) compute(latestRect);
    };

    const ro = new ResizeObserver((entries) => {
      if (!entries.length) return;
      latestRect = entries[0].contentRect;
      if (rafId === null) rafId = requestAnimationFrame(flush);
    });
    ro.observe(el);

    return () => {
      if (rafId !== null) cancelAnimationFrame(rafId);
      ro.disconnect();
    };
  }, [svgW, svgH, printZone.width]);

  // After first paint, enable CSS transitions on layout changes. Set
  // once and stays true for the component's lifetime. The initial sizing
  // (default → fitted) happens inside the useLayoutEffect above, before
  // paint, so we don't want it to animate — only subsequent resizes do.
  useEffect(() => {
    setIsReady(true);
  }, []);

  // ─── Snap guides (#21) ───────────────────────────────────────────────
  //
  // Detect when the element being dragged has its center on the canvas
  // vertical or horizontal centerline (within SNAP_THRESHOLD), pull it
  // there, and render a dashed guide line. Snap state is mirrored into
  // refs so the dragBoundFunc — which fires every frame — can read it
  // without going through React, and `setSnapState` is called only on
  // transitions (entering/leaving snap on either axis). This keeps the
  // drag loop allocation-free in the steady state and avoids per-frame
  // re-renders.
  const snapVRef = useRef(false);
  const snapHRef = useRef(false);
  const [snapState, setSnapState] = useState({ v: false, h: false });
  const clearSnap = useCallback(() => {
    if (snapVRef.current || snapHRef.current) {
      snapVRef.current = false;
      snapHRef.current = false;
      setSnapState({ v: false, h: false });
    }
  }, []);

  // Mirror the `snapEnabled` prop into a ref so canvasDragBound can
  // read it inline without being re-created on every prop change.
  // canvasDragBound is a useCallback with empty deps (Konva binds it
  // to nodes once at attach time, so changing its identity would force
  // a re-attach on every render), which means reading the prop
  // directly from closure would freeze at the first render's value.
  // The ref pattern is the standard fix for "hot-path callback needs
  // to observe a prop without forcing re-renders or re-attaches".
  const snapEnabledRef = useRef(snapEnabled);
  useEffect(() => { snapEnabledRef.current = snapEnabled; }, [snapEnabled]);

  // ─── Multi-select (S3) ─────────────────────────────────────────────
  //
  // The selection model is owned by App.jsx via useDesignEditor (a Set
  // of element ids). The canvas surfaces two ways to manipulate that
  // set:
  //
  //   1. Marquee drag: mousedown on empty stage → pointer-track → if
  //      drag exceeds MARQUEE_THRESHOLD_PX, render a dashed rect; on
  //      mouseup, compute intersected element ids and call setSelection.
  //
  //   2. Shift-click on element: toggles that element's membership via
  //      onToggleSelect (single-element clicks fall through to onSelect
  //      = single-select).
  //
  // Plus a shared Transformer rendered at the canvas level that wraps
  // ALL selected nodes whenever selectedIds.size > 1, so the user can
  // scale/rotate the whole group at once. Per-element Transformers
  // (the ones inside ImageElement / TextElement) only render in the
  // single-select case — controlled by passing `isSelected` as a
  // single-select predicate down to each element.
  //
  // Refs and state below feed all of the above. Mirroring isMobile and
  // shiftHeld into refs keeps the Konva mouse handlers (which Konva
  // captures by identity on attach) reactive to the latest prop
  // values without forcing re-renders or re-attaches.
  const isMobileRef = useRef(isMobile);
  useEffect(() => { isMobileRef.current = isMobile; }, [isMobile]);
  const shiftHeldRef = useRef(shiftHeld);
  useEffect(() => { shiftHeldRef.current = shiftHeld; }, [shiftHeld]);

  // Marquee state. `marqueeStartRef` holds the mousedown position in
  // stage internal coords; null when no drag is in progress.
  // `didMarqueeDragRef` flips to true once the pointer exceeds the
  // activation threshold — read by the stage's onClick handler to
  // distinguish "plain click on empty canvas" (should deselect) from
  // "marquee drag just finished" (should NOT deselect, the marquee
  // already committed the correct selection). `marquee` state is the
  // visible rect; setting it triggers a re-render so the Rect appears.
  //
  // `marqueeRef` mirrors the latest `marquee` state into a ref so
  // handleStageMouseUp can read it without taking `marquee` as a dep
  // — which would re-create the callback on every drag frame (each
  // mousemove sets new marquee state) and force react-konva to
  // re-attach the handler. More importantly, reading the ref means
  // handleStageMouseUp can call setSelection OUTSIDE the setMarquee
  // updater, avoiding the "setState during render of another component"
  // antipattern — see the docblock on handleStageMouseUp below.
  const marqueeStartRef = useRef(null);
  const didMarqueeDragRef = useRef(false);
  const [marquee, setMarquee] = useState(null);
  const marqueeRef = useRef(null);
  useEffect(() => { marqueeRef.current = marquee; }, [marquee]);

  // Refs for the shared multi-select Transformer + the layer holding
  // the user's elements. The layer ref is used by both the marquee
  // (to compute intersection via getClientRect on each child node)
  // and the shared Transformer (to find nodes by Konva `name` attr,
  // which we set to the element id in ImageElement / TextElement).
  //
  // The single-select Transformer ref was added in the bug 8 fix —
  // selection chrome now lives in its own dedicated layer (above the
  // elements layer) so handles always render on top of every element
  // regardless of z-order. Both Transformers share the same
  // findOne-by-name attachment pattern; only the layer they live in
  // differs from the layer they CONTROL.
  const elementsLayerRef = useRef(null);
  const multiTransformerRef = useRef(null);
  const singleTransformerRef = useRef(null);

  // Resolve the single-select target. Mirrors the per-element
  // `isSingleSelected` computation used to live inside elements.map:
  // selectedIds wins when non-empty, falls back to selectedId for
  // callers that haven't been wired up for multi-select. Returns null
  // whenever the multi-transformer should take over (2+ selected) so
  // the two Transformers never both attach to the same node.
  const singleSelectedEl = useMemo(() => {
    if (selectedIds.size === 1) {
      const id = [...selectedIds][0];
      return elements.find((e) => e.id === id) ?? null;
    }
    if (selectedIds.size === 0 && selectedId) {
      return elements.find((e) => e.id === selectedId) ?? null;
    }
    return null;
  }, [selectedIds, selectedId, elements]);

  // Wire the shared Transformer's `nodes` whenever the selection or
  // the elements array changes. Both deps matter:
  //   • selectedIds: obviously, what we're attaching to.
  //   • elements: re-running when elements change ensures we re-attach
  //     after add/delete/reorder, since the Konva node identities can
  //     change (e.g. duplicate creates a new node; the Transformer
  //     would otherwise keep a stale reference to the source node).
  //
  // Skipped when selectedIds.size < 2 — single-select uses the
  // dedicated single-Transformer effect below, which lives in the
  // same dedicated transformer layer.
  //
  // batchDraw target: the Transformer's OWN layer (not the elements
  // layer). Both Transformers now live in a separate layer above the
  // elements layer; calling batchDraw on the elements layer wouldn't
  // redraw the transformer overlay.
  useEffect(() => {
    const tr = multiTransformerRef.current;
    const layer = elementsLayerRef.current;
    if (!tr || !layer) return;

    if (selectedIds.size < 2) {
      // Detach when dropping back to single/none. Leaving stale nodes()
      // would render leftover handles around the previously-selected
      // group even though the single-Transformer effect is now in charge.
      tr.nodes([]);
      tr.getLayer()?.batchDraw();
      return;
    }

    const nodes = [];
    for (const id of selectedIds) {
      const node = layer.findOne('.' + id);
      if (node) nodes.push(node);
    }
    tr.nodes(nodes);
    tr.getLayer()?.batchDraw();
  }, [selectedIds, elements]);

  // Single-select Transformer attachment effect (bug 8 fix).
  //
  // Mirrors the multi-transformer effect above but for the single-
  // select case. Attaches the single-Transformer to the one selected
  // node, or detaches (nodes([])) whenever:
  //
  //   • nothing is selected,
  //   • multi-select is active (the multi-Transformer takes over),
  //   • crop mode is active on the selected element (CropOverlay owns
  //     the chrome).
  //
  // `forceUpdate()` is needed because changes to text content / font /
  // arc shift the node's getSelfRect-reported geometry without
  // changing node identity. Konva's Transformer caches handle
  // positions; forceUpdate is its documented "the attached node's
  // geometry changed, please re-measure" API. The dep on `elements`
  // catches every relevant change (a re-render of a text element
  // produces a new elements array entry via useDesignEditor's
  // immutable updates).
  //
  // This effect used to live inside TextElement.jsx and ImageElement.jsx
  // (one `attachTransformer` callback per component). Centralising here
  // is the change that makes the dedicated-layer architecture possible:
  // those components no longer hold a Transformer at all.
  useEffect(() => {
    const tr = singleTransformerRef.current;
    const layer = elementsLayerRef.current;
    if (!tr || !layer) return;

    // Multi-select wins.
    if (selectedIds.size > 1) {
      tr.nodes([]);
      tr.getLayer()?.batchDraw();
      return;
    }

    if (!singleSelectedEl) {
      tr.nodes([]);
      tr.getLayer()?.batchDraw();
      return;
    }

    // Suppress while cropping this same element — CropOverlay owns
    // the selection chrome during a crop, and a Transformer would
    // visually compete with the crop rect's anchors.
    if (cropping?.elementId === singleSelectedEl.id) {
      tr.nodes([]);
      tr.getLayer()?.batchDraw();
      return;
    }

    const node = layer.findOne('.' + singleSelectedEl.id);
    if (node) {
      tr.nodes([node]);
      tr.forceUpdate();
    } else {
      tr.nodes([]);
    }
    tr.getLayer()?.batchDraw();
  }, [singleSelectedEl, selectedIds, elements, cropping]);

  // Compute element ids whose visual bbox intersects the marquee. Reads
  // each node's getClientRect (rotated AABB in stage coords) and tests
  // for AABB intersection with the marquee. Locked elements are
  // excluded — locked is locked, and including them in a marquee then
  // failing to transform them would be confusing.
  //
  // The marquee rect arg is in stage internal coords (same space as
  // node.getClientRect with relativeTo: stage). Returns an array of
  // element ids, in the order they appear in `elements` (which
  // matches z-order — useful for diagnostic logging but not strictly
  // needed by setSelection, which collapses to a Set).
  const computeMarqueeHits = useCallback((m) => {
    const layer = elementsLayerRef.current;
    if (!layer) return [];
    const stage = layer.getStage();
    if (!stage) return [];

    const minX = Math.min(m.x1, m.x2);
    const maxX = Math.max(m.x1, m.x2);
    const minY = Math.min(m.y1, m.y2);
    const maxY = Math.max(m.y1, m.y2);

    const hits = [];
    for (const el of elements) {
      if (el.locked) continue;
      const node = layer.findOne('.' + el.id);
      if (!node) continue;
      const rect = node.getClientRect({ relativeTo: stage });
      // AABB intersection: rectangles overlap iff their x-ranges and
      // y-ranges both overlap.
      if (
        rect.x + rect.width >= minX && rect.x <= maxX
        && rect.y + rect.height >= minY && rect.y <= maxY
      ) {
        hits.push(el.id);
      }
    }
    return hits;
  }, [elements]);

  // Stage event handlers for marquee drag-select. All four are wired
  // to the Stage component below; element clicks cancelBubble before
  // reaching the stage, so these only fire for genuinely empty-canvas
  // interactions. Mobile bails out of mousedown so the gesture never
  // starts.
  const handleStageMouseDown = useCallback((e) => {
    if (isMobileRef.current) return;
    const stage = e.target.getStage?.();
    if (!stage) return;
    // Only start a marquee when the mousedown landed on the stage itself
    // (i.e. no shape was hit). Mousedown on an element is reserved for
    // that element's drag.
    if (e.target !== stage) return;
    const pos = stage.getPointerPosition();
    if (!pos) return;
    marqueeStartRef.current = { x: pos.x, y: pos.y };
    didMarqueeDragRef.current = false;
  }, []);

  const handleStageMouseMove = useCallback((e) => {
    const start = marqueeStartRef.current;
    if (!start) return;
    const stage = e.target.getStage?.();
    if (!stage) return;
    const pos = stage.getPointerPosition();
    if (!pos) return;
    const dx = pos.x - start.x;
    const dy = pos.y - start.y;
    // Hysteresis: don't activate until the pointer has moved at least
    // MARQUEE_THRESHOLD_PX. Below this, the mousedown→mouseup pair will
    // resolve as a click (deselect on empty canvas).
    if (!didMarqueeDragRef.current && (dx * dx + dy * dy) < MARQUEE_THRESHOLD_PX * MARQUEE_THRESHOLD_PX) {
      return;
    }
    didMarqueeDragRef.current = true;
    setMarquee({ x1: start.x, y1: start.y, x2: pos.x, y2: pos.y });
  }, []);

  // Stage mouse-up handler. Commits the marquee selection (if any) and
  // clears the marquee rect.
  //
  // Why setSelection is called OUTSIDE the setMarquee updater
  // ──────────────────────────────────────────────────────
  // Previous version put the setSelection call inside the setMarquee
  // updater, like:
  //
  //   setMarquee((curr) => {
  //     if (!curr) return curr;
  //     setSelection?.(computeMarqueeHits(curr));
  //     return null;
  //   });
  //
  // That fired React's "Cannot update a component (App) while rendering
  // a different component (DesignCanvas)" warning intermittently. The
  // mechanism: React invokes the setMarquee updater during DesignCanvas's
  // commit phase. Calling setSelection (an App-owned setter) from inside
  // that updater queues a state update on App's hook mid-commit, which
  // React detects as a cross-component update-during-render. State
  // updaters are supposed to be pure (no side effects) precisely so this
  // can't happen — the warning is React enforcing that contract. It was
  // intermittent rather than constant because the cross-component
  // detection depends on which commit boundary the inner setState lands
  // on, and StrictMode's double-invocation of updaters in dev makes it
  // more likely to fire.
  //
  // Fix: read the marquee value from `marqueeRef` (synchronized to state
  // via the useEffect above), call setSelection at top level if a
  // marquee was active, then setMarquee(null) to clear. Both setSelection
  // and setMarquee are called from the event handler proper — not nested
  // inside another updater — so React batches them normally without
  // tripping the warning.
  //
  // Race protection (preserved from the original): if didMarqueeDrag is
  // true but `marquee` state is null (a brief race where mouseup landed
  // between threshold-cross and the setMarquee state settling), the
  // `if (curr)` check still bails before committing a phantom empty
  // selection. The follow-up click handler resolves correctly because
  // didMarqueeDragRef stays true until it's read once.
  const handleStageMouseUp = useCallback(() => {
    marqueeStartRef.current = null;
    const curr = marqueeRef.current;
    if (curr) {
      const hits = computeMarqueeHits(curr);
      // setSelection is optional; degrade gracefully if the consumer
      // hasn't wired it up.
      setSelection?.(hits);
    }
    setMarquee(null);
  }, [computeMarqueeHits, setSelection]);

  const handleStageMouseLeave = useCallback(() => {
    // Pointer left the stage mid-drag. Abandon the marquee — the user
    // can re-start by mousing back into the stage and dragging again.
    // Doing this rather than continuing the marquee with the last-known
    // position avoids a stuck-looking dashed rect when the user releases
    // the mouse outside the canvas.
    if (marqueeStartRef.current || marquee) {
      marqueeStartRef.current = null;
      didMarqueeDragRef.current = false;
      setMarquee(null);
    }
  }, [marquee]);

  const handleStageClick = useCallback(() => {
    // Marquee just finished — swallow the click so the marquee's
    // commited selection isn't immediately wiped by onDeselect.
    if (didMarqueeDragRef.current) {
      didMarqueeDragRef.current = false;
      return;
    }
    // Plain click on empty canvas — delegate to App's deselectAll.
    onDeselect?.();
  }, [onDeselect]);

  // Per-element click handler. Routes to either single-select
  // (onSelect) or selection-toggle (onToggleSelect) based on whether
  // shift is held. Used by both ImageElement and TextElement — each
  // calls this with its own id when its onClick fires.
  //
  // Wrapped in useCallback so the inline arrow that wraps it in the
  // elements.map below doesn't create a new function identity each
  // render (which would defeat the React.memo wrappers on the element
  // components). The dependency on shiftHeldRef means we don't need to
  // re-create when shiftHeld changes — the ref read picks up the
  // current value at click time.
  const handleElementSelect = useCallback((id) => {
    if (shiftHeldRef.current && onToggleSelect) {
      onToggleSelect(id);
    } else {
      onSelect?.(id);
    }
  }, [onSelect, onToggleSelect]);

  // ─── Slot upload (URL-loaded templates) ───────────────────────────
  //
  // Templates can declare "slots" — named regions where the user is
  // expected to drop their own image (e.g. team-sport's "Number" slot).
  // Now that the templates tab is gone, the canvas's empty slot
  // placeholders ARE the click target. Clicking opens a hidden file
  // input; the picked file is read as a data URL and handed to the
  // parent's `onSlotImageUpload` callback, which routes through
  // `useTemplate.assignImageToSlot` for the C8 async-crop fix.
  //
  // We track the slot id that triggered the picker via a ref instead of
  // state because the value is only consumed inside the next file-input
  // change event — no render needs to depend on it. Using state would
  // produce one render per click that doesn't change anything visible.
  const slotFileInputRef = useRef(null);
  const pendingSlotIdRef = useRef(null);
  // Slot-upload loading state (S25). Tracks the id of the slot currently
  // having its image processed by the parent's `onSlotImageUpload`
  // (which awaits useTemplate.assignImageToSlot to do an Image-load +
  // auto-crop pass). The SlotPlaceholder for that slot renders a
  // "Loading…" overlay so the user sees that their click registered
  // and the canvas is working. Cleared on success OR failure.
  const [loadingSlotId, setLoadingSlotId] = useState(null);

  const handleSlotClick = useCallback((slotId) => {
    if (!onSlotImageUpload) return;
    pendingSlotIdRef.current = slotId;
    slotFileInputRef.current?.click();
  }, [onSlotImageUpload]);

  const handleSlotFile = useCallback(async (e) => {
    const file = e.target.files?.[0];
    const slotId = pendingSlotIdRef.current;
    // Reset the input value early so picking the same file twice in a
    // row still fires `change` (mirrors the C12 fix in UploadTab).
    e.target.value = '';
    pendingSlotIdRef.current = null;

    if (!file || !slotId || !onSlotImageUpload) return;

    setLoadingSlotId(slotId);
    try {
      // Read as data URL, then await the parent. The parent's
      // assignImageToSlot does an Image-load to compute the auto-crop
      // (C8), so awaiting until it resolves guarantees the loading
      // state covers the actual user-perceptible delay.
      const dataUrl = await new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = (ev) => resolve(ev.target.result);
        reader.onerror = () => reject(new Error('FileReader failed'));
        reader.readAsDataURL(file);
      });
      await onSlotImageUpload(slotId, dataUrl);
    } catch {
      // The parent surfaces user-facing errors via toast. We just need
      // to clear the loading state so the slot becomes clickable again.
    } finally {
      setLoadingSlotId(null);
    }
  }, [onSlotImageUpload]);

  // ───────────────────────────────────────────────────────────────────────
  // PERFORMANCE-CRITICAL HOT PATH — DO NOT MUTATE
  //
  // boundBoxFunc — runs many times per second during scale/rotate.
  //   • Allocation-free in the fast path (no array spread, no Math.min(...arr)).
  //   • Returns the same newBox object reference when in-bounds so React-Konva
  //     can skip pointless re-renders.
  //   • For rotation changes, uses a cheap slide-or-revert check.
  //   • For non-rotation changes, falls back to the linear-interpolation
  //     algorithm that smoothly stops the box at the edge.
  //   • Bounds are the visible WRAPPER, not the print zone. The user can
  //     scale/rotate an element past the print area; the out-of-bounds
  //     warning fires from `countOutOfBounds` in elementBounds.js.
  // ───────────────────────────────────────────────────────────────────────
  const constrainTransform = useCallback((oldBox, newBox) => {
    // Konva's Transformer passes oldBox/newBox in STAGE coordinates
    // (absolute), not layer-local. Our wrapper bounds (WRAPPER_LEFT
    // etc.) are in DESIGN coordinates — same space as element.x/y
    // and node.x()/y(), because the elements layer is offset by
    // (LAYER_OFFSET_X, LAYER_OFFSET_Y) and Konva reports node coords
    // relative to that offset.
    //
    // Without this translation, the bound check compares stage-coord
    // AABB-mins (large positive numbers) against design-coord wrapper
    // bounds (e.g. WRAPPER_RIGHT = 422.73), which falsely reports
    // most placed-right-of-center elements as out of bounds. The
    // slide path then drags them leftward every frame Konva fires
    // the bound function — producing the cumulative "jump" symptom
    // (~60px drift per gesture observed) the user reported on
    // rotation.
    //
    // The translation here is symmetrical for oldBox and newBox so
    // the rotation-vs-scale branch detection (which compares
    // rotations between the two) is unaffected. The returned box
    // must be translated BACK to stage coords before returning to
    // Konva, which happens at each return point below.
    const x = newBox.x - LAYER_OFFSET_X;
    const y = newBox.y - LAYER_OFFSET_Y;
    const { width: w, height: h, rotation } = newBox;
    const oldBoxX = oldBox.x - LAYER_OFFSET_X;
    const oldBoxY = oldBox.y - LAYER_OFFSET_Y;

    // Pure-translation rotated AABB. Cheaper than four distinct points;
    // we lay them out with scalars to avoid array allocation.
    const cos = Math.cos(rotation);
    const sin = Math.sin(rotation);

    const c1x = x;                       const c1y = y;
    const c2x = x + w * cos;             const c2y = y + w * sin;
    const c4x = x - h * sin;             const c4y = y + h * cos;
    const c3x = c2x + (c4x - x);         const c3y = c2y + (c4y - y);

    let minX = c1x; if (c2x < minX) minX = c2x; if (c3x < minX) minX = c3x; if (c4x < minX) minX = c4x;
    let maxX = c1x; if (c2x > maxX) maxX = c2x; if (c3x > maxX) maxX = c3x; if (c4x > maxX) maxX = c4x;
    let minY = c1y; if (c2y < minY) minY = c2y; if (c3y < minY) minY = c3y; if (c4y < minY) minY = c4y;
    let maxY = c1y; if (c2y > maxY) maxY = c2y; if (c3y > maxY) maxY = c3y; if (c4y > maxY) maxY = c4y;

    // Hot path — fully inside the wrapper.
    if (
      minX >= WRAPPER_LEFT && maxX <= WRAPPER_RIGHT
      && minY >= WRAPPER_TOP && maxY <= WRAPPER_BOTTOM
    ) {
      // Debug overlay hook (see DebugOverlay.jsx) — boolean argument
      // is `rejected`, i.e. "did this call modify or revert the
      // user-proposed box?". Gated on the global so it's a no-op
      // when the overlay isn't mounted. Kept as instrumentation
      // because the rotation/clamp interaction is the most likely
      // re-failure surface and a future regression would be much
      // faster to diagnose if the counters are already wired.
      if (typeof window !== 'undefined' && window.__pawDebugBound) {
        window.__pawDebugBound.noteBoundCall(false);
      }
      return newBox;
    }

    const rotationChanged = Math.abs(oldBox.rotation - newBox.rotation) > 1e-3;

    if (rotationChanged) {
      // Rotation handle is being dragged. Try to slide the rotated AABB back
      // inside the wrapper; if it fits after the slide, return the slid box;
      // otherwise revert.
      let dx = 0, dy = 0;
      if (minX < WRAPPER_LEFT) dx = WRAPPER_LEFT - minX;
      else if (maxX > WRAPPER_RIGHT) dx = WRAPPER_RIGHT - maxX;
      if (minY < WRAPPER_TOP) dy = WRAPPER_TOP - minY;
      else if (maxY > WRAPPER_BOTTOM) dy = WRAPPER_BOTTOM - maxY;

      if (
        minX + dx >= WRAPPER_LEFT - BOUNDS_EPSILON && maxX + dx <= WRAPPER_RIGHT + BOUNDS_EPSILON
        && minY + dy >= WRAPPER_TOP - BOUNDS_EPSILON && maxY + dy <= WRAPPER_BOTTOM + BOUNDS_EPSILON
      ) {
        if (dx === 0 && dy === 0) {
          if (typeof window !== 'undefined' && window.__pawDebugBound) {
            window.__pawDebugBound.noteBoundCall(false);
          }
          return newBox;
        }
        // Slid the box back in. Reported as REJECTED (clamped) because
        // the user-proposed newBox was modified. The returned box is
        // translated BACK to stage coords because that's what Konva
        // expects from boundBoxFunc.
        if (typeof window !== 'undefined' && window.__pawDebugBound) {
          window.__pawDebugBound.noteBoundCall(true);
        }
        return {
          x: x + dx + LAYER_OFFSET_X,
          y: y + dy + LAYER_OFFSET_Y,
          width: w,
          height: h,
          rotation,
        };
      }
      // Couldn't fit — revert. Definitively rejected.
      if (typeof window !== 'undefined' && window.__pawDebugBound) {
        window.__pawDebugBound.noteBoundCall(true);
      }
      return oldBox;
    }

    // Scale handle (rotation unchanged). Use linear interpolation between
    // oldBox and newBox to find the largest t∈(0, 1] where the rotated AABB
    // stays in bounds. With constant rotation, every corner coordinate is
    // linear in t.
    //
    // oldBoxX/Y here are the LAYER-LOCAL (design coord) versions,
    // already translated at the top of the function. Same coord
    // system as c1..c4 above, so the linear-interpolation math
    // operates in design coords throughout.
    const oc1x = oldBoxX;                               const oc1y = oldBoxY;
    const oc2x = oldBoxX + oldBox.width * cos;          const oc2y = oldBoxY + oldBox.width * sin;
    const oc4x = oldBoxX - oldBox.height * sin;         const oc4y = oldBoxY + oldBox.height * cos;
    const oc3x = oc2x + (oc4x - oldBoxX);               const oc3y = oc2y + (oc4y - oldBoxY);

    let maxT = 1;
    // For each corner, each axis: find the t at which the linear interpolation
    // crosses the wrapper boundary, keep the smallest valid one.
    const checkCorner = (oa, na, limit) => {
      const delta = na - oa;
      if (Math.abs(delta) < 1e-3) return;
      const t = (limit - oa) / delta;
      if (t >= 0 && t < maxT) maxT = t;
    };
    if (c1x < WRAPPER_LEFT) checkCorner(oc1x, c1x, WRAPPER_LEFT); else if (c1x > WRAPPER_RIGHT) checkCorner(oc1x, c1x, WRAPPER_RIGHT);
    if (c2x < WRAPPER_LEFT) checkCorner(oc2x, c2x, WRAPPER_LEFT); else if (c2x > WRAPPER_RIGHT) checkCorner(oc2x, c2x, WRAPPER_RIGHT);
    if (c3x < WRAPPER_LEFT) checkCorner(oc3x, c3x, WRAPPER_LEFT); else if (c3x > WRAPPER_RIGHT) checkCorner(oc3x, c3x, WRAPPER_RIGHT);
    if (c4x < WRAPPER_LEFT) checkCorner(oc4x, c4x, WRAPPER_LEFT); else if (c4x > WRAPPER_RIGHT) checkCorner(oc4x, c4x, WRAPPER_RIGHT);
    if (c1y < WRAPPER_TOP)  checkCorner(oc1y, c1y, WRAPPER_TOP);  else if (c1y > WRAPPER_BOTTOM) checkCorner(oc1y, c1y, WRAPPER_BOTTOM);
    if (c2y < WRAPPER_TOP)  checkCorner(oc2y, c2y, WRAPPER_TOP);  else if (c2y > WRAPPER_BOTTOM) checkCorner(oc2y, c2y, WRAPPER_BOTTOM);
    if (c3y < WRAPPER_TOP)  checkCorner(oc3y, c3y, WRAPPER_TOP);  else if (c3y > WRAPPER_BOTTOM) checkCorner(oc3y, c3y, WRAPPER_BOTTOM);
    if (c4y < WRAPPER_TOP)  checkCorner(oc4y, c4y, WRAPPER_TOP);  else if (c4y > WRAPPER_BOTTOM) checkCorner(oc4y, c4y, WRAPPER_BOTTOM);

    if (maxT < 1e-3) {
      if (typeof window !== 'undefined' && window.__pawDebugBound) {
        window.__pawDebugBound.noteBoundCall(true);
      }
      return oldBox;
    }

    if (typeof window !== 'undefined' && window.__pawDebugBound) {
      window.__pawDebugBound.noteBoundCall(maxT < 0.999);
    }
    // Interpolated return — interpolate in STAGE coords (the original
    // box space) since that's what Konva expects. The maxT scalar is
    // the same in either coord system because the layer offset is a
    // constant translation that doesn't affect the interpolation
    // parameter — only the bounds-vs-corners comparison needed the
    // translation, and that's already done above.
    return {
      x: oldBox.x + (newBox.x - oldBox.x) * maxT,
      y: oldBox.y + (newBox.y - oldBox.y) * maxT,
      width: oldBox.width + (newBox.width - oldBox.width) * maxT,
      height: oldBox.height + (newBox.height - oldBox.height) * maxT,
      rotation,
    };
  }, []);

  // dragBoundFunc — runs continuously while dragging.
  // Regular function (not arrow) so Konva can bind `this` to the dragged node;
  // we read width/height/rotation from `this` to compute the rotated AABB.
  // Allocation-free except for the returned position object and the rare
  // setSnapState call when a snap transition occurs.
  const canvasDragBound = useCallback(function (pos) {
    // Per-element visible bbox in node-local coords. TextElement attaches
    // _pawSnapBbox{Min,Max}{X,Y} as custom Konva attrs encoding the actual
    // rendered glyph extent — NOT Konva.Text's line-height bbox, which
    // includes empty space above the baseline that the user doesn't see.
    // ImageElement doesn't set these attrs, so falls through to
    // (0, 0, this.width(), this.height()), giving the same behavior as
    // before for images (Konva.Image's getters are pixel-exact).
    //
    // The math below was previously hard-coded to corners (0, 0), (w, 0),
    // (w, h), (0, h) under the assumption that every element's bbox
    // started at the node origin. That broke once we needed to handle
    // bboxes whose minY is non-zero (text's baseline offset) or whose
    // minY is negative (curve-up arc'd text extending above y=0). The
    // generalized form below computes all four rotated corners explicitly
    // from arbitrary (xMin, yMin) to (xMax, yMax), which collapses to
    // the old behavior in the all-zero-min case.
    const xMin = this.attrs._pawSnapBboxMinX ?? 0;
    const yMin = this.attrs._pawSnapBboxMinY ?? 0;
    const xMax = this.attrs._pawSnapBboxMaxX ?? this.width();
    const yMax = this.attrs._pawSnapBboxMaxY ?? this.height();
    const rad = (this.rotation() * Math.PI) / 180;
    const cos = Math.cos(rad);
    const sin = Math.sin(rad);

    // Four corners of the bbox (xMin, yMin) to (xMax, yMax), rotated by
    // the node's rotation around the local origin. Standard 2D rotation:
    // (x', y') = (x·cos - y·sin, x·sin + y·cos).
    const c1x = xMin * cos - yMin * sin;  const c1y = xMin * sin + yMin * cos;
    const c2x = xMax * cos - yMin * sin;  const c2y = xMax * sin + yMin * cos;
    const c3x = xMax * cos - yMax * sin;  const c3y = xMax * sin + yMax * cos;
    const c4x = xMin * cos - yMax * sin;  const c4y = xMin * sin + yMax * cos;

    let minX = c1x; if (c2x < minX) minX = c2x; if (c3x < minX) minX = c3x; if (c4x < minX) minX = c4x;
    let maxX = c1x; if (c2x > maxX) maxX = c2x; if (c3x > maxX) maxX = c3x; if (c4x > maxX) maxX = c4x;
    let minY = c1y; if (c2y < minY) minY = c2y; if (c3y < minY) minY = c3y; if (c4y < minY) minY = c4y;
    let maxY = c1y; if (c2y > maxY) maxY = c2y; if (c3y > maxY) maxY = c3y; if (c4y > maxY) maxY = c4y;

    // Bound clamping — clamp the rotated AABB to the WRAPPER (visible
    // shirt) instead of the print zone. The user can drag elements
    // anywhere on the shirt; the out-of-bounds warning still fires when
    // they extend past [0, CANVAS_SIZE]² (handled by countOutOfBounds
    // in App.jsx / elementBounds.js).
    const boundedX = Math.max(
      LAYER_OFFSET_X + WRAPPER_LEFT - minX,
      Math.min(pos.x, LAYER_OFFSET_X + WRAPPER_RIGHT - maxX)
    );
    const boundedY = Math.max(
      LAYER_OFFSET_Y + WRAPPER_TOP - minY,
      Math.min(pos.y, LAYER_OFFSET_Y + WRAPPER_BOTTOM - maxY)
    );

    // Snap detection. The element's center in design coords is the
    // bounded stage-coord position minus the layer offset plus the AABB
    // midpoint. If close to the canvas centerline, pull to it; flag the
    // corresponding guide; only update React state on transitions to
    // avoid per-frame renders. The snap target is the PRINT-ZONE center
    // (CANVAS_CENTER_DESIGN) — alignment lines are most useful relative
    // to the design area, even when the user can position elsewhere on
    // the shirt.
    //
    // Early-out when snapEnabled is false: we still need to clear any
    // lingering snap state (if the user disabled snap mid-drag while a
    // guide was visible) but we skip the pull and the threshold check.
    if (!snapEnabledRef.current) {
      if (snapVRef.current || snapHRef.current) {
        snapVRef.current = false;
        snapHRef.current = false;
        setSnapState({ v: false, h: false });
      }
      return { x: boundedX, y: boundedY };
    }

    const centerDX = boundedX - LAYER_OFFSET_X + (minX + maxX) * 0.5;
    const centerDY = boundedY - LAYER_OFFSET_Y + (minY + maxY) * 0.5;
    const vSnap = Math.abs(centerDX - CANVAS_CENTER_DESIGN) < SNAP_THRESHOLD;
    const hSnap = Math.abs(centerDY - CANVAS_CENTER_DESIGN) < SNAP_THRESHOLD;
    const finalX = vSnap ? boundedX + (CANVAS_CENTER_DESIGN - centerDX) : boundedX;
    const finalY = hSnap ? boundedY + (CANVAS_CENTER_DESIGN - centerDY) : boundedY;
    if (vSnap !== snapVRef.current || hSnap !== snapHRef.current) {
      snapVRef.current = vSnap;
      snapHRef.current = hSnap;
      setSnapState({ v: vSnap, h: hSnap });
    }

    return { x: finalX, y: finalY };
  }, []);
  // ───────────────────────────────────────────────────────────────────────
  // END PERFORMANCE-CRITICAL HOT PATH
  // ───────────────────────────────────────────────────────────────────────

  // Print zone position as percentage of wrapper. With aspect-ratio-locked
  // wrapper these are constant regardless of rendered size, so we can
  // position the Stage purely with CSS percentages — no pixel arithmetic.
  const printZoneXPct = (printZone.x / svgW) * 100;
  const printZoneYPct = (printZone.y / svgH) * 100;
  const printZoneWPct = (printZone.width / svgW) * 100;
  const printZoneHPct = (printZone.height / svgH) * 100;

  // CSS-only zoom: scaling the *wrapper* (rather than the Konva stage itself)
  // keeps every coordinate-space calculation identical to its un-zoomed form.
  // The hot-path bound functions, drag-end x/y values, transformer corner
  // positions — all of them stay in the canonical 300×300 coordinate system.
  // Konva is happy, the export pipeline is happy, and the user gets a smooth
  // GPU-accelerated zoom.
  //
  // Width/height are pixel-explicit (not aspect-ratio + auto) so we can
  // animate them via CSS transition. The aspect ratio is enforced by
  // the layout effect's compute(), so the rendered shape is always correct.
  // The zoom transform is composed AFTER the size, so a non-1 zoom still
  // visually scales the (already-fitted) wrapper around its center.
  const wrapperStyle = {
    width: `${layout.width}px`,
    height: `${layout.height}px`,
    ...(zoom !== 1 ? { transform: `scale(${zoom})` } : null),
  };

  // Stage CSS top-left lands HANDLE_PADDING * visualScale before the
  // wrapper's top-left (in CSS px), so the stage's internal
  // (LAYER_OFFSET_X, LAYER_OFFSET_Y) point — i.e. design (0, 0) —
  // aligns with the print zone's top-left within the wrapper. Stage
  // internal size = full wrapper + HANDLE_PADDING on each side, in
  // design units; the CSS scale converts to DOM px.
  const padOffsetPx = HANDLE_PADDING * layout.stageVisualScale;
  const stageStyle = {
    position: 'absolute',
    left: `${-padOffsetPx}px`,
    top: `${-padOffsetPx}px`,
    transform: `scale(${layout.stageVisualScale})`,
    transformOrigin: 'top left',
    zIndex: 2,
  };

  const borderStyle = {
    position: 'absolute',
    left: `${printZoneXPct}%`,
    top: `${printZoneYPct}%`,
    width: `${printZoneWPct}%`,
    height: `${printZoneHPct}%`,
    pointerEvents: 'none',
    zIndex: 3,
  };

  return (
    <div
      className={`design-canvas-frame${isReady ? ' design-canvas-frame--ready' : ''}`}
      ref={frameRef}
    >
      {showHint && <CanvasHint />}
      <div
        className="design-canvas-wrapper"
        ref={wrapperRef}
        style={wrapperStyle}
      >
        <TShirtSVG color={shirtColor} />

        {/* Selection-feedback dashed border — sits exactly on the print zone
            (not the Stage's HANDLE_PADDING-inflated bounds), so the user
            sees "this is your design area" not "this is the transformer
            handle reach". Renders the highlighted variant whenever ANY
            element is selected (single or multi). */}
        <div
          className={`design-canvas-border${(selectedId || selectedIds.size > 0) ? ' selected' : ''}`}
          style={borderStyle}
        />

        <Stage
          width={STAGE_W} height={STAGE_H}
          /* Stage event wiring. onClick handles the click-deselect flow
           * (with marquee-drag suppression baked in via
           * handleStageClick). Mouse-down/move/up drive the marquee
           * drag-select. Mouse-leave abandons an in-progress marquee
           * if the cursor exits the stage. onTap keeps the existing
           * tap-to-deselect path for touch devices, which don't fire
           * mousedown/mouseup. */
          onClick={handleStageClick} onTap={onDeselect}
          onMouseDown={handleStageMouseDown}
          onMouseMove={handleStageMouseMove}
          onMouseUp={handleStageMouseUp}
          onMouseLeave={handleStageMouseLeave}
          onDragEnd={clearSnap}
          className="design-canvas-stage"
          style={stageStyle}
          // Expose Konva's stage container DOM node to the parent via the
          // optional `stageContainerRef` prop. App.jsx uses it to detect
          // "this click landed on the Konva stage" in its document-level
          // mousedown handler — a precise replacement for the brittle
          // `target.tagName === 'CANVAS'` check.
          ref={(stage) => {
            if (stageContainerRef && stage) {
              try {
                stageContainerRef.current = stage.container();
              } catch {
                stageContainerRef.current = null;
              }
            }
          }}
        >
          <Layer x={LAYER_OFFSET_X} y={LAYER_OFFSET_Y}>
            {currentTemplate && <TemplateLayer template={currentTemplate} canvasSize={CANVAS_SIZE} />}
          </Layer>
          <Layer x={LAYER_OFFSET_X} y={LAYER_OFFSET_Y} listening={false}>
            {slots.map((slot) => <SlotBoundsGuide key={slot.id} slot={slot} />)}
          </Layer>
          {/* Slot placeholders render BELOW user elements so any
              element placed over a slot visually obscures the slot's
              dashed outline and intercepts clicks first. The layer
              listens (no `listening={false}`) so empty placeholders
              can fire their own onClick to open the file picker. */}
          <Layer x={LAYER_OFFSET_X} y={LAYER_OFFSET_Y}>
            {slots.map((slot) => (
              <SlotPlaceholder
                key={slot.id}
                slot={slot}
                isEmpty={!occupiedSlotIds.has(slot.id)}
                isLoading={loadingSlotId === slot.id}
                onClick={() => handleSlotClick(slot.id)}
              />
            ))}
          </Layer>

          <Layer ref={elementsLayerRef} x={LAYER_OFFSET_X} y={LAYER_OFFSET_Y}>
            {elements.map((el) => {
              // `isSingleSelected` retained for parity with the old
              // per-element `isSelected` prop. The element components
              // ignore it after the bug 8 refactor (the dedicated
              // single-Transformer layer attaches via findOne by id),
              // but we keep computing + passing it so this map's body
              // stays compatible with any future re-introduction of
              // per-element selection chrome, and so the downstream
              // ImageElement / TextElement signatures remain stable.
              const isSingleSelected = selectedIds.size > 0
                ? (selectedIds.size === 1 && selectedIds.has(el.id))
                : (el.id === selectedId);
              // Stickers are images schema-wise (data: URL src, width/height,
              // optional crop) but tagged separately so type-based gates
              // work cleanly elsewhere. Both render through ImageElement.
              if (el.type === 'image' || el.type === 'sticker') {
                // During crop mode, the targeted element renders
                // without selection chrome — no Transformer handles,
                // no out-of-bounds warning (the user is mid-edit;
                // showing a separate warning is noise). The dim mask
                // from CropOverlay sits on top of the element's
                // unmasked area so the visual emphasis is on the
                // crop, not the selection.
                const isCropTarget = cropping?.elementId === el.id;
                return (
                  <ImageElement key={el.id} id={el.id} x={el.x} y={el.y} width={el.width} height={el.height}
                    rotation={el.rotation} src={el.src} crop={el.crop}
                    opacity={el.opacity} flipX={el.flipX} flipY={el.flipY}
                    locked={el.locked || isCropTarget}
                    filter={el.filter}
                    brightness={el.brightness}
                    contrast={el.contrast}
                    isSelected={isSingleSelected && !isCropTarget}
                    isOutOfBounds={outOfBoundsIds.has(el.id) && !isCropTarget}
                    shiftHeld={shiftHeld}
                    /* Click-to-select only — we deliberately don't toggle deselection on
                     * re-click of the already-selected element. Doing so produced a
                     * confusing failure mode where, after closing the photo editor, the
                     * user's first click on their photo would silently deselect it
                     * (because it was still selected from before opening the editor).
                     * Deselect is still reachable: click background, press Escape, or
                     * select another element. handleElementSelect routes through to
                     * onSelect or onToggleSelect depending on shift state. */
                    onSelect={() => handleElementSelect(el.id)}
                    onUpdate={(attrs) => onUpdate(el.id, attrs)}
                    onCommit={onCommit}
                    dragBoundFunc={canvasDragBound}
                    transformBoundFunc={constrainTransform}
                  />
                );
              }
              if (el.type === 'text') {
                return (
                  <TextElement key={el.id} id={el.id} x={el.x} y={el.y} text={el.text} fontSize={el.fontSize}
                    fontFamily={el.fontFamily} fill={el.fill}
                    stroke={el.stroke} strokeWidth={el.strokeWidth}
                    rotation={el.rotation}
                    opacity={el.opacity} flipX={el.flipX} flipY={el.flipY}
                    arc={el.arc}
                    locked={el.locked}
                    isSelected={isSingleSelected}
                    isOutOfBounds={outOfBoundsIds.has(el.id)}
                    shiftHeld={shiftHeld}
                    onSelect={() => handleElementSelect(el.id)}
                    onUpdate={(attrs) => onUpdate(el.id, attrs)}
                    onCommit={onCommit}
                    onStartEdit={onStartTextEdit}
                    dragBoundFunc={canvasDragBound}
                    transformBoundFunc={constrainTransform}
                  />
                );
              }
              return null;
            })}
          </Layer>

          {/* Dedicated transformer layer (bug 8 fix).
           *
           * Renders ABOVE the elements layer so resize / rotate handles
           * always sit on top of every element regardless of array order
           * (which corresponds to Konva z-order). Previously each element
           * rendered its own <Transformer> as a sibling inside the
           * elements layer; later elements in the array drew over the
           * transformer of earlier elements, hiding handles for any
           * element that wasn't on top.
           *
           * Two Transformers share this layer:
           *
           *   • The single-select one attaches to the one selected node
           *     (via the singleTransformerRef useEffect above). Its
           *     boundBoxFunc enforces type-specific min-size: image /
           *     sticker elements get the MIN_ELEMENT_SIZE floor that
           *     used to live in ImageElement; text doesn't (it commits
           *     a MIN_FONT_SIZE floor in onTransformEnd instead, so a
           *     bbox-size gate during the drag would actively get in
           *     the way for small text).
           *
           *   • The shared multi-select one wires nodes() from the
           *     selectedIds Set. Its boundBox keeps the MIN_ELEMENT_SIZE
           *     guard unconditionally because group transforms aren't
           *     a great moment to discover one of the group is text.
           *
           * Both share the elements layer's offset so handle positions
           * line up with the visible element bboxes — Konva's transformer
           * geometry is computed in the attached node's coordinate
           * space, but rendered in the Transformer's own layer, so
           * matching offsets is what makes "the handles appear where
           * the element is" without further translation.
           */}
          <Layer x={LAYER_OFFSET_X} y={LAYER_OFFSET_Y}>
            <Transformer
              ref={singleTransformerRef}
              enabledAnchors={CORNER_ANCHORS}
              keepRatio
              resizeEnabled={!singleSelectedEl?.locked}
              rotateEnabled={!singleSelectedEl?.locked}
              rotationSnaps={shiftHeld ? ROTATION_SNAPS_15 : []}
              rotationSnapTolerance={ROTATION_SNAP_TOLERANCE}
              boundBoxFunc={(oldBox, newBox) => {
                const t = singleSelectedEl?.type;
                const isImg = t === 'image' || t === 'sticker';
                if (isImg && (Math.abs(newBox.width) < MIN_ELEMENT_SIZE || Math.abs(newBox.height) < MIN_ELEMENT_SIZE)) {
                  return oldBox;
                }
                return constrainTransform(oldBox, newBox);
              }}
              {...TRANSFORMER_VISUAL_STYLE}
              borderDash={singleSelectedEl?.locked ? [4, 4] : undefined}
            />
            {/* Shared multi-select Transformer. Always mounted so its ref
                stays stable across renders; nodes() is wired by the
                useEffect above whenever selectedIds changes (and is set
                to [] when selectedIds.size < 2, so it renders nothing
                in the single-select / none case). When active, it wraps
                every selected node's getClientRect and exposes corner
                handles that scale/rotate the whole group proportionally.
                Per-element onTransformEnd handlers fire on each child
                node and commit their individual updates; the debounce
                in useDesignEditor collapses them into one history entry.
                boundBoxFunc enforces wrapper bounds via the same
                constrainTransform used by the single-select Transformer. */}
            <Transformer
              ref={multiTransformerRef}
              enabledAnchors={CORNER_ANCHORS}
              keepRatio
              rotateEnabled
              rotationSnaps={shiftHeld ? ROTATION_SNAPS_15 : []}
              rotationSnapTolerance={ROTATION_SNAP_TOLERANCE}
              boundBoxFunc={(oldBox, newBox) => {
                if (Math.abs(newBox.width) < MIN_ELEMENT_SIZE || Math.abs(newBox.height) < MIN_ELEMENT_SIZE) return oldBox;
                return constrainTransform(oldBox, newBox);
              }}
              {...TRANSFORMER_VISUAL_STYLE}
            />
          </Layer>
          {/* Snap-guide layer — dashed pink lines indicating which canvas
              axis the dragged element is currently aligned to. Listening
              is disabled so the guides never intercept events; they're
              purely visual. The two Lines render only when their snap
              flag is true, so when nothing is dragged or aligned the layer
              draws nothing. (Fix for #21.) */}
          <Layer x={LAYER_OFFSET_X} y={LAYER_OFFSET_Y} listening={false}>
            {snapState.v && (
              <Line
                points={[CANVAS_CENTER_DESIGN, 0, CANVAS_CENTER_DESIGN, CANVAS_SIZE]}
                stroke="#ec4899"
                strokeWidth={1}
                dash={[4, 4]}
                opacity={0.85}
              />
            )}
            {snapState.h && (
              <Line
                points={[0, CANVAS_CENTER_DESIGN, CANVAS_SIZE, CANVAS_CENTER_DESIGN]}
                stroke="#ec4899"
                strokeWidth={1}
                dash={[4, 4]}
                opacity={0.85}
              />
            )}
          </Layer>
          {/* Marquee selection rect (S3). Rendered on a top-most layer
              above all elements and the shared Transformer. Coords are
              in stage internal space (NOT design space), so this layer
              has no LAYER_OFFSET. listening={false} since the marquee
              is a purely visual overlay and must not intercept the
              mousemove events that drive its own updates. Conditional
              render keeps the layer absent when no drag is in progress
              — lighter than maintaining an invisible Rect. */}
          {marquee && (
            <Layer listening={false}>
              <Rect
                x={Math.min(marquee.x1, marquee.x2)}
                y={Math.min(marquee.y1, marquee.y2)}
                width={Math.abs(marquee.x2 - marquee.x1)}
                height={Math.abs(marquee.y2 - marquee.y1)}
                fill={MARQUEE_FILL}
                stroke={MARQUEE_STROKE}
                strokeWidth={1}
                dash={[4, 4]}
              />
            </Layer>
          )}
          {/* Crop overlay (image-only). Rendered as the ABSOLUTE LAST
              Layer in the Stage so it sits above everything else —
              including the snap-guides and marquee layers. Each Konva
              Layer is a separate <canvas> DOM element; the topmost
              listening canvas is what the browser dispatches mouse
              events to first. Even though the snap-guides and marquee
              layers have listening={false} (which sets pointer-events:
              none on their canvas elements), there were intermittent
              hit-test issues when the crop overlay sat below them in
              z-order — likely related to Konva's hit-canvas being
              cached per-layer. Putting the crop overlay last
              eliminates the question entirely: clicks on its anchors
              hit its canvas directly without traversing any
              listening=false layer above.

              CropOverlay's own Layer carries the same offset as the
              elements layer so the crop rect's x/y are in canonical
              0..CANVAS_SIZE design coords, matching the image
              element's x/y/width/height. */}
          {cropping && (
            <CropOverlay
              element={elements.find((e) => e.id === cropping.elementId)}
              cropRect={cropping.rect}
              onCropRectChange={cropping.onRectChange}
              layerX={LAYER_OFFSET_X}
              layerY={LAYER_OFFSET_Y}
            />
          )}
        </Stage>
      </div>

      {/* Hidden file input wired up to the empty-slot click target.
          Lives inside the canvas frame (rather than at the App level)
          so the click → picker round-trip is local to this component
          and doesn't require plumbing a ref through the tree. The
          `onChange` handler routes the picked file's data URL back to
          the parent's onSlotImageUpload callback. */}
      <input
        ref={slotFileInputRef}
        type="file"
        accept="image/*"
        onChange={handleSlotFile}
        style={{ display: 'none' }}
        aria-hidden="true"
        tabIndex={-1}
      />

      {/* Bounds-violation warning. Sits at the bottom of the canvas frame,
          "under the graphic" so the user sees it in their main field of
          view as soon as something extends past the print area. The cart
          button in the right panel is independently disabled (App.jsx →
          Sidebar) using the same outOfBoundsCount value, so the warning
          here is informational — not the only enforcement. */}
      {outOfBoundsCount > 0 && (
        <div className="design-canvas-warning" role="status" aria-live="polite">
          <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
            <line x1="12" y1="9" x2="12" y2="13" />
            <line x1="12" y1="17" x2="12.01" y2="17" />
          </svg>
          <span>
            {outOfBoundsCount === 1
              ? '1 element is outside the print area and won\u2019t be printed.'
              : `${outOfBoundsCount} elements are outside the print area and won\u2019t be printed.`}
          </span>
        </div>
      )}
    </div>
  );
});
