// Assembly Canvas — n8n-style open canvas view of the assembly line.
// Stations and their Human Review branches are free-floating, draggable
// nodes connected by animated bezier curves. Supports pan (trackpad
// scroll OR drag-tool), zoom, snap-to-grid, dotted grid background, and
// persistent positions in localStorage.

const { useState: useStateAC, useRef: useRefAC, useEffect: useEffectAC, useCallback: useCallbackAC, useMemo: useMemoAC } = React;

const GRID = 24;
// v5: every default coordinate is now a GRID multiple. The old values
// (80/200/660) weren't, so a dragged node — which snaps to GRID — always
// landed one cell above or below an un-dragged neighbour and could never be
// hand-aligned back to the default. Bumping the key discards stale
// non-aligned positions saved under v4.
const POSITIONS_KEY = "assembly-canvas:positions:v7"; // bump to drop stale node seats (incl. a hardcoded 'done' column) from earlier layouts
const VIEW_KEY      = "assembly-canvas:view:v1";   // remembered pan + zoom

// Layout geometry, all in GRID units so defaults land exactly on grid lines.
// Station bodies render the linear-flow StationArea (~604px wide); a 28-cell
// column pitch leaves a comfortable gutter between columns.
const COL_PITCH = GRID * 28; // 672 — horizontal distance between columns
const BASE_X    = GRID * 3;  // 72  — left margin / first column
const ROW_Y     = GRID * 9;  // 216 — top row of stations
const HR_GAP    = GRID * 1;  // 24  — grid gap a branch sits below its parent (one drag-step)
// Main (horizontal) connectors tether this far below each node's TOP rather
// than at its vertical center, so stations with aligned tops get straight
// arrows no matter how their heights differ.
const ANCHOR_TOP_Y = GRID * 3; // 72 — connector tether offset; lands on the station title
// Fallback row for the global Stuck lane — used only to reserve world-bounds /
// fit space. The rendered lane auto-seats below the left column (see below).
const STUCK_Y   = GRID * 32; // 768

// Default layout — stations + the Shipped (done) node have explicit defaults.
// The HR branches and the global Stuck lane have none: they auto-position
// below their parent station / the left column unless the user drags them, in
// which case the override is stored in localStorage.
const DEFAULT_POSITIONS = {
  pm:        { x: BASE_X,                 y: ROW_Y },
  design:    { x: BASE_X + COL_PITCH,     y: ROW_Y },
  dev:       { x: BASE_X + COL_PITCH * 2, y: ROW_Y },
  // No `done` default: the End-of-Line node is seated one column after the LAST
  // station in effectivePositions, so it tracks the line length automatically
  // instead of floating at a fixed column when stations are added/removed.
};

const snap = (v) => Math.round(v / GRID) * GRID;

// ─── Position state hook ─────────────────────────────────────────────
const usePositions = () => {
  const [positions, setPositions] = useStateAC(() => {
    try {
      const raw = localStorage.getItem(POSITIONS_KEY);
      if (raw) return { ...DEFAULT_POSITIONS, ...JSON.parse(raw) };
    } catch (e) {}
    return { ...DEFAULT_POSITIONS };
  });

  useEffectAC(() => {
    try { localStorage.setItem(POSITIONS_KEY, JSON.stringify(positions)); } catch (e) {}
  }, [positions]);

  const setPos = useCallbackAC((id, x, y) => {
    setPositions((p) => ({ ...p, [id]: { x: snap(x), y: snap(y) } }));
  }, []);

  const reset = useCallbackAC(() => setPositions({ ...DEFAULT_POSITIONS }), []);

  return [positions, setPos, reset];
};

// ─── Pan/zoom hook ───────────────────────────────────────────────────
const useViewport = () => {
  // Restore the last pan + zoom (saved by setViewSafe) so switching to the
  // About tab and back — or reloading — returns you to the same view.
  const [view, setView] = useStateAC(() => {
    try {
      const raw = localStorage.getItem(VIEW_KEY);
      if (raw) {
        const v = JSON.parse(raw);
        if (v && typeof v.k === "number") return { x: Number(v.x) || 0, y: Number(v.y) || 0, k: v.k };
      }
    } catch (e) {}
    return { x: 0, y: 0, k: 0.75 };
  });
  const setViewSafe = useCallbackAC((updater) => {
    setView((v) => {
      const next = typeof updater === "function" ? updater(v) : updater;
      const k = Math.max(0.25, Math.min(1.5, next.k));
      const out = { x: next.x, y: next.y, k };
      try { localStorage.setItem(VIEW_KEY, JSON.stringify(out)); } catch (e) {}
      return out;
    });
  }, []);
  return [view, setViewSafe];
};

// ─── Draggable Node wrapper ──────────────────────────────────────────
const CanvasNode = ({ id, x, y, width, onMove, viewK, children, zIndex = 10 }) => {
  // width may be undefined → node sizes itself to its content.
  const [drag, setDrag] = useStateAC(null);
  const onPointerDown = (e) => {
    // Only initiate drag from header handle (filter via target attribute).
    if (!e.target.closest?.("[data-node-handle]")) return;
    e.stopPropagation();
    e.preventDefault();
    e.currentTarget.setPointerCapture?.(e.pointerId);
    setDrag({ startX: e.clientX, startY: e.clientY, origX: x, origY: y, pid: e.pointerId });
  };
  const onPointerMove = (e) => {
    if (!drag) return;
    const dx = (e.clientX - drag.startX) / viewK;
    const dy = (e.clientY - drag.startY) / viewK;
    onMove(id, drag.origX + dx, drag.origY + dy, /*live*/ true);
  };
  const onPointerUp = (e) => {
    if (!drag) return;
    const dx = (e.clientX - drag.startX) / viewK;
    const dy = (e.clientY - drag.startY) / viewK;
    onMove(id, drag.origX + dx, drag.origY + dy, /*live*/ false);
    setDrag(null);
  };
  return (
    <div
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerUp}
      style={{
        position: "absolute",
        transform: `translate3d(${x}px, ${y}px, 0)`,
        width,
        zIndex: drag ? 100 : zIndex,
        touchAction: "none",
        willChange: "transform",
      }}
    >
      {children}
    </div>
  );
};

// ─── Station Node body ───────────────────────────────────────────────
// Wraps the linear-flow StationArea (dotted-border container with idle
// agents row, engaged chips perched over cards, and tether SVG layer) so
// the canvas and linear views share station + card styling.
//
// A thin drag-handle strip sits ABOVE the StationArea — StationArea's
// own title is a clickable button (opens station details), so we can't
// reuse it as the drag handle without breaking the click.
const StationNode = ({ station, index, tasks, library, onOpenStation, onOpenTask, onOpenAgent, onDecision }) => {
  const StationAreaCmp = window.StationArea;
  if (!StationAreaCmp) {
    return (
      <div className="bg-white border-2 border-dashed border-neutral-300 rounded-2xl p-6 text-xs text-neutral-400">
        Loading station area…
      </div>
    );
  }
  // Human-review tasks surface in the HR branch; stuck tasks surface in the
  // global Stuck lane — filter both out of the main station body.
  const stationTasks = (station.humanReview
    ? tasks.filter((t) => t.status !== "human-review")
    : tasks).filter((t) => t.status !== "stuck");
  return (
    <div className="rounded-2xl">
      {/* Thin drag-handle strip — transparent so the canvas/belt show through */}
      <div
        data-node-handle
        className="px-3 py-1.5 bg-neutral-200/40 border border-neutral-300/60 border-b-0 rounded-t-2xl cursor-grab active:cursor-grabbing select-none flex items-center gap-2 backdrop-blur-sm"
      >
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-neutral-400" aria-hidden>
          <circle cx="9" cy="6" r="1" />
          <circle cx="9" cy="12" r="1" />
          <circle cx="9" cy="18" r="1" />
          <circle cx="15" cy="6" r="1" />
          <circle cx="15" cy="12" r="1" />
          <circle cx="15" cy="18" r="1" />
        </svg>
        <span className="text-[10px] uppercase font-bold tracking-widest text-neutral-500 flex-1">
          Station {index + 1} · drag
        </span>
        {station.humanReview && (
          <span className="text-[9px] uppercase font-bold tracking-widest text-orange-600">Human Review →</span>
        )}
      </div>
      <StationAreaCmp
        station={station}
        tasks={stationTasks}
        library={library}
        index={index}
        onOpenStation={onOpenStation}
        onOpenTask={onOpenTask}
        onOpenAgent={onOpenAgent}
        onDecision={onDecision}
      />
    </div>
  );
};

// ─── Human Review Branch Node ────────────────────────────────────────
// Uses the same dotted-border container style as StationArea (linear flow),
// tinted orange to mark it as the human-review branch.
const HumanReviewNode = ({ station, tasks, renderTask }) => {
  return (
    <div className="rounded-2xl">
      <div
        data-node-handle
        className="px-3 py-1.5 bg-orange-100/60 border border-orange-200/70 border-b-0 rounded-t-2xl cursor-grab active:cursor-grabbing select-none flex items-center gap-2 backdrop-blur-sm"
      >
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-orange-500" aria-hidden>
          <circle cx="9" cy="6" r="1" /><circle cx="9" cy="12" r="1" /><circle cx="9" cy="18" r="1" />
          <circle cx="15" cy="6" r="1" /><circle cx="15" cy="12" r="1" /><circle cx="15" cy="18" r="1" />
        </svg>
        <span className="text-[10px] uppercase font-bold tracking-widest text-orange-700 flex-1">
          {station.title} — Human Review · drag
        </span>
        {tasks.length > 0 && (
          <span className="px-2 py-0.5 rounded-full bg-orange-500 text-white text-[10px] font-bold">{tasks.length}</span>
        )}
      </div>
      {/* Same fixed 2-column grid as the station body (StationArea) so review
          cards seat two-per-row and line up under the station's columns. A
          flex-wrap layout wrapped to one-per-row because two 280px cards + gap
          marginally exceed the node's inner width. */}
      <div
        onPointerDown={(e) => e.stopPropagation()}
        className="relative border-2 border-dashed border-orange-300 rounded-b-2xl p-4 grid grid-cols-2 gap-3 justify-start"
        style={{ minHeight: '140px', gridTemplateColumns: "repeat(2, 280px)" }}
      >
        {tasks.map((t) => renderTask(t, station))}
        {tasks.length === 0 && (
          <div className="text-orange-700/50 text-xs italic text-center py-6" style={{ gridColumn: '1 / -1' }}>No tasks awaiting human review</div>
        )}
      </div>
    </div>
  );
};

// ─── Shipped Node ────────────────────────────────────────────────────
// Matches StationArea's dotted-border container, tinted emerald to mark
// end-of-line, with the title block styled like the linear flow's
// "Shipped Product" column header.
const ShippedNode = ({ tasks, renderTask }) => {
  return (
    <div className="rounded-2xl">
      <div
        data-node-handle
        className="px-3 py-1.5 bg-emerald-100/60 border border-emerald-200/70 border-b-0 rounded-t-2xl cursor-grab active:cursor-grabbing select-none flex items-center gap-2 backdrop-blur-sm"
      >
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-emerald-500" aria-hidden>
          <circle cx="9" cy="6" r="1" /><circle cx="9" cy="12" r="1" /><circle cx="9" cy="18" r="1" />
          <circle cx="15" cy="6" r="1" /><circle cx="15" cy="12" r="1" /><circle cx="15" cy="18" r="1" />
        </svg>
        <span className="text-[10px] uppercase font-bold tracking-widest text-emerald-700 flex-1">
          End of line · drag
        </span>
      </div>
      <div
        onPointerDown={(e) => e.stopPropagation()}
        className="relative border-2 border-dashed border-emerald-300 rounded-b-2xl p-4"
        style={{ minHeight: '216px' }}
      >
        <div className="flex items-center justify-between mb-3">
          <div>
            <div className="text-[10px] font-bold uppercase tracking-widest text-emerald-600">Shipped</div>
            <div className="text-base font-black text-emerald-700">Shipped Product</div>
          </div>
          <div className="text-[10px] font-bold uppercase tracking-widest text-neutral-400">
            {tasks.length} shipped
          </div>
        </div>
        <div className="flex flex-wrap gap-3 pt-2">
          {tasks.map((t) => renderTask(t))}
          {tasks.length === 0 && (
            <div className="w-full text-emerald-700/40 text-xs italic text-center py-6">No feature shipped</div>
          )}
        </div>
      </div>
    </div>
  );
};

// ─── Stuck Node ──────────────────────────────────────────────────────
// Red-tinted collector for tasks the auto-remediation + orchestrator gave
// up on (status === 'stuck'). Mirrors the board's dedicated Stuck column.
// A global lane (not per-station) because a task can get stuck anywhere; the
// card surfaces which station via its diagnosis.
const StuckNode = ({ tasks, renderTask }) => {
  return (
    <div className="rounded-2xl">
      <div
        data-node-handle
        className="px-3 py-1.5 bg-red-100/60 border border-red-200/70 border-b-0 rounded-t-2xl cursor-grab active:cursor-grabbing select-none flex items-center gap-2 backdrop-blur-sm"
      >
        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-red-500" aria-hidden>
          <circle cx="9" cy="6" r="1" /><circle cx="9" cy="12" r="1" /><circle cx="9" cy="18" r="1" />
          <circle cx="15" cy="6" r="1" /><circle cx="15" cy="12" r="1" /><circle cx="15" cy="18" r="1" />
        </svg>
        <span className="text-[10px] uppercase font-bold tracking-widest text-red-700 flex-1">
          Stuck — needs a human · drag
        </span>
        {tasks.length > 0 && (
          <span className="px-2 py-0.5 rounded-full bg-red-500 text-white text-[10px] font-bold">{tasks.length}</span>
        )}
      </div>
      <div
        onPointerDown={(e) => e.stopPropagation()}
        className="relative border-2 border-dashed border-red-300 rounded-b-2xl p-4 flex flex-wrap gap-3"
        style={{ minHeight: '140px' }}
      >
        {tasks.map((t) => renderTask(t))}
        {tasks.length === 0 && (
          <div className="w-full text-red-700/50 text-xs italic text-center py-6">No stuck tasks</div>
        )}
      </div>
    </div>
  );
};

// ─── Main canvas component ───────────────────────────────────────────
const AssemblyCanvas = ({ tasks, renderTask, library, onOpenStation, onOpenTask, onOpenAgent, panMode = "scroll", onDecision, stations: stationsOverride }) => {
  const STATIONS_LIST = (stationsOverride && stationsOverride.length > 0)
    ? stationsOverride
    : (window.STATIONS || []);
  const containerRef = useRefAC(null);
  const nodeRefs = useRefAC({});
  const [view, setView] = useViewport();
  const [positions, setPos, resetPos] = usePositions();
  const [nodeSizes, setNodeSizes] = useStateAC({});
  const [panDrag, setPanDrag] = useStateAC(null);

  // ─── Fill height ───────────────────────────────────────────────────
  // Stretch the canvas from its own top edge down to the viewport bottom.
  // A fixed `calc(100vh - 200px)` was tuned for chrome that no longer exists
  // (the page footer was removed), so it left a dead band below the canvas.
  // Measuring the container's top is robust to any chrome above it — notably
  // the WorkflowHeader, which grows/shrinks with the run state and so shifts
  // the canvas's top. Recompute after every render (a no-op guard keeps it
  // from looping) and on resize.
  const [containerH, setContainerH] = useStateAC("calc(100vh - 200px)");
  const recomputeH = useCallbackAC(() => {
    const el = containerRef.current;
    if (!el) return;
    const top = el.getBoundingClientRect().top;
    // −1px so the wrapper's bottom border lands on the viewport edge exactly
    // (otherwise a 1px page scrollbar appears).
    const next = `${Math.max(400, Math.round(window.innerHeight - top - 1))}px`;
    setContainerH((prev) => (prev === next ? prev : next));
  }, []);
  useEffectAC(() => { recomputeH(); }); // after every render — chrome height can change
  useEffectAC(() => {
    window.addEventListener("resize", recomputeH);
    return () => window.removeEventListener("resize", recomputeH);
  }, [recomputeH]);

  // Measure node sizes so connectors anchor at the visual center of each node.
  // The outer ref wrapper has 0 height because the CanvasNode child is
  // position:absolute — measure the inner rendered element instead.
  useEffectAC(() => {
    // Re-measure on every real size change. A ResizeObserver catches height
    // changes that aren't tied to a tasks/positions update — e.g. a station
    // body growing as a task card expands when a task moves into it — so the
    // Human Review branch (seated below its station via measuredStationH) and
    // the connector anchors always follow the station's ACTUAL bottom instead
    // of a stale measurement. setNodeSizes no-ops when nothing changed, so the
    // observer can't drive a render loop.
    const measure = () => {
      setNodeSizes((prev) => {
        const next = {};
        let changed = false;
        Object.entries(nodeRefs.current).forEach(([id, el]) => {
          const inner = el?.firstElementChild;
          if (!inner) return;
          const w = inner.offsetWidth, h = inner.offsetHeight;
          next[id] = { w, h };
          const p = prev[id];
          if (!p || p.w !== w || p.h !== h) changed = true;
        });
        if (Object.keys(next).length !== Object.keys(prev).length) changed = true;
        return changed ? next : prev;
      });
    };
    measure();
    const t = setTimeout(measure, 200); // safety net for async content before the observer settles
    let ro = null;
    if (typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver(() => measure());
      Object.values(nodeRefs.current).forEach((el) => {
        const inner = el?.firstElementChild;
        if (inner) ro.observe(inner);
      });
    }
    return () => { if (ro) ro.disconnect(); clearTimeout(t); };
  }, [tasks, positions]);

  // ─── Wheel: trackpad scroll = pan, ctrl+wheel = zoom ──────────────
  // Batched via requestAnimationFrame so fast trackpad wheels collapse
  // into one state update per frame — prevents re-render jank that makes
  // cards visually jump during pan.
  const wheelPending = useRefAC({ dx: 0, dy: 0, zoom: null, rafId: null });
  const flushWheel = useCallbackAC(() => {
    const p = wheelPending.current;
    p.rafId = null;
    if (p.zoom) {
      const z = p.zoom;
      p.zoom = null;
      setView((v) => {
        const factor = Math.exp(-z.deltaY * 0.01);
        const newK = Math.max(0.25, Math.min(1.5, v.k * factor));
        const wx = (z.mx - v.x) / v.k;
        const wy = (z.my - v.y) / v.k;
        return { k: newK, x: z.mx - wx * newK, y: z.my - wy * newK };
      });
    }
    if (p.dx !== 0 || p.dy !== 0) {
      const dx = p.dx; const dy = p.dy;
      p.dx = 0; p.dy = 0;
      setView((v) => ({ ...v, x: v.x - dx, y: v.y - dy }));
    }
  }, [setView]);
  const scheduleFlush = useCallbackAC(() => {
    if (wheelPending.current.rafId == null) {
      wheelPending.current.rafId = requestAnimationFrame(flushWheel);
    }
  }, [flushWheel]);
  const onWheel = useCallbackAC((e) => {
    e.preventDefault();
    if (e.ctrlKey || e.metaKey) {
      const rect = containerRef.current.getBoundingClientRect();
      wheelPending.current.zoom = {
        deltaY: e.deltaY,
        mx: e.clientX - rect.left,
        my: e.clientY - rect.top,
      };
    } else {
      wheelPending.current.dx += e.deltaX;
      wheelPending.current.dy += e.deltaY;
    }
    scheduleFlush();
  }, [scheduleFlush]);

  useEffectAC(() => {
    const el = containerRef.current;
    if (!el) return;
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, [onWheel]);

  // ─── Drag-to-pan (when panMode === "drag" or middle-mouse / space) ─
  const onCanvasPointerDown = (e) => {
    // ignore if clicking a node (let node handle its own drags)
    if (e.target.closest?.("[data-canvas-node]")) return;
    if (panMode !== "drag" && e.button !== 1) return;
    e.preventDefault();
    containerRef.current.setPointerCapture?.(e.pointerId);
    setPanDrag({ startX: e.clientX, startY: e.clientY, origX: view.x, origY: view.y, pid: e.pointerId });
  };
  const onCanvasPointerMove = (e) => {
    if (!panDrag) return;
    setView((v) => ({ ...v, x: panDrag.origX + (e.clientX - panDrag.startX), y: panDrag.origY + (e.clientY - panDrag.startY) }));
  };
  const onCanvasPointerUp = () => setPanDrag(null);

  // Effective positions: user-dragged → legacy defaults → derived columns.
  // Single source of truth for node seating, connector anchors, fit-to-view,
  // and world bounds — so custom station ids (snapshot / ?run=) get real
  // coordinates instead of falling through to null/origin (which silently
  // dropped every connector and collapsed the fit).
  const effectivePositions = useMemoAC(() => {
    const out = { ...positions };
    STATIONS_LIST.forEach((s, i) => {
      if (!out[s.id]) out[s.id] = DEFAULT_POSITIONS[s.id] || { x: BASE_X + i * COL_PITCH, y: ROW_Y };
    });
    if (!out.done) {
      // Seat the End-of-Line (Shipped) node one column after the LAST station's
      // ACTUAL position, so it never floats far away when the line length
      // changes (a stale length-based column was leaving a big gap on load).
      const last = STATIONS_LIST[STATIONS_LIST.length - 1];
      const lastX = last && out[last.id]
        ? out[last.id].x
        : BASE_X + Math.max(0, STATIONS_LIST.length - 1) * COL_PITCH;
      out.done = { x: lastX + COL_PITCH, y: ROW_Y };
    }
    if (!out.stuck) {
      // Reservation only — the rendered lane computes its own seat below the
      // left column from measured node heights (see the Stuck node below).
      out.stuck = { x: BASE_X, y: STUCK_Y };
    }
    return out;
  }, [positions, STATIONS_LIST]);

  // ─── Fit-to-view ──────────────────────────────────────────────────
  const fitToView = useCallbackAC(() => {
    const rect = containerRef.current.getBoundingClientRect();
    const xs = Object.values(effectivePositions).map((p) => p.x);
    const ys = Object.values(effectivePositions).map((p) => p.y);
    const minX = Math.min(...xs) - 40;
    const minY = Math.min(...ys) - 40;
    const maxX = Math.max(...xs) + 520 + 40;
    const maxY = Math.max(...ys) + 400 + 40;
    const k = Math.min(rect.width / (maxX - minX), rect.height / (maxY - minY), 1);
    setView({ k, x: -minX * k + (rect.width - (maxX - minX) * k) / 2, y: -minY * k + (rect.height - (maxY - minY) * k) / 2 });
  }, [effectivePositions, setView]);

  // Demo opening view: fit to the LINE'S WIDTH (top-aligned), ignoring the tall
  // empty human-review lanes below — so the stations open as large as the width
  // allows instead of the tiny "fit everything" zoom.
  const fitDemoView = useCallbackAC(() => {
    const rect = containerRef.current.getBoundingClientRect();
    const xs = Object.values(effectivePositions).map((p) => p.x);
    const ys = Object.values(effectivePositions).map((p) => p.y);
    const minX = Math.min(...xs) - 40;
    const minY = Math.min(...ys) - 40;
    const maxX = Math.max(...xs) + 520 + 40;
    const k = Math.min(rect.width / (maxX - minX), 1);
    setView({ k, x: -minX * k + (rect.width - (maxX - minX) * k) / 2, y: -minY * k + 24 });
  }, [effectivePositions, setView]);

  useEffectAC(() => {
    // The demo always opens fresh-fitted (ignores any remembered pan/zoom, which
    // could be a stale zoomed-out state). The live viewer keeps its remembered
    // view; only fits on first mount when there's none.
    const isDemo = typeof window !== "undefined" && window.location.pathname === "/demo";
    let saved = false;
    try { saved = !!localStorage.getItem(VIEW_KEY); } catch (e) {}
    if (saved && !isDemo) return;
    const t = setTimeout(() => (isDemo ? fitDemoView() : fitToView()), 50);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ─── Connectors ────────────────────────────────────────────────────
  const connectors = useMemoAC(() => {
    const list = [];
    // station → next station
    for (let i = 0; i < STATIONS_LIST.length - 1; i++) {
      list.push({ from: STATIONS_LIST[i].id, to: STATIONS_LIST[i + 1].id, kind: "main" });
    }
    if (STATIONS_LIST.length) {
      list.push({ from: STATIONS_LIST[STATIONS_LIST.length - 1].id, to: "done", kind: "main" });
    }
    // station → its HR branch
    STATIONS_LIST.filter((s) => s.humanReview).forEach((s) => {
      list.push({ from: s.id, to: `${s.id}:hr`, kind: "branch" });
    });
    return list;
  }, [STATIONS_LIST]);

  const getAnchor = (id, side) => {
    const p = effectivePositions[id];
    const sz = nodeSizes[id] || { w: id.endsWith(":hr") ? 360 : 520, h: 280 };
    if (!p) return null; // :hr ids aren't in effectivePositions — same as legacy
    if (side === "right")  return { x: p.x + sz.w, y: p.y + ANCHOR_TOP_Y };
    if (side === "left")   return { x: p.x,        y: p.y + ANCHOR_TOP_Y };
    if (side === "bottom") return { x: p.x + sz.w / 2, y: p.y + sz.h };
    if (side === "top")    return { x: p.x + sz.w / 2, y: p.y };
    return { x: p.x, y: p.y };
  };

  const onMove = useCallbackAC((id, x, y) => {
    setPos(id, x, y);
  }, [setPos]);

  // ─── Bounds for the SVG layer (covers all node area) ──────────────
  // Use effectivePositions, not raw positions: in snapshot mode `positions`
  // is empty (no user drags) so raw bounds would collapse to the origin and
  // clip the connectors out to the last station / Shipped.
  const worldBounds = useMemoAC(() => {
    const xs = Object.values(effectivePositions).map((p) => p.x);
    const ys = Object.values(effectivePositions).map((p) => p.y);
    return {
      minX: Math.min(...xs, 0) - 200,
      minY: Math.min(...ys, 0) - 200,
      maxX: Math.max(...xs, 0) + 520 + 800,
      maxY: Math.max(...ys, 0) + 1000,
    };
  }, [effectivePositions]);

  return (
    <div
      ref={containerRef}
      className="w-full relative bg-neutral-100 overflow-hidden"
      style={{
        height: containerH,
        minHeight: 600,
        cursor: panMode === "drag" ? (panDrag ? "grabbing" : "grab") : "default",
        backgroundImage: `radial-gradient(circle, #cbd5e1 1px, transparent 1px)`,
        backgroundSize: `${GRID * view.k}px ${GRID * view.k}px`,
        backgroundPosition: `${view.x}px ${view.y}px`,
      }}
      onPointerDown={onCanvasPointerDown}
      onPointerMove={onCanvasPointerMove}
      onPointerUp={onCanvasPointerUp}
      onPointerCancel={onCanvasPointerUp}
    >
      {/* World layer — translated/scaled by view.
          translate3d promotes this to a GPU compositor layer so children
          don't shift subpixel between re-renders during pan. */}
      <div
        style={{
          position: "absolute",
          left: 0, top: 0,
          transform: `translate3d(${view.x}px, ${view.y}px, 0) scale(${view.k})`,
          transformOrigin: "0 0",
          width: 0, height: 0,
          willChange: "transform",
        }}
      >
        {/* SVG connectors — sized to world bounds. Sits BELOW node layers. */}
        <svg
          width={worldBounds.maxX - worldBounds.minX}
          height={worldBounds.maxY - worldBounds.minY}
          style={{
            position: "absolute",
            left: worldBounds.minX,
            top: worldBounds.minY,
            pointerEvents: "none",
            overflow: "visible",
            zIndex: 1,
          }}
        >
          <defs>
            <marker id="ac-arrow-main" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
              <path d="M 0 0 L 10 5 L 0 10 z" fill="#94a3b8" />
            </marker>
            <marker id="ac-arrow-branch" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
              <path d="M 0 0 L 10 5 L 0 10 z" fill="#fb923c" />
            </marker>
          </defs>
          {connectors.map((c) => {
            const isBranch = c.kind === "branch";
            const sa = getAnchor(c.from, isBranch ? "bottom" : "right");
            const ta = getAnchor(c.to, isBranch ? "top" : "left");
            if (!sa || !ta) return null;
            const x1 = sa.x - worldBounds.minX;
            const y1 = sa.y - worldBounds.minY;
            const x2 = ta.x - worldBounds.minX;
            const y2 = ta.y - worldBounds.minY;
            if (isBranch) {
              const sameX = Math.abs(x1 - x2) < 1;
              const d = sameX
                ? `M ${x1} ${y1} L ${x2} ${y2}`
                : `M ${x1} ${y1} L ${x1} ${(y1 + y2) / 2} L ${x2} ${(y1 + y2) / 2} L ${x2} ${y2}`;
              return (
                <g key={`${c.from}->${c.to}`}>
                  <path
                    d={d}
                    fill="none"
                    stroke="#fb923c"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeDasharray="2 6"
                    opacity="0.7"
                  />
                </g>
              );
            }
            const sameY = Math.abs(y1 - y2) < 1;
            const d = sameY
              ? `M ${x1} ${y1} L ${x2} ${y2}`
              : `M ${x1} ${y1} L ${(x1 + x2) / 2} ${y1} L ${(x1 + x2) / 2} ${y2} L ${x2} ${y2}`;
            return (
              <path
                key={`${c.from}->${c.to}`}
                d={d}
                fill="none"
                stroke="#94a3b8"
                strokeWidth="2"
                strokeLinecap="round"
                opacity="0.7"
                markerEnd="url(#ac-arrow-main)"
              />
            );
          })}
        </svg>

        {/* Station nodes */}
        {STATIONS_LIST.map((station, idx) => {
          const stationTasks = tasks.filter((t) => t.area === station.id && t.status !== "stuck");
          const mainTasks = station.humanReview
            ? stationTasks.filter((t) => t.status !== "human-review")
            : stationTasks;
          const hrTasks = station.humanReview
            ? stationTasks.filter((t) => t.status === "human-review")
            : [];
          // Stations are always wide enough for 2 task cards side-by-side:
          // 32px padding + 2 × 280 cards + 12px gap = 604px. Rows stack
          // downward as more tasks arrive.
          const stationWidth = 604;
          // Seat from the shared effectivePositions memo (single source of
          // truth shared with the connector anchors) so nodes and arrows agree.
          const pos = effectivePositions[station.id];
          return (
            <React.Fragment key={station.id}>
              <div
                ref={(el) => { nodeRefs.current[station.id] = el; }}
                data-canvas-node
              >
                <CanvasNode
                  id={station.id}
                  x={pos.x}
                  y={pos.y}
                  width={stationWidth}
                  onMove={onMove}
                  viewK={view.k}
                >
                  <StationNode
                    station={station}
                    index={idx}
                    tasks={stationTasks}
                    library={library}
                    onOpenStation={onOpenStation}
                    onOpenTask={onOpenTask}
                    onOpenAgent={onOpenAgent}
                    onDecision={onDecision}
                  />
                </CanvasNode>
              </div>
              {station.humanReview && (() => {
                // Estimate station height deterministically from the task count
                // rather than waiting for offsetHeight measurement — that race
                // was leaving the HR detour stacked on top of the station body
                // until the next re-render. Tasks lay out 2-per-row, so the
                // height grows in 400px row increments.
                const taskRows = Math.ceil(mainTasks.length / 2);
                // 440 = ~345 body + 62 pill block (working/validating tasks)
                // + spare. Empty rows count 0.
                const taskAreaH = taskRows * 440 + Math.max(0, taskRows - 1) * 12;
                // Station chrome: drag handle + title row + idle-agent row + paddings.
                const stationChromeH = 32 + 50 + 36 + 32;
                // Prefer the measured station height so the HR branch sits a fixed
                // gap below the station's ACTUAL bottom. The deterministic estimate
                // overshoots (it assumes 440px/row + a 216px min), which floated the
                // branch hundreds of px away. Fall back to the estimate only before
                // the first offsetHeight measurement lands — that keeps the branch
                // below the station (never overlapping) on the very first paint.
                const stationHeightEstimate = stationChromeH + Math.max(216, taskAreaH);
                const measuredStationH = nodeSizes[station.id]?.h;
                const userMoved = positions[`${station.id}:hr`];
                // Snap the auto-derived seat to the grid: the station's measured
                // height is content-dependent (handle + variable body), so without
                // snapping the branch would land off-grid. pos.x/pos.y are already
                // grid-aligned, so snapping y keeps the whole branch on grid lines.
                const hrPos = userMoved || {
                  x: pos.x,
                  y: snap(pos.y + (measuredStationH || stationHeightEstimate) + HR_GAP),
                };
                // Yellow dotted tether from the station's bottom-center down to
                // its Human Review branch.
                const stationBottom = pos.y + (measuredStationH || stationHeightEstimate);
                const tetherTop = Math.min(stationBottom, hrPos.y);
                const tetherH = Math.max(0, hrPos.y - stationBottom);
                return (
                  <React.Fragment>
                    <div
                      aria-hidden
                      style={{
                        position: "absolute",
                        left: pos.x + stationWidth / 2,
                        top: tetherTop,
                        height: tetherH,
                        borderLeft: "2px dotted #eab308",
                        zIndex: 8,
                        pointerEvents: "none",
                      }}
                    />
                    <div
                      ref={(el) => { nodeRefs.current[`${station.id}:hr`] = el; }}
                      data-canvas-node
                    >
                      <CanvasNode
                        id={`${station.id}:hr`}
                        x={hrPos.x}
                        y={hrPos.y}
                        width={stationWidth}
                        onMove={onMove}
                        viewK={view.k}
                        zIndex={9}
                      >
                        <HumanReviewNode station={station} tasks={hrTasks} renderTask={renderTask} />
                      </CanvasNode>
                    </div>
                  </React.Fragment>
                );
              })()}
            </React.Fragment>
          );
        })}

        {/* Shipped node */}
        {(() => {
          // Seat from the shared effectivePositions memo (single source of
          // truth shared with the connector anchors).
          const pos = effectivePositions.done;
          const shippedTasks = tasks.filter((t) => t.area === "done");
          return (
            <div
              ref={(el) => { nodeRefs.current["done"] = el; }}
              data-canvas-node
            >
              <CanvasNode
                id="done"
                x={pos.x}
                y={pos.y}
                width={380}
                onMove={onMove}
                viewK={view.k}
              >
                <ShippedNode tasks={shippedTasks} renderTask={renderTask} />
              </CanvasNode>
            </div>
          );
        })()}

        {/* Stuck node — global collector for status === 'stuck'. It's an
            exception lane, so it renders ONLY when something is actually stuck:
            an empty red "needs a human" box is just noise, and (seated in the
            left column) it overlapped Station 1's Human Review branch. */}
        {(() => {
          const stuckTasks = tasks.filter((t) => t.status === 'stuck');
          if (stuckTasks.length === 0) return null;
          // Seat: a user drag wins; otherwise auto-seat below the LEFT column.
          // Only station 0 and its HR branch share the lane's column (BASE_X) —
          // every other station sits a full COL_PITCH to the right — so the lane
          // only has to clear those two. Mirror the HR branch's measured-bottom
          // math so it tracks the column's actual height, then snap to the grid.
          let pos = positions.stuck;
          if (!pos) {
            const first = STATIONS_LIST[0];
            const sY = first ? (effectivePositions[first.id]?.y ?? ROW_Y) : ROW_Y;
            const sH = first ? (nodeSizes[first.id]?.h ?? 360) : 360;
            let colBottom = sY + sH;
            if (first?.humanReview) {
              const hrY = snap(sY + sH + HR_GAP);
              const hrH = nodeSizes[`${first.id}:hr`]?.h ?? 200;
              colBottom = Math.max(colBottom, hrY + hrH);
            }
            pos = { x: BASE_X, y: snap(colBottom + HR_GAP) };
          }
          return (
            <div
              ref={(el) => { nodeRefs.current["stuck"] = el; }}
              data-canvas-node
            >
              <CanvasNode
                id="stuck"
                x={pos.x}
                y={pos.y}
                width={604}
                onMove={onMove}
                viewK={view.k}
                zIndex={9}
              >
                <StuckNode tasks={stuckTasks} renderTask={renderTask} />
              </CanvasNode>
            </div>
          );
        })()}
      </div>

      {/* ─── Floating toolbar (zoom + reset) ────────────────────── */}
      <div className="absolute bottom-4 left-4 flex items-center gap-1 bg-white border border-neutral-200 rounded-xl shadow-md p-1 z-20">
        <button
          onClick={() => setView((v) => ({ ...v, k: Math.max(0.25, v.k - 0.1) }))}
          className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-neutral-100 text-neutral-600 font-bold text-lg"
          title="Zoom out"
        >−</button>
        <div className="px-2 text-xs font-mono font-bold text-neutral-600 tabular-nums w-12 text-center">{Math.round(view.k * 100)}%</div>
        <button
          onClick={() => setView((v) => ({ ...v, k: Math.min(1.5, v.k + 0.1) }))}
          className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-neutral-100 text-neutral-600 font-bold text-lg"
          title="Zoom in"
        >+</button>
        <div className="w-px h-5 bg-neutral-200 mx-1" />
        <button
          onClick={fitToView}
          className="px-2.5 h-8 flex items-center gap-1.5 rounded-lg hover:bg-neutral-100 text-neutral-600 text-xs font-bold"
          title="Fit to view"
        >
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
            <polyline points="4 14 4 20 10 20" />
            <polyline points="20 10 20 4 14 4" />
            <line x1="14" y1="10" x2="21" y2="3" />
            <line x1="3" y1="21" x2="10" y2="14" />
          </svg>
          Fit
        </button>
        <button
          onClick={resetPos}
          className="px-2.5 h-8 flex items-center gap-1.5 rounded-lg hover:bg-neutral-100 text-neutral-600 text-xs font-bold"
          title="Reset node positions"
        >
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
            <polyline points="1 4 1 10 7 10" />
            <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
          </svg>
          Reset layout
        </button>
      </div>

      {/* hint */}
      <div className="absolute bottom-4 right-4 bg-white/90 border border-neutral-200 rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-neutral-500 z-20 shadow-sm pointer-events-none">
        {panMode === "drag" ? "Drag canvas to pan · ⌘ + scroll to zoom" : "Scroll to pan · ⌘ + scroll to zoom"}
      </div>
    </div>
  );
};

window.AssemblyCanvas = AssemblyCanvas;
