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

// ── Phase 34b — n8n-style canvas editor ──────────────────────────
//
// Route: workflows/canvas?id=N (or via dashboardState.composerDraft.id)
// Replaces the wizard as the primary editing surface for compositions.
// Wizard remains accessible at workflows/edit (URL "?wizard=1" in spirit).

async function renderWorkflowCanvas() {
  const id = dashboardState.composerDraft?.id || dashboardState.currentParams?.id;

  // Phase A: support brand-new (unsaved) drafts. When there's no id, use the
  // local empty draft from dashboardState (set by "+ New workflow" or
  // "+ Start from scratch"). First save POSTs to create; subsequent saves PATCH.
  let c;
  if (!id) {
    if (!dashboardState.composerDraft) {
      dashboardState.composerDraft = _composerEmptyDraft();
    }
    c = dashboardState.composerDraft;
  } else {
    const compResp = await api("composition-get", { id }).catch((e) => ({ error: e?.message }));
    if (compResp?.error) {
      setContentNode(
        el("div", { class: "empty-state" }, [
          el("div", {}, "Composition not found"),
          el("div", { class: "muted", style: "font-size: 11px;" }, esc(compResp.error)),
        ]),
      );
      return;
    }
    c = compResp.composition;
  }

  // Phase 34b: working draft — mutations from the canvas (add node, edit node)
  // are made on this object and PATCHed back to the server on Save. The canvas
  // re-renders from this draft so the user sees changes live.
  const draft = JSON.parse(JSON.stringify(c)); // deep clone so we don't mutate the response object
  let dirty = false;

  // Phase 35.4 — undo stack for AI-applied patches.
  // Each entry: { description, prePatchBody, timestamp }. Bounded at 10 entries.
  // Body is a deep clone of { trigger, audience, channels, plays } captured
  // BEFORE the patch is applied. Restoring overwrites the same four fields,
  // re-renders the canvas, and marks dirty.
  const undoStack = [];
  const UNDO_MAX = 10;
  const pushUndo = (description) => {
    undoStack.push({
      description: description || "patch",
      prePatchBody: JSON.parse(
        JSON.stringify({
          trigger: draft.trigger,
          audience: draft.audience,
          channels: draft.channels,
          plays: draft.plays,
        }),
      ),
      timestamp: Date.now(),
    });
    if (undoStack.length > UNDO_MAX) undoStack.shift();
    refreshUndoBtn();
  };

  const root = el("div", { style: "max-width: 100%; padding: 12px 16px;" });

  // Header (n8n-style top bar)
  const dirtyIndicator = el(
    "span",
    {
      class: "muted",
      style: "font-size: 10px; margin-left: 8px; color: #faaa5a; display: none;",
    },
    "● unsaved changes",
  );

  // Phase 35.4 — Undo button (declared up-front so the top bar can reference it).
  // Hidden until the first patch is captured. Disabled when stack is empty.
  const undoBtn = el(
    "button",
    {
      class: "btn btn-tiny btn-secondary",
      type: "button",
      title: "Undo last AI fix (Ctrl+Z)",
      style: "display: none;",
    },
    "↶ Undo",
  );
  const refreshUndoBtn = () => {
    if (undoStack.length === 0) {
      undoBtn.style.display = "none";
      undoBtn.disabled = true;
      return;
    }
    undoBtn.style.display = "";
    undoBtn.disabled = false;
    const top = undoStack[undoStack.length - 1];
    undoBtn.title = `Undo: ${top.description} (Ctrl+Z) — ${undoStack.length} step${undoStack.length === 1 ? "" : "s"} available`;
  };
  const popUndo = () => {
    const entry = undoStack.pop();
    if (!entry) return;
    // Restore the four mutable body fields from the deep-cloned snapshot
    draft.trigger = entry.prePatchBody.trigger;
    draft.audience = entry.prePatchBody.audience;
    draft.channels = entry.prePatchBody.channels;
    draft.plays = entry.prePatchBody.plays;
    markDirty();
    reRenderCanvas();
    refreshUndoBtn();
    toast(`↶ Undid: ${entry.description}`, "ok", 1800);
  };
  undoBtn.onclick = popUndo;

  // We declare the canvas ref up-front so the buttons can reach it.
  let canvasEl = null;

  const reRenderCanvas = () => {
    if (!canvasEl) return;
    const replacement = _renderCompositionCanvas(draft, {
      compositionId: draft.id,
      readonly: draft.status === "archived",
      onEdit: (section, idx, branchPath) => {
        // Phase 34b.3: open the inline config drawer instead of navigating to wizard
        const nodeId = _canvasNodeId(section, idx, branchPath);
        _canvasOpenConfigDrawer(nodeId, draft, replacement, () => {
          markDirty();
          reRenderCanvas();
        });
      },
      // Phase 35.1: drag-from-library drop handler
      onDropNode: (payload, x, y) => {
        _handleCanvasDrop(payload, x, y, draft, replacement);
        markDirty();
        reRenderCanvas();
      },
    });
    canvasEl.replaceWith(replacement);
    canvasEl = replacement;
  };

  const markDirty = () => {
    dirty = true;
    dirtyIndicator.style.display = "inline";
  };

  const saveDraft = async () => {
    if (!dirty) return { ok: true, noOp: true };
    let r;
    if (!draft.id) {
      // Phase A: first save of a brand-new draft → POST to create
      r = await api("composition-create", {
        body: {
          name: draft.name || "Untitled workflow",
          description: draft.description || "",
          trigger: draft.trigger,
          audience: draft.audience,
          channels: draft.channels,
          plays: draft.plays,
          status: draft.status || "draft",
        },
      }).catch((e) => ({ error: e?.message }));
      if (r?.error) {
        toast(`Save failed: ${r.error}`, "err", 5000);
        return r;
      }
      // Capture the new id so subsequent saves PATCH instead of POST
      const newId = r?.composition?.id || r?.id;
      if (newId) {
        draft.id = newId;
        // Reflect in dashboardState so navigation away/back stays consistent
        if (dashboardState.composerDraft) dashboardState.composerDraft.id = newId;
      }
    } else {
      r = await api("composition-update", {
        id: draft.id,
        body: {
          trigger: draft.trigger,
          audience: draft.audience,
          channels: draft.channels,
          plays: draft.plays,
        },
      }).catch((e) => ({ error: e?.message }));
      if (r?.error) {
        toast(`Save failed: ${r.error}`, "err", 5000);
        return r;
      }
    }
    dirty = false;
    dirtyIndicator.style.display = "none";
    toast("Saved", "ok", 1500);
    return r;
  };

  // Task 11a — parked-waits control. A "parked" step is a
  // composition_pending_steps row sitting at a `wait` with a future
  // resume_at. Parked steps carry no node id (remaining_json is the rest of
  // the play sequence, not a specific wait node), so a per-node count isn't
  // attributable — this shows the TOTAL parked count for the composition
  // with one "Resume now" that stamps every parked step due, so the
  // scheduler drains them within ~60s. Only meaningful once the draft is
  // saved (has an id); the fetch runs after the canvas is mounted so it
  // never blocks the render, and a failure just hides the control.
  let parkedSteps = [];
  const parkedChip = el(
    "span",
    {
      class: "pill",
      style:
        "font-size: 10px; padding: 2px 8px; background: #2a2410; color: #daaa5a; display: none;",
    },
    "",
  );
  const parkedResumeBtn = el(
    "button",
    {
      class: "btn btn-tiny btn-secondary",
      type: "button",
      title: "Stamp every parked step due. The scheduler drains them within ~60s",
      style: "display: none;",
    },
    "Resume now",
  );
  const parkedBar = el(
    "div",
    {
      class: "row",
      style: "gap: 8px; align-items: center; margin-bottom: 10px; display: none;",
    },
    [parkedChip, parkedResumeBtn],
  );

  const refreshParkedWaits = async () => {
    if (!draft.id) {
      parkedBar.style.display = "none";
      return;
    }
    const ps = await api("composition-pending-steps", { id: draft.id }).catch(() => null);
    parkedSteps = ps?.steps || [];
    if (parkedSteps.length > 0) {
      parkedChip.textContent = `⏸ ${parkedSteps.length} parked`;
      parkedChip.style.display = "inline-block";
      parkedResumeBtn.style.display = "inline-block";
      parkedBar.style.display = "flex";
    } else {
      parkedBar.style.display = "none";
    }
  };

  parkedResumeBtn.onclick = async () => {
    if (parkedResumeBtn.disabled || !draft.id) return;
    parkedResumeBtn.disabled = true;
    const oldLabel = parkedResumeBtn.textContent;
    parkedResumeBtn.textContent = "Resuming…";
    try {
      let resumed = 0;
      for (const step of parkedSteps) {
        const r = await api("composition-resume-step", { id: draft.id, stepId: step.id }).catch(
          () => null,
        );
        if (r?.ok) resumed++;
      }
      toast(`Resumed ${resumed} parked step(s). They run within ~60s`, "ok");
      await refreshParkedWaits();
    } catch (err) {
      toast(`Resume failed: ${err?.message || err}`, "err", 5000);
    } finally {
      parkedResumeBtn.disabled = false;
      parkedResumeBtn.textContent = oldLabel;
    }
  };

  const topBar = el(
    "div",
    {
      class: "row",
      style:
        "justify-content: space-between; align-items: center; margin-bottom: 12px; padding: 6px 0;",
    },
    [
      el("div", { class: "row", style: "gap: 8px; align-items: center;" }, [
        el(
          "button",
          {
            class: "btn btn-tiny btn-secondary",
            type: "button",
            onclick: async () => {
              if (dirty) {
                if (!confirm("You have unsaved changes. Leave anyway?")) return;
              }
              navigate("workflows");
            },
          },
          "←",
        ),
        el("div", {}, [
          el("div", { style: "font-weight: 600; font-size: 14px;" }, [draft.name, dirtyIndicator]),
          el("div", { class: "muted", style: "font-size: 10px;" }, draft.description || ""),
        ]),
      ]),
      el("div", { class: "row", style: "gap: 6px;" }, [
        // Phase 34b.2: Add node opens the library drawer
        el(
          "button",
          {
            class: "btn btn-tiny btn-secondary",
            type: "button",
            title: "Add a resolver, channel, or play to the canvas",
            onclick: () => {
              if (!canvasEl) return;
              _canvasOpenLibraryDrawer(draft, canvasEl, () => {
                markDirty();
                reRenderCanvas();
              });
            },
          },
          "+ Add node",
        ),
        // Phase 35: AI suggestions via DeepSeek
        el(
          "button",
          {
            class: "btn btn-tiny btn-secondary",
            type: "button",
            title: "Analyze this composition with DeepSeek and surface improvement suggestions",
            onclick: async (ev) => {
              if (!canvasEl) return;
              if (dirty) await saveDraft();
              const btn = ev.target;
              const oldLabel = btn.textContent;
              btn.disabled = true;
              btn.textContent = "Analyzing…";
              try {
                const r = await api("composition-suggestions", { id: draft.id }).catch((e) => ({
                  error: e?.message,
                }));
                if (r?.error) {
                  toast(`Suggestions failed: ${r.error}`, "err", 5000);
                  return;
                }
                _canvasOpenSuggestionsDrawer(
                  r,
                  draft,
                  canvasEl,
                  () => {
                    // No-op close; per-suggestion Apply buttons handle mutations
                  },
                  {
                    // Phase 35.3: hand the drawer a "patch applied" callback so it
                    // can mark dirty + re-render the canvas after each apply.
                    onPatchApplied: () => {
                      markDirty();
                      reRenderCanvas();
                    },
                    // Phase 35.4: capture the pre-patch state BEFORE _applyPatchToDraft
                    // mutates the draft, so undo can roll back.
                    onBeforePatch: (patch) => {
                      pushUndo(_summarizePatch(patch));
                    },
                  },
                );
              } finally {
                btn.disabled = false;
                btn.textContent = oldLabel;
              }
            },
          },
          "Suggest",
        ),
        // Phase 35.4: Undo button — restores the most recent pre-patch state
        undoBtn,
        el(
          "button",
          {
            class: "btn btn-tiny btn-secondary",
            type: "button",
            title: "Save draft (PATCH composition body to server)",
            onclick: () => saveDraft(),
          },
          "Save",
        ),
        el(
          "button",
          {
            class: "btn btn-tiny btn-secondary",
            type: "button",
            title: "Switch to the legacy 4-step wizard editor",
            onclick: async () => {
              if (dirty) await saveDraft();
              dashboardState.composerDraft = { ...draft };
              navigate("workflows/edit");
            },
          },
          "Wizard",
        ),
        el(
          "button",
          {
            class: "btn btn-tiny btn-secondary",
            type: "button",
            onclick: async (ev) => {
              if (dirty) await saveDraft();
              const btn = ev.target;
              await _canvasExecuteWithLiveStatus(draft, canvasEl, btn, { test_mode: true });
              refreshParkedWaits();
            },
          },
          "Test on 3",
        ),
        el(
          "button",
          {
            class: "btn btn-tiny btn-primary",
            type: "button",
            onclick: async (ev) => {
              if (dirty) await saveDraft();
              const btn = ev.target;
              await _canvasExecuteWithLiveStatus(draft, canvasEl, btn, {});
              refreshParkedWaits();
            },
          },
          "▶ Execute workflow",
        ),
      ]),
    ],
  );
  root.appendChild(topBar);
  root.appendChild(parkedBar);

  // Phase 35.4 — Ctrl+Z keybind. Attached to document and gated by root.isConnected
  // so it no-ops once the user navigates away. Skip when the event target is a text
  // input / textarea / contenteditable — let the browser handle text undo natively.
  // Phase K — proactive cleanup via MutationObserver: as soon as root is removed
  // from the DOM (when setContentNode swaps in another page), detach the listener
  // and disconnect the observer so we don't leak per-navigation.
  const onKeyDown = (ev) => {
    if (!root.isConnected) return;
    if (!(ev.ctrlKey || ev.metaKey) || ev.key !== "z" || ev.shiftKey) return;
    const t = ev.target;
    const tag = (t?.tagName || "").toLowerCase();
    if (tag === "input" || tag === "textarea" || t?.isContentEditable) return;
    if (undoStack.length === 0) return;
    ev.preventDefault();
    popUndo();
  };
  document.addEventListener("keydown", onKeyDown);

  // Watch document.body for subtree changes; when our root disconnects,
  // tear down. Observer is light (one childList check per mutation batch).
  const cleanupObserver = new MutationObserver(() => {
    if (!root.isConnected) {
      document.removeEventListener("keydown", onKeyDown);
      cleanupObserver.disconnect();
    }
  });
  // Defer attaching the observer until the root is mounted (setContentNode
  // appends asynchronously after this function returns).
  requestAnimationFrame(() => {
    if (root.isConnected) {
      cleanupObserver.observe(document.body, { childList: true, subtree: true });
    } else {
      // Edge case: root was never mounted (e.g. immediate re-navigation).
      // Clean up immediately so we don't leave a dangling listener.
      document.removeEventListener("keydown", onKeyDown);
    }
  });

  // Phase 34b.7 — n8n-style tab strip: Editor / Executions
  // (Evaluations is a future stub — performance dashboard already lives at workflows/performance)
  let activeTab = "editor"; // canvas-local; not URL-routed for v1
  const tabStrip = el("div", {
    class: "row",
    style:
      "gap: 4px; margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 4px;",
  });
  const tabContent = el("div", {});

  const renderTabs = () => {
    tabStrip.innerHTML = "";
    const mkTab = (id, label) =>
      el(
        "button",
        {
          class: `btn btn-tiny ${activeTab === id ? "btn-primary" : "btn-secondary"}`,
          type: "button",
          style: "border-radius: 4px 4px 0 0; border-bottom: none;",
          onclick: () => {
            activeTab = id;
            renderTabs();
            renderTabContent();
          },
        },
        label,
      );
    tabStrip.appendChild(mkTab("editor", "Editor"));
    tabStrip.appendChild(mkTab("executions", "⏱ Executions"));
    tabStrip.appendChild(mkTab("evaluations", "Evaluations"));
  };

  const renderTabContent = async () => {
    tabContent.innerHTML = "";
    if (activeTab === "editor") {
      // Canvas — render fresh each time (positions persisted in localStorage)
      canvasEl = _renderCompositionCanvas(draft, {
        compositionId: draft.id,
        readonly: draft.status === "archived",
        onEdit: (section, idx, branchPath) => {
          const nodeId = _canvasNodeId(section, idx, branchPath);
          _canvasOpenConfigDrawer(nodeId, draft, canvasEl, () => {
            markDirty();
            reRenderCanvas();
          });
        },
        // Phase 35.1: drag-from-library drop handler
        onDropNode: (payload, x, y) => {
          _handleCanvasDrop(payload, x, y, draft, canvasEl);
          markDirty();
          reRenderCanvas();
        },
      });
      tabContent.appendChild(canvasEl);

      // Phase 34b.7 — Logs panel at the bottom of the canvas (collapsible)
      const logsPanel = _canvasBuildLogsPanel(draft);
      tabContent.appendChild(logsPanel);

      // Hint
      tabContent.appendChild(
        el(
          "div",
          {
            class: "muted",
            style: "font-size: 10px; margin-top: 8px; line-height: 1.5;",
          },
          "Drag nodes to reposition · Mouse wheel to zoom · Drag empty space to pan · Click a node to edit · + Add node opens the library drawer · 💾 Save persists draft · 🧪 Test on 3 / ▶ Execute up top",
        ),
      );
    } else if (activeTab === "executions") {
      tabContent.appendChild(await _canvasBuildExecutionsView(draft));
    } else if (activeTab === "evaluations") {
      tabContent.appendChild(
        _canvasBuildEvaluationsView(draft, {
          // Phase 36c: hand the evaluations view a callback that wires the
          // promote action into the canvas's undo stack + save flow.
          onPromoteWinner: (playIdx, variantId) => {
            pushUndo(`promote variant "${variantId}" on play ${playIdx + 1}`);
            const ok = _applyPatchToDraft(draft, {
              patch_type: "promote_variant",
              play_idx: playIdx,
              variant_id: variantId,
            });
            if (!ok) {
              toast("Could not promote variant — see console.", "err", 4000);
              return;
            }
            markDirty();
            reRenderCanvas();
            // Auto-save so the change persists immediately. User can still undo
            // via Ctrl+Z (the undo stack restores the pre-promote body and re-saves).
            saveDraft().then(() => {
              toast(`Promoted "${variantId}" — variants[] dropped, saved.`, "ok", 2500);
            });
          },
        }),
      );
    }
  };

  renderTabs();
  await renderTabContent();
  root.appendChild(tabStrip);
  root.appendChild(tabContent);

  setContentNode(root);

  // Task 11a — fetch parked-wait count after the canvas is already on
  // screen (fire-and-forget, not awaited) so a slow or failing fetch never
  // delays the render.
  refreshParkedWaits();

  // Phase ZC — re-render the session bar after every canvas paint so it
  // stays on top across navigations.
  if (typeof _renderTestAllSessionBar === "function") _renderTestAllSessionBar();

  // Phase ZC — auto-fire Test on 3 when this canvas is the current step in
  // an active test-all session. Wait briefly for the canvas to settle, then
  // call _canvasExecuteWithLiveStatus directly (same path the 🧪 button uses).
  // When done, advance the session.
  const _zcSession = dashboardState.testAllSession;
  if (
    _zcSession?.status === "running" &&
    _zcSession.queue[_zcSession.currentIndex]?.id === draft.id
  ) {
    setTimeout(async () => {
      // Re-check — user may have paused / cancelled in the 1.5s window
      const sNow = dashboardState.testAllSession;
      if (!sNow || sNow.status !== "running") return;
      if (sNow.queue[sNow.currentIndex]?.id !== draft.id) return;
      const startMs = Date.now();
      let result, errorMsg, classified;
      try {
        result = await _canvasExecuteWithLiveStatus(draft, canvasEl, null, { test_mode: true });
        refreshParkedWaits();
      } catch (err) {
        errorMsg = err?.message || String(err);
      }
      // Classify outcome — _canvasExecuteWithLiveStatus returns the run row
      // (or undefined if it didn't poll to completion within timeout).
      if (errorMsg) classified = { status: "fail", summary: errorMsg };
      else if (!result) classified = { status: "fail", summary: "no run returned (timeout?)" };
      else if (result.status === "success")
        classified = {
          status: "pass",
          summary: `${result.plays_succeeded || 0}/${result.plays_executed || 0} plays succeeded across ${result.audience_size || 0} contact(s)`,
        };
      else if (
        result.status === "skipped" ||
        (result.audience_size === 0 && result.plays_executed === 0)
      ) {
        classified = { status: "skipped", summary: "empty audience or skipped by gate" };
      } else
        classified = {
          status: "fail",
          summary: result.error || `status=${result.status}`,
        };
      advanceTestAllSession({
        compositionId: draft.id,
        name: draft.name,
        status: classified.status,
        summary: classified.summary,
        durationMs: Date.now() - startMs,
        runId: result?.id ?? null,
      });
    }, 1500);
  }
}

/**
 * Phase 34b.7 — Logs panel at the bottom of the canvas (n8n-style).
 *
 * Collapsible panel that shows recent activity. During a run, it polls
 * composition_play_log rows and appends them as terminal-style log lines.
 * Idle: shows the most-recent run's audit lines as a static history.
 */
function _canvasBuildLogsPanel(draft) {
  const wrap = el("div", {
    style:
      "margin-top: 8px; background: var(--bg-0); border: 1px solid var(--border); border-radius: 6px; overflow: hidden;",
  });
  let isOpen = false;
  const header = el("div", {
    class: "row",
    style:
      "padding: 8px 12px; background: var(--bg-0); border-bottom: 1px solid var(--border); cursor: pointer; align-items: center; justify-content: space-between;",
  });
  const headerLabel = el("div", { style: "font-size: 11px; font-weight: 500;" }, "Logs");
  const headerHint = el("div", { class: "muted", style: "font-size: 10px;" }, "▼ click to expand");
  header.appendChild(headerLabel);
  header.appendChild(headerHint);
  wrap.appendChild(header);

  const body = el("div", {
    style:
      "display: none; padding: 10px 12px; max-height: 220px; overflow-y: auto; font-family: ui-monospace, Menlo, monospace; font-size: 10px; line-height: 1.5; color: var(--text-2); background: var(--bg-0);",
  });
  wrap.appendChild(body);

  const refreshLogs = async () => {
    const runs = await api("composition-runs", { id: draft.id, limit: 1 }).catch(() => null);
    const latest = runs?.runs?.[0];
    body.innerHTML = "";
    if (!latest) {
      body.appendChild(el("div", { class: "muted" }, "No runs yet. Click ▶ Execute to fire one."));
      return;
    }
    body.appendChild(
      el(
        "div",
        { style: "color: var(--green); margin-bottom: 6px;" },
        `Run #${latest.id} · ${latest.status} · ${latest.audience_size || 0} contacts · ${latest.plays_executed || 0} plays`,
      ),
    );
    const ps = await api("composition-run-play-status", { runId: latest.id }).catch(() => null);
    if (ps?.plays?.length > 0) {
      for (const p of ps.plays) {
        const playType = draft.plays?.[p.play_idx]?.type || "?";
        const color = p.fail > 0 ? "var(--red)" : p.ok > 0 ? "var(--green)" : "#daaa5a";
        body.appendChild(
          el(
            "div",
            { style: `color: ${color};` },
            `[play ${p.play_idx}] ${playType} → ${p.ok} ok · ${p.fail} fail · ${p.skip} skip · ${p.total} total`,
          ),
        );
      }
    } else {
      body.appendChild(
        el(
          "div",
          { class: "muted" },
          `Run completed without per-play log rows (test_mode or empty audience).`,
        ),
      );
    }
  };

  header.addEventListener("click", () => {
    isOpen = !isOpen;
    body.style.display = isOpen ? "block" : "none";
    headerHint.textContent = isOpen ? "▲ click to collapse" : "▼ click to expand";
    if (isOpen) refreshLogs();
  });

  return wrap;
}

/**
 * Phase 34b.7 — Executions tab: list of recent runs for this composition.
 * Click a run row → expands inline to show per-play breakdown + summary.
 */
async function _canvasBuildExecutionsView(draft) {
  const wrap = el("div", { style: "padding: 12px;" });
  wrap.appendChild(
    el(
      "div",
      { style: "font-weight: 500; font-size: 13px; margin-bottom: 12px;" },
      "⏱ Recent executions",
    ),
  );

  const runs = await api("composition-runs", { id: draft.id, limit: 30 }).catch(() => ({
    error: "Could not load",
  }));
  if (runs?.error) {
    wrap.appendChild(
      el("div", { class: "muted", style: "font-size: 12px;" }, `Error: ${runs.error}`),
    );
    return wrap;
  }
  if (!Array.isArray(runs?.runs) || runs.runs.length === 0) {
    wrap.appendChild(
      el(
        "div",
        { class: "muted", style: "font-size: 12px;" },
        "No runs yet. Switch to Editor and click ▶ Execute.",
      ),
    );
    return wrap;
  }

  const table = el("div", {
    style:
      "background: var(--bg-0); border: 1px solid var(--border); border-radius: 6px; overflow: hidden;",
  });
  const headerRow = el(
    "div",
    {
      style:
        "display: grid; grid-template-columns: 80px 100px 140px 120px 1fr 100px; gap: 8px; padding: 8px 12px; background: var(--bg-0); border-bottom: 1px solid var(--border); font-size: 10px; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px;",
    },
    [
      el("div", {}, "Run #"),
      el("div", {}, "Status"),
      el("div", {}, "Started"),
      el("div", {}, "Audience"),
      el("div", {}, "Plays (succ/exec/fail)"),
      el("div", {}, "Triggered by"),
    ],
  );
  table.appendChild(headerRow);

  for (const r of runs.runs) {
    const row = el("div", {
      style:
        "display: grid; grid-template-columns: 80px 100px 140px 120px 1fr 100px; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 11px; cursor: pointer;",
    });
    const statusColor =
      r.status === "success"
        ? "var(--green)"
        : r.status === "error"
          ? "var(--red)"
          : r.status === "partial"
            ? "#daaa5a"
            : r.status === "skipped"
              ? "var(--text-3)"
              : "var(--text-2)";
    row.appendChild(el("div", {}, `#${r.id}`));
    row.appendChild(el("div", { style: `color: ${statusColor};` }, r.status));
    row.appendChild(
      el("div", { class: "muted" }, r.started_at ? new Date(r.started_at).toLocaleString() : "—"),
    );
    row.appendChild(el("div", {}, String(r.audience_size || 0)));
    row.appendChild(
      el(
        "div",
        {},
        `${r.plays_succeeded || 0}/${r.plays_executed || 0}${(r.plays_failed || 0) > 0 ? ` (${r.plays_failed} fail)` : ""}`,
      ),
    );
    row.appendChild(el("div", { class: "muted" }, r.triggered_by || "manual"));

    // Expandable detail
    const detail = el("div", {
      style:
        "display: none; grid-column: 1 / -1; padding: 12px; background: var(--bg-0); margin-top: 6px; border-radius: 4px; font-family: ui-monospace, Menlo, monospace; font-size: 10px; line-height: 1.5; color: var(--text-2);",
    });
    let expanded = false;

    row.addEventListener("click", async () => {
      if (expanded) {
        detail.style.display = "none";
        expanded = false;
        return;
      }
      detail.innerHTML = "Loading…";
      detail.style.display = "block";
      expanded = true;
      const ps = await api("composition-run-play-status", { runId: r.id }).catch(() => null);
      detail.innerHTML = "";
      if (!ps?.plays?.length) {
        detail.appendChild(el("div", { class: "muted" }, "No per-play log for this run."));
      } else {
        for (const p of ps.plays) {
          const playType = draft.plays?.[p.play_idx]?.type || "?";
          const color = p.fail > 0 ? "var(--red)" : p.ok > 0 ? "var(--green)" : "#daaa5a";
          detail.appendChild(
            el(
              "div",
              { style: `color: ${color};` },
              `[play ${p.play_idx}] ${playType} → ${p.ok} ok · ${p.fail} fail · ${p.skip} skip · ${p.total} total`,
            ),
          );
        }
      }
      if (r.error) {
        detail.appendChild(
          el("div", { style: "color: var(--red); margin-top: 6px;" }, `error: ${r.error}`),
        );
      }
    });

    table.appendChild(row);
    table.appendChild(detail);
  }
  wrap.appendChild(table);
  return wrap;
}

/**
 * Phase 34b.7 — Evaluations tab (stub).
 * Surfaces a link to the global Workflow Performance dashboard
 * (the per-template aggregations from Phase 32). Future: per-composition
 * evaluation runs (synthetic test contacts × baseline comparisons).
 */
/**
 * Phase 36 — Evaluations tab.
 *
 * Three sections:
 *   1. Funnel — per-stage counts (matched → eligible → attempted → sent →
 *      replied → converted) with bar widths proportional to the largest stage.
 *   2. Baseline comparison — if this composition was cloned from a starter
 *      template, compares its reply_rate vs the same template's average across
 *      ALL the user's compositions cloned from it. Shows ± delta.
 *   3. A/B variants — scans the draft's plays for `variants` arrays. If any
 *      exist, surfaces an inventory + a note about per-variant attribution
 *      (deferred to Phase 36b runner change).
 *
 * Renders synchronously with placeholders, then resolves API calls in parallel
 * and patches the DOM. Refresh button at the top re-fetches.
 */
function _canvasBuildEvaluationsView(draft, opts = {}) {
  // opts.onPromoteWinner(playIdx, variantId) — called when the user clicks
  // 🚀 Promote on a winner row. Caller is responsible for pushing to undo
  // stack, marking dirty, and re-rendering the canvas.
  const wrap = el("div", { style: "padding: 16px; max-width: 760px;" });

  const header = el("div", {
    class: "row",
    style: "justify-content: space-between; align-items: center; margin-bottom: 16px;",
  });
  header.appendChild(
    el("div", {}, [
      el("div", { style: "font-weight: 600; font-size: 14px;" }, "Evaluations"),
      el(
        "div",
        { class: "muted", style: "font-size: 11px; margin-top: 2px;" },
        "Funnel · baseline comparison · variants",
      ),
    ]),
  );
  const refreshBtn = el(
    "button",
    {
      class: "btn btn-tiny btn-secondary",
      type: "button",
      title: "Re-fetch funnel + baseline data",
    },
    "↻ Refresh",
  );
  header.appendChild(refreshBtn);
  wrap.appendChild(header);

  // ── Section 1: Funnel ────────────────────────────────────────────────
  const funnelSection = el("div", {
    style:
      "margin-bottom: 20px; padding: 14px; background: var(--bg-0); border: 1px solid var(--border); border-radius: 6px;",
  });
  funnelSection.appendChild(
    el(
      "div",
      { style: "font-size: 12px; font-weight: 500; margin-bottom: 10px;" },
      "Funnel (lifetime)",
    ),
  );
  const funnelBars = el("div", {});
  funnelSection.appendChild(funnelBars);
  wrap.appendChild(funnelSection);

  // ── Section 2: Baseline comparison ───────────────────────────────────
  const baselineSection = el("div", {
    style:
      "margin-bottom: 20px; padding: 14px; background: var(--bg-0); border: 1px solid var(--border); border-radius: 6px;",
  });
  baselineSection.appendChild(
    el(
      "div",
      { style: "font-size: 12px; font-weight: 500; margin-bottom: 10px;" },
      "vs template baseline",
    ),
  );
  const baselineBody = el("div", {});
  baselineSection.appendChild(baselineBody);
  wrap.appendChild(baselineSection);

  // ── Section 3: A/B variants ──────────────────────────────────────────
  const variantsSection = el("div", {
    style:
      "margin-bottom: 20px; padding: 14px; background: var(--bg-0); border: 1px solid var(--border); border-radius: 6px;",
  });
  variantsSection.appendChild(
    el(
      "div",
      { style: "font-size: 12px; font-weight: 500; margin-bottom: 10px;" },
      "A/B variants in this composition",
    ),
  );
  const variantsBody = el("div", {});
  variantsSection.appendChild(variantsBody);
  wrap.appendChild(variantsSection);

  // ── Footer: link to global dashboard ─────────────────────────────────
  wrap.appendChild(
    el("div", { class: "row", style: "gap: 8px; margin-top: 8px;" }, [
      el(
        "button",
        {
          class: "btn btn-tiny btn-secondary",
          type: "button",
          onclick: () => navigate("workflows/performance"),
        },
        "Open global performance dashboard →",
      ),
    ]),
  );

  // Helper: render a horizontal funnel bar.
  const renderFunnelBars = (funnel) => {
    funnelBars.innerHTML = "";
    const stages = [
      { id: "matched", label: "Matched", color: "#5a8aff" },
      { id: "eligible", label: "Eligible", color: "#5adada" },
      { id: "attempted", label: "Attempted", color: "#daaa5a" },
      { id: "sent", label: "Sent", color: "#fa8a5a" },
      { id: "replied", label: "Replied", color: "var(--green)" },
      { id: "converted", label: "Converted", color: "#da5ada" },
    ];
    const max = Math.max(
      1,
      funnel.matched || 0,
      funnel.eligible || 0,
      funnel.attempted || 0,
      funnel.sent || 0,
      funnel.replied || 0,
      funnel.converted || 0,
    );
    if (max === 0 || (funnel.matched || 0) === 0) {
      funnelBars.appendChild(
        el(
          "div",
          { class: "muted", style: "font-size: 11px; padding: 8px 0;" },
          "No funnel data yet. Run the composition at least once to populate.",
        ),
      );
      return;
    }
    for (let i = 0; i < stages.length; i++) {
      const s = stages[i];
      const n = funnel[s.id] || 0;
      const pct = max > 0 ? Math.max(2, (n / max) * 100) : 0;
      // Drop-off vs previous stage
      let dropoff = "";
      if (i > 0) {
        const prev = funnel[stages[i - 1].id] || 0;
        if (prev > 0) {
          const rate = (n / prev) * 100;
          dropoff = ` · ${rate.toFixed(0)}% from ${stages[i - 1].label.toLowerCase()}`;
        }
      }
      const row = el(
        "div",
        {
          style:
            "margin-bottom: 6px; display: grid; grid-template-columns: 90px 1fr 130px; gap: 8px; align-items: center;",
        },
        [
          el("div", { style: "font-size: 11px; color: var(--text-2);" }, s.label),
          el(
            "div",
            {
              style:
                "background: var(--bg-0); border: 1px solid #151515; border-radius: 3px; height: 16px; overflow: hidden;",
            },
            [
              el("div", {
                style: `width: ${pct.toFixed(1)}%; background: ${s.color}; height: 100%; transition: width 240ms ease;`,
              }),
            ],
          ),
          el(
            "div",
            {
              style:
                "font-size: 11px; color: var(--text-2); text-align: right; font-variant-numeric: tabular-nums;",
            },
            `${n}${dropoff}`,
          ),
        ],
      );
      funnelBars.appendChild(row);
    }
    // Headline metrics row
    const replyRate = funnel.sent > 0 ? ((funnel.replied / funnel.sent) * 100).toFixed(1) : "0.0";
    const convertRate =
      funnel.replied > 0 ? ((funnel.converted / funnel.replied) * 100).toFixed(1) : "0.0";
    funnelBars.appendChild(
      el(
        "div",
        {
          class: "row",
          style:
            "gap: 16px; margin-top: 10px; padding-top: 10px; border-top: 1px solid #151515; font-size: 11px;",
        },
        [
          el("div", {}, [
            el("span", { class: "muted" }, "Reply rate: "),
            el("strong", { style: "color: var(--green);" }, `${replyRate}%`),
          ]),
          el("div", {}, [
            el("span", { class: "muted" }, "Convert rate: "),
            el("strong", { style: "color: #da5ada;" }, `${convertRate}%`),
          ]),
        ],
      ),
    );
  };

  // Helper: render baseline comparison.
  const renderBaseline = (myFunnel, perTemplate) => {
    baselineBody.innerHTML = "";
    const tplId = draft.source_template_id;
    if (!tplId) {
      baselineBody.appendChild(
        el(
          "div",
          { class: "muted", style: "font-size: 11px;" },
          "This composition wasn't cloned from a starter template — no baseline available.",
        ),
      );
      return;
    }
    const baseline = (perTemplate || []).find((t) => t.template_id === tplId);
    if (!baseline) {
      baselineBody.appendChild(
        el(
          "div",
          { class: "muted", style: "font-size: 11px;" },
          `Template "${tplId}" — no aggregate funnel data yet (need at least one other composition cloned from it).`,
        ),
      );
      return;
    }
    const myReplyRate = myFunnel.sent > 0 ? myFunnel.replied / myFunnel.sent : 0;
    const baseReplyRate = baseline.ratios?.reply_rate || 0;
    const deltaPp = (myReplyRate - baseReplyRate) * 100;
    const deltaColor = deltaPp >= 0 ? "var(--green)" : "var(--red)";
    const deltaSign = deltaPp >= 0 ? "+" : "";
    baselineBody.appendChild(
      el(
        "div",
        { style: "display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; font-size: 11px;" },
        [
          el("div", {}, [
            el("div", { class: "muted", style: "font-size: 10px;" }, "This composition"),
            el(
              "div",
              { style: "font-size: 16px; font-weight: 500; color: var(--green); margin-top: 2px;" },
              `${(myReplyRate * 100).toFixed(1)}%`,
            ),
            el(
              "div",
              { class: "muted", style: "font-size: 10px; margin-top: 2px;" },
              `${myFunnel.replied || 0} / ${myFunnel.sent || 0} sent`,
            ),
          ]),
          el("div", {}, [
            el("div", { class: "muted", style: "font-size: 10px;" }, `Template "${tplId}" avg`),
            el(
              "div",
              { style: "font-size: 16px; font-weight: 500; margin-top: 2px;" },
              `${(baseReplyRate * 100).toFixed(1)}%`,
            ),
            el(
              "div",
              { class: "muted", style: "font-size: 10px; margin-top: 2px;" },
              `${baseline.funnel?.replied || 0} / ${baseline.funnel?.sent || 0} sent`,
            ),
          ]),
          el("div", {}, [
            el("div", { class: "muted", style: "font-size: 10px;" }, "Delta"),
            el(
              "div",
              {
                style: `font-size: 16px; font-weight: 500; color: ${deltaColor}; margin-top: 2px;`,
              },
              `${deltaSign}${deltaPp.toFixed(1)}pp`,
            ),
            el(
              "div",
              { class: "muted", style: "font-size: 10px; margin-top: 2px;" },
              deltaPp >= 0 ? "above baseline" : "below baseline",
            ),
          ]),
        ],
      ),
    );
  };

  // Helper: render per-variant performance table with winner highlighting.
  // Phase 36b — backed by /api/compositions/:id/variants which aggregates
  // composition_outcomes rows stamped with variant_id by the runner.
  const renderVariants = (variantsResp) => {
    variantsBody.innerHTML = "";
    // Inventory from the draft (always reliable — runs purely client-side)
    const declaredByPlay = new Map();
    (draft.plays || []).forEach((p, idx) => {
      if (Array.isArray(p?.variants) && p.variants.length > 1) {
        declaredByPlay.set(idx, p);
      }
    });
    if (declaredByPlay.size === 0) {
      variantsBody.appendChild(
        el(
          "div",
          { class: "muted", style: "font-size: 11px;" },
          "No A/B variants declared. Add `variants: [{template, weight}, ...]` on a send_dm or email_send play to enable testing.",
        ),
      );
      return;
    }

    const liveData = Array.isArray(variantsResp?.variants) ? variantsResp.variants : [];
    const winner = variantsResp?.winner || null;
    const minSamples = variantsResp?.min_samples_for_winner || 30;

    // Group live rows by play_idx so we can render per-play tables
    const liveByPlay = new Map();
    for (const v of liveData) {
      if (!liveByPlay.has(v.play_idx)) liveByPlay.set(v.play_idx, []);
      liveByPlay.get(v.play_idx).push(v);
    }

    for (const [playIdx, play] of declaredByPlay) {
      const playWrap = el("div", { style: "margin-bottom: 12px;" });
      playWrap.appendChild(
        el(
          "div",
          {
            class: "row",
            style: "justify-content: space-between; align-items: center; margin-bottom: 6px;",
          },
          [
            el(
              "div",
              { style: "font-size: 11px; font-weight: 500;" },
              `Play #${playIdx + 1} · ${play.type}`,
            ),
            el(
              "span",
              {
                class: "pill",
                style: "background: #1a2a2a; color: #5adada; font-size: 9px; padding: 2px 6px;",
              },
              `${play.variants.length} variants`,
            ),
          ],
        ),
      );

      const liveForPlay = liveByPlay.get(playIdx) || [];
      const liveByVariantId = new Map(liveForPlay.map((v) => [v.variant_id, v]));

      // Render one row per declared variant (so even unfired variants show)
      const table = el("div", {
        style: "border: 1px solid #151515; border-radius: 4px; overflow: hidden;",
      });
      // Header row — 6 cols (last is "Action" reserved for 🚀 Promote on winner)
      table.appendChild(
        el(
          "div",
          {
            style:
              "display: grid; grid-template-columns: 1.4fr 0.6fr 0.6fr 0.8fr 0.8fr 0.8fr; padding: 6px 10px; background: var(--bg-0); font-size: 10px; color: var(--text-3); gap: 6px; border-bottom: 1px solid #151515;",
          },
          [
            el("div", {}, "Variant"),
            el("div", { style: "text-align: right;" }, "Sent"),
            el("div", { style: "text-align: right;" }, "Replied"),
            el("div", { style: "text-align: right;" }, "Reply rate"),
            el("div", { style: "text-align: right;" }, "Status"),
            el("div", { style: "text-align: right;" }, "Action"),
          ],
        ),
      );

      for (let vi = 0; vi < play.variants.length; vi++) {
        const variantDef = play.variants[vi];
        const variantId = variantDef?.id || variantDef?.variant_id || `v${vi + 1}`;
        const live = liveByVariantId.get(variantId) || {
          sent: 0,
          replied: 0,
          converted: 0,
          reply_rate: 0,
        };
        const isWinner = winner && winner.play_idx === playIdx && winner.variant_id === variantId;
        const enoughSamples = live.sent >= minSamples;
        const rowBg = isWinner ? "rgba(74, 222, 128, 0.08)" : "transparent";
        const rateColor = isWinner ? "var(--green)" : enoughSamples ? "#daaa5a" : "var(--text-2)";
        const labelPreview =
          String(variantDef?.template || variantDef?.subject || variantDef?.body || "")
            .replace(/\s+/g, " ")
            .trim()
            .slice(0, 50) || `(no template)`;

        // Phase 36c: Promote button — only on the winner row, only when a callback exists
        let actionCell;
        if (isWinner && typeof opts.onPromoteWinner === "function") {
          const promoteBtn = el(
            "button",
            {
              class: "btn btn-tiny btn-primary",
              type: "button",
              style: "font-size: 10px; padding: 2px 6px;",
              title: `Replace play #${playIdx + 1}'s variants with just "${variantId}". Loser variants are dropped. Undoable.`,
            },
            "Promote",
          );
          promoteBtn.onclick = () => {
            const confirmMsg =
              `Promote variant "${variantId}" as the only template on play #${playIdx + 1}?\n\n` +
              `This drops the other ${play.variants.length - 1} variant(s). Reply rate so far: ${(live.reply_rate * 100).toFixed(1)}% (${live.replied}/${live.sent}).\n\n` +
              `You can undo from the canvas top bar.`;
            if (!confirm(confirmMsg)) return;
            promoteBtn.disabled = true;
            promoteBtn.textContent = "Promoted";
            promoteBtn.classList.remove("btn-primary");
            promoteBtn.classList.add("btn-secondary");
            opts.onPromoteWinner(playIdx, variantId);
          };
          actionCell = el("div", { style: "text-align: right;" }, [promoteBtn]);
        } else {
          actionCell = el("div", { style: "text-align: right;" }, "");
        }

        table.appendChild(
          el(
            "div",
            {
              style: `display: grid; grid-template-columns: 1.4fr 0.6fr 0.6fr 0.8fr 0.8fr 0.8fr; padding: 6px 10px; background: ${rowBg}; font-size: 11px; gap: 6px; border-bottom: 1px solid #151515; align-items: center;`,
            },
            [
              el("div", {}, [
                el(
                  "div",
                  {},
                  [
                    el("strong", { style: "color: var(--text-2);" }, variantId),
                    isWinner
                      ? el(
                          "span",
                          { style: "margin-left: 6px; color: var(--green); font-weight: 500;" },
                          "winner",
                        )
                      : null,
                  ].filter(Boolean),
                ),
                el(
                  "div",
                  { class: "muted", style: "font-size: 10px; margin-top: 2px;" },
                  labelPreview,
                ),
              ]),
              el(
                "div",
                { style: "text-align: right; font-variant-numeric: tabular-nums;" },
                String(live.sent),
              ),
              el(
                "div",
                { style: "text-align: right; font-variant-numeric: tabular-nums;" },
                String(live.replied),
              ),
              el(
                "div",
                {
                  style: `text-align: right; font-variant-numeric: tabular-nums; color: ${rateColor}; font-weight: 500;`,
                },
                `${(live.reply_rate * 100).toFixed(1)}%`,
              ),
              el(
                "div",
                { style: "text-align: right; font-size: 10px;" },
                enoughSamples
                  ? isWinner
                    ? "winner"
                    : "samples ok"
                  : `${live.sent}/${minSamples} samples`,
              ),
              actionCell,
            ],
          ),
        );
      }

      playWrap.appendChild(table);
      variantsBody.appendChild(playWrap);
    }

    // Footer hint
    if (winner) {
      variantsBody.appendChild(
        el(
          "div",
          {
            style:
              "margin-top: 6px; padding: 8px 10px; background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.25); border-radius: 4px; font-size: 11px;",
          },
          [
            el("strong", { style: "color: var(--green);" }, "Winner detected: "),
            el("span", {}, `Play #${winner.play_idx + 1}, variant "${winner.variant_id}". `),
            el(
              "span",
              { class: "muted" },
              `Consider promoting it (set as the play's only template) or extending the test to gather more samples.`,
            ),
          ],
        ),
      );
    } else if (liveData.length > 0) {
      variantsBody.appendChild(
        el(
          "div",
          { class: "muted", style: "margin-top: 6px; font-size: 10px; line-height: 1.5;" },
          `No winner yet — need ≥${minSamples} sends per variant for statistical confidence. Keep running.`,
        ),
      );
    } else {
      variantsBody.appendChild(
        el(
          "div",
          { class: "muted", style: "margin-top: 6px; font-size: 10px; line-height: 1.5;" },
          "No variant attribution data yet. Run the composition to start collecting per-variant reply rates.",
        ),
      );
    }
  };

  // Loaders + refresh
  const loadAll = async () => {
    refreshBtn.disabled = true;
    funnelBars.innerHTML = "";
    funnelBars.appendChild(
      el("div", { class: "muted", style: "font-size: 11px; padding: 8px 0;" }, "Loading…"),
    );
    baselineBody.innerHTML = "";
    baselineBody.appendChild(el("div", { class: "muted", style: "font-size: 11px;" }, "Loading…"));
    variantsBody.innerHTML = "";
    variantsBody.appendChild(el("div", { class: "muted", style: "font-size: 11px;" }, "Loading…"));
    try {
      const [funnelResp, perfResp, variantsResp] = await Promise.all([
        api("composition-funnel", { id: draft.id }).catch((e) => ({ error: e?.message })),
        api("composition-performance-templates").catch((e) => ({ error: e?.message })),
        api("composition-variants", { id: draft.id }).catch((e) => ({ error: e?.message })),
      ]);
      const funnel = funnelResp?.funnel || {
        matched: 0,
        eligible: 0,
        attempted: 0,
        sent: 0,
        replied: 0,
        converted: 0,
      };
      renderFunnelBars(funnel);
      renderBaseline(funnel, perfResp?.templates);
      renderVariants(variantsResp);
    } finally {
      refreshBtn.disabled = false;
    }
  };

  refreshBtn.onclick = () => loadAll();
  loadAll();

  return wrap;
}

/**
 * Phase 34b.2 — Library drawer for adding nodes from the canvas.
 *
 * Opens a slide-in panel on the right of the canvas with three searchable
 * sections (Audience sources / Channel platforms / Plays). Click an entry
 * to add it to the composition draft. After add, the node is positioned
 * next to the most-recent node in its section and persisted.
 *
 * Reuses existing catalogs (`_ensureSourceCatalog`, `_ensurePlayCatalog`)
 * and seeders (`_seedResolverFromCatalog`, `_seedPlayFromCatalog`) so a
 * library-added node has the same default knobs as one added via the
 * existing wizard popovers.
 */
async function _canvasOpenLibraryDrawer(draft, canvasRoot, onAdded) {
  // Lazy-load catalogs in parallel
  const [{ catalog: sourceCat }, { catalog: playCat }] = await Promise.all([
    _ensureSourceCatalog(),
    _ensurePlayCatalog(),
  ]);
  const accounts = await api("composition-account-budgets").catch(() => null);
  const accountList = accounts?.accounts || [];

  // Filter out deprecated entries
  const nonDeprecatedSources = sourceCat.filter((s) => !s.deprecated);
  const nonDeprecatedPlays = playCat.filter((p) => !p.deprecated);

  // Channel platforms (static — 4)
  const platforms = [
    { id: "telegram", label: "Telegram", icon: "💬" },
    { id: "linkedin", label: "LinkedIn", icon: "🔗" },
    { id: "twitter", label: "X / Twitter", icon: "🐦" },
    { id: "email", label: "Email", icon: "✉️" },
  ];

  // Drawer DOM
  const drawer = el("div", { class: "canvas-drawer" });
  const header = el("div", { class: "canvas-drawer-header" }, [
    el("div", { class: "row", style: "justify-content: space-between; align-items: center;" }, [
      el("div", { style: "font-weight: 600; font-size: 13px;" }, "+ Add node"),
      el(
        "button",
        {
          class: "btn btn-tiny btn-secondary",
          type: "button",
          onclick: () => {
            drawer.classList.remove("open");
            setTimeout(() => drawer.remove(), 220);
          },
        },
        "✕",
      ),
    ]),
    el("input", {
      type: "text",
      placeholder: "Search resolvers, channels, plays…",
      class: "wizard-input",
      style: "width: 100%; margin-top: 8px; font-size: 12px;",
      oninput: (ev) => {
        renderResults(ev.target.value.toLowerCase());
      },
    }),
  ]);
  drawer.appendChild(header);

  const body = el("div", { class: "canvas-drawer-body" });
  drawer.appendChild(body);

  /**
   * Place a new node next to the section's last existing node so it doesn't
   * land on top of something. Uses the canvas state's positions map.
   */
  function findNextSlotForSection(section) {
    const state = canvasRoot._canvasGetState ? canvasRoot._canvasGetState() : null;
    if (!state) return { x: 100, y: 100 };
    const positions = state.positions || {};
    const sectionPrefix =
      section === "audience"
        ? "audience["
        : section === "channels"
          ? "channels["
          : section === "plays"
            ? "plays["
            : null;
    if (!sectionPrefix) return { x: 100, y: 100 };
    let maxY = 0,
      anchorX = null;
    for (const [id, pos] of Object.entries(positions)) {
      if (id.startsWith(sectionPrefix)) {
        if (anchorX == null) anchorX = pos.x;
        if (pos.y > maxY) maxY = pos.y;
      }
    }
    if (anchorX == null) {
      // No existing nodes in this section — use the section's column default
      anchorX = section === "audience" ? 380 : section === "channels" ? 720 : 1060;
    }
    return { x: anchorX, y: maxY + 100 };
  }

  function renderResults(query = "") {
    body.innerHTML = "";

    // Section 1: Audience sources
    const filteredSources = query
      ? nonDeprecatedSources.filter((s) =>
          (s.id + " " + (s.label || "") + " " + (s.description || ""))
            .toLowerCase()
            .includes(query),
        )
      : nonDeprecatedSources;
    if (filteredSources.length > 0) {
      body.appendChild(
        el(
          "div",
          {
            style:
              "font-size: 11px; font-weight: 500; color: var(--text-2); margin-bottom: 8px; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px;",
          },
          `Audience sources (${filteredSources.length})`,
        ),
      );
      for (const s of filteredSources.slice(0, 50)) {
        body.appendChild(
          _canvasLibraryEntry(
            s,
            () => {
              // Add resolver to draft.audience.resolvers
              if (!draft.audience) draft.audience = { resolvers: [], combine: "union" };
              if (!draft.audience.resolvers) draft.audience.resolvers = [];
              const newResolver = _seedResolverFromCatalog(s);
              draft.audience.resolvers.push(newResolver);
              // Position the new node
              const slot = findNextSlotForSection("audience");
              const newIdx = draft.audience.resolvers.length - 1;
              if (canvasRoot._canvasGetState) {
                const state = canvasRoot._canvasGetState();
                state.positions[`audience[${newIdx}]`] = slot;
              }
              drawer.classList.remove("open");
              setTimeout(() => drawer.remove(), 220);
              if (typeof onAdded === "function") onAdded();
            },
            { kind: "resolver", entry: s },
          ),
        );
      }
    }

    // Section 2: Channel platforms
    const filteredPlatforms = query
      ? platforms.filter((p) => (p.id + " " + p.label).toLowerCase().includes(query))
      : platforms;
    if (filteredPlatforms.length > 0) {
      body.appendChild(
        el(
          "div",
          {
            style:
              "font-size: 11px; font-weight: 500; color: var(--text-2); margin-bottom: 8px; margin-top: 16px; text-transform: uppercase; letter-spacing: 0.5px;",
          },
          `Channel platforms (${filteredPlatforms.length})`,
        ),
      );
      for (const p of filteredPlatforms) {
        const platformAccounts = accountList.filter(
          (a) => String(a.platform).toLowerCase() === p.id,
        );
        const subtitle =
          platformAccounts.length > 0
            ? `${platformAccounts.length} account${platformAccounts.length === 1 ? "" : "s"} available`
            : "No accounts configured";
        const platformMeta = { icon: p.icon, label: p.label, description: subtitle };
        body.appendChild(
          _canvasLibraryEntry(
            platformMeta,
            () => {
              // Pick first available unlocked account
              const acct =
                platformAccounts.find((a) => !a.lockout_state || a.lockout_state === "ok") ||
                platformAccounts[0];
              if (!acct) {
                toast(
                  `No ${p.label} accounts configured. Add one in Account budgets first.`,
                  "err",
                  5000,
                );
                return;
              }
              if (!draft.channels) draft.channels = [];
              const newChannel = {
                platform: p.id,
                account_id: acct.id,
                use_when: `has_${p.id === "twitter" ? "x" : p.id}`,
              };
              draft.channels.push(newChannel);
              const slot = findNextSlotForSection("channels");
              const newIdx = draft.channels.length - 1;
              if (canvasRoot._canvasGetState) {
                const state = canvasRoot._canvasGetState();
                state.positions[`channels[${newIdx}]`] = slot;
              }
              drawer.classList.remove("open");
              setTimeout(() => drawer.remove(), 220);
              if (typeof onAdded === "function") onAdded();
            },
            { kind: "channel", entry: { platform: p.id } },
          ),
        );
      }
    }

    // Section 3: Plays
    const filteredPlays = query
      ? nonDeprecatedPlays.filter((p) =>
          (p.id + " " + (p.label || "") + " " + (p.description || ""))
            .toLowerCase()
            .includes(query),
        )
      : nonDeprecatedPlays;
    if (filteredPlays.length > 0) {
      body.appendChild(
        el(
          "div",
          {
            style:
              "font-size: 11px; font-weight: 500; color: var(--text-2); margin-bottom: 8px; margin-top: 16px; text-transform: uppercase; letter-spacing: 0.5px;",
          },
          `▶ Plays (${filteredPlays.length})`,
        ),
      );
      for (const p of filteredPlays.slice(0, 50)) {
        body.appendChild(
          _canvasLibraryEntry(
            p,
            () => {
              if (!draft.plays) draft.plays = [];
              const newPlay = _seedPlayFromCatalog(p);
              draft.plays.push(newPlay);
              const slot = findNextSlotForSection("plays");
              const newIdx = draft.plays.length - 1;
              if (canvasRoot._canvasGetState) {
                const state = canvasRoot._canvasGetState();
                state.positions[`plays[${newIdx}]`] = slot;
              }
              drawer.classList.remove("open");
              setTimeout(() => drawer.remove(), 220);
              if (typeof onAdded === "function") onAdded();
            },
            { kind: "play", entry: p },
          ),
        );
      }
    }

    if (
      filteredSources.length === 0 &&
      filteredPlatforms.length === 0 &&
      filteredPlays.length === 0
    ) {
      body.appendChild(
        el("div", { class: "muted", style: "font-size: 12px; padding: 16px;" }, "No matches."),
      );
    }
  }

  renderResults();
  canvasRoot.appendChild(drawer);
  // Trigger animation
  requestAnimationFrame(() => drawer.classList.add("open"));
}

/**
 * Phase 34b.3 — Inline config drawer.
 *
 * Click a node on the canvas → drawer slides in from the right with the
 * editing UI for that node. Reuses existing wizard sub-components
 * (`_composerTriggerSection`, `_resolverCard`, `_playCard`) so editing
 * logic stays single-source-of-truth between wizard and canvas.
 *
 * Includes "Delete this node" button (destructive, confirms first).
 * Mutations call `onChange()` which lets the parent mark dirty + redraw.
 */
async function _canvasOpenConfigDrawer(nodeId, draft, canvasRoot, onChange) {
  const parsed = _canvasParseNodeId(nodeId);
  if (!parsed?.section) return;

  const drawer = el("div", { class: "canvas-drawer" });
  const headerRow = el("div", {
    class: "row",
    style: "justify-content: space-between; align-items: center;",
  });
  const headerTitle = el("div", { style: "font-weight: 600; font-size: 13px;" });
  const closeBtn = el(
    "button",
    {
      class: "btn btn-tiny btn-secondary",
      type: "button",
      onclick: () => {
        drawer.classList.remove("open");
        setTimeout(() => drawer.remove(), 220);
      },
    },
    "✕",
  );
  headerRow.appendChild(headerTitle);
  headerRow.appendChild(closeBtn);

  const header = el("div", { class: "canvas-drawer-header" }, [headerRow]);
  drawer.appendChild(header);

  const body = el("div", { class: "canvas-drawer-body" });
  drawer.appendChild(body);

  // Footer with delete + save&close (delete is hidden for trigger; trigger always exists)
  const footerActions = el("div", {
    class: "row",
    style:
      "padding: 12px 16px; border-top: 1px solid var(--border); gap: 6px; justify-content: flex-end; position: sticky; bottom: 0; background: var(--bg-0);",
  });
  const deleteBtn = el(
    "button",
    {
      class: "btn btn-tiny btn-danger",
      type: "button",
      title: "Remove this node from the composition",
      onclick: () => {
        if (!confirm("Delete this node?")) return;
        _canvasDeleteNode(nodeId, draft, canvasRoot);
        drawer.classList.remove("open");
        setTimeout(() => drawer.remove(), 220);
        if (typeof onChange === "function") onChange();
      },
    },
    "Delete",
  );
  const saveCloseBtn = el(
    "button",
    {
      class: "btn btn-tiny btn-primary",
      type: "button",
      onclick: () => {
        drawer.classList.remove("open");
        setTimeout(() => drawer.remove(), 220);
        if (typeof onChange === "function") onChange();
      },
    },
    "Save & close",
  );
  if (parsed.section !== "trigger") footerActions.appendChild(deleteBtn);
  footerActions.appendChild(saveCloseBtn);

  // Per-section editor
  if (parsed.section === "trigger") {
    headerTitle.textContent = "▶ Trigger";
    try {
      // _composerTriggerSection is async; it returns the full trigger editor
      const triggerEditor = await _composerTriggerSection(draft);
      body.appendChild(triggerEditor);
    } catch (err) {
      body.appendChild(
        el(
          "div",
          { class: "muted", style: "font-size: 12px;" },
          `Could not load trigger editor: ${err?.message || err}`,
        ),
      );
    }
  } else if (parsed.section === "audience") {
    headerTitle.textContent = `Audience source #${parsed.idx != null ? parsed.idx + 1 : "—"}`;
    if (parsed.idx == null || !draft.audience?.resolvers?.[parsed.idx]) {
      body.appendChild(
        el(
          "div",
          { class: "muted", style: "font-size: 12px;" },
          "Audience node — open the library drawer to add a resolver.",
        ),
      );
    } else {
      const r = draft.audience.resolvers[parsed.idx];
      try {
        const card = _resolverCard(r, parsed.idx, draft, () => {
          if (typeof onChange === "function") onChange();
        });
        body.appendChild(card);
      } catch (err) {
        body.appendChild(
          el(
            "div",
            { class: "muted", style: "font-size: 12px;" },
            `Could not load resolver editor: ${err?.message || err}`,
          ),
        );
      }
    }
  } else if (parsed.section === "channels") {
    headerTitle.textContent = `Channel #${parsed.idx != null ? parsed.idx + 1 : "—"}`;
    if (parsed.idx == null || !draft.channels?.[parsed.idx]) {
      body.appendChild(
        el(
          "div",
          { class: "muted", style: "font-size: 12px;" },
          "Channel node — open the library drawer to add a platform.",
        ),
      );
    } else {
      const ch = draft.channels[parsed.idx];
      body.appendChild(_canvasChannelEditor(ch, parsed.idx, draft, onChange));
    }
  } else if (parsed.section === "plays") {
    // Walk into branchPath if any
    let playsArr = draft.plays;
    let play = null;
    if (Array.isArray(parsed.branchPath) && parsed.branchPath.length > 0) {
      let cur = playsArr?.[parsed.idx];
      for (const p of parsed.branchPath) {
        cur = cur?.then?.[p];
      }
      play = cur;
    } else {
      play = playsArr?.[parsed.idx];
    }
    if (!play) {
      headerTitle.textContent = `▶ Play`;
      body.appendChild(
        el(
          "div",
          { class: "muted", style: "font-size: 12px;" },
          "Play node — open the library drawer to add a play.",
        ),
      );
    } else {
      headerTitle.textContent = `▶ ${play.type}`;
      try {
        const card = _playCard(play, parsed.idx, draft, () => {
          if (typeof onChange === "function") onChange();
        });
        body.appendChild(card);
      } catch (err) {
        body.appendChild(
          el(
            "div",
            { class: "muted", style: "font-size: 12px;" },
            `Could not load play editor: ${err?.message || err}`,
          ),
        );
      }
    }
  } else {
    headerTitle.textContent = "Node";
    body.appendChild(
      el(
        "div",
        { class: "muted", style: "font-size: 12px;" },
        `Unknown node type: ${parsed.section}`,
      ),
    );
  }

  drawer.appendChild(footerActions);
  canvasRoot.appendChild(drawer);
  requestAnimationFrame(() => drawer.classList.add("open"));
}

/**
 * Compact channel editor for the inline drawer. Mirrors the inline channel
 * controls in `_composerWhereForm` (use_when select, share weight, daily
 * cap override, account picker) without the full step-2 layout.
 */
function _canvasChannelEditor(ch, idx, draft, onChange) {
  const wrap = el("div", {
    style: "display: flex; flex-direction: column; gap: 12px; font-size: 12px;",
  });

  wrap.appendChild(
    el("div", {}, [
      el("div", { class: "muted", style: "font-size: 11px; margin-bottom: 4px;" }, "Platform"),
      el(
        "div",
        { style: "font-weight: 500;" },
        `${ch.platform === "twitter" ? "X / Twitter" : ch.platform[0].toUpperCase() + ch.platform.slice(1)} @ account ${ch.account_id || "?"}`,
      ),
    ]),
  );

  const useWhenSel = el("select", { class: "wizard-input", style: "max-width: 100%;" }, [
    el(
      "option",
      { value: `has_${ch.platform === "twitter" ? "x" : ch.platform}` },
      `Use only if has ${ch.platform}`,
    ),
    el("option", { value: "always" }, "Always (skip handle check)"),
  ]);
  useWhenSel.value = ch.use_when || `has_${ch.platform === "twitter" ? "x" : ch.platform}`;
  useWhenSel.addEventListener("change", () => {
    ch.use_when = useWhenSel.value;
    if (typeof onChange === "function") onChange();
  });
  wrap.appendChild(
    el("div", {}, [
      el(
        "div",
        { class: "muted", style: "font-size: 11px; margin-bottom: 4px;" },
        "Reachability check",
      ),
      useWhenSel,
    ]),
  );

  const shareInput = el("input", {
    type: "number",
    step: "0.5",
    min: "0.1",
    max: "10",
    class: "wizard-input",
    value: ch.account_share_weight ?? 1,
    style: "width: 100%;",
  });
  shareInput.addEventListener("change", () => {
    const v = parseFloat(shareInput.value);
    ch.account_share_weight = Number.isFinite(v) && v > 0 ? v : 1.0;
    if (typeof onChange === "function") onChange();
  });
  wrap.appendChild(
    el("div", {}, [
      el(
        "div",
        { class: "muted", style: "font-size: 11px; margin-bottom: 4px;" },
        "Share weight (when multiple comps share this account)",
      ),
      shareInput,
    ]),
  );

  const capInput = el("input", {
    type: "number",
    min: "1",
    class: "wizard-input",
    value: ch.daily_cap_override ?? "",
    placeholder: "platform default",
    style: "width: 100%;",
  });
  capInput.addEventListener("change", () => {
    const v = parseInt(capInput.value, 10);
    if (Number.isFinite(v) && v > 0) ch.daily_cap_override = v;
    else delete ch.daily_cap_override;
    if (typeof onChange === "function") onChange();
  });
  wrap.appendChild(
    el("div", {}, [
      el(
        "div",
        { class: "muted", style: "font-size: 11px; margin-bottom: 4px;" },
        "Daily cap override (per this composition)",
      ),
      capInput,
    ]),
  );

  // Reorder buttons
  const reorderRow = el("div", { class: "row", style: "gap: 4px; margin-top: 6px;" }, [
    el(
      "button",
      {
        class: "btn btn-tiny btn-secondary",
        type: "button",
        title: "Move up (higher fallback priority)",
        disabled: idx === 0 ? "disabled" : null,
        onclick: () => {
          if (idx === 0) return;
          [draft.channels[idx - 1], draft.channels[idx]] = [
            draft.channels[idx],
            draft.channels[idx - 1],
          ];
          if (typeof onChange === "function") onChange();
        },
      },
      "↑ Move up",
    ),
    el(
      "button",
      {
        class: "btn btn-tiny btn-secondary",
        type: "button",
        title: "Move down (lower fallback priority)",
        disabled: idx === draft.channels.length - 1 ? "disabled" : null,
        onclick: () => {
          if (idx === draft.channels.length - 1) return;
          [draft.channels[idx + 1], draft.channels[idx]] = [
            draft.channels[idx],
            draft.channels[idx + 1],
          ];
          if (typeof onChange === "function") onChange();
        },
      },
      "↓ Move down",
    ),
  ]);
  wrap.appendChild(reorderRow);

  return wrap;
}

/**
 * Delete a node from the composition draft + clean up its localStorage
 * position. Called from the config drawer's delete button.
 */
function _canvasDeleteNode(nodeId, draft, canvasRoot) {
  const parsed = _canvasParseNodeId(nodeId);
  if (!parsed) return;
  if (parsed.section === "trigger") return; // trigger is required, can't delete
  if (parsed.section === "audience" && parsed.idx != null) {
    if (draft.audience?.resolvers) draft.audience.resolvers.splice(parsed.idx, 1);
  } else if (parsed.section === "channels" && parsed.idx != null) {
    if (draft.channels) draft.channels.splice(parsed.idx, 1);
  } else if (parsed.section === "plays" && parsed.idx != null) {
    if (Array.isArray(parsed.branchPath) && parsed.branchPath.length > 0) {
      // Walk into the branch to find parent + last index
      let parent = draft.plays?.[parsed.idx];
      const last = parsed.branchPath[parsed.branchPath.length - 1];
      for (let i = 0; i < parsed.branchPath.length - 1; i++) {
        parent = parent?.then?.[parsed.branchPath[i]];
      }
      if (parent?.then) parent.then.splice(last, 1);
    } else {
      if (draft.plays) draft.plays.splice(parsed.idx, 1);
    }
  }
  // Drop the position from localStorage so the node doesn't ghost
  if (canvasRoot._canvasGetState) {
    const state = canvasRoot._canvasGetState();
    delete state.positions[nodeId];
  }
}

/**
 * Phase 34b.4 — Execute workflow with live status flashing.
 *
 * Visual flow:
 *   1. Click ▶ Execute → all play nodes get "running" pulse (CSS animation)
 *   2. Run fires via composition-run; awaits the response
 *   3. Response parsed; per-play status applied (best-effort from preview lines:
 *      runner returns `preview: ["...", "  send_dm: ok ...", ...]` which we can
 *      grep for play-execution lines)
 *   4. Nodes flip to ✓/✗/↷; button re-enables; toast summary
 *
 * v1 limitation: status is whole-run, not live per-play. Phase 34b.6 (deferred)
 * adds per-play log streaming for true real-time flashing.
 */
async function _canvasExecuteWithLiveStatus(draft, canvasRoot, btn, runBody) {
  if (!canvasRoot) return;
  const originalLabel = btn.textContent;
  btn.disabled = true;
  btn.textContent = "Running…";

  // Mark every play node as "running" (pulse)
  const playNodes = canvasRoot.querySelectorAll('.canvas-node[data-node-id^="plays"]');
  playNodes.forEach((n) => n.classList.add("canvas-node-running"));

  // Phase 34b.6 — poll the play-status endpoint during the run so node
  // badges flash live as plays fire (not just at completion). The
  // composition-run call blocks until the run finishes; we poll a parallel
  // channel by run_id every 1.2s. The run_id is returned at run start
  // (recorded in composition_runs immediately) — but composition-run only
  // returns when done. So we use a "fire-and-forget" pattern: kick the run,
  // start polling for the latest run by composition_id, update badges as
  // rows accumulate, stop when the run object's status flips.
  let pollTimer = null;
  let polling = true;
  const pollLatestRun = async () => {
    if (!polling) return;
    try {
      // Find the most-recent run for this composition (covers both in-flight
      // and just-completed runs). composition-runs endpoint returns runs
      // ordered by id desc, so [0] is latest.
      const runsResp = await api("composition-runs", { id: draft.id, limit: 1 }).catch(() => null);
      const latest = runsResp?.runs?.[0];
      if (latest?.id) {
        const psResp = await api("composition-run-play-status", { runId: latest.id }).catch(
          () => null,
        );
        if (psResp?.plays) {
          // Apply per-play status with live counts
          _canvasApplyLiveStatus(canvasRoot, psResp.plays, draft);
        }
        // If the run finished, stop pulsing
        if (psResp?.run_status && psResp.run_status !== "running") {
          playNodes.forEach((n) => n.classList.remove("canvas-node-running"));
        }
      }
    } catch {
      /* swallow — polling is best-effort */
    }
    if (polling) pollTimer = setTimeout(pollLatestRun, 1200);
  };
  pollTimer = setTimeout(pollLatestRun, 600); // start polling slightly after run kicks

  try {
    const r = await api("composition-run", { id: draft.id, body: runBody || {} }).catch((e) => ({
      error: e?.message,
    }));
    polling = false;
    if (pollTimer) clearTimeout(pollTimer);
    // Final badge sweep — pulse stops; status badges from the final response
    playNodes.forEach((n) => n.classList.remove("canvas-node-running"));

    if (r?.error) {
      toast(`Run failed: ${r.error}`, "err", 5000);
      return;
    }
    if (r?.skipped) {
      toast(`Skipped: ${r.reason || r.summary}`, "ok", 4000);
      return;
    }

    // One last fetch from the play-status endpoint so we get the canonical
    // per-play counts (the previous poll might have missed the last few rows).
    if (r.runId) {
      const psResp = await api("composition-run-play-status", { runId: r.runId }).catch(() => null);
      if (psResp?.plays?.length > 0) {
        _canvasApplyLiveStatus(canvasRoot, psResp.plays, draft);
      } else {
        // Test runs and test_mode may not have play-log rows (empty audience etc.)
        // — fall back to preview parsing for whole-run status.
        const status = _canvasParsePerPlayStatus(r.preview || [], draft);
        _canvasApplyStatusBadges(canvasRoot, status);
      }
    }

    const ratio = `${r.playsSucceeded || 0}/${r.playsExecuted || 0}`;
    const audMsg =
      r.audienceSize != null
        ? ` on ${r.audienceSize} contact${r.audienceSize === 1 ? "" : "s"}`
        : "";
    if (runBody?.test_mode) {
      toast(`Test · ${ratio} plays${audMsg}`, "ok", 5000);
    } else {
      toast(`▶ Run · ${ratio} plays${audMsg}`, "ok", 5000);
    }
  } catch (err) {
    polling = false;
    if (pollTimer) clearTimeout(pollTimer);
    playNodes.forEach((n) => n.classList.remove("canvas-node-running"));
    toast(`Run error: ${err?.message || err}`, "err", 5000);
  } finally {
    btn.disabled = false;
    btn.textContent = originalLabel;
  }
}

/**
 * Phase 34b.6 — Apply per-play live status from the play-status endpoint.
 * Each entry: { play_idx, ok, fail, skip, total }. Renders a count badge
 * (e.g., "5/10") on each play node + colors it green/orange/red based on
 * the dominant status.
 */
function _canvasApplyLiveStatus(canvasRoot, plays, draft) {
  const totalContacts = (() => {
    let max = 0;
    for (const p of plays) max = Math.max(max, p.total || 0);
    return max;
  })();
  for (const p of plays) {
    const node = canvasRoot.querySelector(`.canvas-node[data-node-id="plays[${p.play_idx}]"]`);
    if (!node) continue;
    // Remove any existing status badge
    const existing = node.querySelector(".canvas-status-badge");
    if (existing) existing.remove();
    // Decide color: any fail → red; all ok → green; mixed/skip → amber
    const dominantStatus = p.fail > 0 ? "fail" : p.ok > 0 ? "ok" : "skip";
    const color =
      dominantStatus === "ok"
        ? "var(--green)"
        : dominantStatus === "fail"
          ? "var(--red)"
          : "#daaa5a";
    const symbol = dominantStatus === "ok" ? "✓" : dominantStatus === "fail" ? "✗" : "↷";
    const countLabel =
      totalContacts > 0 && p.total < totalContacts
        ? `${p.total}/${totalContacts}`
        : p.total > 1
          ? String(p.total)
          : "";
    const badge = el(
      "div",
      {
        class: "canvas-status-badge",
        style: `position: absolute; bottom: 6px; right: 8px; color: ${color}; font-size: 12px; font-weight: bold; display: flex; gap: 4px; align-items: center;`,
      },
      [
        countLabel ? el("span", { style: "font-size: 9px; opacity: 0.7;" }, countLabel) : null,
        el("span", {}, symbol),
      ].filter(Boolean),
    );
    node.appendChild(badge);
  }
}

/**
 * Parse runner preview lines for per-play status. Returns a map of
 * play_idx → "ok" | "fail" | "skip".
 *
 * Preview shape (from runComposition):
 *   "Alice via linkedin@me_li"
 *   "  send_dm: ok ..."
 *   "  branch (no_reply): MATCH"
 *   "    update_lead_score: ok ..."
 *   "  send_dm: FAIL (...)"
 *
 * We map play type → first matching play_idx. If a play type appears
 * multiple times in the preview (multi-contact run), the LAST status wins.
 */
function _canvasParsePerPlayStatus(previewLines, draft) {
  const status = {};
  const plays = draft.plays || [];
  // Build a type → idx lookup for top-level plays only (branches get their
  // own indexing in 34b.6)
  const typeToIdx = new Map();
  plays.forEach((p, i) => {
    if (p?.type && !typeToIdx.has(p.type)) typeToIdx.set(p.type, i);
  });
  for (const line of previewLines) {
    if (!line || typeof line !== "string") continue;
    // Match leading whitespace + play type + status keyword
    const m = line.match(/^\s+(\w+):\s+(ok|FAIL|fail|skip|MATCH|STOP)/);
    if (!m) continue;
    const playType = m[1];
    const verdict = m[2].toLowerCase();
    if (!typeToIdx.has(playType)) continue;
    const idx = typeToIdx.get(playType);
    if (verdict === "ok" || verdict === "match") status[idx] = "ok";
    else if (verdict === "fail") status[idx] = "fail";
    else if (verdict === "skip" || verdict === "stop") status[idx] = "skip";
  }
  return status;
}

/**
 * Apply per-play status badges to existing canvas nodes (without a full
 * re-render — just inject status divs on each `plays[N]` node).
 */
function _canvasApplyStatusBadges(canvasRoot, statusMap) {
  for (const [idx, status] of Object.entries(statusMap)) {
    const node = canvasRoot.querySelector(`.canvas-node[data-node-id="plays[${idx}]"]`);
    if (!node) continue;
    // Remove any existing status badge
    const existing = node.querySelector(".canvas-status-badge");
    if (existing) existing.remove();
    const badge = el(
      "div",
      {
        class: "canvas-status-badge",
        style: `position: absolute; bottom: 6px; right: 8px; color: ${status === "ok" ? "var(--green)" : status === "fail" ? "var(--red)" : "#daaa5a"}; font-size: 14px; font-weight: bold;`,
      },
      status === "ok" ? "✓" : status === "fail" ? "✗" : "↷",
    );
    node.appendChild(badge);
  }
}

/**
 * Phase 35 — AI suggestions drawer.
 *
 * Renders the response from /api/compositions/:id/suggestions in a slide-in
 * panel. Each suggestion shows: severity color stripe, section badge, title,
 * body (rationale), action_hint, plus "Open this section" → focuses the
 * canvas on the relevant node + opens the inline config drawer.
 *
 * No auto-apply — the user reads, decides, and edits manually. This keeps
 * the AI advisory rather than autonomous (safer for a writes-touch-real-accounts
 * surface).
 */
function _canvasOpenSuggestionsDrawer(suggestionsResp, draft, canvasRoot, onClose, opts = {}) {
  const drawer = el("div", { class: "canvas-drawer" });

  const modelBadge =
    suggestionsResp?.model === "deepseek-chat"
      ? el(
          "span",
          {
            class: "pill",
            style:
              "background: #1a2a2a; color: #5adada; font-size: 9px; padding: 2px 6px; margin-left: 6px;",
          },
          "DeepSeek",
        )
      : el(
          "span",
          {
            class: "pill",
            style:
              "background: #2a2a1a; color: #daaa5a; font-size: 9px; padding: 2px 6px; margin-left: 6px;",
          },
          "Rules (fallback)",
        );

  const header = el("div", { class: "canvas-drawer-header" }, [
    el("div", { class: "row", style: "justify-content: space-between; align-items: center;" }, [
      el("div", {}, [
        el("div", { style: "font-weight: 600; font-size: 13px;" }, ["AI Suggestions ", modelBadge]),
        el(
          "div",
          { class: "muted", style: "font-size: 10px; margin-top: 4px;" },
          `Analyzed ${suggestionsResp?.analyzed_at ? new Date(suggestionsResp.analyzed_at).toLocaleTimeString() : "now"}. Read-only — no auto-apply.`,
        ),
      ]),
      el(
        "button",
        {
          class: "btn btn-tiny btn-secondary",
          type: "button",
          onclick: () => {
            drawer.classList.remove("open");
            setTimeout(() => drawer.remove(), 220);
            if (typeof onClose === "function") onClose();
          },
        },
        "✕",
      ),
    ]),
  ]);
  drawer.appendChild(header);

  const body = el("div", { class: "canvas-drawer-body" });
  drawer.appendChild(body);

  const suggestions = Array.isArray(suggestionsResp?.suggestions)
    ? suggestionsResp.suggestions
    : [];

  if (suggestions.length === 0) {
    body.appendChild(
      el(
        "div",
        {
          style:
            "padding: 16px; background: #0a2a0a; border: 1px solid #1a4a1a; border-radius: 6px; text-align: center;",
        },
        [
          el("div", { style: "font-size: 24px; margin-bottom: 8px;" }, icon("check")),
          el(
            "div",
            { style: "font-weight: 500; font-size: 12px; margin-bottom: 4px;" },
            "Composition looks solid",
          ),
          el(
            "div",
            { class: "muted", style: "font-size: 11px;" },
            "DeepSeek didn't surface any improvements right now. Add more nodes or adjust knobs and re-analyze.",
          ),
        ],
      ),
    );
  } else {
    // Group by severity
    const bySev = { critical: [], warn: [], info: [] };
    for (const s of suggestions) (bySev[s.severity] || bySev.info).push(s);
    const sevOrder = ["critical", "warn", "info"];
    const sevColors = {
      critical: { stripe: "#fa5a5a", bg: "#2a0a0a", border: "#5a1a1a", label: "Critical" },
      warn: { stripe: "#faaa5a", bg: "#2a1a0a", border: "#5a3a1a", label: "Warning" },
      info: { stripe: "#5a8aff", bg: "#0a1a2a", border: "#1a3a5a", label: "Info" },
    };

    for (const sev of sevOrder) {
      const items = bySev[sev];
      if (items.length === 0) continue;
      for (const s of items) {
        const cfg = sevColors[sev] || sevColors.info;
        const card = el("div", {
          style: `padding: 12px; background: ${cfg.bg}; border: 1px solid ${cfg.border}; border-left: 3px solid ${cfg.stripe}; border-radius: 4px; margin-bottom: 10px;`,
        });
        // Title + section badge
        card.appendChild(
          el(
            "div",
            {
              class: "row",
              style: "justify-content: space-between; align-items: center; margin-bottom: 6px;",
            },
            [
              el("div", { style: "font-weight: 500; font-size: 12px;" }, s.title || "(untitled)"),
              el(
                "span",
                {
                  class: "pill",
                  style: `background: ${cfg.stripe}; color: #000; font-size: 9px; padding: 2px 6px;`,
                },
                s.section || "general",
              ),
            ],
          ),
        );
        // Body
        if (s.body) {
          card.appendChild(
            el(
              "div",
              {
                style:
                  "font-size: 11px; line-height: 1.5; margin-bottom: 6px; color: var(--text-2);",
              },
              s.body,
            ),
          );
        }
        // Action hint
        if (s.action_hint) {
          card.appendChild(
            el(
              "div",
              {
                style:
                  "font-size: 10px; padding: 6px 8px; background: rgba(255,255,255,0.04); border-radius: 3px; margin-bottom: 6px;",
              },
              [
                el("strong", { style: "color: var(--text-2);" }, "Action: "),
                el("span", {}, s.action_hint),
              ],
            ),
          );
        }
        // Action row — Open section + Apply patch (Phase 35.3) when present
        const actionRow = el("div", { class: "row", style: "gap: 6px; flex-wrap: wrap;" });

        if (s.proposed_patch) {
          // Render a small preview of what the patch will do
          const patchSummary = _summarizePatch(s.proposed_patch);
          const applyBtn = el(
            "button",
            {
              class: "btn btn-tiny btn-primary",
              type: "button",
              style: "font-size: 10px;",
              title: patchSummary,
            },
            "Apply fix",
          );
          let applied = false;
          applyBtn.onclick = () => {
            if (applied) return;
            // Phase 35.4: capture pre-patch snapshot BEFORE mutating the draft
            if (typeof opts.onBeforePatch === "function") {
              try {
                opts.onBeforePatch(s.proposed_patch);
              } catch {
                /* non-fatal */
              }
            }
            const ok = _applyPatchToDraft(draft, s.proposed_patch);
            if (!ok) {
              toast("Could not apply patch — check the suggestion details.", "err", 5000);
              return;
            }
            applied = true;
            applyBtn.textContent = "Applied";
            applyBtn.disabled = true;
            applyBtn.classList.remove("btn-primary");
            applyBtn.classList.add("btn-secondary");
            applyBtn.style.opacity = "0.6";
            // Inline confirmation
            card.appendChild(
              el(
                "div",
                {
                  style:
                    "margin-top: 6px; padding: 6px 8px; background: rgba(74, 222, 128, 0.1); border-radius: 3px; font-size: 10px; color: var(--green);",
                },
                `Applied: ${patchSummary}`,
              ),
            );
            if (typeof opts.onPatchApplied === "function") opts.onPatchApplied();
          };
          actionRow.appendChild(applyBtn);
        }

        if (s.section && s.section !== "general") {
          actionRow.appendChild(
            el(
              "button",
              {
                class: "btn btn-tiny btn-secondary",
                type: "button",
                style: "font-size: 10px;",
                onclick: () => {
                  // Find a representative node id for the section and open its config drawer
                  let nodeId = s.section;
                  if (s.section === "audience" && (draft.audience?.resolvers || []).length > 0)
                    nodeId = "audience[0]";
                  if (s.section === "channels" && (draft.channels || []).length > 0)
                    nodeId = "channels[0]";
                  if (s.section === "plays" && (draft.plays || []).length > 0) nodeId = "plays[0]";
                  drawer.classList.remove("open");
                  setTimeout(() => drawer.remove(), 220);
                  if (typeof onClose === "function") onClose();
                  setTimeout(() => {
                    _canvasOpenConfigDrawer(nodeId, draft, canvasRoot, () => {});
                  }, 250);
                },
              },
              `→ Open ${s.section}`,
            ),
          );
        }

        if (actionRow.children.length > 0) card.appendChild(actionRow);
        body.appendChild(card);
      }
    }
  }

  // Footer hint
  body.appendChild(
    el(
      "div",
      {
        class: "muted",
        style:
          "font-size: 10px; line-height: 1.5; padding: 12px 0; border-top: 1px solid var(--border); margin-top: 12px;",
      },
      suggestionsResp?.model === "deepseek-chat"
        ? "Suggestions powered by DeepSeek. AI is advisory; review each before editing."
        : "DeepSeek unavailable — showing rule-based suggestions instead.",
    ),
  );

  canvasRoot.appendChild(drawer);
  requestAnimationFrame(() => drawer.classList.add("open"));
}

/**
 * Phase 35.3 — Apply a structured patch from an AI suggestion to the draft.
 * Returns true if the patch was applied successfully, false otherwise.
 * Supported patch shapes match the backend validator:
 *   { patch_type: "set_field", path: "<dotted+bracketed>", value: <any> }
 *   { patch_type: "add_play", play, at_index? }
 *   { patch_type: "add_resolver", resolver }
 *   { patch_type: "add_channel", channel }
 *   { patch_type: "remove_play"|"remove_resolver"|"remove_channel", index }
 *   { patch_type: "promote_variant", play_idx, variant_id }   // Phase 36c
 */
function _applyPatchToDraft(draft, patch) {
  if (!patch || typeof patch !== "object" || !patch.patch_type) return false;
  try {
    switch (patch.patch_type) {
      case "set_field":
        return _setNestedPath(draft, patch.path, patch.value);
      case "promote_variant": {
        // Phase 36c: replace a play's `variants` array with just the winner's
        // fields merged onto the play. Strips out variants[] so the play
        // becomes a regular single-template play.
        if (!Array.isArray(draft.plays)) return false;
        const play = draft.plays[patch.play_idx];
        if (!play || !Array.isArray(play.variants)) return false;
        const winner = play.variants.find((v) => (v.id || v.variant_id) === patch.variant_id);
        if (!winner) return false;
        // Merge winner fields onto the play, skipping variant-control keys
        for (const k of Object.keys(winner)) {
          if (k === "id" || k === "variant_id" || k === "weight") continue;
          play[k] = winner[k];
        }
        delete play.variants;
        return true;
      }
      case "add_play":
        if (!Array.isArray(draft.plays)) draft.plays = [];
        if (Number.isFinite(patch.at_index)) {
          draft.plays.splice(
            Math.max(0, Math.min(patch.at_index, draft.plays.length)),
            0,
            patch.play,
          );
        } else {
          draft.plays.push(patch.play);
        }
        return true;
      case "add_resolver":
        if (!draft.audience) draft.audience = { resolvers: [], combine: "union" };
        if (!Array.isArray(draft.audience.resolvers)) draft.audience.resolvers = [];
        draft.audience.resolvers.push(patch.resolver);
        return true;
      case "add_channel":
        if (!Array.isArray(draft.channels)) draft.channels = [];
        draft.channels.push(patch.channel);
        return true;
      case "remove_play":
        if (Array.isArray(draft.plays) && Number.isFinite(patch.index)) {
          draft.plays.splice(patch.index, 1);
          return true;
        }
        return false;
      case "remove_resolver":
        if (Array.isArray(draft.audience?.resolvers) && Number.isFinite(patch.index)) {
          draft.audience.resolvers.splice(patch.index, 1);
          return true;
        }
        return false;
      case "remove_channel":
        if (Array.isArray(draft.channels) && Number.isFinite(patch.index)) {
          draft.channels.splice(patch.index, 1);
          return true;
        }
        return false;
      default:
        return false;
    }
  } catch {
    return false;
  }
}

/**
 * Set a value at a dotted+bracketed path (e.g. "audience.exclude_already_contacted_within_days"
 * or "plays[2].template"). Creates intermediate objects but not arrays — patches that need
 * a new index should use add_play / add_resolver / add_channel instead.
 */
function _setNestedPath(obj, path, value) {
  // Tokenize: splits "plays[2].template" → ["plays", 2, "template"]
  const tokens = [];
  const re = /([a-zA-Z_][\w_]*)|\[(\d+)\]/g;
  let m;
  while ((m = re.exec(path)) !== null) {
    if (m[1] !== undefined) tokens.push(m[1]);
    else if (m[2] !== undefined) tokens.push(parseInt(m[2], 10));
  }
  if (tokens.length === 0) return false;
  let cur = obj;
  for (let i = 0; i < tokens.length - 1; i++) {
    const k = tokens[i];
    if (cur[k] == null) {
      // Auto-create intermediate object only — array indices must already exist
      if (typeof tokens[i + 1] === "number") return false;
      cur[k] = {};
    }
    cur = cur[k];
    if (cur == null || typeof cur !== "object") return false;
  }
  cur[tokens[tokens.length - 1]] = value;
  return true;
}

/**
 * Phase 35.1 — Handle a node drop from the library drawer onto the canvas.
 * Mutates the draft + sets the new node's position to the drop coords.
 * Mirrors the click-to-add logic in _canvasOpenLibraryDrawer but lands the
 * node where the user actually dropped instead of next-to-most-recent.
 */
function _handleCanvasDrop(payload, x, y, draft, canvasRoot) {
  if (!payload?.kind || !payload?.entry) return;
  const state = canvasRoot._canvasGetState ? canvasRoot._canvasGetState() : null;
  if (!state) return;

  if (payload.kind === "resolver") {
    if (!draft.audience) draft.audience = { resolvers: [], combine: "union" };
    if (!draft.audience.resolvers) draft.audience.resolvers = [];
    draft.audience.resolvers.push(_seedResolverFromCatalog(payload.entry));
    const idx = draft.audience.resolvers.length - 1;
    state.positions[`audience[${idx}]`] = { x, y };
  } else if (payload.kind === "channel") {
    if (!draft.channels) draft.channels = [];
    // For channel drops we can't pick the account inside the drop handler
    // (we don't have the account list cached here). Use null and let the
    // user pick via the inline config drawer. Better than blocking the drop.
    const newChannel = {
      platform: payload.entry.platform,
      account_id: null,
      use_when: `has_${payload.entry.platform === "twitter" ? "x" : payload.entry.platform}`,
    };
    draft.channels.push(newChannel);
    const idx = draft.channels.length - 1;
    state.positions[`channels[${idx}]`] = { x, y };
    // Open config drawer immediately so user picks the account
    setTimeout(() => {
      _canvasOpenConfigDrawer(`channels[${idx}]`, draft, canvasRoot, () => {});
    }, 50);
  } else if (payload.kind === "play") {
    if (!draft.plays) draft.plays = [];
    draft.plays.push(_seedPlayFromCatalog(payload.entry));
    const idx = draft.plays.length - 1;
    state.positions[`plays[${idx}]`] = { x, y };
  }
}

/**
 * Phase 35.3 — Human-readable summary of a proposed_patch.
 * Used as the Apply button's tooltip + the inline confirmation text.
 */
function _summarizePatch(patch) {
  if (!patch?.patch_type) return "(unknown patch)";
  switch (patch.patch_type) {
    case "set_field": {
      const v = typeof patch.value === "string" ? `"${patch.value}"` : JSON.stringify(patch.value);
      return `Set ${patch.path} = ${v}`;
    }
    case "add_play":
      return `Add play "${patch.play?.type}"${Number.isFinite(patch.at_index) ? ` at position ${patch.at_index + 1}` : ""}`;
    case "add_resolver":
      return `Add resolver "${patch.resolver?.type}" to audience`;
    case "add_channel":
      return `Add ${patch.channel?.platform} channel`;
    case "remove_play":
      return `Remove play at position ${patch.index + 1}`;
    case "remove_resolver":
      return `Remove resolver at position ${patch.index + 1}`;
    case "remove_channel":
      return `Remove channel at position ${patch.index + 1}`;
    case "promote_variant":
      return `Promote variant "${patch.variant_id}" on play ${patch.play_idx + 1} (drops other variants)`;
    default:
      return `${patch.patch_type}`;
  }
}

function _canvasLibraryEntry(meta, onClick, dragPayload) {
  const card = el(
    "div",
    {
      style:
        "padding: 10px 12px; background: var(--bg-0); border: 1px solid var(--border); border-radius: 6px; cursor: grab; margin-bottom: 6px; transition: border-color 100ms;",
      draggable: dragPayload ? "true" : null,
      onmouseenter: (ev) => {
        ev.currentTarget.style.borderColor = "#3a5aaa";
      },
      onmouseleave: (ev) => {
        ev.currentTarget.style.borderColor = "var(--border)";
      },
      onclick: onClick,
    },
    [
      el(
        "div",
        { style: "font-size: 12px; font-weight: 500; color: #ddd; margin-bottom: 2px;" },
        `${meta.icon || "🧩"} ${meta.label || meta.id}`,
      ),
      el(
        "div",
        { class: "muted", style: "font-size: 10px; line-height: 1.4;" },
        (meta.description || "").slice(0, 100),
      ),
    ],
  );
  // Phase 35.1: HTML5 drag — make library entries draggable so users can
  // drop them onto the canvas at the precise position they want.
  if (dragPayload) {
    card.addEventListener("dragstart", (ev) => {
      try {
        ev.dataTransfer.effectAllowed = "copy";
        ev.dataTransfer.setData("application/x-composer-node", JSON.stringify(dragPayload));
      } catch {
        /* some browsers restrict custom MIME on drag */
      }
      card.style.opacity = "0.5";
    });
    card.addEventListener("dragend", () => {
      card.style.opacity = "1";
    });
  }
  return card;
}
