import { useEffect, useRef, useState } from 'react';
import Konva from 'konva';

/**
 * Position / transform debug overlay.
 *
 * What this is
 * ────────────
 * A developer-only HUD that reports the selected element's live
 * coordinate state in a fixed bottom-left panel. Built originally
 * for diagnosing a rotation-pivot bug where bbox-clamping in
 * `constrainTransform` operated on a different coord system than
 * the wrapper bounds it was comparing against (stage vs design),
 * producing per-frame drift on rotation gestures near canvas edges.
 * The bug is fixed; the overlay is kept as a permanent diagnostic
 * surface since rotation/clamp/offset interactions are the most
 * likely re-failure area as the editor evolves and a future
 * regression will be much faster to diagnose if the readouts are
 * already wired up.
 *
 * What it shows
 * ─────────────
 * Four sections, all updated on requestAnimationFrame while mounted:
 *
 *   • element (state)  — React state for the selected element. The
 *     numbers fed BACK into the next render. If state and node
 *     disagree about position/rotation, the next paint will jump.
 *
 *   • node (live konva) — the live Konva node's attrs. node.x/y is
 *     the pivot position in layer-local coords (with always-center
 *     offset, this equals element.x + W/2, element.y + H/2 by
 *     construction). Offset should be glued to (W/2, H/2).
 *
 *   • derived — the comparison section. `visual TL (konva)` is the
 *     ground truth: where Konva's own getClientRect says the visible
 *     top-left lands. `simple formula` is what unflipReportedXY
 *     computes when committing position changes. `rot-aware` is
 *     what the formula would be if you naively rotated the offset
 *     vector before subtracting (kept around as a foil to remind
 *     you the simple formula is the round-trip-correct one, even
 *     though it doesn't match visual TL in any obvious way).
 *
 *   • last gesture — summary of the most recent drag/transform. Most
 *     useful field is `bound calls (N clamped)`: if N > 0 during a
 *     gesture that should have been free, the wrapper clamp is
 *     interfering and the user will see drift.
 *
 * Activation
 * ──────────
 * Two ways to turn it on, neither requiring a code edit:
 *
 *   • URL: append `?debug=1` to the editor URL.
 *   • LocalStorage: `localStorage.setItem('paw_debug_overlay', '1')`,
 *     then reload.
 *
 * The URL path also persists to localStorage so subsequent reloads
 * keep it on without re-appending the param. The on-panel ×
 * button clears both and reloads.
 *
 * The entire overlay component is gated behind a `DEBUG_OVERLAY_ENABLED`
 * constant in App.jsx (read once at module load). When the flag is
 * off, the conditional render short-circuits and this file is dead
 * code from the user's perspective; the bundler will not tree-shake
 * the import itself, but the component never mounts and the only
 * runtime cost is the import being parsed.
 *
 * UX
 * ───
 * Fixed bottom-left, semi-transparent dark panel with mono-font
 * readouts. A small "×" closes it for the rest of the session
 * (clears the URL param via history.replaceState and the
 * localStorage key, then reloads). A "copy" button next to the
 * close emits a markdown-formatted snapshot to the clipboard for
 * paste-into-chat diagnostics. The panel doesn't intercept canvas
 * pointer events outside its own box, so it never gets in the way
 * of an interaction.
 *
 * Performance
 * ──────────
 * Polls the Konva node every animation frame while mounted. That's
 * fine for a developer overlay — adds one rAF per frame on top of
 * Konva's own rendering, well below the budget — but it's the reason
 * the component is opt-in. We deliberately don't subscribe to Konva
 * events for the rAF poll, because we want the readouts to update
 * during continuous transforms, when no React state changes are
 * firing. (Gesture event subscriptions DO exist, separately, to
 * capture transform start/move/end snapshots for the "last gesture"
 * summary.)
 */
export function DebugOverlay({ element, stageContainerRef }) {
  // Live readouts. Updated on each rAF tick while mounted; null when
  // there's no element selected or no node found.
  const [readout, setReadout] = useState(null);
  const rafRef = useRef(null);

  // Gesture-tracking state. Captured by listeners on the Konva node's
  // transformstart / transform / transformend events. Refs (not
  // state) because we want the per-frame `transform` handler to be
  // allocation-light — only the React state we render from updates
  // when there's meaningful new info.
  //
  // Shape:
  //   gestureRef.current = {
  //     startedAt:  Date.now() at transformstart
  //     startState: { element snapshot, node snapshot, visual TL }
  //     firstFrame: same shape, captured on FIRST transform event
  //                 (i.e. the very first frame after start)
  //     lastFrame:  same shape, captured on EVERY transform event
  //     endState:   captured on transformend
  //     boundRejects: number of times constrainTransform returned
  //                   oldBox during this gesture (i.e. clamped)
  //     boundCalls:   total number of constrainTransform calls
  //   }
  //
  // The overlay displays the latest completed gesture's delta plus a
  // running count of bound-rejects on the active gesture. That's
  // enough to distinguish "first-frame jump" from "clamp-induced
  // jump" from "transform-end round-trip jump".
  const gestureRef = useRef(null);
  const [gestureSummary, setGestureSummary] = useState(null);

  // Hook the active Konva node's transform events. Re-runs whenever
  // the selected element changes (so we re-attach to the new node).
  // We use a separate effect from the rAF poll so the event
  // subscription doesn't churn when the rAF restarts.
  useEffect(() => {
    if (!element) return undefined;

    const stageContainer = stageContainerRef?.current;
    if (!stageContainer) return undefined;

    // Resolve the node. If it isn't mounted yet (e.g. a freshly-added
    // element that hasn't hit Konva's render pass), retry on the next
    // frame. The retry loop is bounded by the effect's cleanup.
    let cancelled = false;
    let retryRaf = null;
    let attachedNode = null;

    const snapshot = (node) => {
      // Best-effort snapshot of everything we care about. Returns null
      // if the node has vanished (e.g. element deleted mid-gesture);
      // callers tolerate null.
      if (!node) return null;
      const layer = node.getLayer?.();
      let clientRect = null;
      try {
        clientRect = layer
          ? node.getClientRect({ relativeTo: layer })
          : node.getClientRect();
      } catch { /* ignore */ }
      return {
        t: Date.now(),
        elementX: element.x ?? 0,
        elementY: element.y ?? 0,
        elementRotation: element.rotation ?? 0,
        nodeX: node.x(),
        nodeY: node.y(),
        nodeRotation: node.rotation(),
        offsetX: node.offsetX(),
        offsetY: node.offsetY(),
        visualX: clientRect?.x,
        visualY: clientRect?.y,
        visualW: clientRect?.width,
        visualH: clientRect?.height,
      };
    };

    const onStart = () => {
      gestureRef.current = {
        startedAt: Date.now(),
        startState: snapshot(attachedNode),
        firstFrame: null,
        lastFrame: null,
        endState: null,
        boundRejects: 0,
        boundCalls: 0,
      };
    };

    const onTransform = () => {
      const g = gestureRef.current;
      if (!g) return;
      const snap = snapshot(attachedNode);
      if (!g.firstFrame) g.firstFrame = snap;
      g.lastFrame = snap;
    };

    const onEnd = () => {
      const g = gestureRef.current;
      if (!g) return;
      g.endState = snapshot(attachedNode);
      // Materialize a frozen summary for the overlay to render. The
      // ref-tracked gestureRef gets reset on the next gesture's start.
      setGestureSummary({
        durationMs: g.endState ? g.endState.t - g.startedAt : null,
        boundCalls: g.boundCalls,
        boundRejects: g.boundRejects,
        startState: g.startState,
        firstFrame: g.firstFrame,
        endState: g.endState,
      });
    };

    const tryAttach = () => {
      if (cancelled) return;
      const stage = (() => {
        if (Konva && Array.isArray(Konva.stages)) {
          for (const s of Konva.stages) {
            try { if (s.container() === stageContainer) return s; } catch { /* ignore */ }
          }
        }
        return null;
      })();
      const node = stage?.findOne('.' + element.id);
      if (!node) {
        retryRaf = requestAnimationFrame(tryAttach);
        return;
      }
      attachedNode = node;
      // Namespace events with .pawdbg so we can off() them cleanly
      // without disturbing anything else Konva or the app has
      // attached.
      node.on('transformstart.pawdbg', onStart);
      node.on('transform.pawdbg', onTransform);
      node.on('transformend.pawdbg', onEnd);
      // Drag events too — the same "jump" symptom could appear on
      // drag if the bound function clamps an early frame.
      node.on('dragstart.pawdbg', onStart);
      node.on('dragmove.pawdbg', onTransform);
      node.on('dragend.pawdbg', onEnd);
    };
    tryAttach();

    return () => {
      cancelled = true;
      if (retryRaf != null) cancelAnimationFrame(retryRaf);
      if (attachedNode) {
        try {
          attachedNode.off('transformstart.pawdbg');
          attachedNode.off('transform.pawdbg');
          attachedNode.off('transformend.pawdbg');
          attachedNode.off('dragstart.pawdbg');
          attachedNode.off('dragmove.pawdbg');
          attachedNode.off('dragend.pawdbg');
        } catch { /* ignore */ }
      }
    };
  }, [element, stageContainerRef]);

  // Bound-function instrumentation. We expose a global hook the
  // production canvasDragBound / constrainTransform can call into IF
  // the debug overlay has set window.__pawDebugBound. DesignCanvas
  // checks the global before each bound-call increment, so the
  // instrumentation is a no-op when the overlay isn't mounted.
  //
  // The hook surface is intentionally minimal: a single boolean
  // `rejected` argument indicating whether constrainTransform
  // modified or reverted the user-proposed box. Per-call payloads
  // were tried during the original diagnostic session but added
  // allocation pressure to the bound hot path with little additional
  // diagnostic value over the count-of-clamps. If a future
  // investigation needs more detail it can be re-added — the
  // existing hook is a stable insertion point.
  //
  // Why a global rather than React context: the bound functions live
  // inside DesignCanvas's useCallback with an empty dep array (Konva
  // binds them by identity at mount time, and changing identity would
  // force a re-attach). Reading the latest hook ref through a global
  // avoids both the re-bind cost and the gymnastics of passing a ref
  // through props.
  useEffect(() => {
    if (typeof window === 'undefined') return undefined;
    window.__pawDebugBound = {
      noteBoundCall: (rejected) => {
        const g = gestureRef.current;
        if (!g) return;
        g.boundCalls += 1;
        if (rejected) g.boundRejects += 1;
      },
    };
    return () => {
      delete window.__pawDebugBound;
    };
  }, []);

  useEffect(() => {
    let cancelled = false;

    // Resolve the Konva stage owning our container. Same pattern used
    // by TextEditAffordance and the crop handler in App.jsx.
    const resolveStage = () => {
      const container = stageContainerRef?.current;
      if (!container) return null;
      if (Konva && Array.isArray(Konva.stages)) {
        for (const s of Konva.stages) {
          try { if (s.container() === container) return s; } catch { /* ignore */ }
        }
      }
      const canvas = container.querySelector?.('canvas');
      return canvas?._konvaNode?.getStage?.() ?? null;
    };

    const tick = () => {
      if (cancelled) return;

      if (!element) {
        setReadout(null);
        rafRef.current = requestAnimationFrame(tick);
        return;
      }

      const stage = resolveStage();
      const node = stage?.findOne('.' + element.id);

      if (!node) {
        // No node — but we still have an element. Element data alone
        // is useful; mark the node-derived fields as missing.
        setReadout({
          elementId: element.id,
          element: pickElementFields(element),
          node: null,
          derived: null,
        });
        rafRef.current = requestAnimationFrame(tick);
        return;
      }

      // Node-reported values. All of these come straight from Konva's
      // attrs — node.x() / .y() / .rotation() / .offsetX() / .offsetY()
      // / .scaleX() / .scaleY() / .width() / .height() are accessors.
      const nodeX = node.x();
      const nodeY = node.y();
      const nodeW = node.width?.() ?? 0;
      const nodeH = node.height?.() ?? 0;
      const rotDeg = node.rotation();
      const offsetX = node.offsetX();
      const offsetY = node.offsetY();
      const scaleX = node.scaleX();
      const scaleY = node.scaleY();

      // Stage-coord visual AABB. Definitive "where is this element on
      // screen" — Konva computes this from the full transform chain.
      //
      // IMPORTANT: relativeTo here is the NODE'S OWN LAYER, not the
      // stage. The layer is offset by LAYER_OFFSET_X/Y in stage coords
      // (set in DesignCanvas via `<Layer x={LAYER_OFFSET_X} ... />`),
      // while node.x() reports layer-LOCAL coords. Asking for the
      // client rect relative to the stage would add the layer offset
      // back in and we'd be comparing two different coord systems.
      // relativeTo the layer keeps everything in the same
      // (~design-coord) space as node.x/y, snap bbox attrs, and
      // element.x/y.
      let clientRect = null;
      try {
        const layer = node.getLayer?.();
        clientRect = layer
          ? node.getClientRect({ relativeTo: layer })
          : node.getClientRect();
      } catch { /* ignore */ }

      // Derived: what the CURRENT unflipReportedXY would write back
      // to element.x/y if a drag/transform ended right now. The
      // production code path is unflipReportedXY(node, width, height,
      // flipX, flipY); since the helper now ignores flip flags, this
      // simplifies to (nodeX - W/2, nodeY - H/2) using whichever width
      // the onTransformEnd handler reads (the freshly-computed
      // newWidth, not node.width()).
      //
      // For diagnostic purposes we report the SIMPLE formula's output
      // using element.width as the size source (matches the rotation
      // case which doesn't change W/H, just the rotation angle).
      const simpleReportedX = nodeX - (element.width ?? nodeW) / 2;
      const simpleReportedY = nodeY - (element.height ?? nodeH) / 2;

      // CORRECT formula: rotate the offset vector by the node's
      // rotation before subtracting. For a node with offsetX/Y at
      // (W/2, H/2) and rotation R, the visual top-left in stage
      // coords is:
      //   visualX = nodeX - (W/2)·cos(R) + (H/2)·sin(R)
      //   visualY = nodeY - (W/2)·sin(R) - (H/2)·cos(R)
      // Equivalent to: rotate the local offset point (W/2, H/2)
      // by R, then subtract from nodeX/Y to get where (0,0) in
      // local space lands. That (0,0) is the visual top-left of
      // an axis-aligned bbox spanning (0,0) → (W,H) in local space.
      //
      // The clientRect.x/y values above are what Konva itself says
      // the visual top-left is, so if our formula is right these
      // two should match to within float noise. Comparing them is
      // the single most useful check for "is the rotation-aware
      // formula correct?"
      const rad = (rotDeg * Math.PI) / 180;
      const cos = Math.cos(rad);
      const sin = Math.sin(rad);
      const W = element.width ?? nodeW;
      const H = element.height ?? nodeH;
      const correctReportedX = nodeX - (W / 2) * cos + (H / 2) * sin;
      const correctReportedY = nodeY - (W / 2) * sin - (H / 2) * cos;

      // Round-trip predictions: if we write {simpleReportedX} into
      // element.x, next render will draw the node at renderX =
      // element.x + W/2 = nodeX. So the node's position is
      // preserved — but its bbox is NOT, because the rotation pivot
      // moved between the old and new state. The visible top-left
      // jumps by (correctReportedX - simpleReportedX, ...) on the
      // next paint. That delta is what the user sees as "jumping."
      const simpleDeltaX = simpleReportedX - correctReportedX;
      const simpleDeltaY = simpleReportedY - correctReportedY;
      const predictedJumpPx = Math.hypot(simpleDeltaX, simpleDeltaY);

      setReadout({
        elementId: element.id,
        element: pickElementFields(element),
        node: {
          x: nodeX,
          y: nodeY,
          width: nodeW,
          height: nodeH,
          rotation: rotDeg,
          offsetX,
          offsetY,
          scaleX,
          scaleY,
        },
        derived: {
          clientRect,
          simpleReportedX,
          simpleReportedY,
          correctReportedX,
          correctReportedY,
          simpleDeltaX,
          simpleDeltaY,
          predictedJumpPx,
        },
      });

      rafRef.current = requestAnimationFrame(tick);
    };

    rafRef.current = requestAnimationFrame(tick);
    return () => {
      cancelled = true;
      if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
    };
  }, [element, stageContainerRef]);

  const handleDismiss = () => {
    try {
      localStorage.removeItem('paw_debug_overlay');
    } catch { /* ignore */ }
    try {
      const url = new URL(window.location.href);
      url.searchParams.delete('debug');
      window.history.replaceState({}, '', url.toString());
    } catch { /* ignore */ }
    // Force a reload so the App-level gate re-evaluates and unmounts
    // this component cleanly. Simpler than threading a callback up.
    window.location.reload();
  };

  // "Just copied" flash state for the copy button. Flips true on a
  // successful clipboard write, back to false ~1.2s later via a
  // timeout. The button reads its label from this so the user sees
  // immediate feedback that the click landed — clipboard writes are
  // invisible otherwise. Stored as state (not a ref) so the label
  // re-renders.
  const [justCopied, setJustCopied] = useState(false);
  const copyTimeoutRef = useRef(null);
  useEffect(() => () => {
    // Cleanup on unmount: the timeout would harmlessly fire after
    // unmount and setState into a stale tree if we didn't clear it.
    if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current);
  }, []);

  const handleCopy = async () => {
    // Build the markdown payload from the current readout +
    // gestureSummary. Both can be null (e.g. nothing selected, or no
    // gesture has happened yet); the formatter tolerates that and
    // omits sections rather than emitting empty tables.
    const text = formatReadoutForClipboard(readout, gestureSummary);
    if (!text) return;

    // Two-tier write: modern Clipboard API first, falling back to the
    // legacy execCommand path if the modern one is unavailable (older
    // browsers, insecure contexts, some embedded webviews). The
    // fallback uses a hidden textarea + selectAll + execCommand which
    // is the documented compat technique. Either way we flip the
    // "just copied" flash on success.
    let ok = false;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(text);
        ok = true;
      }
    } catch { /* fall through to fallback */ }

    if (!ok) {
      try {
        const ta = document.createElement('textarea');
        ta.value = text;
        ta.style.position = 'fixed';
        ta.style.left = '-9999px';
        ta.style.opacity = '0';
        document.body.appendChild(ta);
        ta.select();
        ok = document.execCommand('copy');
        document.body.removeChild(ta);
      } catch { /* ignore */ }
    }

    if (ok) {
      setJustCopied(true);
      if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current);
      copyTimeoutRef.current = setTimeout(() => setJustCopied(false), 1200);
    }
  };

  // Panel styles inline. The overlay is a developer tool; not worth
  // adding a CSS file we'd want to delete when the bug is fixed.
  const panelStyle = {
    position: 'fixed',
    left: '0.5rem',
    bottom: '0.5rem',
    zIndex: 9999,
    background: 'rgba(15, 23, 42, 0.92)',
    color: '#e2e8f0',
    fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
    fontSize: '11px',
    lineHeight: 1.45,
    padding: '0.6rem 0.75rem',
    borderRadius: '8px',
    maxWidth: '320px',
    boxShadow: '0 8px 24px rgba(0, 0, 0, 0.35)',
    pointerEvents: 'auto',
    userSelect: 'text',
  };

  const headerStyle = {
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'space-between',
    marginBottom: '0.4rem',
    fontWeight: 600,
    color: '#f59e0b',
    fontSize: '12px',
  };

  const dismissBtnStyle = {
    background: 'transparent',
    border: 'none',
    color: '#94a3b8',
    fontSize: '14px',
    cursor: 'pointer',
    padding: 0,
    lineHeight: 1,
  };

  // Copy button. Slightly larger hit target than the dismiss button
  // because it's the action the user reaches for most often during
  // a debug session, and uses a subtle pink tint when not flashing
  // so it reads as the primary affordance on this panel. When
  // `justCopied` is true, the button briefly swaps its label to
  // "copied!" with a green tint as confirmation.
  const copyBtnStyle = {
    background: 'transparent',
    border: '1px solid rgba(148, 163, 184, 0.35)',
    color: justCopied ? '#86efac' : '#cbd5e1',
    fontSize: '10px',
    fontWeight: 600,
    textTransform: 'uppercase',
    letterSpacing: '0.04em',
    cursor: 'pointer',
    padding: '0.15rem 0.4rem',
    borderRadius: '4px',
    lineHeight: 1.3,
    transition: 'color 0.15s ease, border-color 0.15s ease',
    borderColor: justCopied ? 'rgba(134, 239, 172, 0.55)' : 'rgba(148, 163, 184, 0.35)',
  };

  const headerActionsStyle = {
    display: 'flex',
    alignItems: 'center',
    gap: '0.5rem',
  };

  const sectionStyle = {
    borderTop: '1px solid rgba(148, 163, 184, 0.18)',
    paddingTop: '0.35rem',
    marginTop: '0.35rem',
  };

  const sectionTitle = {
    color: '#94a3b8',
    fontSize: '10px',
    textTransform: 'uppercase',
    letterSpacing: '0.04em',
    marginBottom: '0.15rem',
  };

  const highlightDelta = (val) => {
    // Color-code the delta so a glance tells you whether it's a real
    // discrepancy or float noise. Threshold at 0.1px — anything below
    // that is rounding, anything above is the bug.
    const abs = Math.abs(val);
    if (abs < 0.1) return '#94a3b8';
    if (abs < 2) return '#fbbf24';
    return '#f87171';
  };

  return (
    <div style={panelStyle} role="region" aria-label="Debug overlay">
      <div style={headerStyle}>
        <span>🐛 element debug</span>
        <div style={headerActionsStyle}>
          <button
            type="button"
            onClick={handleCopy}
            style={copyBtnStyle}
            aria-label="Copy debug data to clipboard"
            title="Copy as markdown"
          >
            {justCopied ? 'copied!' : 'copy'}
          </button>
          <button
            type="button"
            onClick={handleDismiss}
            style={dismissBtnStyle}
            aria-label="Close debug overlay"
            title="Close (clears flag + reloads)"
          >
            ×
          </button>
        </div>
      </div>

      {!readout && (
        <div style={{ color: '#94a3b8' }}>No element selected.</div>
      )}

      {readout && (
        <>
          <div>
            <span style={{ color: '#94a3b8' }}>id: </span>
            <span>{readout.elementId.slice(0, 12)}</span>
          </div>

          <div style={sectionStyle}>
            <div style={sectionTitle}>element (state)</div>
            <Row label="x" value={readout.element.x} />
            <Row label="y" value={readout.element.y} />
            <Row label="w" value={readout.element.width} />
            <Row label="h" value={readout.element.height} />
            <Row label="rot°" value={readout.element.rotation} />
            <Row label="flip" value={`${readout.element.flipX ? 'X' : '·'}${readout.element.flipY ? 'Y' : '·'}`} mono />
          </div>

          {readout.node && (
            <div style={sectionStyle}>
              <div style={sectionTitle}>node (live konva)</div>
              <Row label="x" value={readout.node.x} />
              <Row label="y" value={readout.node.y} />
              <Row label="off" value={`${fmt(readout.node.offsetX)}, ${fmt(readout.node.offsetY)}`} mono />
              <Row label="scale" value={`${fmt(readout.node.scaleX)}, ${fmt(readout.node.scaleY)}`} mono />
              <Row label="rot°" value={readout.node.rotation} />
            </div>
          )}

          {readout.derived && (
            <div style={sectionStyle}>
              <div style={sectionTitle}>derived</div>
              {readout.derived.clientRect && (
                <Row
                  label="visual TL (konva)"
                  value={`${fmt(readout.derived.clientRect.x)}, ${fmt(readout.derived.clientRect.y)}`}
                  mono
                />
              )}
              <Row
                label="simple formula"
                value={`${fmt(readout.derived.simpleReportedX)}, ${fmt(readout.derived.simpleReportedY)}`}
                mono
                hint="node.x - W/2  /  node.y - H/2"
              />
              <Row
                label="rot-aware"
                value={`${fmt(readout.derived.correctReportedX)}, ${fmt(readout.derived.correctReportedY)}`}
                mono
                hint="subtracts rotated offset vector"
              />
              <Row
                label="Δ x,y"
                value={`${fmt(readout.derived.simpleDeltaX)}, ${fmt(readout.derived.simpleDeltaY)}`}
                mono
                color={highlightDelta(readout.derived.predictedJumpPx)}
              />
              <Row
                label="predicted jump"
                value={`${fmt(readout.derived.predictedJumpPx)}px`}
                mono
                color={highlightDelta(readout.derived.predictedJumpPx)}
                hint="how far the bbox will visibly jump on transform-end"
              />
            </div>
          )}

          {gestureSummary && (
            <div style={sectionStyle}>
              <div style={sectionTitle}>last gesture</div>
              <Row
                label="duration"
                value={gestureSummary.durationMs != null ? `${gestureSummary.durationMs}ms` : '—'}
                mono
              />
              <Row
                label="bound calls"
                value={`${gestureSummary.boundCalls} (${gestureSummary.boundRejects} clamped)`}
                mono
                color={gestureSummary.boundRejects > 0 ? '#fbbf24' : undefined}
                hint="how many times canvasDragBound/constrainTransform fired; clamped = returned oldBox or a slid box"
              />
              {gestureSummary.startState && gestureSummary.firstFrame && (
                <>
                  <Row
                    label="first-frame Δ visual"
                    value={fmtDeltaPair(
                      gestureSummary.firstFrame.visualX,
                      gestureSummary.startState.visualX,
                      gestureSummary.firstFrame.visualY,
                      gestureSummary.startState.visualY,
                    )}
                    mono
                    color={highlightDelta(
                      hypotPair(
                        gestureSummary.firstFrame.visualX,
                        gestureSummary.startState.visualX,
                        gestureSummary.firstFrame.visualY,
                        gestureSummary.startState.visualY,
                      ),
                    )}
                    hint="how far visual TL moved between gesture-start and FIRST frame; big = jump on grab"
                  />
                  <Row
                    label="first-frame Δ rot"
                    value={fmtDelta(
                      gestureSummary.firstFrame.nodeRotation,
                      gestureSummary.startState.nodeRotation,
                    )}
                    mono
                    hint="how much rotation changed between gesture-start and FIRST frame"
                  />
                </>
              )}
              {gestureSummary.startState && gestureSummary.endState && (
                <>
                  <Row
                    label="total Δ visual"
                    value={fmtDeltaPair(
                      gestureSummary.endState.visualX,
                      gestureSummary.startState.visualX,
                      gestureSummary.endState.visualY,
                      gestureSummary.startState.visualY,
                    )}
                    mono
                    hint="visual TL drift from start to end of gesture"
                  />
                  <Row
                    label="total Δ rot"
                    value={fmtDelta(
                      gestureSummary.endState.nodeRotation,
                      gestureSummary.startState.nodeRotation,
                    )}
                    mono
                  />
                </>
              )}
            </div>
          )}
        </>
      )}
    </div>
  );
}

/* Helpers ───────────────────────────────────────────────────────────── */

function pickElementFields(el) {
  return {
    x: el.x ?? 0,
    y: el.y ?? 0,
    width: el.width ?? el.fontSize ?? 0,
    height: el.height ?? el.fontSize ?? 0,
    rotation: el.rotation ?? 0,
    flipX: !!el.flipX,
    flipY: !!el.flipY,
  };
}

function fmt(n) {
  if (n == null || Number.isNaN(n)) return '—';
  // Two decimal places — enough resolution to spot sub-pixel drift
  // without making numbers visually noisy. Negative zero collapses
  // to plain "0.00".
  const v = Math.round(n * 100) / 100;
  return Object.is(v, -0) ? '0.00' : v.toFixed(2);
}

function fmtDelta(after, before) {
  if (after == null || before == null) return '—';
  const d = after - before;
  const sign = d > 0 ? '+' : '';
  return `${sign}${fmt(d)}`;
}

function fmtDeltaPair(ax, bx, ay, by) {
  return `${fmtDelta(ax, bx)}, ${fmtDelta(ay, by)}`;
}

function hypotPair(ax, bx, ay, by) {
  if (ax == null || bx == null || ay == null || by == null) return 0;
  return Math.hypot(ax - bx, ay - by);
}

/**
 * Serialize the current readout + gesture summary into a markdown
 * payload suitable for pasting into chat (issue tracker, doc, etc.).
 *
 * Format choices
 * ──────────────
 * One markdown table per logical section, with a small header
 * preamble carrying timestamp + element id. Tables render cleanly
 * in most chat clients (this one included) and remain readable as
 * plain text if the renderer doesn't pick them up.
 *
 * Numbers go through the same `fmt` helper used by the on-screen
 * rows so the clipboard contents match what the user sees in the
 * panel character-for-character (two decimals, signed deltas, em-
 * dash for missing values). No surprise reformat between visual
 * and pasted data.
 *
 * Each section is independently optional — if there's no node, the
 * "node (live konva)" table is omitted entirely rather than rendered
 * with em-dashes everywhere. If no gesture has happened yet, the
 * "last gesture" section is omitted. The header line is always
 * present so a paste with just the element-state table is still
 * self-describing.
 *
 * Returns the empty string when there's nothing meaningful to copy
 * (no readout at all); the caller bails before writing to the
 * clipboard in that case so we don't replace whatever the user had
 * there before with empty text.
 */
function formatReadoutForClipboard(readout, gestureSummary) {
  if (!readout) return '';

  const lines = [];
  const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
  lines.push(`### element debug — ${ts}`);
  lines.push(`**id**: \`${readout.elementId}\``);
  lines.push('');

  // element (state)
  lines.push('**element (state)**');
  lines.push('');
  lines.push('| field | value |');
  lines.push('|---|---|');
  lines.push(`| x | ${fmt(readout.element.x)} |`);
  lines.push(`| y | ${fmt(readout.element.y)} |`);
  lines.push(`| w | ${fmt(readout.element.width)} |`);
  lines.push(`| h | ${fmt(readout.element.height)} |`);
  lines.push(`| rot° | ${fmt(readout.element.rotation)} |`);
  lines.push(`| flip | ${readout.element.flipX ? 'X' : '·'}${readout.element.flipY ? 'Y' : '·'} |`);
  lines.push('');

  // node (live konva)
  if (readout.node) {
    lines.push('**node (live konva)**');
    lines.push('');
    lines.push('| field | value |');
    lines.push('|---|---|');
    lines.push(`| x | ${fmt(readout.node.x)} |`);
    lines.push(`| y | ${fmt(readout.node.y)} |`);
    lines.push(`| offset | ${fmt(readout.node.offsetX)}, ${fmt(readout.node.offsetY)} |`);
    lines.push(`| scale | ${fmt(readout.node.scaleX)}, ${fmt(readout.node.scaleY)} |`);
    lines.push(`| rot° | ${fmt(readout.node.rotation)} |`);
    lines.push('');
  }

  // derived
  if (readout.derived) {
    lines.push('**derived**');
    lines.push('');
    lines.push('| field | value |');
    lines.push('|---|---|');
    if (readout.derived.clientRect) {
      lines.push(`| visual TL (konva) | ${fmt(readout.derived.clientRect.x)}, ${fmt(readout.derived.clientRect.y)} |`);
    }
    lines.push(`| simple formula | ${fmt(readout.derived.simpleReportedX)}, ${fmt(readout.derived.simpleReportedY)} |`);
    lines.push(`| rot-aware | ${fmt(readout.derived.correctReportedX)}, ${fmt(readout.derived.correctReportedY)} |`);
    lines.push(`| Δ x,y | ${fmt(readout.derived.simpleDeltaX)}, ${fmt(readout.derived.simpleDeltaY)} |`);
    lines.push(`| predicted jump | ${fmt(readout.derived.predictedJumpPx)}px |`);
    lines.push('');
  }

  // last gesture
  if (gestureSummary) {
    lines.push('**last gesture**');
    lines.push('');
    lines.push('| field | value |');
    lines.push('|---|---|');
    lines.push(`| duration | ${gestureSummary.durationMs != null ? `${gestureSummary.durationMs}ms` : '—'} |`);
    lines.push(`| bound calls | ${gestureSummary.boundCalls} (${gestureSummary.boundRejects} clamped) |`);
    if (gestureSummary.startState && gestureSummary.firstFrame) {
      lines.push(`| first-frame Δ visual | ${fmtDeltaPair(
        gestureSummary.firstFrame.visualX,
        gestureSummary.startState.visualX,
        gestureSummary.firstFrame.visualY,
        gestureSummary.startState.visualY,
      )} |`);
      lines.push(`| first-frame Δ rot | ${fmtDelta(
        gestureSummary.firstFrame.nodeRotation,
        gestureSummary.startState.nodeRotation,
      )} |`);
    }
    if (gestureSummary.startState && gestureSummary.endState) {
      lines.push(`| total Δ visual | ${fmtDeltaPair(
        gestureSummary.endState.visualX,
        gestureSummary.startState.visualX,
        gestureSummary.endState.visualY,
        gestureSummary.startState.visualY,
      )} |`);
      lines.push(`| total Δ rot | ${fmtDelta(
        gestureSummary.endState.nodeRotation,
        gestureSummary.startState.nodeRotation,
      )} |`);
    }
    lines.push('');
  }

  // Trim the trailing blank line so the payload ends without an
  // awkward extra newline when pasted into a tight text field.
  while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();

  return lines.join('\n');
}

function Row({ label, value, mono, hint, color }) {
  const rowStyle = {
    display: 'flex',
    justifyContent: 'space-between',
    alignItems: 'baseline',
    gap: '0.5rem',
  };
  const labelStyle = {
    color: '#94a3b8',
    fontSize: '10.5px',
  };
  const valueStyle = {
    fontFamily: mono ? 'inherit' : undefined,
    color: color ?? '#e2e8f0',
    fontVariantNumeric: 'tabular-nums',
  };
  return (
    <div style={rowStyle} title={hint || undefined}>
      <span style={labelStyle}>{label}</span>
      <span style={valueStyle}>{typeof value === 'number' ? fmt(value) : value}</span>
    </div>
  );
}

/**
 * Read the debug-flag state from URL and localStorage. Either source
 * is enough to enable. Called once at App mount; the result drives
 * conditional rendering of <DebugOverlay /> for the rest of the session
 * (a reload re-reads, so toggles take effect on next page load).
 *
 * Defensive try/catches because SSR and storage-disabled environments
 * (private windows in some browsers) throw on access.
 */
export function readDebugFlag() {
  try {
    if (typeof window !== 'undefined') {
      const url = new URL(window.location.href);
      const param = url.searchParams.get('debug');
      if (param === '1' || param === 'true') {
        // Persist so subsequent reloads keep it on without the URL.
        try { localStorage.setItem('paw_debug_overlay', '1'); } catch { /* ignore */ }
        return true;
      }
    }
  } catch { /* ignore */ }
  try {
    if (typeof localStorage !== 'undefined') {
      return localStorage.getItem('paw_debug_overlay') === '1';
    }
  } catch { /* ignore */ }
  return false;
}
