// dashboard-canvas-state.js — extracted verbatim from dashboard.js (classic script,
// shared global scope). Loaded before dashboard.js. Pure move, no logic change.

// ── Phase 34b.1 — Canvas state (localStorage-backed) ──────────────
//
// Canvas-only metadata (node positions, viewport zoom/pan, sticky notes)
// lives client-side, keyed per composition. The composition body itself
// stays unchanged — runner consumes the same shape it always did.
//
// Migration path: if/when teams want positions to sync across devices, add
// a `canvas_json` column to `workflow_compositions` and copy this on save.
// For v1, single-device localStorage is good enough.

const CANVAS_STATE_KEY_PREFIX = "composer:canvas:";
const CANVAS_GRID_SIZE = 20;
const CANVAS_DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 };

function _canvasNodeId(section, idx, branchPath) {
  // section: "trigger" | "audience" | "channels" | "plays"
  // idx: index within the section (for resolvers / channels / plays)
  // branchPath: array of indices to traverse into branch.then[] sub-trees
  if (section === "trigger") return "trigger";
  if (section === "audience") return idx == null ? "audience" : `audience[${idx}]`;
  if (section === "channels") return idx == null ? "channels" : `channels[${idx}]`;
  if (section === "plays") {
    let id = idx == null ? "plays" : `plays[${idx}]`;
    if (Array.isArray(branchPath)) {
      for (const p of branchPath) id += `.then[${p}]`;
    }
    return id;
  }
  return section;
}

function _loadCanvasState(compositionId) {
  if (!compositionId) return _emptyCanvasState();
  try {
    const raw = localStorage.getItem(CANVAS_STATE_KEY_PREFIX + compositionId);
    if (!raw) return _emptyCanvasState();
    const parsed = JSON.parse(raw);
    return {
      viewport: parsed.viewport || { ...CANVAS_DEFAULT_VIEWPORT },
      positions: parsed.positions || {},
      notes: Array.isArray(parsed.notes) ? parsed.notes : [],
    };
  } catch {
    return _emptyCanvasState();
  }
}

function _emptyCanvasState() {
  return { viewport: { ...CANVAS_DEFAULT_VIEWPORT }, positions: {}, notes: [] };
}

let _canvasSaveDebounce = null;
function _saveCanvasState(compositionId, state) {
  if (!compositionId) return;
  if (_canvasSaveDebounce) clearTimeout(_canvasSaveDebounce);
  _canvasSaveDebounce = setTimeout(() => {
    try {
      localStorage.setItem(CANVAS_STATE_KEY_PREFIX + compositionId, JSON.stringify(state));
    } catch {
      /* quota exceeded — silently drop */
    }
  }, 200);
}

/**
 * Auto-arrange a composition body's nodes into default canvas positions.
 * Layout: left-to-right column layout — one column per section.
 *   Trigger    @ x=40
 *   Audience   @ x=380 (one node per resolver, stacked vertically + a "combine" merge node)
 *   Channels   @ x=720 (stacked vertically, fallback chain)
 *   Plays      @ x=1060+ (stacked vertically; branches indent +280)
 *
 * Returns a `positions` map { nodeId → {x, y} }.
 */
function _autoArrangeCanvas(body) {
  const positions = {};
  const COL_W = 340;
  const ROW_H = 100;
  const TRIGGER_X = 40;
  const AUDIENCE_X = TRIGGER_X + COL_W;
  const CHANNELS_X = AUDIENCE_X + COL_W;
  const PLAYS_X = CHANNELS_X + COL_W;

  positions["trigger"] = { x: TRIGGER_X, y: 80 };

  const resolvers = body?.audience?.resolvers || [];
  if (resolvers.length === 0) {
    positions["audience"] = { x: AUDIENCE_X, y: 80 };
  } else {
    resolvers.forEach((_, i) => {
      positions[`audience[${i}]`] = { x: AUDIENCE_X, y: 40 + i * ROW_H };
    });
  }

  const channels = body?.channels || [];
  if (channels.length === 0) {
    positions["channels"] = { x: CHANNELS_X, y: 80 };
  } else {
    channels.forEach((_, i) => {
      positions[`channels[${i}]`] = { x: CHANNELS_X, y: 40 + i * ROW_H };
    });
  }

  // Plays — flatten branches with depth indent
  const flattenedPlays = (function flatten(plays, depth = 0, branchPath = []) {
    const out = [];
    (plays || []).forEach((p, i) => {
      out.push({ p, depth, branchPath: [...branchPath, i], idx: i });
      if (p?.type === "branch" && Array.isArray(p.then)) {
        out.push(...flatten(p.then, depth + 1, [...branchPath, i]));
      }
    });
    return out;
  })(body?.plays || []);

  flattenedPlays.forEach((row, idx) => {
    const id =
      row.depth === 0
        ? `plays[${row.idx}]`
        : `plays[${row.branchPath[0]}]` +
          row.branchPath
            .slice(1)
            .map((p) => `.then[${p}]`)
            .join("");
    positions[id] = {
      x: PLAYS_X + row.depth * COL_W,
      y: 40 + idx * ROW_H,
    };
  });

  return positions;
}

/**
 * Phase 34b.1 — Canvas renderer.
 *
 * Builds an interactive canvas with:
 *   - Pan via mousedown on empty space
 *   - Zoom via wheel
 *   - Drag-to-move per node (snap to 20px grid)
 *   - Bottom toolbar (zoom in/out/fit/reset)
 *   - All Phase 34a smart-hint warnings + status badges preserved
 *
 * State persistence: localStorage keyed on compositionId. Body is unchanged.
 *
 * @param {object} body — composition body
 * @param {object} opts
 *   - compositionId (required for state persistence; null disables)
 *   - audiencePreview, lockedAccounts, lastRunStatus — same as Phase 34a
 *   - onEdit(section, idx?) — click-handler
 *   - readonly — disables drag + toolbar editing
 *   - onCanvasInteraction() — fires after each drag/pan/zoom (lets parent
 *     component re-validate or show "modified" indicator)
 */
function _renderCompositionCanvas(body, opts = {}) {
  const compositionId = opts.compositionId || null;
  const readonly = !!opts.readonly;
  const onEdit = typeof opts.onEdit === "function" ? opts.onEdit : null;

  let state = _loadCanvasState(compositionId);
  // If no positions yet, auto-arrange
  if (Object.keys(state.positions).length === 0) {
    state.positions = _autoArrangeCanvas(body);
    _saveCanvasState(compositionId, state);
  }

  const root = el("div", { class: "canvas-editor" });
  root.style.position = "relative";
  root.style.width = "100%";
  root.style.height = "70vh";
  root.style.minHeight = "500px";
  root.style.background = "#0a0a0a";
  root.style.borderRadius = "8px";
  root.style.border = "1px solid #1a1a1a";
  root.style.overflow = "hidden";
  root.style.userSelect = "none";

  // Inner canvas — the part that pans/zooms
  const canvas = el("div", { class: "canvas-editor-inner" });
  canvas.style.position = "absolute";
  canvas.style.top = "0";
  canvas.style.left = "0";
  canvas.style.transformOrigin = "0 0";
  canvas.style.willChange = "transform";
  // Background grid (rendered on the inner so it scrolls with content)
  canvas.style.backgroundImage = `
    radial-gradient(circle, #1f1f1f 1px, transparent 1px)
  `;
  canvas.style.backgroundSize = `${CANVAS_GRID_SIZE}px ${CANVAS_GRID_SIZE}px`;
  canvas.style.width = "5000px";
  canvas.style.height = "3000px";
  root.appendChild(canvas);

  const applyViewport = () => {
    canvas.style.transform = `translate(${state.viewport.x}px, ${state.viewport.y}px) scale(${state.viewport.zoom})`;
  };
  applyViewport();

  // ─── Render nodes ────────────────────────────────────────────────
  const nodeEls = new Map(); // nodeId → DOM element
  const renderAllNodes = () => {
    nodeEls.forEach((n) => n.remove());
    nodeEls.clear();
    _canvasRenderNodes(body, canvas, state, opts, nodeEls, renderAllNodes);
  };
  renderAllNodes();

  // ─── Render sticky notes (Phase 34b.5) ────────────────────────────
  const noteEls = new Map(); // noteId → DOM element
  const renderAllNotes = () => {
    noteEls.forEach((n) => n.remove());
    noteEls.clear();
    _canvasRenderNotes(
      canvas,
      state,
      noteEls,
      () => {
        _saveCanvasState(compositionId, state);
      },
      readonly,
    );
  };
  renderAllNotes();

  // ─── Edges (SVG overlay) ─────────────────────────────────────────
  const edgesSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  edgesSvg.style.position = "absolute";
  edgesSvg.style.top = "0";
  edgesSvg.style.left = "0";
  edgesSvg.style.width = "5000px";
  edgesSvg.style.height = "3000px";
  edgesSvg.style.pointerEvents = "none";
  edgesSvg.setAttribute("width", "5000");
  edgesSvg.setAttribute("height", "3000");
  canvas.insertBefore(edgesSvg, canvas.firstChild);

  const redrawEdges = () => {
    while (edgesSvg.firstChild) edgesSvg.removeChild(edgesSvg.firstChild);
    _canvasRenderEdges(body, state, edgesSvg);
  };
  redrawEdges();

  // Drag fast-path: the node set is unchanged, so only the edges that touch
  // the moved node need new geometry. Every other path's `d` would come out of
  // a full redraw byte-identical (nothing but this node's position changed).
  const repointEdgesFor = (nodeId) => {
    const sel = `path[data-from="${nodeId}"],path[data-to="${nodeId}"]`;
    for (const path of edgesSvg.querySelectorAll(sel)) {
      const f = state.positions[path.getAttribute("data-from")];
      const t = state.positions[path.getAttribute("data-to")];
      if (f && t) path.setAttribute("d", _canvasEdgePathD(f, t));
    }
  };

  // ─── Pan via mousedown on empty (canvas, not a node) ─────────────
  // The document-level handlers are attached only for the duration of a pan
  // (same lifecycle the node-drag code uses). Attaching them at render time
  // leaked two listeners — closing over this root, canvas and state — on every
  // re-render, so discarded canvases could never be collected.
  let panStart = null;
  const onPanMove = (ev) => {
    if (!panStart) return;
    state.viewport.x = panStart.vx + (ev.clientX - panStart.x);
    state.viewport.y = panStart.vy + (ev.clientY - panStart.y);
    applyViewport();
  };
  const onPanUp = () => {
    document.removeEventListener("mousemove", onPanMove);
    document.removeEventListener("mouseup", onPanUp);
    if (!panStart) return;
    panStart = null;
    root.style.cursor = "";
    _saveCanvasState(compositionId, state);
  };
  root.addEventListener("mousedown", (ev) => {
    if (readonly) return;
    if (ev.target !== root && ev.target !== canvas && ev.target !== edgesSvg) return;
    panStart = { x: ev.clientX, y: ev.clientY, vx: state.viewport.x, vy: state.viewport.y };
    root.style.cursor = "grabbing";
    document.addEventListener("mousemove", onPanMove);
    document.addEventListener("mouseup", onPanUp);
  });

  // ─── Zoom via wheel ──────────────────────────────────────────────
  root.addEventListener(
    "wheel",
    (ev) => {
      ev.preventDefault();
      const delta = -ev.deltaY * 0.001;
      const oldZoom = state.viewport.zoom;
      const newZoom = Math.max(0.25, Math.min(2.5, oldZoom + delta));
      // Zoom toward mouse position
      const rect = root.getBoundingClientRect();
      const mx = ev.clientX - rect.left;
      const my = ev.clientY - rect.top;
      state.viewport.x = mx - (mx - state.viewport.x) * (newZoom / oldZoom);
      state.viewport.y = my - (my - state.viewport.y) * (newZoom / oldZoom);
      state.viewport.zoom = newZoom;
      applyViewport();
      _saveCanvasState(compositionId, state);
    },
    { passive: false },
  );

  // ─── Drag-to-move nodes ───────────────────────────────────────────
  // Implemented per-node when nodes are rendered; see _canvasRenderNodes.
  // Provides (nodeId, newX, newY) → updates state.positions + the node's edges.
  root._canvasMoveNode = (nodeId, x, y) => {
    // Snap to grid
    const sx = Math.round(x / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE;
    const sy = Math.round(y / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE;
    state.positions[nodeId] = { x: sx, y: sy };
    const node = nodeEls.get(nodeId);
    if (node) {
      node.style.left = sx + "px";
      node.style.top = sy + "px";
    }
    repointEdgesFor(nodeId);
    _saveCanvasState(compositionId, state);
  };
  root._canvasGetState = () => state;
  root._canvasRedrawEdges = redrawEdges;
  root._canvasRerenderNodes = renderAllNodes;

  // Phase 35.1: HTML5 drag-and-drop from library drawer.
  // The library entries set dataTransfer["application/x-composer-node"] to a
  // JSON payload { kind: "resolver"|"channel"|"play", entry: {...} }. The
  // canvas listens for dragover (preventDefault to allow drop) and drop
  // (compute position from event coords + viewport, fire opts.onDropNode).
  root.addEventListener("dragover", (ev) => {
    if (readonly) return;
    // Only show drop target if we know it's our payload type
    const types = ev.dataTransfer?.types || [];
    if (Array.from(types).includes("application/x-composer-node")) {
      ev.preventDefault();
      ev.dataTransfer.dropEffect = "copy";
    }
  });
  root.addEventListener("drop", (ev) => {
    if (readonly) return;
    let raw = "";
    try {
      raw = ev.dataTransfer?.getData("application/x-composer-node") || "";
    } catch {}
    if (!raw) return;
    let payload;
    try {
      payload = JSON.parse(raw);
    } catch {
      return;
    }
    if (!payload?.kind || !payload?.entry) return;
    ev.preventDefault();
    // Compute drop position in canvas coords (account for pan + zoom)
    const rect = root.getBoundingClientRect();
    const cx = (ev.clientX - rect.left - state.viewport.x) / state.viewport.zoom;
    const cy = (ev.clientY - rect.top - state.viewport.y) / state.viewport.zoom;
    const sx = Math.round(cx / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE;
    const sy = Math.round(cy / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE;
    if (typeof opts.onDropNode === "function") {
      opts.onDropNode(payload, sx, sy);
    }
  });

  // Phase 34b.5: add a sticky note at the current viewport center
  root._canvasAddNote = (color = "#3a2a0a") => {
    const noteId = `note_${Date.now()}`;
    const rect = root.getBoundingClientRect();
    // Place at the visible-viewport center, accounting for current pan/zoom
    const cx = (rect.width / 2 - state.viewport.x) / state.viewport.zoom;
    const cy = (rect.height / 2 - state.viewport.y) / state.viewport.zoom;
    const note = {
      id: noteId,
      x: Math.round(cx / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE,
      y: Math.round(cy / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE,
      w: 220,
      h: 140,
      text: "Click to edit",
      color,
    };
    state.notes.push(note);
    _saveCanvasState(compositionId, state);
    renderAllNotes();
  };

  // ─── Bottom toolbar (zoom/fit/reset) ─────────────────────────────
  if (!readonly) {
    const toolbar = el("div", { class: "canvas-toolbar" });
    toolbar.style.position = "absolute";
    toolbar.style.bottom = "12px";
    toolbar.style.left = "12px";
    toolbar.style.display = "flex";
    toolbar.style.gap = "4px";
    toolbar.style.background = "#0c0c0c";
    toolbar.style.border = "1px solid #222";
    toolbar.style.borderRadius = "6px";
    toolbar.style.padding = "4px";
    toolbar.style.zIndex = "10";

    const mkBtn = (label, title, onclick) => {
      const b = el("button", { class: "btn btn-tiny btn-secondary", type: "button", title }, label);
      b.style.padding = "4px 8px";
      b.style.minWidth = "32px";
      b.onclick = onclick;
      return b;
    };
    toolbar.appendChild(
      mkBtn("+", "Zoom in", () => {
        state.viewport.zoom = Math.min(2.5, state.viewport.zoom + 0.15);
        applyViewport();
        _saveCanvasState(compositionId, state);
      }),
    );
    toolbar.appendChild(
      mkBtn("−", "Zoom out", () => {
        state.viewport.zoom = Math.max(0.25, state.viewport.zoom - 0.15);
        applyViewport();
        _saveCanvasState(compositionId, state);
      }),
    );
    toolbar.appendChild(
      mkBtn("⛶", "Fit to screen", () => {
        state.viewport = { ...CANVAS_DEFAULT_VIEWPORT };
        applyViewport();
        _saveCanvasState(compositionId, state);
      }),
    );
    toolbar.appendChild(
      mkBtn("⤺", "Auto-arrange (reset positions)", () => {
        if (!confirm("Reset all node positions to the auto-arranged layout?")) return;
        state.positions = _autoArrangeCanvas(body);
        state.viewport = { ...CANVAS_DEFAULT_VIEWPORT };
        applyViewport();
        renderAllNodes();
        redrawEdges();
        _saveCanvasState(compositionId, state);
      }),
    );
    // Phase 34b.5: add sticky note (with color picker on hover/click)
    toolbar.appendChild(
      mkBtn("📝", "Add sticky note", () => {
        root._canvasAddNote("#3a2a0a"); // default yellow
      }),
    );
    root.appendChild(toolbar);
  }

  return root;
}

/**
 * Phase 34b.5 — Render all sticky notes in the canvas state.
 * Each note is a freely-positioned div with: drag-to-reposition, resize
 * handle (bottom-right), color picker (top-right dropdown), delete button,
 * and an inline-editable text body. Notes render BEHIND nodes (lower z-index)
 * so they don't block clicks on the actual workflow.
 */
function _canvasRenderNotes(canvas, state, noteEls, onSave, readonly) {
  for (const note of state.notes || []) {
    const div = el("div", {
      class: "canvas-sticky",
      "data-note-id": note.id,
    });
    div.style.position = "absolute";
    div.style.left = note.x + "px";
    div.style.top = note.y + "px";
    div.style.width = (note.w || 220) + "px";
    div.style.height = (note.h || 140) + "px";
    div.style.background = note.color || "#3a2a0a";
    div.style.border = "1px solid rgba(255, 255, 255, 0.06)";
    div.style.borderRadius = "6px";
    div.style.padding = "10px 12px";
    div.style.fontSize = "11px";
    div.style.color = "#eee";
    div.style.lineHeight = "1.5";
    div.style.zIndex = "1"; // behind nodes (z=2)
    div.style.boxSizing = "border-box";
    div.style.overflow = "hidden";
    div.style.userSelect = "none";

    // Header strip — drag handle + delete button (top edge of the note)
    const header = el("div", {
      class: "canvas-sticky-header",
      style:
        "display: flex; justify-content: space-between; align-items: center; height: 14px; margin-bottom: 6px; cursor: move; opacity: 0.6;",
    });
    const colors = [
      { id: "yellow", bg: "#3a2a0a" },
      { id: "green", bg: "#0a2a1a" },
      { id: "blue", bg: "#0a1a2a" },
      { id: "purple", bg: "#2a0a2a" },
    ];
    const colorRow = el(
      "div",
      { style: "display: flex; gap: 4px;" },
      colors.map((c) =>
        el("div", {
          style: `width: 10px; height: 10px; border-radius: 50%; background: ${c.bg}; cursor: pointer; border: 1px solid rgba(255,255,255,0.2);`,
          title: c.id,
          onclick: (ev) => {
            ev.stopPropagation();
            note.color = c.bg;
            div.style.background = c.bg;
            if (typeof onSave === "function") onSave();
          },
        }),
      ),
    );
    header.appendChild(colorRow);
    if (!readonly) {
      header.appendChild(
        el(
          "span",
          {
            style: "cursor: pointer; padding: 0 4px;",
            title: "Delete note",
            onclick: (ev) => {
              ev.stopPropagation();
              if (!confirm("Delete this note?")) return;
              state.notes = (state.notes || []).filter((n) => n.id !== note.id);
              div.remove();
              if (typeof onSave === "function") onSave();
            },
          },
          "✕",
        ),
      );
    }
    div.appendChild(header);

    // Editable text body
    const txt = el("div", {
      class: "canvas-sticky-body",
      contenteditable: readonly ? null : "true",
      style:
        "outline: none; min-height: 80px; max-height: calc(100% - 30px); overflow-y: auto; word-wrap: break-word; user-select: text;",
    });
    txt.textContent = note.text || "";
    if (!readonly) {
      txt.addEventListener("input", () => {
        note.text = txt.textContent;
        if (typeof onSave === "function") onSave();
      });
      // Stop drag on text edit (so user can select text)
      txt.addEventListener("mousedown", (ev) => ev.stopPropagation());
    }
    div.appendChild(txt);

    // Resize handle (bottom-right)
    if (!readonly) {
      const handle = el("div", {
        style:
          "position: absolute; bottom: 0; right: 0; width: 12px; height: 12px; cursor: nwse-resize; opacity: 0.4;",
        title: "Drag to resize",
      });
      handle.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2,10 L10,10 M5,10 L10,5 M8,10 L10,8" stroke="white" stroke-width="1.5" fill="none"/></svg>`;
      let resizeStart = null;
      handle.addEventListener("mousedown", (ev) => {
        ev.stopPropagation();
        const zoom = state.viewport.zoom || 1;
        resizeStart = { mx: ev.clientX, my: ev.clientY, w: note.w, h: note.h, zoom };
        const moveHandler = (ev2) => {
          const dw = (ev2.clientX - resizeStart.mx) / resizeStart.zoom;
          const dh = (ev2.clientY - resizeStart.my) / resizeStart.zoom;
          note.w = Math.max(
            120,
            Math.round((resizeStart.w + dw) / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE,
          );
          note.h = Math.max(
            80,
            Math.round((resizeStart.h + dh) / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE,
          );
          div.style.width = note.w + "px";
          div.style.height = note.h + "px";
        };
        const upHandler = () => {
          document.removeEventListener("mousemove", moveHandler);
          document.removeEventListener("mouseup", upHandler);
          resizeStart = null;
          if (typeof onSave === "function") onSave();
        };
        document.addEventListener("mousemove", moveHandler);
        document.addEventListener("mouseup", upHandler);
      });
      div.appendChild(handle);
    }

    // Drag-to-reposition (mousedown on the body, but not on the editable text)
    if (!readonly) {
      let dragStart = null;
      div.addEventListener("mousedown", (ev) => {
        // Skip if clicking on contenteditable text or controls
        if (ev.target === txt || ev.target.closest(".canvas-sticky-body")) return;
        if (ev.target.closest("svg")) return; // resize handle
        ev.stopPropagation();
        const zoom = state.viewport.zoom || 1;
        dragStart = { mx: ev.clientX, my: ev.clientY, x0: note.x, y0: note.y, zoom };
        div.style.cursor = "grabbing";
        const moveHandler = (ev2) => {
          const dx = (ev2.clientX - dragStart.mx) / dragStart.zoom;
          const dy = (ev2.clientY - dragStart.my) / dragStart.zoom;
          note.x = Math.round((dragStart.x0 + dx) / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE;
          note.y = Math.round((dragStart.y0 + dy) / CANVAS_GRID_SIZE) * CANVAS_GRID_SIZE;
          div.style.left = note.x + "px";
          div.style.top = note.y + "px";
        };
        const upHandler = () => {
          document.removeEventListener("mousemove", moveHandler);
          document.removeEventListener("mouseup", upHandler);
          div.style.cursor = "";
          if (typeof onSave === "function") onSave();
        };
        document.addEventListener("mousemove", moveHandler);
        document.addEventListener("mouseup", upHandler);
      });
    }

    canvas.appendChild(div);
    noteEls.set(note.id, div);
  }
}

/**
 * Render every composition node as an absolutely-positioned div on the
 * canvas. Each node is draggable (mousedown on the node body → drag).
 * Click (without drag) opens the edit handler.
 */
function _canvasRenderNodes(body, canvas, state, opts, nodeEls, renderAllNodes) {
  const readonly = !!opts.readonly;
  const onEdit = typeof opts.onEdit === "function" ? opts.onEdit : null;
  const audiencePreview = opts.audiencePreview;
  const lockedAccounts = new Set((opts.lockedAccounts || []).map(String));
  const lastRunStatus = opts.lastRunStatus || {};

  const NODE_W = 240;
  const NODE_H = 70;

  // Helper: paint one node element at its stored position
  function paint(nodeId, label, sublabel, color, borderColor, opts2 = {}) {
    const pos = state.positions[nodeId] || { x: 100, y: 100 };
    const node = el("div", { class: "canvas-node", "data-node-id": nodeId });
    node.style.position = "absolute";
    node.style.left = pos.x + "px";
    node.style.top = pos.y + "px";
    node.style.width = NODE_W + "px";
    node.style.minHeight = NODE_H + "px";
    node.style.padding = "10px 12px";
    node.style.background = color;
    node.style.border = `1.5px solid ${borderColor}`;
    node.style.borderRadius = "8px";
    node.style.cursor = readonly ? "default" : "grab";
    node.style.boxSizing = "border-box";
    node.style.userSelect = "none";
    node.style.fontSize = "12px";

    const labelDiv = el(
      "div",
      { style: "font-weight: 500; color: #ddd; margin-bottom: 4px;" },
      label,
    );
    node.appendChild(labelDiv);
    if (sublabel) {
      const subDiv = el(
        "div",
        { style: "color: #888; font-size: 10px; line-height: 1.3;" },
        sublabel.length > 64 ? sublabel.slice(0, 60) + "…" : sublabel,
      );
      node.appendChild(subDiv);
    }
    if (opts2.badge) {
      const badge = el(
        "div",
        {
          style: `position: absolute; top: 8px; right: 8px; background: #0a0a0a; border: 1px solid #444; border-radius: 9px; padding: 2px 8px; font-size: 10px; color: #aaa;`,
        },
        opts2.badge,
      );
      node.appendChild(badge);
    }
    if (opts2.warning) {
      const warn = el(
        "div",
        {
          style: "position: absolute; top: 8px; right: 8px; color: #faaa5a; font-size: 14px;",
          title: opts2.warning,
        },
        "⚠",
      );
      node.appendChild(warn);
    }
    if (opts2.status) {
      const sb = el(
        "div",
        {
          style: `position: absolute; bottom: 6px; right: 8px; color: ${opts2.status === "ok" ? "#5ada5a" : opts2.status === "fail" ? "#fa8a8a" : "#daaa5a"}; font-size: 14px; font-weight: bold;`,
        },
        opts2.status === "ok" ? "✓" : opts2.status === "fail" ? "✗" : "↷",
      );
      node.appendChild(sb);
    }

    // Drag-to-move
    if (!readonly) {
      let dragStart = null;
      let didDrag = false;
      node.addEventListener("mousedown", (ev) => {
        ev.stopPropagation();
        const root = canvas.parentElement;
        const zoom = state.viewport.zoom || 1;
        dragStart = {
          mx: ev.clientX,
          my: ev.clientY,
          x0: pos.x,
          y0: pos.y,
        };
        didDrag = false;
        node.style.cursor = "grabbing";
        const moveHandler = (ev2) => {
          const dx = (ev2.clientX - dragStart.mx) / zoom;
          const dy = (ev2.clientY - dragStart.my) / zoom;
          if (Math.abs(dx) + Math.abs(dy) > 3) didDrag = true;
          const newX = dragStart.x0 + dx;
          const newY = dragStart.y0 + dy;
          root._canvasMoveNode(nodeId, newX, newY);
          pos.x = state.positions[nodeId].x;
          pos.y = state.positions[nodeId].y;
        };
        const upHandler = () => {
          document.removeEventListener("mousemove", moveHandler);
          document.removeEventListener("mouseup", upHandler);
          node.style.cursor = "grab";
          if (!didDrag && onEdit) {
            // Treat as click — open edit handler
            const parsed = _canvasParseNodeId(nodeId);
            onEdit(parsed.section, parsed.idx, parsed.branchPath);
          }
        };
        document.addEventListener("mousemove", moveHandler);
        document.addEventListener("mouseup", upHandler);
      });
    } else if (onEdit) {
      node.addEventListener("click", () => {
        const parsed = _canvasParseNodeId(nodeId);
        onEdit(parsed.section, parsed.idx, parsed.branchPath);
      });
    }

    canvas.appendChild(node);
    nodeEls.set(nodeId, node);
  }

  // 1. Trigger
  const trig = body.trigger || { type: "manual" };
  let triggerLabel = "▶ Manual trigger",
    triggerSub = "Fires only on Run now";
  let triggerWarn = null;
  if (trig.type === "schedule") {
    triggerLabel = "⏰ Schedule (cron)";
    triggerSub = `${trig.cron || "0 9 * * *"} · ${trig.timezone || "UTC"}`;
  } else if (trig.type === "schedule_simple") {
    triggerLabel = "⏰ Schedule";
    const every = trig.every || "day";
    if (every === "day" || every === "week")
      triggerSub = `Every ${every} at ${trig.at || "09:00"}${trig.weekdays_only ? " (weekdays)" : ""}`;
    else if (every === "hour") triggerSub = `Every hour at min ${trig.at_minute ?? 0}`;
    else triggerSub = `Every ${trig.step_minutes ?? 15} min`;
  } else if (trig.type === "event") {
    triggerLabel = "💬 Event trigger";
    triggerSub =
      trig.source + (trig.filter ? ` · filter: ${Object.keys(trig.filter).join(",")}` : "");
    const highFreq = [
      "tg_group_message",
      "li_post_engagement",
      "email_opened",
      "email_clicked",
      "x_mention",
    ];
    if (highFreq.includes(trig.source) && (!trig.filter || Object.keys(trig.filter).length === 0)) {
      triggerWarn = `"${trig.source}" can fire often. Add a filter or trigger.max_per_hour cap.`;
    }
  }
  paint("trigger", triggerLabel, triggerSub, "#1a2a3a", "#3a5a7a", { warning: triggerWarn });

  // 2. Audience — one node per resolver
  const resolvers = body.audience?.resolvers || [];
  if (resolvers.length === 0) {
    paint(
      "audience",
      "👥 Audience (empty)",
      "Click + Add node to add a resolver",
      "#1a2a1a",
      "#3a5a3a",
      {
        warning: "No resolvers — composition has no audience.",
      },
    );
  } else {
    resolvers.forEach((r, i) => {
      const knobs = Object.keys(r)
        .filter((k) => k !== "type")
        .map((k) => `${k}: ${JSON.stringify(r[k]).slice(0, 30)}`)
        .join(", ");
      const badge =
        audiencePreview && i === 0 && audiencePreview.count != null
          ? String(audiencePreview.count)
          : null;
      paint(`audience[${i}]`, `👥 ${r.type}`, knobs || "(no params)", "#1a2a1a", "#3a5a3a", {
        badge,
      });
    });
  }

  // 3. Channels — one node per channel in fallback chain
  const channels = body.channels || [];
  if (channels.length === 0) {
    paint(
      "channels",
      "📡 No channels",
      "Side-effect plays only. Click + Add node to add a platform.",
      "#2a1a2a",
      "#5a3a5a",
    );
  } else {
    channels.forEach((ch, i) => {
      const isLocked = lockedAccounts.has(String(ch.account_id));
      const sub = `${ch.use_when || "always"}${ch.daily_cap_override ? ` · cap ${ch.daily_cap_override}/day` : ""}`;
      paint(
        `channels[${i}]`,
        `📡 ${ch.platform}@${ch.account_id || "?"}`,
        sub,
        "#2a1a2a",
        "#5a3a5a",
        {
          warning: isLocked ? `Account ${ch.account_id} is locked.` : null,
        },
      );
    });
  }

  // 4. Plays — one node per play (recursive for branches)
  function paintPlays(plays, branchPath = []) {
    (plays || []).forEach((p, i) => {
      const nodeId =
        branchPath.length === 0
          ? `plays[${i}]`
          : `plays[${branchPath[0]}]` +
            branchPath
              .slice(1)
              .map((p2) => `.then[${p2}]`)
              .join("") +
            `.then[${i}]`;
      const status = lastRunStatus[i];
      const isOrch = [
        "wait",
        "branch",
        "stop_on",
        "webhook_call",
        "pause_composition_self",
      ].includes(p.type);
      const isSideEffect = [
        "add_tag",
        "remove_tag",
        "update_stage",
        "update_lead_score",
        "dnc_add",
        "mark_replied",
        "enrich_contact_now",
        "notify_admin",
        "log_outcome",
        "record_event",
      ].includes(p.type);
      const color = isOrch ? "#2a2a1a" : isSideEffect ? "#1a2a2a" : "#1a1a2a";
      const borderColor = isOrch ? "#5a5a3a" : isSideEffect ? "#3a5a5a" : "#3a3a5a";
      let sub = "";
      if (p.type === "send_dm" || p.type === "email_send" || p.type === "comment") {
        sub = (p.template || p.body || "").slice(0, 60);
        if (Array.isArray(p.variants) && p.variants.length > 0)
          sub = `🧪 ${p.variants.length} A/B variants`;
      } else if (p.type === "wait") {
        sub = `${p.duration_minutes_min || 0}–${p.duration_minutes_max || 0} min`;
      } else if (p.type === "branch") {
        sub = `if ${p.if} → ${(p.then || []).length} sub-plays`;
      } else if (p.type === "add_tag") {
        sub = `+tag: ${p.tag}`;
      } else if (p.type === "post_status") {
        sub = `${p.platform || "?"} → ${(p.body || "").slice(0, 50)}`;
      } else if (p.type === "enroll_in_email_sequence") {
        sub = `sequence #${p.sequence_id}`;
      }
      paint(nodeId, `▶ ${p.type}`, sub, color, borderColor, { status });
      if (p.type === "branch" && Array.isArray(p.then)) {
        paintPlays(p.then, [...branchPath, i]);
      }
    });
  }
  paintPlays(body.plays || []);

  if ((body.plays || []).length === 0) {
    paint("plays", "(no plays)", "Click + Add node to add a play", "#1a1a1a", "#444");
  }
}

function _canvasParseNodeId(nodeId) {
  // "trigger" → { section: "trigger" }
  // "audience[3]" → { section: "audience", idx: 3 }
  // "plays[2].then[1]" → { section: "plays", idx: 2, branchPath: [1] }
  if (nodeId === "trigger") return { section: "trigger" };
  const audMatch = nodeId.match(/^audience(?:\[(\d+)\])?$/);
  if (audMatch)
    return { section: "audience", idx: audMatch[1] != null ? parseInt(audMatch[1], 10) : null };
  const chMatch = nodeId.match(/^channels(?:\[(\d+)\])?$/);
  if (chMatch)
    return { section: "channels", idx: chMatch[1] != null ? parseInt(chMatch[1], 10) : null };
  const playMatch = nodeId.match(/^plays(?:\[(\d+)\])?(.*)$/);
  if (playMatch) {
    const idx = playMatch[1] != null ? parseInt(playMatch[1], 10) : null;
    const rest = playMatch[2] || "";
    const branchPath = [];
    const re = /\.then\[(\d+)\]/g;
    let m;
    while ((m = re.exec(rest)) !== null) branchPath.push(parseInt(m[1], 10));
    return { section: "plays", idx, branchPath };
  }
  return { section: nodeId };
}

const CANVAS_EDGE_NODE_W = 240;
const CANVAS_EDGE_NODE_H = 70;

/**
 * Bezier path data for the edge between two node positions — the single
 * source of truth for edge geometry, shared by the full render below and the
 * in-place update used while a node is being dragged.
 *
 * @param {{x:number,y:number}} f — source node position
 * @param {{x:number,y:number}} t — target node position
 * @returns {string} SVG path `d` attribute
 */
function _canvasEdgePathD(f, t) {
  const fx = f.x + CANVAS_EDGE_NODE_W;
  const fy = f.y + CANVAS_EDGE_NODE_H / 2;
  const tx = t.x;
  const ty = t.y + CANVAS_EDGE_NODE_H / 2;
  const midX = fx + (tx - fx) / 2;
  return `M${fx},${fy} C${midX},${fy} ${midX},${ty} ${tx},${ty}`;
}

/**
 * Render edges between nodes by walking the composition shape and drawing
 * a Bezier curve from each node's right edge to the next node's left edge.
 */
function _canvasRenderEdges(body, state, svg) {
  const ns = "http://www.w3.org/2000/svg";
  const positions = state.positions;
  const drawEdge = (fromId, toId) => {
    const f = positions[fromId];
    const t = positions[toId];
    if (!f || !t) return;
    const path = document.createElementNS(ns, "path");
    path.setAttribute("d", _canvasEdgePathD(f, t));
    path.setAttribute("stroke", "#444");
    path.setAttribute("stroke-width", "1.5");
    path.setAttribute("fill", "none");
    // Endpoint ids let a node drag re-point just the edges it touches.
    path.setAttribute("data-from", fromId);
    path.setAttribute("data-to", toId);
    svg.appendChild(path);
  };

  // trigger → each audience resolver
  const resolvers = body.audience?.resolvers || [];
  if (resolvers.length === 0) drawEdge("trigger", "audience");
  else resolvers.forEach((_, i) => drawEdge("trigger", `audience[${i}]`));

  // each audience resolver → first channel (or "channels" stub)
  const channels = body.channels || [];
  const firstChannelId = channels.length === 0 ? "channels" : "channels[0]";
  if (resolvers.length === 0) drawEdge("audience", firstChannelId);
  else resolvers.forEach((_, i) => drawEdge(`audience[${i}]`, firstChannelId));

  // channels chain (each → next) — represents fallback
  for (let i = 0; i < channels.length - 1; i++) {
    drawEdge(`channels[${i}]`, `channels[${i + 1}]`);
  }

  // last channel → first play
  const plays = body.plays || [];
  const lastChannelId = channels.length === 0 ? "channels" : `channels[${channels.length - 1}]`;
  const firstPlayId = plays.length === 0 ? "plays" : "plays[0]";
  drawEdge(lastChannelId, firstPlayId);

  // plays chain — each → next, recursive into branches
  function chainPlays(plays, branchPath = [], prevId) {
    (plays || []).forEach((p, i) => {
      const nodeId =
        branchPath.length === 0
          ? `plays[${i}]`
          : `plays[${branchPath[0]}]` +
            branchPath
              .slice(1)
              .map((p2) => `.then[${p2}]`)
              .join("") +
            `.then[${i}]`;
      if (prevId) drawEdge(prevId, nodeId);
      if (p.type === "branch" && Array.isArray(p.then)) {
        chainPlays(p.then, [...branchPath, i], nodeId);
      }
      prevId = nodeId;
    });
    return prevId;
  }
  chainPlays(plays);
}

function _renderCompositionDiagram(body, opts = {}) {
  const audiencePreview = opts.audiencePreview;
  const lockedAccounts = new Set((opts.lockedAccounts || []).map(String));
  const lastRunStatus = opts.lastRunStatus || {};
  const onEdit = typeof opts.onEdit === "function" ? opts.onEdit : null;

  const NODE_W = 280;
  const NODE_H = 64;
  const V_GAP = 28; // between nodes
  const H_INDENT = 40; // branch indent
  const PAD_X = 40;
  const PAD_Y = 24;

  // Walk plays into a flattened list with depth (for branch indent)
  function flattenPlays(plays, depth = 0, parentLabel = null) {
    const out = [];
    for (const p of plays || []) {
      out.push({ play: p, depth, parentLabel });
      if (p?.type === "branch" && Array.isArray(p.then)) {
        out.push(...flattenPlays(p.then, depth + 1, `if ${p.if}`));
      }
    }
    return out;
  }
  const playRows = flattenPlays(body.plays || []);

  // Compute total dimensions
  const totalNodes = 3 + playRows.length; // trigger + audience + channels + plays
  const svgH = PAD_Y * 2 + totalNodes * (NODE_H + V_GAP) - V_GAP;
  const maxDepth = playRows.reduce((m, r) => Math.max(m, r.depth), 0);
  const svgW = PAD_X * 2 + NODE_W + maxDepth * H_INDENT;

  const ns = "http://www.w3.org/2000/svg";
  const svg = document.createElementNS(ns, "svg");
  svg.setAttribute("width", "100%");
  svg.setAttribute("viewBox", `0 0 ${svgW} ${svgH}`);
  svg.style.background = "#050505";
  svg.style.borderRadius = "6px";
  svg.style.border = "1px solid #1a1a1a";
  svg.style.maxHeight = "70vh";

  // Helper: build a node rect + label
  let yCursor = PAD_Y;
  const nodeCenters = [];

  function drawNode({
    x = PAD_X,
    label,
    sublabel,
    badge,
    color = "#1a1a2a",
    borderColor = "#3a3a5a",
    warning,
    onclick,
    status,
  }) {
    // Status overrides border/badge (post-run)
    let statusBadge = null;
    if (status === "ok") {
      borderColor = "#2a5a2a";
      statusBadge = "✓";
    } else if (status === "fail") {
      borderColor = "#5a2a2a";
      statusBadge = "✗";
    } else if (status === "skip") {
      borderColor = "#5a5a2a";
      statusBadge = "↷";
    }

    const g = document.createElementNS(ns, "g");
    g.style.cursor = onclick ? "pointer" : "default";
    if (onclick) g.addEventListener("click", onclick);

    const rect = document.createElementNS(ns, "rect");
    rect.setAttribute("x", x);
    rect.setAttribute("y", yCursor);
    rect.setAttribute("width", NODE_W);
    rect.setAttribute("height", NODE_H);
    rect.setAttribute("rx", 8);
    rect.setAttribute("fill", color);
    rect.setAttribute("stroke", borderColor);
    rect.setAttribute("stroke-width", 1.5);
    g.appendChild(rect);

    // Label
    const labelText = document.createElementNS(ns, "text");
    labelText.setAttribute("x", x + 14);
    labelText.setAttribute("y", yCursor + 22);
    labelText.setAttribute("fill", "#ddd");
    labelText.setAttribute("font-family", "system-ui, sans-serif");
    labelText.setAttribute("font-size", "12");
    labelText.setAttribute("font-weight", "500");
    labelText.textContent = label;
    g.appendChild(labelText);

    // Sublabel (smaller)
    if (sublabel) {
      const sub = document.createElementNS(ns, "text");
      sub.setAttribute("x", x + 14);
      sub.setAttribute("y", yCursor + 42);
      sub.setAttribute("fill", "#888");
      sub.setAttribute("font-family", "system-ui, sans-serif");
      sub.setAttribute("font-size", "10");
      sub.textContent = sublabel.length > 48 ? sublabel.slice(0, 45) + "…" : sublabel;
      g.appendChild(sub);
    }

    // Badge (right side, e.g., audience count)
    if (badge) {
      const badgeBg = document.createElementNS(ns, "rect");
      badgeBg.setAttribute("x", x + NODE_W - 60);
      badgeBg.setAttribute("y", yCursor + 8);
      badgeBg.setAttribute("width", 50);
      badgeBg.setAttribute("height", 18);
      badgeBg.setAttribute("rx", 9);
      badgeBg.setAttribute("fill", "#0a0a0a");
      badgeBg.setAttribute("stroke", "#444");
      g.appendChild(badgeBg);
      const badgeText = document.createElementNS(ns, "text");
      badgeText.setAttribute("x", x + NODE_W - 35);
      badgeText.setAttribute("y", yCursor + 21);
      badgeText.setAttribute("fill", "#aaa");
      badgeText.setAttribute("font-family", "system-ui, sans-serif");
      badgeText.setAttribute("font-size", "10");
      badgeText.setAttribute("text-anchor", "middle");
      badgeText.textContent = badge;
      g.appendChild(badgeText);
    }

    // Warning icon (left of badge)
    if (warning) {
      const warnText = document.createElementNS(ns, "text");
      warnText.setAttribute("x", x + NODE_W - 80);
      warnText.setAttribute("y", yCursor + 22);
      warnText.setAttribute("fill", "#faaa5a");
      warnText.setAttribute("font-family", "system-ui, sans-serif");
      warnText.setAttribute("font-size", "14");
      warnText.setAttribute("text-anchor", "middle");
      warnText.textContent = "⚠";
      const title = document.createElementNS(ns, "title");
      title.textContent = warning;
      warnText.appendChild(title);
      g.appendChild(warnText);
    }

    // Status badge (post-run check/cross/skip)
    if (statusBadge) {
      const sb = document.createElementNS(ns, "text");
      sb.setAttribute("x", x + NODE_W - 18);
      sb.setAttribute("y", yCursor + NODE_H - 8);
      sb.setAttribute(
        "fill",
        status === "ok" ? "#5ada5a" : status === "fail" ? "#fa8a8a" : "#daaa5a",
      );
      sb.setAttribute("font-family", "system-ui, sans-serif");
      sb.setAttribute("font-size", "12");
      sb.setAttribute("font-weight", "bold");
      sb.textContent = statusBadge;
      g.appendChild(sb);
    }

    svg.appendChild(g);

    const center = { x: x + NODE_W / 2, top: yCursor, bottom: yCursor + NODE_H };
    nodeCenters.push(center);
    yCursor += NODE_H + V_GAP;
    return center;
  }

  function drawEdge(fromCenter, toCenter, color = "#444") {
    if (!fromCenter || !toCenter) return;
    const line = document.createElementNS(ns, "path");
    // Smooth bezier vertical connector
    const fromX = fromCenter.x,
      fromY = fromCenter.bottom;
    const toX = toCenter.x,
      toY = toCenter.top;
    const midY = fromY + (toY - fromY) / 2;
    line.setAttribute("d", `M${fromX},${fromY} C${fromX},${midY} ${toX},${midY} ${toX},${toY}`);
    line.setAttribute("stroke", color);
    line.setAttribute("stroke-width", 1.5);
    line.setAttribute("fill", "none");
    svg.appendChild(line);
  }

  // 1. Trigger node — Phase 34 smart-hint: warn if event trigger has no
  // filter shape on a high-frequency source (would fire for every owner-event)
  const trig = body.trigger || { type: "manual" };
  let triggerWarn = null;
  if (trig.type === "event") {
    const highFreqSources = [
      "tg_group_message",
      "li_post_engagement",
      "email_opened",
      "email_clicked",
      "x_mention",
    ];
    if (
      highFreqSources.includes(trig.source) &&
      (!trig.filter || Object.keys(trig.filter).length === 0)
    ) {
      triggerWarn = `Event source "${trig.source}" can fire frequently. Without a filter, this composition runs on EVERY matching event. Consider adding a filter or trigger.max_per_hour cap (Advanced controls).`;
    }
  }
  if (
    trig.type === "schedule_simple" &&
    trig.every === "minute_of_hour" &&
    (Number(trig.step_minutes) || 15) < 5
  ) {
    triggerWarn = `Sub-5-minute schedule fires very often. Add a cooldown_after_run_minutes (Advanced controls) to debounce, or move to "every hour" granularity.`;
  }
  let triggerLabel = "▶ Manual trigger",
    triggerSub = "Fires only on Run now";
  if (trig.type === "schedule") {
    triggerLabel = "⏰ Schedule (cron)";
    triggerSub = `${trig.cron || "0 9 * * *"} · ${trig.timezone || "UTC"}`;
  } else if (trig.type === "schedule_simple") {
    triggerLabel = "⏰ Schedule";
    const every = trig.every || "day";
    if (every === "day" || every === "week")
      triggerSub = `Every ${every} at ${trig.at || "09:00"}${trig.weekdays_only ? " (weekdays only)" : ""}`;
    else if (every === "hour")
      triggerSub = `Every hour at minute ${trig.at_minute ?? 0}${trig.weekdays_only ? " (weekdays only)" : ""}`;
    else
      triggerSub = `Every ${trig.step_minutes ?? 15} minutes${trig.weekdays_only ? " (weekdays only)" : ""}`;
  } else if (trig.type === "event") {
    triggerLabel = "💬 Event trigger";
    triggerSub =
      trig.source + (trig.filter ? ` · filter: ${Object.keys(trig.filter).join(", ")}` : "");
  }
  const triggerCenter = drawNode({
    label: triggerLabel,
    sublabel: triggerSub,
    color: "#1a2a3a",
    borderColor: "#3a5a7a",
    warning: triggerWarn,
    onclick: onEdit ? () => onEdit("trigger") : null,
  });

  // 2. Audience node
  const resolvers = body.audience?.resolvers || [];
  const audSub =
    resolvers.length === 0 ? "(empty — no resolvers)" : resolvers.map((r) => r.type).join(", ");
  const audWarning =
    audiencePreview && audiencePreview.count === 0
      ? "Audience preview returned 0 contacts. Check params or recency exclusion."
      : resolvers.length === 0
        ? "No resolvers — composition won't have any audience."
        : null;
  const audBadge = audiencePreview?.count != null ? `${audiencePreview.count}` : null;
  const audienceCenter = drawNode({
    label: `👥 Audience (${resolvers.length} source${resolvers.length === 1 ? "" : "s"})`,
    sublabel: audSub,
    color: "#1a2a1a",
    borderColor: "#3a5a3a",
    badge: audBadge,
    warning: audWarning,
    onclick: onEdit ? () => onEdit("audience") : null,
  });
  drawEdge(triggerCenter, audienceCenter);

  // 3. Channels node
  const channels = body.channels || [];
  const chSub =
    channels.length === 0
      ? "(none — side-effect plays only)"
      : channels.map((ch) => `${ch.platform}@${ch.account_id || "?"}`).join(" → ");
  const lockedChannel = channels.find((ch) => lockedAccounts.has(String(ch.account_id)));
  const chWarning = lockedChannel
    ? `Account ${lockedChannel.platform}@${lockedChannel.account_id} is locked. Recover before activating.`
    : null;
  // Phase 34: smart hint — composition has plays but no channels (and not
  // all side-effect plays). Surface as warning so user knows they need to
  // add a channel before activating.
  const allSideEffect = (body.plays || []).every((p) =>
    [
      "add_tag",
      "remove_tag",
      "update_stage",
      "update_lead_score",
      "dnc_add",
      "mark_replied",
      "enrich_contact_now",
      "notify_admin",
      "log_outcome",
      "record_event",
      "wait",
      "branch",
      "stop_on",
      "webhook_call",
      "pause_composition_self",
      "post_status",
    ].includes(p?.type),
  );
  const channelLessWarn =
    channels.length === 0 && !allSideEffect && (body.plays || []).length > 0
      ? "No channels configured but plays need a channel. Add at least one channel or remove the channel-dependent plays."
      : chWarning;
  const channelCenter = drawNode({
    label: `📡 Channels (${channels.length})`,
    sublabel: chSub,
    color: "#2a1a2a",
    borderColor: "#5a3a5a",
    warning: channelLessWarn,
    onclick: onEdit ? () => onEdit("channels") : null,
  });
  drawEdge(audienceCenter, channelCenter);

  // 4. Plays — vertical, indented for branches
  let prevCenter = channelCenter;
  let playIdx = 0;
  for (const row of playRows) {
    const p = row.play;
    const indentX = PAD_X + row.depth * H_INDENT;
    const isSideEffect = [
      "add_tag",
      "remove_tag",
      "update_stage",
      "update_lead_score",
      "dnc_add",
      "mark_replied",
      "enrich_contact_now",
      "notify_admin",
      "log_outcome",
      "record_event",
    ].includes(p.type);
    const isOrch = ["wait", "branch", "stop_on", "webhook_call", "pause_composition_self"].includes(
      p.type,
    );
    const color = isOrch ? "#2a2a1a" : isSideEffect ? "#1a2a2a" : "#1a1a2a";
    const borderColor = isOrch ? "#5a5a3a" : isSideEffect ? "#3a5a5a" : "#3a3a5a";

    // Build sublabel per play type
    let sub = "";
    if (p.type === "send_dm" || p.type === "email_send" || p.type === "comment") {
      sub = (p.template || p.body || "").slice(0, 60);
      if (Array.isArray(p.variants) && p.variants.length > 0)
        sub = `🧪 ${p.variants.length} A/B variants`;
    } else if (p.type === "wait") {
      sub = `${p.duration_minutes_min || 0}–${p.duration_minutes_max || 0} min`;
    } else if (p.type === "branch") {
      sub = `if ${p.if} → ${(p.then || []).length} sub-plays`;
    } else if (p.type === "stop_on") {
      sub = `stop on: ${p.condition || p.if}`;
    } else if (p.type === "add_tag") {
      sub = `+tag: ${p.tag}`;
    } else if (p.type === "post_status") {
      sub = `${p.platform || "?"} → ${(p.body || "").slice(0, 50)}`;
    } else if (p.type === "enroll_in_email_sequence") {
      sub = `sequence #${p.sequence_id}`;
    } else if (row.parentLabel) {
      sub = `(branch: ${row.parentLabel})`;
    }

    const status = lastRunStatus[playIdx];
    const playLabel = `${row.depth > 0 ? "↳ " : ""}${playIdx + 1}. ${p.type}`;
    const playCenter = drawNode({
      x: indentX,
      label: playLabel,
      sublabel: sub,
      color,
      borderColor,
      onclick: onEdit ? () => onEdit("plays", playIdx) : null,
      status,
    });
    drawEdge(prevCenter, playCenter);
    prevCenter = playCenter;
    playIdx++;
  }

  if (playRows.length === 0) {
    drawNode({
      label: "(no plays defined)",
      sublabel: "Add plays in step 3 of the wizard",
      color: "#1a1a1a",
      borderColor: "#444",
      onclick: onEdit ? () => onEdit("plays") : null,
    });
    drawEdge(channelCenter, nodeCenters[nodeCenters.length - 1]);
  }

  return svg;
}
