// Watchflows Interactive Canvas Workflow Diagrams
// SPEC-010 — Declarative JSON-driven canvas diagrams matching the app's visual language
(function () {
  'use strict';

  // ── Visual Constants (from app source: CanvasConstants.swift, NodeCardLayer.swift, etc.) ──

  var C = {
    nodeWidth: 200,
    nodeMinHeight: 80,
    headerHeight: 34,
    cornerRadius: 8,
    portRadius: 6,
    portSpacing: 28,
    portStroke: '#121217',
    portStrokeWidth: 1,
    portLabelOffset: 12,
    portLabelFont: '10px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
    portLabelColor: 'rgba(191, 191, 191, 1)',
    bodyColor: '#242429',
    borderColor: 'rgba(255, 255, 255, 0.06)',
    borderWidth: 0.5,
    shadowColor: 'rgba(0, 0, 0, 0.2)',
    shadowBlur: 4,
    shadowOffsetY: 2,
    titleFont: 'bold 12px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
    titleColor: '#FFFFFF',
    configFont: '10px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
    configColor: '#8C8C8C',
    descFont: '11px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
    descColor: '#C2C2C6',
    descLineHeight: 15,
    accentDotSize: 8,
    canvasBg: '#19191C',
    gridMinorSpacing: 20,
    gridMajorSpacing: 100,
    gridMinorColor: 'rgba(255, 255, 255, 0.06)',
    gridMajorColor: 'rgba(255, 255, 255, 0.12)',
    gridDotRadius: 1.0,
    gridMajorDotRadius: 1.5,
    connectionStroke: 'rgba(128, 128, 128, 0.8)',
    connectionWidth: 2,
    connectionLabelFont: '10px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
    connectionLabelColor: 'rgba(180, 180, 180, 0.9)',
    layoutColGap: 340,
    layoutRowGap: 160,
    layoutPadX: 80,
    layoutPadY: 100,
    hintFont: '11px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
    hintColor: 'rgba(255, 255, 255, 0.25)'
  };

  var CATEGORY_COLORS = {
    trigger: '#139948',
    emitter: '#009298',
    condition: '#CB4F73',
    transformer: '#C16300',
    action: '#487CDE',
    ai: '#9B61C9',
    utility: '#79818D'
  };

  var PORT_TYPE_COLORS = {
    string: '#8CD454',
    number: '#5999F2',
    boolean: '#E65966',
    json: '#F2BF40',
    any: '#B3B3B3',
    file: '#CC80E6',
    image: '#F28C4D'
  };

  // ── Helpers ──

  function hexToRGBA(hex, alpha) {
    var r = parseInt(hex.slice(1, 3), 16);
    var g = parseInt(hex.slice(3, 5), 16);
    var b = parseInt(hex.slice(5, 7), 16);
    return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
  }

  function roundRect(ctx, x, y, w, h, r) {
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.lineTo(x + w - r, y);
    ctx.arcTo(x + w, y, x + w, y + r, r);
    ctx.lineTo(x + w, y + h - r);
    ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
    ctx.lineTo(x + r, y + h);
    ctx.arcTo(x, y + h, x, y + h - r, r);
    ctx.lineTo(x, y + r);
    ctx.arcTo(x, y, x + r, y, r);
    ctx.closePath();
  }

  // ── Implicit Port Generation ──

  function generatePorts(node) {
    if (!node.inlets) {
      if (node.type === 'trigger' || node.type === 'emitter') {
        node.inlets = [];
      } else {
        node.inlets = [{ id: 'in', label: 'Input', dataType: 'any' }];
      }
    }
    if (!node.outlets) {
      if (node.type === 'condition') {
        node.outlets = [
          { id: 'yes', label: 'Yes', dataType: 'any', color: '#4DD966' },
          { id: 'no', label: 'No', dataType: 'any', color: '#F24D4D' }
        ];
      } else {
        node.outlets = [{ id: 'out', label: 'Output', dataType: 'any' }];
      }
    }
  }

  function descLines(node) {
    if (!node.description) return [];
    return Array.isArray(node.description) ? node.description : String(node.description).split('\n');
  }

  function calcNodeHeight(node) {
    var portCount = Math.max(node.inlets.length, node.outlets.length);
    var configExtra = node.config ? 20 : 0;
    var lines = descLines(node);
    var descExtra = lines.length > 0 ? lines.length * C.descLineHeight + 14 : 0;
    var h = C.headerHeight + portCount * C.portSpacing + 12 + configExtra + descExtra;
    return Math.max(C.nodeMinHeight, h);
  }

  // ── Auto-Layout ──

  function autoLayout(nodes, connections) {
    var nodeMap = {};
    nodes.forEach(function (n) { nodeMap[n.id] = n; });

    // Build adjacency (forward only)
    var forward = {};
    var inDegree = {};
    nodes.forEach(function (n) { forward[n.id] = []; inDegree[n.id] = 0; });

    var backEdges = [];
    connections.forEach(function (c) {
      var fromId = c.from.split('.')[0];
      var toId = c.to ? c.to.split('.')[0] : null;
      if (!toId || !nodeMap[toId]) return;
      forward[fromId].push(toId);
      inDegree[toId]++;
    });

    // Topological sort (Kahn's) with cycle detection
    var queue = [];
    nodes.forEach(function (n) { if (inDegree[n.id] === 0) queue.push(n.id); });
    var order = [];
    var col = {};

    while (queue.length > 0) {
      var id = queue.shift();
      order.push(id);
      forward[id].forEach(function (toId) {
        inDegree[toId]--;
        if (inDegree[toId] === 0) queue.push(toId);
      });
    }

    // Assign columns (depth from root)
    nodes.forEach(function (n) { col[n.id] = 0; });
    order.forEach(function (id) {
      forward[id].forEach(function (toId) {
        if (col[toId] <= col[id]) {
          col[toId] = col[id] + 1;
        }
      });
    });

    // Detect back edges (loop-back) based on assigned columns
    connections.forEach(function (c) {
      var fromId = c.from.split('.')[0];
      var toId = c.to ? c.to.split('.')[0] : null;
      if (toId && col[toId] <= col[fromId] && fromId !== toId) {
        c._isLoopback = true;
      }
    });

    // Group by column and assign rows
    var columns = {};
    nodes.forEach(function (n) {
      var c = col[n.id];
      if (!columns[c]) columns[c] = [];
      columns[c].push(n);
    });

    // For condition branching: detect Yes/No targets and order them
    connections.forEach(function (c) {
      if (c.label === 'Yes' || (c.from && c.from.endsWith('.yes'))) {
        var toId = c.to ? c.to.split('.')[0] : null;
        if (toId && nodeMap[toId]) nodeMap[toId]._branchOrder = 0;
      }
      if (c.label === 'No' || (c.from && c.from.endsWith('.no'))) {
        var toId2 = c.to ? c.to.split('.')[0] : null;
        if (toId2 && nodeMap[toId2]) nodeMap[toId2]._branchOrder = 1;
      }
    });

    // Sort within each column by branch order, then original order
    Object.keys(columns).forEach(function (c) {
      columns[c].sort(function (a, b) {
        var ao = a._branchOrder !== undefined ? a._branchOrder : 0.5;
        var bo = b._branchOrder !== undefined ? b._branchOrder : 0.5;
        return ao - bo;
      });
    });

    // Assign positions — scale column gap to fit available width
    var maxCol = 0;
    nodes.forEach(function (n) { if (col[n.id] > maxCol) maxCol = col[n.id]; });
    var maxRow = 0;

    Object.keys(columns).forEach(function (c) {
      columns[c].forEach(function (n, i) {
        if (n.x === undefined) n.x = C.layoutPadX + parseInt(c) * C.layoutColGap;
        if (n.y === undefined) n.y = C.layoutPadY + i * C.layoutRowGap;
        if (i > maxRow) maxRow = i;
      });
    });

    // Store maxCol for width scaling in resize()
    nodes._maxCol = maxCol;

    // Calculate bounding box and center vertically
    var minY = Infinity, maxY = 0;
    nodes.forEach(function (n) {
      if (n.y < minY) minY = n.y;
      var bottom = n.y + n._height;
      if (bottom > maxY) maxY = bottom;
    });

    return { maxRow: maxRow };
  }

  // ── Port Position Helpers ──

  function getPortPos(node, portId, side) {
    var ports = side === 'outlet' ? node.outlets : node.inlets;
    var idx = 0;
    for (var i = 0; i < ports.length; i++) {
      if (ports[i].id === portId) { idx = i; break; }
    }
    var nw = node._nw || C.nodeWidth;
    var px = side === 'outlet' ? node.x + nw : node.x;
    var py = node.y + C.headerHeight + C.portSpacing * (idx + 0.5);
    return { x: px, y: py };
  }

  function resolvePort(nodes, ref, side) {
    var parts = ref.split('.');
    var nodeId = parts[0];
    var portId = parts[1];
    var node = null;
    for (var i = 0; i < nodes.length; i++) {
      if (nodes[i].id === nodeId) { node = nodes[i]; break; }
    }
    if (!node) return null;

    if (!portId) {
      // Default: first port on the given side
      var ports = side === 'outlet' ? node.outlets : node.inlets;
      if (ports.length === 0) return null;
      portId = ports[0].id;
    }
    return getPortPos(node, portId, side);
  }

  // ── Drawing Functions ──

  function drawGrid(ctx, w, h) {
    // Minor dots
    ctx.fillStyle = C.gridMinorColor;
    for (var x = C.gridMinorSpacing; x < w; x += C.gridMinorSpacing) {
      for (var y = C.gridMinorSpacing; y < h; y += C.gridMinorSpacing) {
        ctx.beginPath();
        ctx.arc(x, y, C.gridDotRadius, 0, Math.PI * 2);
        ctx.fill();
      }
    }
    // Major dots
    ctx.fillStyle = C.gridMajorColor;
    for (var mx = C.gridMajorSpacing; mx < w; mx += C.gridMajorSpacing) {
      for (var my = C.gridMajorSpacing; my < h; my += C.gridMajorSpacing) {
        ctx.beginPath();
        ctx.arc(mx, my, C.gridMajorDotRadius, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }

  function drawNode(ctx, node) {
    var x = node.x, y = node.y, w = node._nw || C.nodeWidth, h = node._height;
    var catColor = CATEGORY_COLORS[node.type] || '#808080';

    // Shadow
    ctx.save();
    ctx.shadowColor = C.shadowColor;
    ctx.shadowBlur = C.shadowBlur;
    ctx.shadowOffsetX = 0;
    ctx.shadowOffsetY = C.shadowOffsetY;

    // Body
    roundRect(ctx, x, y, w, h, C.cornerRadius);
    ctx.fillStyle = C.bodyColor;
    ctx.fill();
    ctx.restore();

    // Border
    roundRect(ctx, x, y, w, h, C.cornerRadius);
    ctx.strokeStyle = C.borderColor;
    ctx.lineWidth = C.borderWidth;
    ctx.stroke();

    // Header background
    ctx.save();
    ctx.beginPath();
    ctx.moveTo(x + C.cornerRadius, y);
    ctx.lineTo(x + w - C.cornerRadius, y);
    ctx.arcTo(x + w, y, x + w, y + C.cornerRadius, C.cornerRadius);
    ctx.lineTo(x + w, y + C.headerHeight);
    ctx.lineTo(x, y + C.headerHeight);
    ctx.lineTo(x, y + C.cornerRadius);
    ctx.arcTo(x, y, x + C.cornerRadius, y, C.cornerRadius);
    ctx.closePath();
    ctx.fillStyle = hexToRGBA(catColor, 0.4);
    ctx.fill();
    ctx.restore();

    // Header divider line
    ctx.beginPath();
    ctx.moveTo(x, y + C.headerHeight);
    ctx.lineTo(x + w, y + C.headerHeight);
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
    ctx.lineWidth = 0.5;
    ctx.stroke();

    // Accent dot (top-right)
    ctx.beginPath();
    ctx.arc(x + w - 14, y + C.headerHeight / 2, C.accentDotSize / 2, 0, Math.PI * 2);
    ctx.fillStyle = hexToRGBA(catColor, 0.6);
    ctx.fill();

    // Title
    ctx.font = C.titleFont;
    ctx.fillStyle = C.titleColor;
    ctx.textBaseline = 'middle';
    ctx.fillText(node.name, x + 10, y + C.headerHeight / 2, w - 30);

    // Config subtitle (below ports area)
    if (node.config) {
      ctx.font = C.configFont;
      ctx.fillStyle = C.configColor;
      var portCount = Math.max(node.inlets.length, node.outlets.length);
      var configY = y + C.headerHeight + portCount * C.portSpacing + 16;
      ctx.textBaseline = 'top';
      ctx.fillText(node.config, x + 10, configY, w - 20);
    }

    // Multi-line description (below ports area) — for explainer nodes that
    // describe their own role in prose rather than carrying a config value.
    var lines = descLines(node);
    if (lines.length > 0) {
      ctx.font = C.descFont;
      ctx.fillStyle = C.descColor;
      var pc = Math.max(node.inlets.length, node.outlets.length);
      var descY = y + C.headerHeight + pc * C.portSpacing + 10;
      ctx.textBaseline = 'top';
      lines.forEach(function (line, li) {
        ctx.fillText(line, x + 12, descY + li * C.descLineHeight, w - 22);
      });
    }

    // Ports — inlets (left side)
    node.inlets.forEach(function (port, i) {
      var py = y + C.headerHeight + C.portSpacing * (i + 0.5);
      var px = x;
      var color = port.color || PORT_TYPE_COLORS[port.dataType] || '#B3B3B3';

      ctx.beginPath();
      ctx.arc(px, py, C.portRadius, 0, Math.PI * 2);
      ctx.fillStyle = color;
      ctx.fill();
      ctx.strokeStyle = C.portStroke;
      ctx.lineWidth = C.portStrokeWidth;
      ctx.stroke();

      // Label
      ctx.font = C.portLabelFont;
      ctx.fillStyle = C.portLabelColor;
      ctx.textAlign = 'left';
      ctx.textBaseline = 'middle';
      ctx.fillText(port.label, px + C.portLabelOffset, py);
    });

    // Ports — outlets (right side)
    node.outlets.forEach(function (port, i) {
      var py = y + C.headerHeight + C.portSpacing * (i + 0.5);
      var px = x + w;
      var color = port.color || PORT_TYPE_COLORS[port.dataType] || '#B3B3B3';

      ctx.beginPath();
      ctx.arc(px, py, C.portRadius, 0, Math.PI * 2);
      ctx.fillStyle = color;
      ctx.fill();
      ctx.strokeStyle = C.portStroke;
      ctx.lineWidth = C.portStrokeWidth;
      ctx.stroke();

      // Label
      ctx.font = C.portLabelFont;
      ctx.fillStyle = C.portLabelColor;
      ctx.textAlign = 'right';
      ctx.textBaseline = 'middle';
      ctx.fillText(port.label, px - C.portLabelOffset, py);
    });

    // Reset text alignment
    ctx.textAlign = 'left';
  }

  function drawConnection(ctx, conn, nodes) {
    var fromPos = resolvePort(nodes, conn.from, 'outlet');
    var toPos = conn.to ? resolvePort(nodes, conn.to, 'inlet') : null;

    if (!fromPos) return;

    // Stub connection (dangling wire)
    if (!toPos) {
      toPos = { x: fromPos.x + 60, y: fromPos.y };
    }

    var dx = toPos.x - fromPos.x;
    var slack = Math.max(Math.abs(dx) * 0.5, 50);

    ctx.beginPath();
    ctx.moveTo(fromPos.x, fromPos.y);

    if (conn._isLoopback) {
      // Route below the diagram
      var loopY = 0;
      nodes.forEach(function (n) {
        var bottom = n.y + n._height + 40;
        if (bottom > loopY) loopY = bottom;
      });
      ctx.bezierCurveTo(
        fromPos.x + 40, fromPos.y,
        fromPos.x + 40, loopY,
        (fromPos.x + toPos.x) / 2, loopY
      );
      ctx.bezierCurveTo(
        toPos.x - 40, loopY,
        toPos.x - 40, toPos.y,
        toPos.x, toPos.y
      );
    } else {
      ctx.bezierCurveTo(
        fromPos.x + slack, fromPos.y,
        toPos.x - slack, toPos.y,
        toPos.x, toPos.y
      );
    }

    ctx.strokeStyle = C.connectionStroke;
    ctx.lineWidth = C.connectionWidth;
    ctx.lineCap = 'round';
    ctx.stroke();

    // Connection label
    if (conn.label) {
      var midX, midY;
      if (conn._isLoopback) {
        midX = (fromPos.x + toPos.x) / 2;
        var loopYB = 0;
        nodes.forEach(function (n) { var b = n.y + n._height + 40; if (b > loopYB) loopYB = b; });
        midY = loopYB + 4;
      } else {
        midX = (fromPos.x + toPos.x) / 2;
        midY = (fromPos.y + toPos.y) / 2 - 8;
      }
      ctx.font = C.connectionLabelFont;
      ctx.fillStyle = C.connectionLabelColor;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'bottom';

      // Background pill for label
      var tw = ctx.measureText(conn.label).width;
      ctx.fillStyle = 'rgba(25, 25, 28, 0.85)';
      roundRect(ctx, midX - tw / 2 - 5, midY - 13, tw + 10, 16, 4);
      ctx.fill();

      ctx.fillStyle = C.connectionLabelColor;
      ctx.fillText(conn.label, midX, midY);
      ctx.textAlign = 'left';
    }
  }

  // ── WorkflowCanvas Class ──

  function WorkflowCanvas(container) {
    var jsonScript = container.querySelector('script[type="application/json"]');
    if (!jsonScript) {
      console.warn('WorkflowCanvas: no JSON script found in container');
      return;
    }

    var data;
    try {
      data = JSON.parse(jsonScript.textContent);
    } catch (e) {
      console.warn('WorkflowCanvas: invalid JSON', e);
      container.innerHTML = '<div style="padding:20px;color:#F24D4D;font-size:13px;">Invalid diagram definition</div>';
      return;
    }

    this.container = container;
    this.nodes = data.nodes || [];
    this.connections = data.connections || [];
    this.dragging = null;
    this.dragOffset = { x: 0, y: 0 };
    this.hovered = false;
    this.showHint = true;
    this.rafId = null;

    // Generate ports
    this.nodes.forEach(generatePorts);

    // Calculate heights
    var self = this;
    this.nodes.forEach(function (n) {
      n._height = calcNodeHeight(n);
    });

    // Style container — left edge aligned with content, right edge stretches to viewport
    container.style.background = C.canvasBg;
    container.style.overflow = 'hidden';
    container.style.position = 'relative';
    container.style.cursor = 'grab';
    container.style.borderRadius = '16px';
    container.style.border = '1px solid rgba(255, 255, 255, 0.10)';

    // Stretch rightward: keep left where it is, extend right to near viewport edge
    var containerRect = container.getBoundingClientRect();
    var viewportWidth = document.documentElement.clientWidth;
    var rightMargin = 32;
    var newWidth = viewportWidth - containerRect.left - rightMargin;
    container.style.width = newWidth + 'px';
    container.style.maxWidth = 'none';

    // Create canvas
    this.canvas = document.createElement('canvas');
    this.canvas.setAttribute('role', 'img');
    this.canvas.setAttribute('aria-label', 'Workflow diagram: ' + this.nodes.map(function(n){return n.name;}).join(' → '));
    this.canvas.style.display = 'block';
    this.canvas.style.width = '100%';
    this.canvas.style.height = '100%';
    container.appendChild(this.canvas);

    // Layout and render
    this.resize();

    // Events
    this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
    this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
    this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
    this.canvas.addEventListener('mouseleave', this.onMouseUp.bind(this));

    // Touch
    this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: false });
    this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false });
    this.canvas.addEventListener('touchend', this.onMouseUp.bind(this));

    // Resize
    if (typeof ResizeObserver !== 'undefined') {
      this._resizeObserver = new ResizeObserver(function () {
        clearTimeout(self._resizeTimer);
        self._resizeTimer = setTimeout(function () { self.resize(); }, 100);
      });
      this._resizeObserver.observe(container);
    }
  }

  WorkflowCanvas.prototype.resize = function () {
    var rect = this.container.getBoundingClientRect();
    var w = rect.width;
    // Minimum canvas height — ignore small inline heights, use at least 400px
    var inlineH = parseInt(this.container.style.height) || 0;
    var specH = Math.max(400, inlineH);

    // Auto-layout (resets positions for non-manual nodes)
    this.nodes.forEach(function (n) { if (!n._manualPos) { n.x = undefined; n.y = undefined; } });
    autoLayout(this.nodes, this.connections);

    // Scale and center layout to fit available width
    var maxCol = this.nodes._maxCol || 0;
    if (maxCol > 0) {
      var neededWidth = C.layoutPadX * 2 + maxCol * C.layoutColGap + C.nodeWidth;
      var gap = C.layoutColGap;

      // Scale node width + gap to fit canvas. Minimum 40px wire space between nodes.
      var edgePad = 40;
      var wireSpace = 40;
      var nodeCount = maxCol + 1;
      var nw = C.nodeWidth;
      // Check if all nodes fit at full width
      var totalNeeded = nodeCount * nw + maxCol * wireSpace + edgePad * 2;
      if (totalNeeded > w) {
        // Shrink nodes to fit
        nw = Math.max(140, (w - edgePad * 2 - maxCol * wireSpace) / nodeCount);
      }
      gap = nw + wireSpace;
      // Center the diagram horizontally
      var diagramWidth = maxCol * gap + nw;
      var offsetX = Math.max(edgePad, (w - diagramWidth) / 2);

      this.nodes.forEach(function (n) {
        n._nw = nw;
        if (!n._manualPos) {
          var c = Math.round((n.x - C.layoutPadX) / C.layoutColGap);
          n.x = offsetX + c * gap;
        }
      });
    } else {
      // Single node — center it
      this.nodes.forEach(function (n) {
        n._nw = C.nodeWidth;
        if (!n._manualPos) {
          n.x = (w - C.nodeWidth) / 2;
        }
      });
    }

    // Compute needed height from nodes
    var maxBottom = 0;
    this.nodes.forEach(function (n) {
      var b = n.y + n._height + C.layoutPadY;
      if (b > maxBottom) maxBottom = b;
    });
    // Check loopback wires which go below
    var hasLoopback = this.connections.some(function (c) { return c._isLoopback; });
    if (hasLoopback) maxBottom += 50;

    var h = Math.max(specH, Math.min(maxBottom, 800));

    // Always center vertically — equal padding top and bottom
    var contentMinY = Infinity, contentMaxY = 0;
    this.nodes.forEach(function (n) {
      if (!n._manualPos) {
        if (n.y < contentMinY) contentMinY = n.y;
        var bot = n.y + n._height;
        if (bot > contentMaxY) contentMaxY = bot;
      }
    });
    if (contentMinY !== Infinity) {
      var contentH = contentMaxY - contentMinY;
      var offset = (h - contentH) / 2 - contentMinY;
      this.nodes.forEach(function (n) {
        if (!n._manualPos) { n.y += offset; }
      });
    }

    var dpr = window.devicePixelRatio || 1;
    this.canvas.width = w * dpr;
    this.canvas.height = h * dpr;
    this.canvas.style.height = h + 'px';
    // Override container height so it doesn't clip the canvas
    this.container.style.height = h + 'px';
    this.w = w;
    this.h = h;
    this.dpr = dpr;

    this.render();
  };

  WorkflowCanvas.prototype.render = function () {
    var ctx = this.canvas.getContext('2d');
    var dpr = this.dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    // Clear
    ctx.fillStyle = C.canvasBg;
    ctx.fillRect(0, 0, this.w, this.h);

    // Grid
    drawGrid(ctx, this.w, this.h);

    // Connections (draw behind nodes)
    var nodes = this.nodes;
    this.connections.forEach(function (conn) {
      drawConnection(ctx, conn, nodes);
    });

    // Nodes
    this.nodes.forEach(function (node) {
      drawNode(ctx, node);
    });

    // Interaction hint
    if (this.showHint && !this.dragging) {
      ctx.font = C.hintFont;
      ctx.fillStyle = C.hintColor;
      ctx.textAlign = 'right';
      ctx.textBaseline = 'bottom';
      ctx.fillText('drag to explore', this.w - 12, this.h - 8);
      ctx.textAlign = 'left';
    }
  };

  // ── Hit Testing ──

  WorkflowCanvas.prototype.hitTest = function (mx, my) {
    for (var i = this.nodes.length - 1; i >= 0; i--) {
      var n = this.nodes[i];
      var nw = n._nw || C.nodeWidth;
      if (mx >= n.x && mx <= n.x + nw && my >= n.y && my <= n.y + n._height) {
        return n;
      }
    }
    return null;
  };

  WorkflowCanvas.prototype.getCanvasPos = function (e) {
    var rect = this.canvas.getBoundingClientRect();
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    };
  };

  // ── Mouse Events ──

  WorkflowCanvas.prototype.onMouseDown = function (e) {
    var pos = this.getCanvasPos(e);
    var node = this.hitTest(pos.x, pos.y);
    if (node) {
      this.dragging = node;
      this.dragOffset.x = pos.x - node.x;
      this.dragOffset.y = pos.y - node.y;
      this.container.style.cursor = 'grabbing';
      this.showHint = false;
      e.preventDefault();
    }
  };

  WorkflowCanvas.prototype.onMouseMove = function (e) {
    var pos = this.getCanvasPos(e);
    if (this.dragging) {
      var newX = pos.x - this.dragOffset.x;
      var newY = pos.y - this.dragOffset.y;
      // Clamp
      var dragNW = this.dragging._nw || C.nodeWidth;
      newX = Math.max(0, Math.min(newX, this.w - dragNW));
      newY = Math.max(0, Math.min(newY, this.h - this.dragging._height));
      this.dragging.x = newX;
      this.dragging.y = newY;
      this.dragging._manualPos = true;
      this.scheduleRender();
    } else {
      var node = this.hitTest(pos.x, pos.y);
      this.container.style.cursor = node ? 'grab' : 'default';
    }
  };

  WorkflowCanvas.prototype.onMouseUp = function () {
    if (this.dragging) {
      this.dragging = null;
      this.container.style.cursor = 'grab';
      this.render();
    }
  };

  // ── Touch Events ──

  WorkflowCanvas.prototype.onTouchStart = function (e) {
    if (e.touches.length === 1) {
      var touch = e.touches[0];
      this.onMouseDown({ clientX: touch.clientX, clientY: touch.clientY, preventDefault: function () { e.preventDefault(); } });
    }
  };

  WorkflowCanvas.prototype.onTouchMove = function (e) {
    if (this.dragging && e.touches.length === 1) {
      e.preventDefault();
      var touch = e.touches[0];
      this.onMouseMove({ clientX: touch.clientX, clientY: touch.clientY });
    }
  };

  // ── Render Throttling ──

  WorkflowCanvas.prototype.scheduleRender = function () {
    if (this.rafId) return;
    var self = this;
    this.rafId = requestAnimationFrame(function () {
      self.rafId = null;
      self.render();
    });
  };

  // ── Init ──

  function init() {
    var containers = document.querySelectorAll('.workflow-canvas');
    containers.forEach(function (el) {
      // Skip if already initialized
      if (el._wfCanvas) return;
      el._wfCanvas = new WorkflowCanvas(el);
    });
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
