import { useSimulatorStore, getEsp32Bridge } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Undo2, Redo2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ESP32_ADC_PIN_MAP } from '../velxio-components/Esp32Element';
import { ComponentPickerModal } from '../ComponentPickerModal';
import { ComponentPropertyDialog } from './ComponentPropertyDialog';
import { CustomChipDialog } from '../customChips/CustomChipDialog';
import { SensorControlPanel } from './SensorControlPanel';
import { SENSOR_CONTROLS } from '../../simulation/sensorControlConfig';
import { DynamicComponent, createComponentFromMetadata } from '../DynamicComponent';
import { InstrumentComponent } from '../components-instruments/InstrumentComponent';
import { ComponentRegistry } from '../../services/ComponentRegistry';
import { getTabSessionId } from '../../simulation/Esp32Bridge';
import { CameraToggle } from './CameraToggle';
import { WireLayer } from './WireLayer';
import type { SegmentHandle, WaypointHandle, AlignmentGuide } from './WireLayer';
import { ElectricalOverlay } from '../analog-ui/ElectricalOverlay';
import { BoardOnCanvas } from './BoardOnCanvas';
import { CanvasMinimap } from './CanvasMinimap';
import { PartSimulationRegistry } from '../../simulation/parts';
import { PROPERTY_CHANGE_EVENT, type PropertyChangeDetail } from '../../simulation/parts/partUtils';
import { isSpiceMapped } from '../../simulation/spice/componentToSpice';
import { PinOverlay } from './PinOverlay';
import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping';
import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils';
import {
  findWireNearPoint,
  findSegmentNearPoint,
  getRenderedPoints,
  getRenderedSegments,
  moveSegment,
  renderedToWaypoints,
  renderedPointsToPath,
  simplifyOrthogonalPath,
  insertWaypointAtSegment,
  collectAlignmentTargets,
  snapToNearest,
} from '../../utils/wireHitDetection';
import { useIsCoarsePointer } from '../../utils/useTouchDevice';
import type { ComponentMetadata } from '../../types/component-metadata';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
import { isEsp32Family } from '../../types/boardOptions';
import { BoardOptionsModal } from './BoardOptionsModal';
import { useOscilloscopeStore } from '../../store/useOscilloscopeStore';
import {
  trackSelectBoard,
  trackAddComponent,
  trackCreateWire,
  trackToggleSerialMonitor,
} from '../../utils/analytics';
import { SelectionActionBar } from './SelectionActionBar';
import { WireModeBanner } from './WireModeBanner';
import { PinPickerDialog } from './PinPickerDialog';
import './SimulatorCanvas.css';

/** World-units of tolerance for alignment snap (scales with zoom). */
const ALIGN_SNAP_PX = 6;

/** Long-press duration for touch context menu (ms). */
const LONG_PRESS_MS = 500;

/** Max movement during long-press before it cancels (px). */
const LONG_PRESS_MOVE_TOLERANCE = 8;

/**
 * Distance (px, screen) a touch must drift before a passthrough-to-
 * wokwi-element touch is promoted to a component drag. Set just above
 * normal tap jitter so accidental drags from a finger press are rare.
 */
const DRAG_PROMOTE_THRESHOLD_PX = 8;

/** Check if a board kind is an ESP32-family board. */
function isEsp32Kind(kind: BoardKind): boolean {
  return (
    kind.startsWith('esp32') ||
    kind === 'xiao-esp32-s3' ||
    kind === 'xiao-esp32-c3' ||
    kind === 'arduino-nano-esp32' ||
    kind === 'aitewinrobot-esp32c3-supermini' ||
    kind === 'esp32-cam' ||
    kind === 'wemos-lolin32-lite' ||
    kind === 'esp32-devkit-c-v4'
  );
}

interface SimulatorCanvasProps {
  /**
   * Optional DOM element to portal the canvas header (board selector,
   * Serial/Scope toggles, zoom controls, Add component button) into. When
   * provided, the header renders into the slot instead of in place — used by
   * EditorPage to merge it with the editor toolbar into a single full-width
   * top bar that doesn't reflow when the editor/canvas splitter moves.
   */
  headerSlot?: HTMLElement | null;
}

export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
  const { t } = useTranslation();
  const isTouchDevice = useIsCoarsePointer();
  // Mirror to a ref so the long-lived touch handler effect (deps deliberately
  // narrow to avoid rebinding listeners on every render) can read the latest
  // value without listing it as a dep.
  const isTouchDeviceRef = useRef(isTouchDevice);
  isTouchDeviceRef.current = isTouchDevice;
  const {
    boards,
    activeBoardId,
    setBoardPosition,
    addBoard,
    components,
    running,
    pinManager,
    initSimulator,
    updateComponentState,
    removeBoard,
    updateBoard,
    updateComponent,
    serialMonitorOpen,
    toggleSerialMonitor,
  } = useSimulatorStore();
  // `addComponent` / `removeComponent` / `removeWire` are no longer used here —
  // every user-initiated mutation routes through the record* actions below
  // so it can be undone. Raw mutators are still available for transient
  // operations (e.g. drag preview frames) but those don't live in this file.

  // Active board (for WiFi/BLE status display)
  const activeBoard = boards.find((b) => b.id === activeBoardId) ?? null;

  // Legacy derived values for components that still use them
  const boardType = useSimulatorStore((s) => s.boardType);
  const boardPosition = useSimulatorStore((s) => s.boardPosition);

  // Wire management from store
  const startWireCreation = useSimulatorStore((s) => s.startWireCreation);
  const updateWireInProgress = useSimulatorStore((s) => s.updateWireInProgress);
  const addWireWaypoint = useSimulatorStore((s) => s.addWireWaypoint);
  const setWireInProgressColor = useSimulatorStore((s) => s.setWireInProgressColor);
  const finishWireCreation = useSimulatorStore((s) => s.finishWireCreation);
  const cancelWireCreation = useSimulatorStore((s) => s.cancelWireCreation);
  const wireInProgress = useSimulatorStore((s) => s.wireInProgress);
  const recalculateAllWirePositions = useSimulatorStore((s) => s.recalculateAllWirePositions);
  const selectedWireId = useSimulatorStore((s) => s.selectedWireId);
  const setSelectedWire = useSimulatorStore((s) => s.setSelectedWire);
  const updateWire = useSimulatorStore((s) => s.updateWire);
  const wires = useSimulatorStore((s) => s.wires);

  // Recorded canvas actions — these wrap the raw mutators above with an
  // undoable CanvasCommand. Use these at the *commit* point of a user
  // interaction (drag-end, click finish, picker confirm); use the raw
  // mutators for transient state during the interaction (drag preview).
  const recordAddComponent = useSimulatorStore((s) => s.recordAddComponent);
  const recordRemoveComponent = useSimulatorStore((s) => s.recordRemoveComponent);
  const recordMove = useSimulatorStore((s) => s.recordMove);
  const recordRotate = useSimulatorStore((s) => s.recordRotate);
  const recordSetProperty = useSimulatorStore((s) => s.recordSetProperty);
  const recordRemoveWire = useSimulatorStore((s) => s.recordRemoveWire);
  // Subscribe to history shape so the undo/redo buttons reactively
  // enable/disable and their tooltips reflect the next command.
  const history = useSimulatorStore((s) => s.history);
  const historyIndex = useSimulatorStore((s) => s.historyIndex);
  const undo = useSimulatorStore((s) => s.undo);
  const redo = useSimulatorStore((s) => s.redo);

  // Oscilloscope
  const oscilloscopeOpen = useOscilloscopeStore((s) => s.open);
  const toggleOscilloscope = useOscilloscopeStore((s) => s.toggleOscilloscope);

  // ESP32 crash notification
  const esp32CrashBoardId = useSimulatorStore((s) => s.esp32CrashBoardId);
  const dismissEsp32Crash = useSimulatorStore((s) => s.dismissEsp32Crash);

  // Component picker modal
  const [showComponentPicker, setShowComponentPicker] = useState(false);
  const [registry] = useState(() => ComponentRegistry.getInstance());
  const [registryLoaded, setRegistryLoaded] = useState(registry.isLoaded);

  // Wait for registry to finish loading before rendering components
  useEffect(() => {
    if (!registryLoaded) {
      registry.loadPromise.then(() => setRegistryLoaded(true));
    }
  }, [registry, registryLoaded]);

  // Component selection
  const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);

  // Hover tracking — drives the conditional pin overlay so the canvas isn't
  // permanently covered in pin chips. Pins show for the hovered/selected
  // component or board, plus all elements while a wire is in progress.
  const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
  const [hoveredBoardId, setHoveredBoardId] = useState<string | null>(null);

  // Touch-friendly pin picker — shown when the user taps a component or board
  // body (not a tiny pin overlay) and we want to let them pick a pin from a
  // list. `kind` distinguishes board vs component so we can pull the right
  // metadata for the dialog title.
  const [pinPicker, setPinPicker] = useState<
    { kind: 'component' | 'board'; targetId: string } | null
  >(null);

  // Component property dialog
  const [showPropertyDialog, setShowPropertyDialog] = useState(false);
  const [propertyDialogComponentId, setPropertyDialogComponentId] = useState<string | null>(null);
  /** When non-null, the Custom Chip designer dialog is open for this component. */
  const [customChipComponentId, setCustomChipComponentId] = useState<string | null>(null);
  const [propertyDialogPosition, setPropertyDialogPosition] = useState({ x: 0, y: 0 });

  // Sensor control panel (shown instead of property dialog for sensor components during simulation)
  const [sensorControlComponentId, setSensorControlComponentId] = useState<string | null>(null);
  const [sensorControlMetadataId, setSensorControlMetadataId] = useState<string | null>(null);

  // Board built-in LED states (pin 13 for AVR, GPIO25 for RP2040, etc.)
  // Tracks directly from pinManager — independent of any led-builtin component.
  const [boardLedStates, setBoardLedStates] = useState<Record<string, boolean>>({});

  // Board context menu (right-click)
  const [boardContextMenu, setBoardContextMenu] = useState<{
    boardId: string;
    x: number;
    y: number;
  } | null>(null);
  // Board removal confirmation dialog
  const [boardToRemove, setBoardToRemove] = useState<string | null>(null);
  // Board Options modal — id of the board whose options are being edited.
  const [boardOptionsModalFor, setBoardOptionsModalFor] = useState<string | null>(null);

  // Click vs drag detection
  const [clickStartTime, setClickStartTime] = useState<number>(0);
  const [clickStartPos, setClickStartPos] = useState({ x: 0, y: 0 });

  // Component dragging state
  const [draggedComponentId, setDraggedComponentId] = useState<string | null>(null);
  // Captures (x, y) of the dragged component at mousedown so a drag-end
  // can record the diff as a single undoable Move. Boards are intentionally
  // skipped — board moves don't go through component history.
  const dragStartPosRef = useRef<{ x: number; y: number } | null>(null);
  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });

  // Canvas ref for coordinate calculations
  const canvasRef = useRef<HTMLDivElement>(null);

  // Pan & zoom state
  const [pan, setPan] = useState({ x: 0, y: 0 });
  const [zoom, setZoom] = useState(1);
  // Use refs during active pan to avoid setState lag
  const isPanningRef = useRef(false);
  const panStartRef = useRef({ mouseX: 0, mouseY: 0, panX: 0, panY: 0 });
  const panRef = useRef({ x: 0, y: 0 });
  const zoomRef = useRef(1);

  // Board-less SPICE circuits (analog / digital examples with no MCU on
  // the canvas) have no concept of a board to "start", so `running` is
  // always false. But the simulation IS effectively live the moment the
  // engine has solved at least once — switches and buttons should toggle
  // their own state on click instead of opening the property dialog.
  // We treat board-less + un-paused as "interaction-running" so the same
  // gating logic that suppresses the dialog for MCU-running mode also
  // covers board-less circuits.
  const electricalPaused = useElectricalStore((s) => s.paused);
  const interactionRunning = running || (boards.length === 0 && !electricalPaused);

  // Refs that mirror state/props for use inside touch event closures
  // (touch listeners are added imperatively and can't access current React state)
  const runningRef = useRef(running);
  runningRef.current = running;
  const interactionRunningRef = useRef(interactionRunning);
  interactionRunningRef.current = interactionRunning;
  const componentsRef = useRef(components);
  componentsRef.current = components;
  const boardPositionRef = useRef(boardPosition);
  boardPositionRef.current = boardPosition;
  const boardsRef = useRef(boards);
  boardsRef.current = boards;

  // Wire interaction state (canvas-level hit detection — bypasses SVG pointer-events issues)
  const [hoveredWireId, setHoveredWireId] = useState<string | null>(null);
  const [segmentDragPreview, setSegmentDragPreview] = useState<{
    wireId: string;
    overridePath: string;
  } | null>(null);
  const segmentDragRef = useRef<{
    wireId: string;
    segIndex: number;
    axis: 'horizontal' | 'vertical';
    renderedPts: { x: number; y: number }[];
    isDragging: boolean;
  } | null>(null);
  /** Active waypoint drag (free 2D move of a single bend point). */
  const waypointDragRef = useRef<{
    wireId: string;
    waypointIndex: number;
    originalWaypoints: { x: number; y: number }[];
    isDragging: boolean;
  } | null>(null);
  const [waypointDragPreview, setWaypointDragPreview] = useState<{
    wireId: string;
    waypoints: { x: number; y: number }[];
  } | null>(null);
  const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
  /** Set to true during mouseup if a segment/waypoint drag committed, so onClick can skip selection. */
  const segmentDragJustCommittedRef = useRef(false);
  const wiresRef = useRef(wires);
  wiresRef.current = wires;

  // Compute midpoint handles for the selected wire's segments
  const segmentHandles = React.useMemo<SegmentHandle[]>(() => {
    if (!selectedWireId) return [];
    const wire = wires.find((w) => w.id === selectedWireId);
    if (!wire) return [];
    return getRenderedSegments(wire).map((seg, i) => ({
      segIndex: i,
      axis: seg.axis,
      mx: (seg.x1 + seg.x2) / 2,
      my: (seg.y1 + seg.y2) / 2,
    }));
  }, [selectedWireId, wires]);

  // Compute bend-point handles (one per waypoint) for the selected wire
  const waypointHandles = React.useMemo<WaypointHandle[]>(() => {
    if (!selectedWireId) return [];
    const wire = wires.find((w) => w.id === selectedWireId);
    if (!wire) return [];
    // While the user is dragging a waypoint, render handles at the live preview
    // positions so the dot tracks the cursor instead of jumping at commit.
    const activeDrag = waypointDragPreview?.wireId === wire.id ? waypointDragPreview : null;
    const wps = activeDrag ? activeDrag.waypoints : (wire.waypoints ?? []);
    return wps.map((wp, i) => ({ index: i, x: wp.x, y: wp.y }));
  }, [selectedWireId, wires, waypointDragPreview]);

  // Touch-specific state refs (for single-finger drag and pinch-to-zoom)
  const touchDraggedComponentIdRef = useRef<string | null>(null);
  const touchDragOffsetRef = useRef({ x: 0, y: 0 });
  const touchClickStartTimeRef = useRef(0);
  const touchClickStartPosRef = useRef({ x: 0, y: 0 });
  const pinchStartDistRef = useRef(0);
  const pinchStartZoomRef = useRef(1);
  const pinchStartMidRef = useRef({ x: 0, y: 0 });
  const pinchStartPanRef = useRef({ x: 0, y: 0 });

  // Refs for touch-based wire creation, selection, and interactive passthrough
  const wireInProgressRef = useRef(wireInProgress);
  wireInProgressRef.current = wireInProgress;
  const selectedWireIdRef = useRef(selectedWireId);
  selectedWireIdRef.current = selectedWireId;
  const touchPassthroughRef = useRef(false);
  // While `touchPassthroughRef` is true (touch let through to an interactive
  // wokwi-element while the simulation runs), we still remember which
  // component the touch started on. If the finger drifts past
  // DRAG_PROMOTE_THRESHOLD_PX, we cancel the passthrough and start a real
  // drag — so the user can rearrange interactive parts live without first
  // pausing the simulation.
  const pendingTouchDragRef = useRef<
    { componentId: string; startX: number; startY: number } | null
  >(null);
  const touchOnPinRef = useRef(false);
  const lastTapTimeRef = useRef(0);

  // Long-press (touch-equivalent of right-click) — opens board context menu on
  // touch devices since right-click isn't available there.
  const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const longPressFiredRef = useRef(false);
  const cancelLongPress = useCallback(() => {
    if (longPressTimerRef.current) {
      clearTimeout(longPressTimerRef.current);
      longPressTimerRef.current = null;
    }
  }, []);

  // Convert viewport coords to world (canvas) coords
  const toWorld = useCallback((screenX: number, screenY: number) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return { x: screenX, y: screenY };
    return {
      x: (screenX - rect.left - panRef.current.x) / zoomRef.current,
      y: (screenY - rect.top - panRef.current.y) / zoomRef.current,
    };
  }, []);

  // Initialize simulator on mount
  useEffect(() => {
    initSimulator();
  }, [initSimulator]);

  // Runtime parts (pots, switches, sensor panels) emit
  // `velxio:property-change` instead of writing the store directly — one
  // listener here routes every mutation through `updateComponent()`, which
  // is the same path the Property Dialog uses. Keeps parts decoupled from
  // Zustand and guarantees the SPICE netlist memo invalidates on every
  // user-driven property change.
  useEffect(() => {
    const onPropertyChange = (evt: Event) => {
      const { componentId, propName, value } = (evt as CustomEvent<PropertyChangeDetail>).detail;
      const state = useSimulatorStore.getState();
      const comp = state.components.find((c) => c.id === componentId);
      if (!comp) return;
      if (String(comp.properties?.[propName]) === String(value)) return;
      state.updateComponent(componentId, {
        properties: { ...comp.properties, [propName]: value },
      });
    };
    window.addEventListener(PROPERTY_CHANGE_EVENT, onPropertyChange);
    return () => window.removeEventListener(PROPERTY_CHANGE_EVENT, onPropertyChange);
  }, []);

  // Auto-start/stop Pi bridges when simulation state changes
  const startBoard = useSimulatorStore((s) => s.startBoard);
  const stopBoard = useSimulatorStore((s) => s.stopBoard);
  useEffect(() => {
    const remoteBoards = boards.filter(
      (b) =>
        b.boardKind === 'raspberry-pi-3' ||
        b.boardKind === 'esp32' ||
        b.boardKind === 'esp32-s3' ||
        b.boardKind === 'esp32-c3',
    );
    remoteBoards.forEach((b) => {
      if (running && !b.running) startBoard(b.id);
      else if (!running && b.running) stopBoard(b.id);
    });
  }, [running, boards, startBoard, stopBoard]);

  // Attach wheel listener as non-passive so preventDefault() works
  useEffect(() => {
    const el = canvasRef.current;
    if (!el) return;
    const onWheel = (e: WheelEvent) => {
      e.preventDefault();
      const rect = el.getBoundingClientRect();
      const factor = e.deltaY < 0 ? 1.1 : 0.9;
      const newZoom = Math.min(5, Math.max(0.1, zoomRef.current * factor));
      const mx = e.clientX - rect.left;
      const my = e.clientY - rect.top;
      const worldX = (mx - panRef.current.x) / zoomRef.current;
      const worldY = (my - panRef.current.y) / zoomRef.current;
      const newPan = { x: mx - worldX * newZoom, y: my - worldY * newZoom };
      zoomRef.current = newZoom;
      panRef.current = newPan;
      setZoom(newZoom);
      setPan(newPan);
    };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, []);

  // Attach touch listeners as non-passive so preventDefault() works, enabling
  // single-finger pan, component drag, wire creation/selection, and two-finger pinch-to-zoom.
  useEffect(() => {
    const el = canvasRef.current;
    if (!el) return;

    const onTouchStart = (e: TouchEvent) => {
      // Reset per-gesture flags
      touchOnPinRef.current = false;
      touchPassthroughRef.current = false;
      pendingTouchDragRef.current = null;
      pinchStartDistRef.current = 0;

      if (e.touches.length === 2) {
        e.preventDefault();
        // Cancel wire in progress and any pending long-press on two-finger gesture
        cancelLongPress();
        if (wireInProgressRef.current) {
          useSimulatorStore.getState().cancelWireCreation();
        }
        // Cancel any active drag/pan and prepare zoom
        isPanningRef.current = false;
        touchDraggedComponentIdRef.current = null;

        const dx = e.touches[0].clientX - e.touches[1].clientX;
        const dy = e.touches[0].clientY - e.touches[1].clientY;
        pinchStartDistRef.current = Math.sqrt(dx * dx + dy * dy);
        pinchStartZoomRef.current = zoomRef.current;
        pinchStartPanRef.current = { ...panRef.current };

        const rect = el.getBoundingClientRect();
        pinchStartMidRef.current = {
          x: (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left,
          y: (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top,
        };
        return;
      }

      if (e.touches.length !== 1) return;
      const touch = e.touches[0];

      // Identify what element was touched
      const target = document.elementFromPoint(touch.clientX, touch.clientY);

      // ── 1. Pin overlay → let pin's onTouchEnd React handler call handlePinClick ──
      if (target?.closest('[data-pin-overlay]')) {
        e.preventDefault();
        touchOnPinRef.current = true;
        return;
      }

      // ── 1b. Wire segment/waypoint handle → let WireLayer's React handler claim the drag.
      // We must skip the canvas-level pan/drag setup so the handle drag works in isolation.
      if (target?.closest('[data-wire-handle]')) {
        e.preventDefault();
        touchPassthroughRef.current = true;
        return;
      }

      // ── 2. Interactive web component during simulation → let browser synthesize mouse events ──
      //    (potentiometer knobs, button presses, etc. need mousedown/mouseup synthesis)
      //    touch-action:none on .canvas-content already prevents browser scroll/zoom.
      // Board-less SPICE circuits piggy-back on the same path so a tap on
      // a slide-switch reaches the wokwi element and triggers its toggle.
      // We still remember the starting position + component id so that if
      // the finger drifts past DRAG_PROMOTE_THRESHOLD_PX onTouchMove can
      // cancel the passthrough and start a real drag, letting the user
      // rearrange interactive parts live without pausing the simulation.
      if (interactionRunningRef.current) {
        const webComp = target?.closest('.web-component-container');
        if (webComp) {
          touchPassthroughRef.current = true;
          const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null;
          const cid = componentWrapper?.getAttribute('data-component-id') || null;
          pendingTouchDragRef.current = cid
            ? { componentId: cid, startX: touch.clientX, startY: touch.clientY }
            : null;
          // Don't preventDefault → browser synthesizes mouse events for the component
          return;
        }
      }

      e.preventDefault();

      touchClickStartTimeRef.current = Date.now();
      touchClickStartPosRef.current = { x: touch.clientX, y: touch.clientY };

      // ── 3. Wire in progress → track for waypoint, update preview ──
      if (wireInProgressRef.current) {
        const world = toWorld(touch.clientX, touch.clientY);
        useSimulatorStore.getState().updateWireInProgress(world.x, world.y);
        // Don't start pan/drag — let touchmove update wire preview, touchend add waypoint
        return;
      }

      // ── 4. Component detection ──
      const componentWrapper = target?.closest('[data-component-id]') as HTMLElement | null;
      const boardOverlay = target?.closest('[data-board-overlay]') as HTMLElement | null;

      if (componentWrapper) {
        const componentId = componentWrapper.getAttribute('data-component-id');
        if (componentId) {
          const component = componentsRef.current.find((c) => c.id === componentId);
          if (component) {
            const world = toWorld(touch.clientX, touch.clientY);
            touchDraggedComponentIdRef.current = componentId;
            touchDragOffsetRef.current = {
              x: world.x - component.x,
              y: world.y - component.y,
            };
            setSelectedComponentId(componentId);
          }
        }
      } else if (boardOverlay && !runningRef.current) {
        // ── 5. Board overlay: use multi-board path ──
        const boardId = boardOverlay.getAttribute('data-board-id');
        const storeBoards = useSimulatorStore.getState().boards;
        const boardInstance = boardId ? storeBoards.find((b) => b.id === boardId) : null;
        if (boardInstance) {
          const world = toWorld(touch.clientX, touch.clientY);
          touchDraggedComponentIdRef.current = `__board__:${boardId}`;
          touchDragOffsetRef.current = {
            x: world.x - boardInstance.x,
            y: world.y - boardInstance.y,
          };

          // Schedule long-press to open the board context menu (touch
          // equivalent of right-click). Cancelled if the user moves enough
          // to start a drag, ends touch quickly, or starts a pinch.
          longPressFiredRef.current = false;
          cancelLongPress();
          const pressX = touch.clientX;
          const pressY = touch.clientY;
          const targetBoardId = boardInstance.id;
          longPressTimerRef.current = setTimeout(() => {
            longPressFiredRef.current = true;
            // Cancel the implicit drag so finger lifting after the menu opens
            // doesn't also fire the short-tap "set active board" branch.
            touchDraggedComponentIdRef.current = null;
            setBoardContextMenu({ boardId: targetBoardId, x: pressX, y: pressY });
          }, LONG_PRESS_MS);
        } else {
          // Fallback to legacy single board
          const board = boardPositionRef.current;
          const world = toWorld(touch.clientX, touch.clientY);
          touchDraggedComponentIdRef.current = '__board__';
          touchDragOffsetRef.current = {
            x: world.x - board.x,
            y: world.y - board.y,
          };
        }
      } else {
        // ── 6. Empty canvas → start pan ──
        isPanningRef.current = true;
        panStartRef.current = {
          mouseX: touch.clientX,
          mouseY: touch.clientY,
          panX: panRef.current.x,
          panY: panRef.current.y,
        };
      }
    };

    const onTouchMove = (e: TouchEvent) => {
      // Let interactive components handle their own touch (potentiometer drag, etc.)
      // — UNLESS the finger has drifted past DRAG_PROMOTE_THRESHOLD_PX,
      // in which case we cancel the passthrough and start a real component
      // drag. Lets users rearrange parts during simulation without pausing.
      if (touchPassthroughRef.current) {
        const pending = pendingTouchDragRef.current;
        if (pending && e.touches.length === 1) {
          const t = e.touches[0];
          const dx = t.clientX - pending.startX;
          const dy = t.clientY - pending.startY;
          if (dx * dx + dy * dy > DRAG_PROMOTE_THRESHOLD_PX * DRAG_PROMOTE_THRESHOLD_PX) {
            const component = componentsRef.current.find((c) => c.id === pending.componentId);
            if (component) {
              const world = toWorld(t.clientX, t.clientY);
              touchDraggedComponentIdRef.current = pending.componentId;
              touchDragOffsetRef.current = {
                x: world.x - component.x,
                y: world.y - component.y,
              };
              // Snapshot the drag start position so touchend can fold the
              // move into a single undoable Move command, mirroring the
              // mouse-drag path.
              dragStartPosRef.current = { x: component.x, y: component.y };
              touchClickStartTimeRef.current = Date.now();
              touchClickStartPosRef.current = { x: t.clientX, y: t.clientY };
              // Release any pending press the wokwi-element registered when
              // the browser synthesized the initial mousedown at touchstart.
              // Without this the interactive part (button, switch knob) stays
              // visually held until the user taps it again.
              const target = document.elementFromPoint(pending.startX, pending.startY);
              if (target) {
                target.dispatchEvent(
                  new MouseEvent('mouseup', {
                    bubbles: true,
                    cancelable: true,
                    clientX: pending.startX,
                    clientY: pending.startY,
                  }),
                );
                target.dispatchEvent(
                  new MouseEvent('mouseleave', { bubbles: false }),
                );
              }
            }
            touchPassthroughRef.current = false;
            pendingTouchDragRef.current = null;
            e.preventDefault();
            // Fall through to normal touch-drag handling below.
          } else {
            return;
          }
        } else {
          return;
        }
      }
      // Pin touch: no move processing needed
      if (touchOnPinRef.current) {
        e.preventDefault();
        return;
      }

      // Cancel pending long-press if the finger drifts beyond tolerance.
      if (longPressTimerRef.current && e.touches.length === 1) {
        const t = e.touches[0];
        const dx = t.clientX - touchClickStartPosRef.current.x;
        const dy = t.clientY - touchClickStartPosRef.current.y;
        if (dx * dx + dy * dy > LONG_PRESS_MOVE_TOLERANCE * LONG_PRESS_MOVE_TOLERANCE) {
          cancelLongPress();
        }
      }

      e.preventDefault();

      if (e.touches.length === 2 && pinchStartDistRef.current > 0) {
        // ── Two-finger pinch: update zoom ──
        const dx = e.touches[0].clientX - e.touches[1].clientX;
        const dy = e.touches[0].clientY - e.touches[1].clientY;
        const dist = Math.sqrt(dx * dx + dy * dy);
        const scale = dist / pinchStartDistRef.current;
        const newZoom = Math.min(5, Math.max(0.1, pinchStartZoomRef.current * scale));

        const mid = pinchStartMidRef.current;
        const startPan = pinchStartPanRef.current;
        const startZoom = pinchStartZoomRef.current;
        const worldX = (mid.x - startPan.x) / startZoom;
        const worldY = (mid.y - startPan.y) / startZoom;
        const newPan = {
          x: mid.x - worldX * newZoom,
          y: mid.y - worldY * newZoom,
        };

        zoomRef.current = newZoom;
        panRef.current = newPan;
        const worldEl = el.querySelector('.canvas-world') as HTMLElement | null;
        if (worldEl) {
          worldEl.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${newZoom})`;
        }
        return;
      }

      if (e.touches.length !== 1) return;
      const touch = e.touches[0];

      // ── Segment drag (wire editing) via touch ──
      if (segmentDragRef.current) {
        const world = toWorld(touch.clientX, touch.clientY);
        const sd = segmentDragRef.current;
        sd.isDragging = true;
        const newValue = sd.axis === 'horizontal' ? world.y : world.x;
        const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue);
        const overridePath = renderedPointsToPath(newPts);
        setSegmentDragPreview({ wireId: sd.wireId, overridePath });
        return;
      }

      // ── Wire preview: update position as finger moves ──
      if (
        wireInProgressRef.current &&
        !isPanningRef.current &&
        !touchDraggedComponentIdRef.current
      ) {
        const world = toWorld(touch.clientX, touch.clientY);
        useSimulatorStore.getState().updateWireInProgress(world.x, world.y);
        return;
      }

      if (isPanningRef.current) {
        // ── Single finger pan ──
        const dx = touch.clientX - panStartRef.current.mouseX;
        const dy = touch.clientY - panStartRef.current.mouseY;
        const newPan = {
          x: panStartRef.current.panX + dx,
          y: panStartRef.current.panY + dy,
        };
        panRef.current = newPan;
        const worldEl = el.querySelector('.canvas-world') as HTMLElement | null;
        if (worldEl) {
          worldEl.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${zoomRef.current})`;
        }
      } else if (touchDraggedComponentIdRef.current) {
        // ── Single finger component/board drag ──
        const world = toWorld(touch.clientX, touch.clientY);
        const touchId = touchDraggedComponentIdRef.current;
        if (touchId && touchId.startsWith('__board__:')) {
          const boardId = touchId.slice('__board__:'.length);
          setBoardPosition(
            {
              x: world.x - touchDragOffsetRef.current.x,
              y: world.y - touchDragOffsetRef.current.y,
            },
            boardId,
          );
        } else if (touchId === '__board__') {
          setBoardPosition({
            x: world.x - touchDragOffsetRef.current.x,
            y: world.y - touchDragOffsetRef.current.y,
          });
        } else {
          updateComponent(touchDraggedComponentIdRef.current!, {
            x: world.x - touchDragOffsetRef.current.x,
            y: world.y - touchDragOffsetRef.current.y,
          } as any);
        }
      }
    };

    const onTouchEnd = (e: TouchEvent) => {
      // Always clear any pending long-press timer on touch release.
      cancelLongPress();
      // If the long-press fired and opened a context menu, swallow this
      // touchend so we don't also fire the short-tap action below.
      if (longPressFiredRef.current) {
        longPressFiredRef.current = false;
        touchDraggedComponentIdRef.current = null;
        e.preventDefault();
        return;
      }
      // Let interactive components handle their own touch
      if (touchPassthroughRef.current) {
        touchPassthroughRef.current = false;
        pendingTouchDragRef.current = null;
        return;
      }
      // Pin touch: let pin's onTouchEnd React handler deal with it
      if (touchOnPinRef.current) {
        touchOnPinRef.current = false;
        e.preventDefault();
        return;
      }

      e.preventDefault();

      // ── Finish pinch zoom: commit values to React state ──
      if (pinchStartDistRef.current > 0 && e.touches.length < 2) {
        setZoom(zoomRef.current);
        setPan({ ...panRef.current });
        pinchStartDistRef.current = 0;
      }

      if (e.touches.length > 0) return; // Still fingers on screen

      // ── Finish segment drag (wire editing) via touch ──
      if (segmentDragRef.current) {
        const sd = segmentDragRef.current;
        if (sd.isDragging) {
          segmentDragJustCommittedRef.current = true;
          const changed = e.changedTouches[0];
          if (changed) {
            const world = toWorld(changed.clientX, changed.clientY);
            const newValue = sd.axis === 'horizontal' ? world.y : world.x;
            const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue);
            updateWire(sd.wireId, { waypoints: renderedToWaypoints(newPts) });
          }
        }
        segmentDragRef.current = null;
        setSegmentDragPreview(null);
        return;
      }

      // ── Finish panning ──
      let wasPanning = false;
      if (isPanningRef.current) {
        isPanningRef.current = false;
        setPan({ ...panRef.current });
        wasPanning = true;
        // Don't return — fall through so short taps can select wires
      }

      const changed = e.changedTouches[0];
      if (!changed) return;

      const elapsed = Date.now() - touchClickStartTimeRef.current;
      const dx = changed.clientX - touchClickStartPosRef.current.x;
      const dy = changed.clientY - touchClickStartPosRef.current.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      const isShortTap = dist < 20 && elapsed < 400;

      // If we actually panned (moved significantly), don't process as tap
      if (wasPanning && !isShortTap) return;

      // ── Finish component/board drag ──
      if (touchDraggedComponentIdRef.current) {
        const touchId = touchDraggedComponentIdRef.current;

        if (isShortTap) {
          if (touchId.startsWith('__board__:')) {
            // Short tap on board: first tap → make it active (so pins show);
            // tap again on the same board → open the touch-friendly pin
            // picker so the user can start a wire by name without poking at
            // a tiny pin overlay with a finger.
            const boardId = touchId.slice('__board__:'.length);
            const state = useSimulatorStore.getState();
            if (state.activeBoardId === boardId) {
              setPinPicker({ kind: 'board', targetId: boardId });
            } else {
              state.setActiveBoardId(boardId);
            }
          } else if (touchId !== '__board__') {
            // Short tap on component → open property dialog or sensor panel.
            // While the simulator is running (MCU or board-less SPICE),
            // components stay interactive — only sensor panels may open;
            // property editing is disabled so the tap reaches the
            // wokwi-element underneath and toggles it.
            const component = componentsRef.current.find((c) => c.id === touchId);
            if (component) {
              if (interactionRunningRef.current) {
                if (SENSOR_CONTROLS[component.metadataId] !== undefined) {
                  setSensorControlComponentId(touchId);
                  setSensorControlMetadataId(component.metadataId);
                }
              } else {
                setPropertyDialogComponentId(touchId);
                setPropertyDialogPosition({
                  x: component.x * zoomRef.current + panRef.current.x,
                  y: component.y * zoomRef.current + panRef.current.y,
                });
                setShowPropertyDialog(true);
              }
            }
          }
        }

        recalculateAllWirePositions();
        touchDraggedComponentIdRef.current = null;
        return;
      }

      // ── Wire in progress: short tap adds waypoint OR opens pin picker ──
      // If the user tapped on a component / board body (not a pin overlay),
      // open the touch-friendly pin picker so they can finish the wire by
      // tapping a pin name from a list — far more reliable than poking at a
      // 12px overlay with a fingertip. Empty-canvas taps still drop waypoints.
      if (wireInProgressRef.current) {
        if (isShortTap) {
          const tapTarget = document.elementFromPoint(changed.clientX, changed.clientY);
          const componentWrapper = tapTarget?.closest('[data-component-id]');
          const boardOverlay = tapTarget?.closest('[data-board-overlay]');
          if (componentWrapper) {
            const id = componentWrapper.getAttribute('data-component-id');
            if (id) setPinPicker({ kind: 'component', targetId: id });
          } else if (boardOverlay) {
            const id = boardOverlay.getAttribute('data-board-id');
            if (id) setPinPicker({ kind: 'board', targetId: id });
          } else {
            const world = toWorld(changed.clientX, changed.clientY);
            useSimulatorStore.getState().addWireWaypoint(world.x, world.y);
          }
        }
        return;
      }

      // ── Short tap on empty canvas: wire selection + double-tap inserts waypoint ──
      if (isShortTap) {
        const now = Date.now();
        const world = toWorld(changed.clientX, changed.clientY);
        const baseThreshold = isTouchDeviceRef.current ? 20 : 8;
        const threshold = baseThreshold / zoomRef.current;
        const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);

        // Double-tap on a wire → insert draggable waypoint at the tap location
        const timeSinceLastTap = now - lastTapTimeRef.current;
        if (timeSinceLastTap < 350 && wire) {
          const seg = findSegmentNearPoint(wire, world.x, world.y, threshold);
          if (seg) {
            const newWaypoints = insertWaypointAtSegment(
              wire.waypoints ?? [],
              seg,
              world.x,
              world.y,
            );
            useSimulatorStore.getState().updateWire(wire.id, { waypoints: newWaypoints });
            useSimulatorStore.getState().setSelectedWire(wire.id);
          }
          lastTapTimeRef.current = 0;
          return;
        }
        lastTapTimeRef.current = now;

        if (wire) {
          const curr = selectedWireIdRef.current;
          useSimulatorStore.getState().setSelectedWire(curr === wire.id ? null : wire.id);
        } else {
          useSimulatorStore.getState().setSelectedWire(null);
          setSelectedComponentId(null);
        }
      }
    };

    el.addEventListener('touchstart', onTouchStart, { passive: false });
    el.addEventListener('touchmove', onTouchMove, { passive: false });
    el.addEventListener('touchend', onTouchEnd, { passive: false });

    return () => {
      el.removeEventListener('touchstart', onTouchStart);
      el.removeEventListener('touchmove', onTouchMove);
      el.removeEventListener('touchend', onTouchEnd);
      cancelLongPress();
    };
  }, [toWorld, setBoardPosition, updateComponent, recalculateAllWirePositions, cancelLongPress]);

  // Recalculate wire positions after web components initialize their pinInfo
  useEffect(() => {
    const timer = setTimeout(() => {
      recalculateAllWirePositions();
    }, 500);
    return () => clearTimeout(timer);
  }, [recalculateAllWirePositions]);

  // Connect components to pin manager
  useEffect(() => {
    const unsubscribers: (() => void)[] = [];

    // Returns true if the component has at least one wire connected to a board
    // GND or power-rail pin (boardPinToNumber returns -1 for these).
    // Used to block output components from activating without a ground connection.
    const componentHasGndWire = (component: any): boolean =>
      wires.some((w) => {
        const isSelfStart = w.start.componentId === component.id;
        const isSelfEnd = w.end.componentId === component.id;
        if (!isSelfStart && !isSelfEnd) return false;
        const otherEndpoint = isSelfStart ? w.end : w.start;
        if (!isBoardComponent(otherEndpoint.componentId)) return false;
        const boardInstance = boards.find((b) => b.id === otherEndpoint.componentId);
        const lookupKey = boardInstance ? boardInstance.boardKind : otherEndpoint.componentId;
        return boardPinToNumber(lookupKey, otherEndpoint.pinName) === -1;
      });

    // Helper to add subscription
    // wireConnected: true when this call came from the wire-scanning path (not properties.pin).
    const subscribeComponentToPin = (
      component: any,
      pin: number,
      componentPinName?: string,
      wireConnected = false,
    ) => {
      // Components with attachEvents in PartSimulationRegistry manage their own
      // visual state (e.g. LED, servo, buzzer). Skip generic digital/PWM updates for
      // them — they already handle GND logic internally via getArduinoPinHelper.
      //
      // SPICE-mapped components (every analog/mixed part — resistors, capacitors,
      // diodes, MOSFETs, op-amps, regulators, …) are ALSO treated as self-managed:
      // SPICE is the authoritative source for their electrical state. If we echoed
      // the raw digital pin value back into `components[i].properties.state` on
      // every toggle, the store update would invalidate the solver debounce every
      // ~2 ms under PWM (490 Hz) and the solver would effectively never run — the
      // root cause of the MOSFET-PWM-LED regression. This single rule makes every
      // current and future SPICE mapper immune to that feedback loop.
      const logic = PartSimulationRegistry.get(component.metadataId);
      const spiceOwned = isSpiceMapped(component.metadataId);
      const hasSelfManagedVisuals = !!(logic && logic.attachEvents) || spiceOwned;

      // Generic GND check: for wire-connected output components that don't manage
      // their own state, require at least one GND wire before activating.
      // Skip the check for pin-property components (no GND wire to detect) and for
      // self-managed components (they handle GND themselves via attachEvents).
      const hasGnd =
        !wireConnected || hasSelfManagedVisuals ? true : componentHasGndWire(component);

      const unsubscribe = pinManager.onPinChange(pin, (_pin, state) => {
        if (!hasSelfManagedVisuals) {
          // Update React state — gate on GND for wire-connected components.
          updateComponentState(component.id, hasGnd && state);
        }

        // Delegate to PartSimulationRegistry for custom visual updates
        if (logic && logic.onPinStateChange) {
          const el = document.getElementById(component.id);
          if (el) {
            logic.onPinStateChange(componentPinName || 'A', hasGnd && state, el);
          }
        }
      });
      unsubscribers.push(unsubscribe);
    };

    components.forEach((component) => {
      // 1. Subscribe by explicit pin property (old-style, no wire needed)
      if (component.properties.pin !== undefined) {
        subscribeComponentToPin(component, component.properties.pin as number, 'A', false);
      } else {
        // 2. Subscribe by finding wires connected to arduino
        const connectedWires = wires.filter(
          (w) => w.start.componentId === component.id || w.end.componentId === component.id,
        );

        connectedWires.forEach((wire) => {
          const isStartSelf = wire.start.componentId === component.id;
          const selfEndpoint = isStartSelf ? wire.start : wire.end;
          const otherEndpoint = isStartSelf ? wire.end : wire.start;

          if (isBoardComponent(otherEndpoint.componentId)) {
            // Use the board's actual boardKind (not just its instance ID) so that
            // a board whose ID is 'arduino-uno' but whose kind is 'esp32' gets the
            // correct GPIO mapping ('GPIO4' → 4, not null).
            const boardInstance = boards.find((b) => b.id === otherEndpoint.componentId);
            const lookupKey = boardInstance ? boardInstance.boardKind : otherEndpoint.componentId;
            const pin = boardPinToNumber(lookupKey, otherEndpoint.pinName);
            if (pin !== null && pin >= 0) {
              subscribeComponentToPin(component, pin, selfEndpoint.pinName, true);
            } else if (pin === null) {
              console.warn(
                `[WirePin] Could not resolve pin "${otherEndpoint.pinName}" on ${lookupKey}`,
              );
            }
            // pin === -1 → power/GND pin, skip silently
          }
        });
      }
    });

    return () => {
      unsubscribers.forEach((unsub) => unsub());
    };
  }, [components, wires, boards, pinManager, updateComponentState]);

  // Board built-in LED: subscribe directly to pinManager for the LED pin of each board.
  // This works even when no external led-builtin component exists (e.g. basic Blink example).
  useEffect(() => {
    if (!pinManager) return;
    const unsubs: (() => void)[] = [];

    boards.forEach((board) => {
      // Determine which GPIO pin drives the board's built-in LED
      let ledPin: number;
      switch (board.boardKind) {
        case 'raspberry-pi-pico':
        case 'pi-pico-w':
        case 'nano-rp2040':
          ledPin = 25; // GPIO25
          break;
        default:
          ledPin = 13; // Pin 13 for Arduino Uno/Nano/Mega, ATtiny85, etc.
      }

      unsubs.push(
        pinManager.onPinChange(ledPin, (_pin, state) => {
          setBoardLedStates((prev) => {
            if (prev[board.id] === state) return prev;
            return { ...prev, [board.id]: state };
          });
        }),
      );
    });

    return () => unsubs.forEach((u) => u());
  }, [boards, pinManager]);

  // ESP32 input components: forward button presses and potentiometer values to QEMU
  useEffect(() => {
    const cleanups: (() => void)[] = [];

    components.forEach((component) => {
      const connectedWires = wires.filter(
        (w) => w.start.componentId === component.id || w.end.componentId === component.id,
      );

      connectedWires.forEach((wire) => {
        const isStartSelf = wire.start.componentId === component.id;
        const selfEndpoint = isStartSelf ? wire.start : wire.end;
        const otherEndpoint = isStartSelf ? wire.end : wire.start;

        if (!isBoardComponent(otherEndpoint.componentId)) return;

        const boardId = otherEndpoint.componentId;
        const bridge = getEsp32Bridge(boardId);
        if (!bridge) return; // not an ESP32 board

        const boardInstance = boards.find((b) => b.id === boardId);
        const lookupKey = boardInstance ? boardInstance.boardKind : boardId;
        const gpioPin = boardPinToNumber(lookupKey, otherEndpoint.pinName);
        if (gpioPin === null) return;

        // Delay lookup so the web component has time to render
        const timeout = setTimeout(() => {
          const el = document.getElementById(component.id);
          if (!el) return;
          const tag = el.tagName.toLowerCase();

          // Push-button: forward press/release as GPIO level changes
          if (tag === 'wokwi-pushbutton') {
            const onPress = () => bridge.sendPinEvent(gpioPin, true);
            const onRelease = () => bridge.sendPinEvent(gpioPin, false);
            el.addEventListener('button-press', onPress);
            el.addEventListener('button-release', onRelease);
            cleanups.push(() => {
              el.removeEventListener('button-press', onPress);
              el.removeEventListener('button-release', onRelease);
            });
          }

          // Potentiometer: forward analog value as ADC millivolts
          if (tag === 'wokwi-potentiometer' && selfEndpoint.pinName === 'SIG') {
            const adcInfo = ESP32_ADC_PIN_MAP[gpioPin];
            if (adcInfo) {
              const onInput = (e: Event) => {
                const pct = parseFloat((e.target as any).value ?? '0'); // 0–100
                bridge.setAdc(adcInfo.chn, Math.round((pct / 100) * 3300));
              };
              el.addEventListener('input', onInput);
              cleanups.push(() => el.removeEventListener('input', onInput));
            }
          }
        }, 300);

        cleanups.push(() => clearTimeout(timeout));
      });
    });

    return () => cleanups.forEach((fn) => fn());
  }, [components, wires, boards]);

  // Handle keyboard delete for components and boards
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Skip when the user is typing in an input/textarea/contenteditable —
      // otherwise Backspace inside the AI chat (or any future text field)
      // also asks to delete the active board.
      const t = e.target as HTMLElement | null;
      if (t) {
        const tag = t.tagName;
        if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) {
          return;
        }
      }
      if (e.key === 'Delete' || e.key === 'Backspace') {
        if (selectedComponentId) {
          // Recorded so the user can Ctrl+Z this back. Cascades wire removal too.
          recordRemoveComponent(selectedComponentId);
          setSelectedComponentId(null);
        } else if (activeBoardId) {
          setBoardToRemove(activeBoardId);
        }
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [selectedComponentId, recordRemoveComponent, activeBoardId]);

  // Handle component selection from modal
  const handleSelectComponent = (metadata: ComponentMetadata) => {
    // Anchor new components to the visible top-left of the canvas, so they
    // appear in the user's current viewport regardless of pan/zoom (instead
    // of growing off-screen at fixed world coords like (400, 100 + row*250)).
    const rect = canvasRef.current?.getBoundingClientRect();
    const z = zoomRef.current || 1;
    const screenMargin = 60; // px on screen — keeps the part off the toolbar/edge
    const worldOrigin = rect
      ? toWorld(rect.left + screenMargin, rect.top + screenMargin)
      : { x: 100, y: 100 };

    // Tile additional drops so they don't stack exactly on top of each other,
    // while still landing inside the viewport.
    const tileStep = 40 / z; // 40 screen-px between successive drops
    const cols = 4;
    const idx = components.length;
    const x = worldOrigin.x + (idx % cols) * tileStep;
    const y = worldOrigin.y + Math.floor(idx / cols) * tileStep;

    const component = createComponentFromMetadata(metadata, x, y);
    trackAddComponent(metadata.id);
    // Recorded — user can Ctrl+Z to remove the just-added component.
    recordAddComponent(component as Parameters<typeof recordAddComponent>[0]);
    setShowComponentPicker(false);

    // Custom Chips need a compile step before they can do anything — open the
    // designer dialog immediately so the user lands in the editor.
    if (metadata.id === 'custom-chip') {
      setCustomChipComponentId(component.id);
    }
  };

  // Component rotation — applies the new angle and records it as a single
  // undoable command (round-trip flips the rotation property both ways).
  const handleRotateComponent = (componentId: string) => {
    const component = components.find((c) => c.id === componentId);
    if (!component) return;

    const currentRotation = (component.properties.rotation as number) || 0;
    const nextRotation = (currentRotation + 90) % 360;
    updateComponent(componentId, {
      properties: {
        ...component.properties,
        rotation: nextRotation,
      },
    } as any);
    recordRotate(componentId, currentRotation, nextRotation);
  };

  // Component dragging handlers
  const handleComponentMouseDown = (componentId: string, e: React.MouseEvent) => {
    if (showPropertyDialog) return;

    // While running, the canvas is read-only and most components
    // (pushbutton, switch, pot, …) need the raw event so their
    // wokwi-element shadow DOM can fire button-press / change events —
    // we let the mousedown propagate. Sensors are the exception: their
    // only interaction is the SensorControlPanel we open ourselves, so
    // we still claim the click for them (mouseUp opens the panel via
    // the SENSOR_CONTROLS branch). Without this, sensor clicks during a
    // run bubble to the canvas pan handler instead (grab cursor, no
    // panel). Mirrors the touch tap flow above.
    if (interactionRunning) {
      const component = components.find((c) => c.id === componentId);
      const isSensor = !!component && SENSOR_CONTROLS[component.metadataId] !== undefined;
      if (!isSensor) return;
    }

    e.stopPropagation();
    const component = components.find((c) => c.id === componentId);
    if (!component) return;

    setClickStartTime(Date.now());
    setClickStartPos({ x: e.clientX, y: e.clientY });

    const world = toWorld(e.clientX, e.clientY);
    setDraggedComponentId(componentId);
    setDragOffset({
      x: world.x - component.x,
      y: world.y - component.y,
    });
    // Snapshot the starting position. mouseup uses this to push a single
    // Move command if the component actually moved (vs being a click).
    dragStartPosRef.current = { x: component.x, y: component.y };
    setSelectedComponentId(componentId);
  };

  const handleCanvasMouseMove = (e: React.MouseEvent) => {
    // Handle active panning (ref-based, no setState lag)
    if (isPanningRef.current) {
      const dx = e.clientX - panStartRef.current.mouseX;
      const dy = e.clientY - panStartRef.current.mouseY;
      const newPan = {
        x: panStartRef.current.panX + dx,
        y: panStartRef.current.panY + dy,
      };
      panRef.current = newPan;
      // Update the transform directly for zero-lag panning
      const world = canvasRef.current?.querySelector('.canvas-world') as HTMLElement | null;
      if (world) {
        world.style.transform = `translate(${newPan.x}px, ${newPan.y}px) scale(${zoomRef.current})`;
      }
      return;
    }

    // Handle component/board dragging
    if (draggedComponentId) {
      const world = toWorld(e.clientX, e.clientY);
      if (draggedComponentId.startsWith('__board__:')) {
        const boardId = draggedComponentId.slice('__board__:'.length);
        setBoardPosition({ x: world.x - dragOffset.x, y: world.y - dragOffset.y }, boardId);
      } else if (draggedComponentId === '__board__') {
        // legacy fallback
        setBoardPosition({ x: world.x - dragOffset.x, y: world.y - dragOffset.y });
      } else {
        updateComponent(draggedComponentId, {
          x: world.x - dragOffset.x,
          y: world.y - dragOffset.y,
        } as any);
      }
    }

    // Handle wire creation preview
    if (wireInProgress) {
      const world = toWorld(e.clientX, e.clientY);
      updateWireInProgress(world.x, world.y);
      return;
    }

    // Handle segment handle dragging
    if (segmentDragRef.current) {
      const world = toWorld(e.clientX, e.clientY);
      const sd = segmentDragRef.current;
      sd.isDragging = true;
      const threshold = ALIGN_SNAP_PX / zoomRef.current;
      const targets = collectAlignmentTargets(wiresRef.current, sd.wireId);
      const guides: AlignmentGuide[] = [];
      let newValue = sd.axis === 'horizontal' ? world.y : world.x;
      const snap = snapToNearest(
        newValue,
        sd.axis === 'horizontal' ? targets.ys : targets.xs,
        threshold,
      );
      if (snap) {
        newValue = snap.snapped;
        guides.push({ axis: sd.axis === 'horizontal' ? 'y' : 'x', value: snap.target });
      }
      setAlignmentGuides(guides);
      const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue);
      const overridePath = renderedPointsToPath(simplifyOrthogonalPath(newPts));
      setSegmentDragPreview({ wireId: sd.wireId, overridePath });
      return;
    }

    // Handle waypoint handle dragging (free 2D move)
    if (waypointDragRef.current) {
      const world = toWorld(e.clientX, e.clientY);
      const wd = waypointDragRef.current;
      wd.isDragging = true;
      const wire = wiresRef.current.find((w) => w.id === wd.wireId);
      if (wire) {
        const threshold = ALIGN_SNAP_PX / zoomRef.current;
        const targets = collectAlignmentTargets(wiresRef.current, wd.wireId);
        const guides: AlignmentGuide[] = [];
        let snappedX = world.x;
        let snappedY = world.y;
        const snapX = snapToNearest(world.x, targets.xs, threshold);
        if (snapX) {
          snappedX = snapX.snapped;
          guides.push({ axis: 'x', value: snapX.target });
        }
        const snapY = snapToNearest(world.y, targets.ys, threshold);
        if (snapY) {
          snappedY = snapY.snapped;
          guides.push({ axis: 'y', value: snapY.target });
        }
        setAlignmentGuides(guides);
        const newWaypoints = wd.originalWaypoints.map((wp, i) =>
          i === wd.waypointIndex ? { x: snappedX, y: snappedY } : { ...wp },
        );
        setWaypointDragPreview({ wireId: wd.wireId, waypoints: newWaypoints });
        // Reflect the moved bend point in the rendered path live
        const stored = [
          { x: wire.start.x, y: wire.start.y },
          ...newWaypoints,
          { x: wire.end.x, y: wire.end.y },
        ];
        const expanded: { x: number; y: number }[] = [stored[0]];
        for (let i = 1; i < stored.length; i++) {
          const prev = stored[i - 1];
          const curr = stored[i];
          if (prev.x !== curr.x && prev.y !== curr.y) {
            expanded.push({ x: curr.x, y: prev.y });
          }
          expanded.push(curr);
        }
        const overridePath = renderedPointsToPath(simplifyOrthogonalPath(expanded));
        setSegmentDragPreview({ wireId: wd.wireId, overridePath });
      }
      return;
    }

    // Wire hover detection (when not dragging anything)
    if (!draggedComponentId) {
      const world = toWorld(e.clientX, e.clientY);
      const threshold = 8 / zoomRef.current;
      const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
      setHoveredWireId(wire ? wire.id : null);
    }
  };

  const handleCanvasMouseUp = (e: React.MouseEvent) => {
    // Finish panning — commit ref value to state so React knows the final pan
    if (isPanningRef.current) {
      isPanningRef.current = false;
      setPan({ ...panRef.current });
      return;
    }

    // Commit segment handle drag
    if (segmentDragRef.current) {
      const sd = segmentDragRef.current;
      if (sd.isDragging) {
        segmentDragJustCommittedRef.current = true;
        const world = toWorld(e.clientX, e.clientY);
        const threshold = ALIGN_SNAP_PX / zoomRef.current;
        const targets = collectAlignmentTargets(wiresRef.current, sd.wireId);
        let newValue = sd.axis === 'horizontal' ? world.y : world.x;
        const snap = snapToNearest(
          newValue,
          sd.axis === 'horizontal' ? targets.ys : targets.xs,
          threshold,
        );
        if (snap) newValue = snap.snapped;
        const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue);
        updateWire(sd.wireId, { waypoints: renderedToWaypoints(newPts) });
      }
      segmentDragRef.current = null;
      setSegmentDragPreview(null);
      setAlignmentGuides([]);
      return;
    }

    // Commit waypoint handle drag
    if (waypointDragRef.current) {
      const wd = waypointDragRef.current;
      if (wd.isDragging) {
        segmentDragJustCommittedRef.current = true;
        const world = toWorld(e.clientX, e.clientY);
        const wire = wiresRef.current.find((w) => w.id === wd.wireId);
        if (wire) {
          const threshold = ALIGN_SNAP_PX / zoomRef.current;
          const targets = collectAlignmentTargets(wiresRef.current, wd.wireId);
          let snappedX = world.x;
          let snappedY = world.y;
          const snapX = snapToNearest(world.x, targets.xs, threshold);
          if (snapX) snappedX = snapX.snapped;
          const snapY = snapToNearest(world.y, targets.ys, threshold);
          if (snapY) snappedY = snapY.snapped;
          const newWaypoints = wd.originalWaypoints.map((wp, i) =>
            i === wd.waypointIndex ? { x: snappedX, y: snappedY } : { ...wp },
          );
          // Run through expand → simplify so collinear waypoints get cleaned up
          const stored = [
            { x: wire.start.x, y: wire.start.y },
            ...newWaypoints,
            { x: wire.end.x, y: wire.end.y },
          ];
          const expanded: { x: number; y: number }[] = [stored[0]];
          for (let i = 1; i < stored.length; i++) {
            const prev = stored[i - 1];
            const curr = stored[i];
            if (prev.x !== curr.x && prev.y !== curr.y) {
              expanded.push({ x: curr.x, y: prev.y });
            }
            expanded.push(curr);
          }
          updateWire(wd.wireId, { waypoints: renderedToWaypoints(expanded) });
        }
      }
      waypointDragRef.current = null;
      setWaypointDragPreview(null);
      setSegmentDragPreview(null);
      setAlignmentGuides([]);
      return;
    }

    if (draggedComponentId) {
      const timeDiff = Date.now() - clickStartTime;
      const posDiff = Math.sqrt(
        Math.pow(e.clientX - clickStartPos.x, 2) + Math.pow(e.clientY - clickStartPos.y, 2),
      );

      if (posDiff < 5 && timeDiff < 300) {
        if (draggedComponentId.startsWith('__board__:')) {
          // Click on a board — make it the active board (editor switches to its code)
          const boardId = draggedComponentId.slice('__board__:'.length);
          useSimulatorStore.getState().setActiveBoardId(boardId);
        } else if (draggedComponentId !== '__board__') {
          const component = components.find((c) => c.id === draggedComponentId);
          if (component) {
            if (interactionRunning) {
              // During simulation (MCU running OR board-less SPICE active)
              // only sensor panels open on click — every other component
              // is interactive (pushbutton, switch, pot, …) and must
              // handle its own clicks, so we suppress the property
              // dialog entirely. This is the path that also unblocks
              // wokwi-slide-switch toggling in the digital examples.
              if (SENSOR_CONTROLS[component.metadataId] !== undefined) {
                setSensorControlComponentId(draggedComponentId);
                setSensorControlMetadataId(component.metadataId);
              }
            } else if (component.metadataId === 'custom-chip') {
              // Custom Chips have their own designer (C editor + chip.json + Compile).
              setCustomChipComponentId(draggedComponentId);
            } else {
              setPropertyDialogComponentId(draggedComponentId);
              setPropertyDialogPosition({
                x: component.x * zoomRef.current + panRef.current.x,
                y: component.y * zoomRef.current + panRef.current.y,
              });
              setShowPropertyDialog(true);
            }
          }
        }
      }

      // If this was a real drag (not a click), commit one Move command
      // so undo can roll the position back. Click-without-drag short-
      // circuits via the (posDiff < 5 && timeDiff < 300) branch above
      // and just opens the property dialog — no history entry needed.
      const start = dragStartPosRef.current;
      const isClick = posDiff < 5 && timeDiff < 300;
      if (
        !isClick &&
        start &&
        draggedComponentId &&
        !draggedComponentId.startsWith('__board__')
      ) {
        const moved = components.find((c) => c.id === draggedComponentId);
        if (moved && (moved.x !== start.x || moved.y !== start.y)) {
          recordMove(draggedComponentId, start, { x: moved.x, y: moved.y });
        }
      }
      dragStartPosRef.current = null;

      recalculateAllWirePositions();
      setDraggedComponentId(null);
    }
  };

  // Start panning on middle-click or right-click
  const handleCanvasMouseDown = (e: React.MouseEvent) => {
    // Middle / right click — always pan, regardless of context.
    // Left click — pan only when clicking ON THE BACKGROUND (the event
    // reaches the canvas because component mousedowns stopPropagation),
    // and only when not wiring (wire mode uses left click to drop
    // waypoints) or running a property dialog. Matches the diagram-editor
    // convention used in Figma / Miro / draw.io.
    const leftButton = e.button === 0;
    const middleOrRight = e.button === 1 || e.button === 2;
    const isPanGesture =
      middleOrRight ||
      (leftButton && !wireInProgress && !showPropertyDialog);

    if (isPanGesture) {
      e.preventDefault();
      isPanningRef.current = true;
      panStartRef.current = {
        mouseX: e.clientX,
        mouseY: e.clientY,
        panX: panRef.current.x,
        panY: panRef.current.y,
      };
    }
  };

  // Handle mousedown on a segment handle circle (called from WireLayer)
  const handleHandleMouseDown = useCallback(
    (e: React.MouseEvent, segIndex: number) => {
      e.stopPropagation();
      e.preventDefault();
      if (!selectedWireId) return;
      const wire = wiresRef.current.find((w) => w.id === selectedWireId);
      if (!wire) return;
      const segments = getRenderedSegments(wire);
      const seg = segments[segIndex];
      if (!seg) return;
      const expandedPts = getRenderedPoints(wire);
      segmentDragRef.current = {
        wireId: wire.id,
        segIndex,
        axis: seg.axis,
        renderedPts: expandedPts,
        isDragging: false,
      };
    },
    [selectedWireId],
  );

  // Handle touchstart on a segment handle circle (mobile wire editing)
  const handleHandleTouchStart = useCallback(
    (e: React.TouchEvent, segIndex: number) => {
      e.stopPropagation();
      if (!selectedWireId) return;
      const wire = wiresRef.current.find((w) => w.id === selectedWireId);
      if (!wire) return;
      const segments = getRenderedSegments(wire);
      const seg = segments[segIndex];
      if (!seg) return;
      const expandedPts = getRenderedPoints(wire);
      segmentDragRef.current = {
        wireId: wire.id,
        segIndex,
        axis: seg.axis,
        renderedPts: expandedPts,
        isDragging: false,
      };
    },
    [selectedWireId],
  );

  // Handle mousedown on a waypoint (bend-point) handle: free 2D drag
  const handleWaypointMouseDown = useCallback(
    (e: React.MouseEvent, waypointIndex: number) => {
      e.stopPropagation();
      e.preventDefault();
      if (!selectedWireId) return;
      const wire = wiresRef.current.find((w) => w.id === selectedWireId);
      if (!wire) return;
      waypointDragRef.current = {
        wireId: wire.id,
        waypointIndex,
        originalWaypoints: (wire.waypoints ?? []).map((wp) => ({ ...wp })),
        isDragging: false,
      };
    },
    [selectedWireId],
  );

  // Handle touchstart on a waypoint handle (mobile)
  const handleWaypointTouchStart = useCallback(
    (e: React.TouchEvent, waypointIndex: number) => {
      e.stopPropagation();
      if (!selectedWireId) return;
      const wire = wiresRef.current.find((w) => w.id === selectedWireId);
      if (!wire) return;
      waypointDragRef.current = {
        wireId: wire.id,
        waypointIndex,
        originalWaypoints: (wire.waypoints ?? []).map((wp) => ({ ...wp })),
        isDragging: false,
      };
    },
    [selectedWireId],
  );

  // Zoom centered on cursor
  const handleWheel = (e: React.WheelEvent) => {
    e.preventDefault();
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;

    const factor = e.deltaY < 0 ? 1.1 : 0.9;
    const newZoom = Math.min(5, Math.max(0.1, zoomRef.current * factor));

    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;
    // Keep the world point under the cursor fixed
    const worldX = (mx - panRef.current.x) / zoomRef.current;
    const worldY = (my - panRef.current.y) / zoomRef.current;
    const newPan = {
      x: mx - worldX * newZoom,
      y: my - worldY * newZoom,
    };

    zoomRef.current = newZoom;
    panRef.current = newPan;
    setZoom(newZoom);
    setPan(newPan);
  };

  const handleResetView = () => {
    zoomRef.current = 1;
    panRef.current = { x: 0, y: 0 };
    setZoom(1);
    setPan({ x: 0, y: 0 });
  };

  // Wire creation via pin clicks
  const handlePinClick = (componentId: string, pinName: string, x: number, y: number) => {
    // Close property dialog when starting wire creation
    if (showPropertyDialog) {
      setShowPropertyDialog(false);
    }

    if (wireInProgress) {
      // Finish wire: the store atomically appends the new wire and clears
      // `wireInProgress`. Once that's done, we look up the wire it just
      // created and push a CanvasCommand with applyNow:false (state is
      // already at the post-add state). Undo removes the wire; redo re-adds.
      finishWireCreation({ componentId, pinName, x, y });
      trackCreateWire();
      const wires = useSimulatorStore.getState().wires;
      const created = wires[wires.length - 1];
      if (created) {
        useSimulatorStore.getState().pushCommand(
          {
            description: 'Add wire',
            execute: () => useSimulatorStore.getState().addWire(created),
            undo: () => useSimulatorStore.getState().removeWire(created.id),
          },
          { applyNow: false },
        );
      }
    } else {
      // Start wire: auto-detect color from pin name
      startWireCreation({ componentId, pinName, x, y }, autoWireColor(pinName));
    }
  };

  // Keyboard handlers for wires
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Escape → cancel in-progress wire
      if (e.key === 'Escape' && wireInProgress) {
        cancelWireCreation();
        return;
      }
      // Delete / Backspace → remove selected wire (recorded for undo).
      if ((e.key === 'Delete' || e.key === 'Backspace') && selectedWireId) {
        recordRemoveWire(selectedWireId);
        return;
      }
      // Color shortcuts (0-9, c, l, m, p, y) — Wokwi style
      const key = e.key.toLowerCase();
      if (key in WIRE_KEY_COLORS) {
        if (wireInProgress) {
          setWireInProgressColor(WIRE_KEY_COLORS[key]);
        } else if (selectedWireId) {
          updateWire(selectedWireId, { color: WIRE_KEY_COLORS[key] });
        }
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [
    wireInProgress,
    cancelWireCreation,
    selectedWireId,
    recordRemoveWire,
    setWireInProgressColor,
    updateWire,
  ]);

  // Recalculate wire positions when components change (e.g., when loading an example)
  useEffect(() => {
    // Wait for components to render and pinInfo to be available
    // Use multiple retries to ensure pinInfo is ready
    const timers: ReturnType<typeof setTimeout>[] = [];

    // Try at 100ms, 300ms, and 500ms to ensure all components have rendered
    timers.push(setTimeout(() => recalculateAllWirePositions(), 100));
    timers.push(setTimeout(() => recalculateAllWirePositions(), 300));
    timers.push(setTimeout(() => recalculateAllWirePositions(), 500));

    return () => timers.forEach((t) => clearTimeout(t));
  }, [components, recalculateAllWirePositions]);

  // Auto-pan/zoom to keep the board and all components visible after a project
  // import/load. We track the previous component count and only re-center when
  // the count jumps (indicating the user loaded a new circuit, not just added
  // one part).
  //
  // On touch-primary devices we also auto-fit the zoom — projects authored on
  // a desktop with a wide canvas otherwise show up cramped at zoom 1 on a
  // ~400px-wide phone, with everything piled into the top-left corner.
  const prevComponentCountRef = useRef(-1);
  useEffect(() => {
    const prev = prevComponentCountRef.current;
    const curr = components.length;
    prevComponentCountRef.current = curr;

    // Only re-fit when the component list transitions from empty/different
    // project to a populated one (i.e., a load/import event).
    const isLoad = curr > 0 && (prev <= 0 || Math.abs(curr - prev) > 2);
    if (!isLoad) return;

    const timer = setTimeout(() => {
      const canvas = canvasRef.current;
      if (!canvas) return;
      const rect = canvas.getBoundingClientRect();

      // Read actual rendered sizes from the DOM so the fit accounts for each
      // board/component's true footprint, not a guessed bounding box.
      const z = zoomRef.current;
      const p = panRef.current;
      const toWorldRect = (r: DOMRect) => ({
        x1: (r.left - rect.left - p.x) / z,
        y1: (r.top - rect.top - p.y) / z,
        x2: (r.right - rect.left - p.x) / z,
        y2: (r.bottom - rect.top - p.y) / z,
      });

      const targets: HTMLElement[] = [];
      boardsRef.current.forEach((b) => {
        const el = canvas.querySelector<HTMLElement>(`[data-board-id="${b.id}"]`);
        if (el) targets.push(el);
      });
      componentsRef.current.forEach((c) => {
        const el = canvas.querySelector<HTMLElement>(`[data-component-id="${c.id}"]`);
        if (el) targets.push(el);
      });

      if (targets.length === 0) return;

      let minX = Infinity;
      let minY = Infinity;
      let maxX = -Infinity;
      let maxY = -Infinity;
      targets.forEach((el) => {
        const wr = toWorldRect(el.getBoundingClientRect());
        if (wr.x1 < minX) minX = wr.x1;
        if (wr.y1 < minY) minY = wr.y1;
        if (wr.x2 > maxX) maxX = wr.x2;
        if (wr.y2 > maxY) maxY = wr.y2;
      });
      if (!isFinite(minX)) return;

      const centerX = (minX + maxX) / 2;
      const centerY = (minY + maxY) / 2;
      const worldW = Math.max(1, maxX - minX);
      const worldH = Math.max(1, maxY - minY);

      // On touch-primary viewports, also adjust zoom so everything fits with
      // padding. On desktop, keep the existing behavior (pan only) so users
      // who set a custom zoom don't lose it on every load.
      let nextZoom = z;
      if (isTouchDeviceRef.current) {
        const PADDING = 32;
        const availW = Math.max(50, rect.width - PADDING * 2);
        const availH = Math.max(50, rect.height - PADDING * 2);
        // Never zoom *in* past 1× — small projects shouldn't get magnified.
        const fit = Math.min(availW / worldW, availH / worldH, 1);
        nextZoom = Math.max(0.2, fit);
      }

      const newPan = {
        x: rect.width / 2 - centerX * nextZoom,
        y: rect.height / 2 - centerY * nextZoom,
      };
      zoomRef.current = nextZoom;
      panRef.current = newPan;
      setZoom(nextZoom);
      setPan(newPan);
    }, 200);

    return () => clearTimeout(timer);
  }, [components.length]);

  // Render component using dynamic renderer
  const renderComponent = (component: any) => {
    // SPICE probes are React components, not web components — render them
    // directly and let PinOverlay read the pinInfo we attach in the wrapper.
    if (component.metadataId === 'instr-voltmeter' || component.metadataId === 'instr-ammeter') {
      const isSelected = selectedComponentId === component.id;
      const isHovered = hoveredComponentId === component.id;
      // Suppress pin overlays whenever a modal-style UI is open over the
      // canvas — they'd just visually conflict with the dialog's controls.
      const dialogOpen =
        showPropertyDialog || customChipComponentId !== null || sensorControlComponentId !== null;
      // On touch devices the pin picker dialog (PinPickerDialog) is the
      // primary way to pick pins, so the tiny overlay squares are hidden —
      // they're hard to hit with a finger anyway. Desktop still uses overlays
      // (hover/select shows them so the user can click with a mouse).
      const showPinsForComponent =
        !dialogOpen && !isTouchDevice && (wireInProgress || isSelected || isHovered);
      return (
        <React.Fragment key={component.id}>
          <div
            className="component-interactive-group"
            onMouseEnter={() => setHoveredComponentId(component.id)}
            onMouseLeave={() =>
              setHoveredComponentId((curr) => (curr === component.id ? null : curr))
            }
            style={{ display: 'contents' }}
          >
            <InstrumentComponent
              id={component.id}
              metadataId={component.metadataId}
              x={component.x}
              y={component.y}
              isSelected={isSelected}
              onMouseDown={(e) => handleComponentMouseDown(component.id, e)}
            />
            {!running && (
              <PinOverlay
                componentId={component.id}
                componentX={component.x}
                componentY={component.y}
                onPinClick={handlePinClick}
                showPins={showPinsForComponent}
                zoom={zoom}
                wrapperOffsetX={0}
                wrapperOffsetY={0}
              />
            )}
          </div>
        </React.Fragment>
      );
    }

    const metadata = registry.getById(component.metadataId);
    if (!metadata) {
      console.warn(`Metadata not found for component: ${component.metadataId}`);
      return null;
    }

    const isSelected = selectedComponentId === component.id;
    const isHovered = hoveredComponentId === component.id;
    const dialogOpen =
      showPropertyDialog || customChipComponentId !== null || sensorControlComponentId !== null;
    // Show pins only when relevant: while a wire is in progress (any pin is a
    // valid target), when this component is selected, or while hovering it.
    // Hidden when a dialog is open. Hidden entirely on touch — there the
    // PinPickerDialog (tap component → list of pins) replaces the overlays.
    const showPinsForComponent =
      !dialogOpen && !isTouchDevice && (wireInProgress || isSelected || isHovered);

    return (
      <React.Fragment key={component.id}>
        <div
          className="component-interactive-group"
          onMouseEnter={() => setHoveredComponentId(component.id)}
          onMouseLeave={() =>
            setHoveredComponentId((curr) => (curr === component.id ? null : curr))
          }
          style={{ display: 'contents' }}
        >
          <DynamicComponent
            id={component.id}
            metadata={metadata}
            properties={component.properties}
            x={component.x}
            y={component.y}
            isSelected={isSelected}
            onMouseDown={(e) => {
              handleComponentMouseDown(component.id, e);
            }}
          />

          {/* Pin overlay for wire creation - hide when running */}
          {!running && (
            <PinOverlay
              componentId={component.id}
              componentX={component.x}
              componentY={component.y}
              onPinClick={handlePinClick}
              showPins={showPinsForComponent}
              zoom={zoom}
            />
          )}
        </div>
      </React.Fragment>
    );
  };

  return (
    <div className="simulator-canvas-container">
      {/* ESP32 crash notification */}
      {esp32CrashBoardId && (
        <div
          style={{
            position: 'absolute',
            top: 8,
            left: '50%',
            transform: 'translateX(-50%)',
            zIndex: 1000,
            background: '#c0392b',
            color: '#fff',
            padding: '8px 16px',
            borderRadius: 6,
            display: 'flex',
            alignItems: 'center',
            gap: 12,
            fontSize: 13,
            boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
          }}
        >
          <span>
            ESP32 crash detected on board <strong>{esp32CrashBoardId}</strong> — cache error (IDF
            incompatibility)
          </span>
          <button
            onClick={dismissEsp32Crash}
            style={{
              background: 'transparent',
              border: '1px solid rgba(255,255,255,0.6)',
              color: '#fff',
              borderRadius: 4,
              padding: '2px 8px',
              cursor: 'pointer',
            }}
          >
            {t('editor.canvas.dismiss')}
          </button>
        </div>
      )}

      {/* Main Canvas */}
      <div className="simulator-canvas">
        {(() => {
          const headerJsx = (
        <div className={`canvas-header${headerSlot ? ' canvas-header--portaled' : ''}`}>
          <div className="canvas-header-left">
            {/* Status LED */}
            <span
              className={`status-dot ${running ? 'running' : 'stopped'}`}
              title={running ? t('editor.canvas.status.running') : t('editor.canvas.status.stopped')}
            />

            {/* Active board selector (multi-board) — hidden when no boards */}
            {boards.length > 0 ? (
              <select
                className="board-selector"
                value={activeBoardId ?? ''}
                onChange={(e) => useSimulatorStore.getState().setActiveBoardId(e.target.value)}
                disabled={running}
                title={t('editor.canvas.activeBoard')}
              >
                {boards.map((b) => (
                  <option key={b.id} value={b.id}>
                    {BOARD_KIND_LABELS[b.boardKind] ?? b.id}
                  </option>
                ))}
              </select>
            ) : (
              <span
                className="board-selector"
                style={{ opacity: 0.55, fontStyle: 'italic', cursor: 'default' }}
                title={t('editor.canvas.noBoardHint')}
              >
                {t('editor.canvas.noBoard')}
              </span>
            )}

            {/* Undo / Redo — canvas-scoped, mirrors the Ctrl+Z / Ctrl+Y
                handler in EditorPage. Tooltip surfaces the description of
                the command that would be applied. Disabled when the stack
                is exhausted in that direction. */}
            <button
              onClick={() => undo()}
              disabled={historyIndex < 0}
              className="canvas-icon-btn"
              title={
                historyIndex >= 0
                  ? t('editor.canvas.undo.title', { description: history[historyIndex].description })
                  : t('editor.canvas.undo.empty')
              }
              aria-label={t('editor.canvas.undo.label')}
            >
              <Undo2 size={16} strokeWidth={2} aria-hidden="true" />
            </button>
            <button
              onClick={() => redo()}
              disabled={historyIndex >= history.length - 1}
              className="canvas-icon-btn"
              title={
                historyIndex < history.length - 1
                  ? t('editor.canvas.redo.title', { description: history[historyIndex + 1].description })
                  : t('editor.canvas.redo.empty')
              }
              aria-label={t('editor.canvas.redo.label')}
            >
              <Redo2 size={16} strokeWidth={2} aria-hidden="true" />
            </button>

            {/* Serial Monitor toggle */}
            <button
              onClick={() => {
                toggleSerialMonitor();
                trackToggleSerialMonitor(!serialMonitorOpen);
              }}
              className={`canvas-serial-btn${serialMonitorOpen ? ' canvas-serial-btn-active' : ''}`}
              title={t('editor.canvas.toggleSerialMonitor')}
            >
              <svg
                width="22"
                height="22"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <rect x="2" y="3" width="20" height="14" rx="2" />
                <path d="M8 21h8M12 17v4" />
              </svg>
              {t('editor.canvas.serial')}
            </button>

            {/* ESP32-CAM webcam stream toggle */}
            {activeBoard?.boardKind === 'esp32-cam' && (
              <CameraToggle boardId={activeBoard.id} />
            )}

            {/* WiFi status indicator (ESP32 boards only) */}
            {activeBoard &&
              isEsp32Kind(activeBoard.boardKind) &&
              activeBoard.wifiStatus &&
              (() => {
                const status = activeBoard.wifiStatus.status;
                const hasIp = status === 'got_ip';
                const sessionId = getTabSessionId();
                const clientId = `${sessionId}::${activeBoard.id}`;
                const backendBase =
                  (import.meta.env.VITE_API_BASE as string | undefined) ??
                  'http://localhost:8001/api';
                const gatewayUrl = `${backendBase}/gateway/${clientId}/`;

                return (
                  <span
                    className={`canvas-wifi-badge canvas-wifi-${status}${hasIp ? ' canvas-wifi-clickable' : ''}`}
                    onClick={() => hasIp && window.open(gatewayUrl, '_blank')}
                    title={
                      hasIp
                        ? `WiFi: ${activeBoard.wifiStatus.ssid ?? 'Velxio-GUEST'} — IP: ${activeBoard.wifiStatus.ip}\nClick to open IoT Gateway ↗`
                        : status === 'connected'
                          ? `WiFi: ${activeBoard.wifiStatus.ssid ?? 'Velxio-GUEST'} — Connecting...`
                          : status === 'initializing'
                            ? 'WiFi: Initializing...'
                            : 'WiFi: Disconnected'
                    }
                  >
                    <svg
                      width="18"
                      height="18"
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    >
                      <path d="M5 12.55a11 11 0 0 1 14.08 0" />
                      <path d="M1.42 9a16 16 0 0 1 21.16 0" />
                      <path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
                      <circle cx="12" cy="20" r="1" />
                    </svg>
                  </span>
                );
              })()}

            {/* BLE status indicator (ESP32 boards only) */}
            {activeBoard && isEsp32Kind(activeBoard.boardKind) && activeBoard.bleStatus && (
              <span
                className={`canvas-ble-badge canvas-ble-${activeBoard.bleStatus.status}`}
                title={
                  activeBoard.bleStatus.status === 'advertising'
                    ? 'BLE: Advertising'
                    : 'BLE: Initialized'
                }
              >
                <svg
                  width="16"
                  height="16"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <polyline points="6.5 6.5 17.5 17.5 12 23 12 1 17.5 6.5 6.5 17.5" />
                </svg>
              </span>
            )}

            {/* Oscilloscope toggle */}
            <button
              onClick={toggleOscilloscope}
              className={`canvas-serial-btn${oscilloscopeOpen ? ' canvas-serial-btn-active' : ''}`}
              title={t('editor.canvas.toggleScope')}
            >
              <svg
                width="22"
                height="22"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <polyline points="2 14 6 8 10 14 14 6 18 14 22 10" />
              </svg>
              {t('editor.canvas.scope')}
            </button>
          </div>

          <div className="canvas-header-right">
            {/* Zoom controls */}
            <div className="zoom-controls">
              <button
                className="zoom-btn"
                onClick={() =>
                  handleWheel({
                    deltaY: 100,
                    clientX: 0,
                    clientY: 0,
                    preventDefault: () => {},
                  } as any)
                }
                title={t('editor.canvas.zoomOut')}
              >
                <svg
                  width="14"
                  height="14"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2.5"
                  strokeLinecap="round"
                >
                  <line x1="5" y1="12" x2="19" y2="12" />
                </svg>
              </button>
              <button
                className="zoom-level"
                onClick={handleResetView}
                title={t('editor.canvas.resetView')}
              >
                {Math.round(zoom * 100)}%
              </button>
              <button
                className="zoom-btn"
                onClick={() =>
                  handleWheel({
                    deltaY: -100,
                    clientX: 0,
                    clientY: 0,
                    preventDefault: () => {},
                  } as any)
                }
                title={t('editor.canvas.zoomIn')}
              >
                <svg
                  width="14"
                  height="14"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2.5"
                  strokeLinecap="round"
                >
                  <line x1="12" y1="5" x2="12" y2="19" />
                  <line x1="5" y1="12" x2="19" y2="12" />
                </svg>
              </button>
            </div>

            {/* Component count */}
            <span
              className="component-count"
              title={t('editor.canvas.componentCount', { count: components.length })}
            >
              <svg
                width="15"
                height="15"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <rect x="2" y="7" width="20" height="14" rx="2" />
                <path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
              </svg>
              {components.length}
            </span>

            {/* Add Component */}
            <button
              className="add-component-btn"
              onClick={() => setShowComponentPicker(true)}
              title={t('editor.canvas.addComponentTitle')}
              disabled={running}
            >
              <svg
                width="22"
                height="22"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2.5"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <line x1="12" y1="5" x2="12" y2="19" />
                <line x1="5" y1="12" x2="19" y2="12" />
              </svg>
              {t('editor.canvas.add')}
            </button>
          </div>
        </div>
          );
          return headerSlot ? createPortal(headerJsx, headerSlot) : headerJsx;
        })()}
        <div
          ref={canvasRef}
          className="canvas-content"
          onMouseDown={handleCanvasMouseDown}
          onMouseMove={handleCanvasMouseMove}
          onMouseUp={handleCanvasMouseUp}
          onMouseLeave={() => {
            isPanningRef.current = false;
            setPan({ ...panRef.current });
            setDraggedComponentId(null);
          }}
          onContextMenu={(e) => {
            e.preventDefault();
            if (wireInProgress) cancelWireCreation();
          }}
          onClick={(e) => {
            if (wireInProgress) {
              const world = toWorld(e.clientX, e.clientY);
              addWireWaypoint(world.x, world.y);
              return;
            }
            // If a segment handle drag just finished, don't also select
            if (segmentDragJustCommittedRef.current) {
              segmentDragJustCommittedRef.current = false;
              return;
            }
            // Wire selection via canvas-level hit detection
            const world = toWorld(e.clientX, e.clientY);
            const threshold = 8 / zoomRef.current;
            const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
            if (wire) {
              setSelectedWire(selectedWireId === wire.id ? null : wire.id);
            } else {
              setSelectedWire(null);
              setSelectedComponentId(null);
            }
          }}
          onDoubleClick={(e) => {
            if (wireInProgress) return;
            const world = toWorld(e.clientX, e.clientY);
            const threshold = 8 / zoomRef.current;
            const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
            if (!wire) return;
            const seg = findSegmentNearPoint(wire, world.x, world.y, threshold);
            if (!seg) return;
            // Insert a draggable waypoint where the user double-clicked,
            // projected onto the segment so the wire stays orthogonal.
            const newWaypoints = insertWaypointAtSegment(
              wire.waypoints ?? [],
              seg,
              world.x,
              world.y,
            );
            updateWire(wire.id, { waypoints: newWaypoints });
            setSelectedWire(wire.id);
          }}
          style={{
            cursor: isPanningRef.current
              ? 'grabbing'
              : wireInProgress
                ? 'crosshair'
                : hoveredWireId
                  ? 'pointer'
                  : 'default',
          }}
        >
          {/* Sensor Control Panel — shown when a sensor component is clicked during simulation.
              key={sensorControlComponentId} forces a fresh mount when the user clicks a
              different instance of the same sensor type (e.g. a second photoresistor); the
              slider state is local and would otherwise show the previously-clicked sensor's
              value until the user manually moved it. */}
          {sensorControlComponentId &&
            sensorControlMetadataId &&
            (() => {
              const meta = registry.getById(sensorControlMetadataId);
              return (
                <SensorControlPanel
                  key={sensorControlComponentId}
                  componentId={sensorControlComponentId}
                  metadataId={sensorControlMetadataId}
                  sensorName={meta?.name ?? sensorControlMetadataId}
                  onClose={() => {
                    setSensorControlComponentId(null);
                    setSensorControlMetadataId(null);
                  }}
                />
              );
            })()}

          {/* Infinite world — pan+zoom applied here */}
          <div
            className="canvas-world"
            style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
          >
            {/* Wire Layer - Renders below all components */}
            <WireLayer
              hoveredWireId={hoveredWireId}
              segmentDragPreview={segmentDragPreview}
              segmentHandles={segmentHandles}
              waypointHandles={waypointHandles}
              alignmentGuides={alignmentGuides}
              onHandleMouseDown={handleHandleMouseDown}
              onHandleTouchStart={handleHandleTouchStart}
              onWaypointMouseDown={handleWaypointMouseDown}
              onWaypointTouchStart={handleWaypointTouchStart}
            />

            {/* All boards on canvas */}
            {boards.map((board) => {
              const isHovered = hoveredBoardId === board.id;
              const isActive = board.id === activeBoardId;
              const dialogOpen =
                showPropertyDialog ||
                customChipComponentId !== null ||
                sensorControlComponentId !== null;
              // Pins show during wiring (every endpoint is a valid target),
              // when hovering the board, or when it's the active board.
              // Suppressed while a dialog is open. Hidden entirely on touch
              // since the PinPickerDialog (tap board to open list) replaces
              // the overlays — fingers can't reliably hit a 12px pin anyway.
              const showPins =
                !dialogOpen && !isTouchDevice && (wireInProgress || isHovered || isActive);
              return (
                <BoardOnCanvas
                  key={board.id}
                  board={board}
                  running={running}
                  isActive={isActive}
                  showPins={showPins}
                  led13={Boolean(boardLedStates[board.id])}
                  onMouseEnter={() => setHoveredBoardId(board.id)}
                  onMouseLeave={() =>
                    setHoveredBoardId((curr) => (curr === board.id ? null : curr))
                  }
                  onMouseDown={(e) => {
                    setClickStartTime(Date.now());
                    setClickStartPos({ x: e.clientX, y: e.clientY });
                    const world = toWorld(e.clientX, e.clientY);
                    setDraggedComponentId(`__board__:${board.id}`);
                    setDragOffset({ x: world.x - board.x, y: world.y - board.y });
                  }}
                  onContextMenu={(e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    setBoardContextMenu({ boardId: board.id, x: e.clientX, y: e.clientY });
                  }}
                  onPinClick={handlePinClick}
                  zoom={zoom}
                />
              );
            })}

            {/* Components using wokwi-elements */}
            <div className="components-area">
              {registryLoaded && components.map(renderComponent)}
            </div>

            {/* Electrical simulation overlay (voltages / warnings) */}
            <ElectricalOverlay />
          </div>

          {/* Wire creation mode banner — visible on both desktop and mobile */}
          {wireInProgress && (
            <WireModeBanner
              message="Tap a pin to connect — tap canvas for waypoints"
              onCancel={() => cancelWireCreation()}
            />
          )}

          {/* Floating action bar for the current selection — primary delete UI
              for touch devices (no Delete key, no right-click). Hidden:
              - while creating a wire (would fight the wire-mode banner)
              - on desktop (delete is bound to the Delete key, rotate is in
                the right-click menu — the floating bar covered nearby pins
                and intercepted clicks on buttons during simulation)
              - while the simulator is running (canvas is read-only). */}
          {!wireInProgress && isTouchDevice && !running &&
            (() => {
              if (selectedWireId) {
                const wire = wires.find((w) => w.id === selectedWireId);
                return (
                  <SelectionActionBar
                    kind="wire"
                    label="Wire"
                    currentColor={wire?.color}
                    onColorChange={(color) => {
                      if (!wire) return;
                      recordUpdateWire(selectedWireId, { color: wire.color }, { color });
                    }}
                    onDelete={() => {
                      // Recorded so the touch / mobile delete is also undoable.
                      recordRemoveWire(selectedWireId);
                      setSelectedWire(null);
                    }}
                    onDeselect={() => setSelectedWire(null)}
                  />
                );
              }
              if (selectedComponentId) {
                const c = components.find((x) => x.id === selectedComponentId);
                if (!c) return null;
                const meta = registry.getById(c.metadataId);
                return (
                  <SelectionActionBar
                    kind="component"
                    label={meta?.name ?? 'Component'}
                    canRotate
                    onRotate={() => handleRotateComponent(selectedComponentId)}
                    onDelete={() => {
                      recordRemoveComponent(selectedComponentId);
                      setSelectedComponentId(null);
                    }}
                    onDeselect={() => setSelectedComponentId(null)}
                  />
                );
              }
              return null;
            })()}

          {/* Minimap — small overview of the world with a draggable viewport
              rectangle, anchored to the canvas-content bottom-right corner.
              Sits inside .canvas-content (not .canvas-world) so it stays
              fixed while the world pans / zooms. */}
          <CanvasMinimap
            pan={pan}
            zoom={zoom}
            setPan={(p) => {
              panRef.current = p;
              setPan(p);
            }}
            components={components}
            boards={boards}
            viewportRef={canvasRef}
          />
        </div>
      </div>

      {/* Touch-friendly pin picker — used to pick a pin from a list when the
          user taps a component or board body (rather than poking a 12px
          overlay). Closes on backdrop tap or after a pin is chosen. */}
      {pinPicker &&
        (() => {
          const id = pinPicker.targetId;
          const el = document.getElementById(id);
          const pins: Array<{ name: string; x: number; y: number; description?: string }> = el
            ? ((el as any).pinInfo ?? [])
            : [];
          let title = pinPicker.kind === 'board' ? 'Board' : 'Component';
          if (pinPicker.kind === 'board') {
            const b = boards.find((x) => x.id === id);
            if (b) title = BOARD_KIND_LABELS[b.boardKind] ?? b.boardKind;
          } else {
            const c = components.find((x) => x.id === id);
            const meta = c ? registry.getById(c.metadataId) : null;
            if (meta) title = meta.name;
          }
          const subtitle = wireInProgress ? 'Tap a pin to connect' : 'Tap a pin to start a wire';
          // Rotate is only meaningful for components (boards have no rotation).
          const handlePickerRotate =
            pinPicker.kind === 'component'
              ? () => {
                  handleRotateComponent(id);
                }
              : undefined;
          // Delete handlers route through the existing flows: components use
          // removeComponent() directly; boards use the confirmation dialog
          // that's already wired for the right-click "Remove board" item.
          const handlePickerDelete = () => {
            if (pinPicker.kind === 'board') {
              setPinPicker(null);
              setBoardToRemove(id);
            } else {
              setPinPicker(null);
              recordRemoveComponent(id);
              setSelectedComponentId(null);
            }
          };
          return (
            <PinPickerDialog
              targetId={id}
              title={title}
              subtitle={subtitle}
              pins={pins}
              onRotate={handlePickerRotate}
              onDelete={handlePickerDelete}
              onClose={() => setPinPicker(null)}
              onPinSelect={(targetId, pinName) => {
                const pin = pins.find((p) => p.name === pinName);
                if (!pin) {
                  setPinPicker(null);
                  return;
                }
                // Resolve world coords from the rendered element rect — this
                // accounts for wrapper offsets, rotation, and current zoom.
                const compEl = document.getElementById(targetId);
                const canvasRect = canvasRef.current?.getBoundingClientRect();
                const compRect = compEl?.getBoundingClientRect();
                let worldX: number;
                let worldY: number;
                if (canvasRect && compRect) {
                  const screenX = compRect.left + pin.x * zoomRef.current;
                  const screenY = compRect.top + pin.y * zoomRef.current;
                  const w = toWorld(screenX, screenY);
                  worldX = w.x;
                  worldY = w.y;
                } else {
                  // Fallback: approximate from the stored x/y of the target.
                  if (pinPicker.kind === 'board') {
                    const b = boards.find((x) => x.id === targetId);
                    worldX = (b?.x ?? 0) + pin.x;
                    worldY = (b?.y ?? 0) + pin.y;
                  } else {
                    const c = components.find((x) => x.id === targetId);
                    worldX = (c?.x ?? 0) + pin.x;
                    worldY = (c?.y ?? 0) + pin.y;
                  }
                }
                setPinPicker(null);
                handlePinClick(targetId, pinName, worldX, worldY);
              }}
            />
          );
        })()}

      {/* Component Property Dialog */}
      {showPropertyDialog &&
        propertyDialogComponentId &&
        (() => {
          const component = components.find((c) => c.id === propertyDialogComponentId);
          const metadata = component ? registry.getById(component.metadataId) : null;
          if (!component || !metadata) return null;

          const element = document.getElementById(propertyDialogComponentId);
          const pinInfo = element ? (element as any).pinInfo : [];

          return (
            <ComponentPropertyDialog
              componentId={propertyDialogComponentId}
              componentMetadata={metadata}
              componentProperties={component.properties}
              position={propertyDialogPosition}
              pinInfo={pinInfo || []}
              wireInProgress={Boolean(wireInProgress)}
              onClose={() => setShowPropertyDialog(false)}
              onRotate={handleRotateComponent}
              onDelete={(id) => {
                recordRemoveComponent(id);
                setShowPropertyDialog(false);
              }}
              onPropertyChange={(id, propName, value) => {
                const comp = components.find((c) => c.id === id);
                if (comp) {
                  const prevValue = comp.properties[propName];
                  updateComponent(id, {
                    properties: { ...comp.properties, [propName]: value },
                  });
                  // Property panel is the canonical UI for property edits — record
                  // each change so Ctrl+Z reverts the value (without re-running the
                  // raw mutation, which already happened).
                  if (prevValue !== value) {
                    recordSetProperty(id, propName, prevValue, value);
                  }
                }
              }}
              onPinSelect={(id, pinName) => {
                // Pick world coords from the live element rect when possible —
                // it accounts for the wokwi-element wrapper offset and the
                // current rotation. Falls back to component origin + pin
                // offset if the rect can't be read.
                const compEl = document.getElementById(id);
                const pin = (pinInfo || []).find((p: { name: string }) => p.name === pinName);
                const c = components.find((x) => x.id === id);
                if (!c || !pin) return;
                const canvasRect = canvasRef.current?.getBoundingClientRect();
                const compRect = compEl?.getBoundingClientRect();
                let worldX: number;
                let worldY: number;
                if (canvasRect && compRect) {
                  // Convert pin's CSS-pixel offset (relative to component) into
                  // world coords via the canvas pan/zoom transform.
                  const screenX = compRect.left + pin.x * zoomRef.current;
                  const screenY = compRect.top + pin.y * zoomRef.current;
                  const w = toWorld(screenX, screenY);
                  worldX = w.x;
                  worldY = w.y;
                } else {
                  worldX = c.x + pin.x;
                  worldY = c.y + pin.y;
                }
                setShowPropertyDialog(false);
                handlePinClick(id, pinName, worldX, worldY);
              }}
            />
          );
        })()}

      {/* Custom Chip Designer Dialog */}
      {customChipComponentId &&
        (() => {
          const comp = components.find((c) => c.id === customChipComponentId);
          if (!comp) return null;
          const props = comp.properties as Record<string, unknown>;
          return (
            <CustomChipDialog
              initial={{
                chipName:   String(props.chipName   ?? 'My Chip'),
                sourceC:    String(props.sourceC    ?? ''),
                chipJson:   String(props.chipJson   ?? ''),
                wasmBase64: String(props.wasmBase64 ?? ''),
                attrs:      (props.attrs as Record<string, number>) ?? {},
              }}
              onClose={() => setCustomChipComponentId(null)}
              onSave={(data) => {
                updateComponent(customChipComponentId, {
                  properties: { ...comp.properties, ...data },
                } as any);
                setCustomChipComponentId(null);
                // Force the chip's pin layout to re-render after the save.
                recalculateAllWirePositions();
              }}
            />
          );
        })()}

      {/* Component Picker Modal */}
      <ComponentPickerModal
        isOpen={showComponentPicker}
        onClose={() => setShowComponentPicker(false)}
        onSelectComponent={handleSelectComponent}
        onSelectBoard={(kind: BoardKind) => {
          trackSelectBoard(kind);
          const sameKind = boards.filter((b) => b.boardKind === kind);
          const newBoardId = sameKind.length === 0 ? kind : `${kind}-${sameKind.length + 1}`;
          const x = boardPosition.x + boards.length * 60 + 420;
          const y = boardPosition.y + boards.length * 30;
          addBoard(kind, x, y);
          // file group is created inside addBoard
          void newBoardId;
        }}
      />

      {/* Board right-click context menu */}
      {boardContextMenu &&
        (() => {
          const board = boards.find((b) => b.id === boardContextMenu.boardId);
          const label = board ? BOARD_KIND_LABELS[board.boardKind] : 'Board';
          const connectedWires = wires.filter(
            (w) =>
              w.start.componentId === boardContextMenu.boardId ||
              w.end.componentId === boardContextMenu.boardId,
          ).length;
          const supportsOptions = board ? isEsp32Family(board.boardKind) : false;
          return (
            <>
              <div
                style={{ position: 'fixed', inset: 0, zIndex: 9998 }}
                onClick={() => setBoardContextMenu(null)}
                onContextMenu={(e) => {
                  e.preventDefault();
                  setBoardContextMenu(null);
                }}
              />
              <div
                style={{
                  position: 'fixed',
                  left: boardContextMenu.x,
                  top: boardContextMenu.y,
                  background: '#252526',
                  border: '1px solid #3c3c3c',
                  borderRadius: 6,
                  padding: '4px 0',
                  zIndex: 9999,
                  minWidth: 200,
                  boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
                  fontSize: 13,
                }}
              >
                <div
                  style={{
                    padding: '6px 14px',
                    color: '#888',
                    fontSize: 11,
                    borderBottom: '1px solid #3c3c3c',
                    marginBottom: 2,
                  }}
                >
                  {label}
                </div>
                <button
                  disabled={!supportsOptions}
                  title={supportsOptions ? undefined : 'ESP32 only'}
                  style={{
                    display: 'flex',
                    alignItems: 'center',
                    gap: 8,
                    width: '100%',
                    padding: '7px 14px',
                    background: 'none',
                    border: 'none',
                    color: supportsOptions ? '#ddd' : '#555',
                    cursor: supportsOptions ? 'pointer' : 'not-allowed',
                    fontSize: 13,
                    textAlign: 'left',
                  }}
                  onMouseEnter={(e) => {
                    if (supportsOptions) e.currentTarget.style.background = '#2a2d2e';
                  }}
                  onMouseLeave={(e) => {
                    e.currentTarget.style.background = 'none';
                  }}
                  onClick={() => {
                    if (!supportsOptions) return;
                    setBoardOptionsModalFor(boardContextMenu.boardId);
                    setBoardContextMenu(null);
                  }}
                >
                  <svg
                    width="14"
                    height="14"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  >
                    <circle cx="12" cy="12" r="3" />
                    <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
                  </svg>
                  Board Options...
                </button>
                <div
                  style={{
                    height: 1,
                    background: '#3c3c3c',
                    margin: '4px 0',
                  }}
                />
                <button
                  style={{
                    display: 'flex',
                    alignItems: 'center',
                    gap: 8,
                    width: '100%',
                    padding: '7px 14px',
                    background: 'none',
                    border: 'none',
                    color: '#e06c75',
                    cursor: 'pointer',
                    fontSize: 13,
                    textAlign: 'left',
                  }}
                  onMouseEnter={(e) => {
                    e.currentTarget.style.background = '#2a2d2e';
                  }}
                  onMouseLeave={(e) => {
                    e.currentTarget.style.background = 'none';
                  }}
                  onClick={() => {
                    setBoardContextMenu(null);
                    setBoardToRemove(boardContextMenu.boardId);
                  }}
                >
                  <svg
                    width="14"
                    height="14"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  >
                    <polyline points="3 6 5 6 21 6" />
                    <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
                  </svg>
                  {t('editor.canvas.removeBoard')}
                  {connectedWires > 0 && (
                    <span style={{ color: '#888', fontSize: 11 }}>
                      ({t('editor.canvas.wireCount', { count: connectedWires })})
                    </span>
                  )}
                </button>
              </div>
            </>
          );
        })()}

      {/* Board Options modal (ESP32 only) */}
      {boardOptionsModalFor &&
        (() => {
          const b = boards.find((x) => x.id === boardOptionsModalFor);
          if (!b) return null;
          return (
            <BoardOptionsModal
              isOpen={true}
              boardId={b.id}
              boardKind={b.boardKind}
              currentOptions={b.boardOptions}
              spiffsFiles={b.spiffsFiles ?? []}
              onClose={() => setBoardOptionsModalFor(null)}
              onApply={(opts) => updateBoard(b.id, { boardOptions: opts })}
              onSpiffsChange={(files) => updateBoard(b.id, { spiffsFiles: files })}
            />
          );
        })()}

      {/* Board removal confirmation dialog */}
      {boardToRemove &&
        (() => {
          const board = boards.find((b) => b.id === boardToRemove);
          const label = board ? BOARD_KIND_LABELS[board.boardKind] : t('editor.canvas.removeConfirm.boardFallback');
          const connectedWires = wires.filter(
            (w) => w.start.componentId === boardToRemove || w.end.componentId === boardToRemove,
          ).length;
          return (
            <div
              style={{
                position: 'fixed',
                inset: 0,
                background: 'rgba(0,0,0,0.5)',
                zIndex: 10000,
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
              }}
            >
              <div
                style={{
                  background: '#1e1e1e',
                  border: '1px solid #3c3c3c',
                  borderRadius: 8,
                  padding: '20px 24px',
                  maxWidth: 380,
                  boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
                }}
              >
                <h3 style={{ margin: '0 0 10px', color: '#e0e0e0', fontSize: 15 }}>
                  {t('editor.canvas.removeConfirm.title', { label })}
                </h3>
                <p style={{ margin: '0 0 16px', color: '#999', fontSize: 13, lineHeight: 1.5 }}>
                  {connectedWires > 0
                    ? t('editor.canvas.removeConfirm.bodyWithWires', { count: connectedWires })
                    : t('editor.canvas.removeConfirm.body')}
                </p>
                <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
                  <button
                    onClick={() => setBoardToRemove(null)}
                    style={{
                      padding: '6px 16px',
                      background: '#333',
                      border: '1px solid #555',
                      borderRadius: 4,
                      color: '#ccc',
                      cursor: 'pointer',
                      fontSize: 13,
                    }}
                  >
                    {t('editor.canvas.removeConfirm.cancel')}
                  </button>
                  <button
                    onClick={() => {
                      removeBoard(boardToRemove);
                      setBoardToRemove(null);
                    }}
                    style={{
                      padding: '6px 16px',
                      background: '#e06c75',
                      border: 'none',
                      borderRadius: 4,
                      color: '#fff',
                      cursor: 'pointer',
                      fontSize: 13,
                    }}
                  >
                    {t('editor.canvas.removeConfirm.remove')}
                  </button>
                </div>
              </div>
            </div>
          );
        })()}
    </div>
  );
};
