(function () {
  const root = document.querySelector(".graph-editor");
  const jsonEl = document.getElementById("graph-template-json");
  if (!root || !jsonEl) return;

  const svg = root.querySelector(".graph-canvas");
  const canvasWrap = root.querySelector(".graph-canvas-wrap");
  const nodeForm = root.querySelector(".graph-node-form");
  const edgeForm = root.querySelector(".graph-edge-form");
  const empty = root.querySelector(".graph-empty-inspector");
  const saveForm = root.closest(".graph-editor-card").querySelector(".graph-save-form");
  const deleteNodeBtn = root.querySelector(".graph-delete-node");
  const deleteEdgeBtn = root.querySelector(".graph-delete-edge");
  const connectBtn = root.querySelector(".graph-connect-node");
  const connectTarget = root.querySelector(".graph-connect-target");
  const typedParamFields = root.querySelector(".graph-typed-param-fields");
  const autoLayoutBtn = root.querySelector(".graph-auto-layout");
  const paletteList = root.querySelector(".graph-node-palette-list");
  const rawJSON = (jsonEl.textContent && jsonEl.textContent.trim())
    || (jsonEl.innerHTML && jsonEl.innerHTML.trim())
    || (jsonEl.content && jsonEl.content.textContent && jsonEl.content.textContent.trim())
    || "{}";
  const doc = JSON.parse(rawJSON);
  const graph = doc.template || {};
  const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
  const edges = Array.isArray(graph.edges) ? graph.edges : [];
  graph.nodes = nodes;
  graph.edges = edges;
  doc.template = graph;

  const fallbackNodeTypes = [
    { type: "input_rtsp", label: "RTSP 输入", category: "输入", icon: "camera", description: "从网络摄像机或流媒体地址读取视频流。", defaults: { id: "input_rtsp", type: "input_rtsp", role: "source", enable: true, url: "${slot:video_input_main.url}" } },
    { type: "input_file", label: "文件输入", category: "输入", icon: "file", description: "从本地视频文件读取帧，常用于离线验证和回放。", defaults: { id: "input_file", type: "input_file", role: "source", enable: true } },
    { type: "preprocess", label: "图像预处理", category: "处理", icon: "adjust", description: "调整尺寸、格式和硬件加速路径，为后续推理或编码准备图像。", defaults: { id: "preprocess", type: "preprocess", role: "filter", enable: true, dst_format: "rgb" } },
    { type: "ai_scrfd", label: "SCRFD 人脸检测", category: "AI 推理", icon: "scan-face", description: "使用 SCRFD 模型做人脸检测，适合固定输入尺寸场景。", defaults: { id: "ai_scrfd", type: "ai_scrfd", role: "filter", enable: true } },
    { type: "ai_scrfd_sliding", label: "滑窗人脸检测", category: "AI 推理", icon: "scan-face", description: "使用滑窗方式执行 SCRFD 人脸检测，适合高分辨率画面。", defaults: { id: "ai_scrfd_sliding", type: "ai_scrfd_sliding", role: "filter", enable: true } },
    { type: "ai_face_det", label: "人脸检测", category: "AI 推理", icon: "face", description: "通用人脸检测节点，输出人脸框和质量信息。", defaults: { id: "ai_face_det", type: "ai_face_det", role: "filter", enable: true } },
    { type: "ai_face_recog", label: "人脸识别", category: "AI 推理", icon: "face-id", description: "对检测到的人脸进行特征提取和人脸库匹配。", defaults: { id: "face_recog", type: "ai_face_recog", role: "filter", enable: true, infer_fps: 2 } },
    { type: "ai_yolo", label: "YOLO 目标检测", category: "AI 推理", icon: "target", description: "使用 YOLO 模型检测人员、PPE 或其他目标。", defaults: { id: "ai_yolo", type: "ai_yolo", role: "filter", enable: true, infer_fps: 2, conf: 0.35, nms: 0.45 } },
    { type: "ai_shoe_det", label: "鞋靴检测", category: "AI 推理", icon: "shoe", description: "检测鞋靴和工鞋相关目标，可配合逻辑节点判断违规。", defaults: { id: "ai_shoe_det", type: "ai_shoe_det", role: "filter", enable: true } },
    { type: "tracker", label: "目标跟踪", category: "处理", icon: "route", description: "对检测目标分配跟踪 ID，保持跨帧目标状态。", defaults: { id: "tracker", type: "tracker", role: "filter", enable: true } },
    { type: "logic_gate", label: "规则判断", category: "规则", icon: "branch", description: "根据检测、跟踪或颜色分析结果进行业务规则判断。", defaults: { id: "logic_gate", type: "logic_gate", role: "filter", enable: true, expression: "" } },
    { type: "event_fusion", label: "事件融合", category: "规则", icon: "merge", description: "融合多路事件，减少重复告警并形成更稳定的业务事件。", defaults: { id: "event_fusion", type: "event_fusion", role: "filter", enable: true } },
    { type: "region_event", label: "区域事件", category: "规则", icon: "region", description: "基于区域、越线或停留规则生成区域行为事件。", defaults: { id: "region_event", type: "region_event", role: "filter", enable: true } },
    { type: "action_recog", label: "行为识别", category: "AI 推理", icon: "activity", description: "识别人员行为或动作事件。", defaults: { id: "action_recog", type: "action_recog", role: "filter", enable: true } },
    { type: "det_post", label: "检测后处理", category: "处理", icon: "filter", description: "对检测结果做过滤、映射、合并或类别转换。", defaults: { id: "det_post", type: "det_post", role: "filter", enable: true } },
    { type: "osd", label: "画面叠加", category: "输出", icon: "overlay", description: "在视频帧上绘制检测框、文字、人脸识别和事件信息。", defaults: { id: "osd", type: "osd", role: "filter", enable: true } },
    { type: "publish", label: "视频输出", category: "输出", icon: "broadcast", description: "编码并发布 RTSP、HLS 或其他视频输出。", defaults: { id: "publish", type: "publish", role: "sink", enable: true } },
    { type: "storage", label: "本地存储", category: "输出", icon: "database", description: "保存帧、事件或中间结果到本地存储。", defaults: { id: "storage", type: "storage", role: "sink", enable: true } },
    { type: "alarm", label: "告警动作", category: "输出", icon: "bell", description: "根据规则触发日志、抓图、录像片段、外部接口等动作。", defaults: { id: "alarm", type: "alarm", role: "sink", enable: true, level: "warning" } },
    { type: "gate", label: "流控闸门", category: "系统", icon: "gate", description: "控制流程分支或限流，保护下游节点。", defaults: { id: "gate", type: "gate", role: "filter", enable: true } },
    { type: "zlm_http", label: "ZLMediaKit HTTP", category: "系统", icon: "server", description: "提供 ZLMediaKit 相关 HTTP 文件服务能力。", defaults: { id: "zlm_http", type: "zlm_http", role: "sink", enable: true } }
  ];

  const coreNodeKeys = new Set(["id", "type", "role", "enable"]);
  const coreEdgeKeys = new Set(["from", "to"]);
  const paramSchemas = {
    input_rtsp: [
      { key: "url", label: "RTSP 地址", type: "text", placeholder: "${slot:video_input_main.url}" },
      { key: "fps", label: "输入帧率", type: "number", step: "1" },
      { key: "width", label: "宽度", type: "number", step: "1" },
      { key: "height", label: "高度", type: "number", step: "1" },
      { key: "force_tcp", label: "强制 TCP", type: "boolean" },
      { key: "reconnect_sec", label: "重连间隔秒", type: "number", step: "1" }
    ],
    preprocess: [
      { key: "dst_w", label: "输出宽度", type: "number", step: "1" },
      { key: "dst_h", label: "输出高度", type: "number", step: "1" },
      { key: "dst_format", label: "输出格式", type: "select", options: ["rgb", "nv12", "bgr"] },
      { key: "resize_mode", label: "缩放方式", type: "select", options: ["stretch", "letterbox"] },
      { key: "use_rga", label: "使用 RGA", type: "boolean" },
      { key: "rga_gate", label: "RGA 通道", type: "text" }
    ],
    ai_yolo: [
      { key: "model_path", label: "模型路径", type: "text" },
      { key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" },
      { key: "model_w", label: "模型宽度", type: "number", step: "1" },
      { key: "model_h", label: "模型高度", type: "number", step: "1" },
      { key: "conf", label: "置信度", type: "number", step: "0.01" },
      { key: "nms", label: "NMS", type: "number", step: "0.01" }
    ],
    ai_shoe_det: [
      { key: "model_path", label: "模型路径", type: "text" },
      { key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" },
      { key: "conf", label: "置信度", type: "number", step: "0.01" },
      { key: "nms", label: "NMS", type: "number", step: "0.01" },
      { key: "append_detections", label: "追加检测结果", type: "boolean" }
    ],
    ai_scrfd_sliding: [
      { key: "model_path", label: "模型路径", type: "text" },
      { key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" },
      { key: "conf_thresh", label: "置信度", type: "number", step: "0.01" },
      { key: "nms_thresh", label: "NMS", type: "number", step: "0.01" },
      { key: "max_faces", label: "最大人脸数", type: "number", step: "1" }
    ],
    ai_face_recog: [
      { key: "model_path", label: "模型路径", type: "text" },
      { key: "infer_fps", label: "推理帧率", type: "number", step: "0.1" },
      { key: "align", label: "人脸对齐", type: "boolean" },
      { key: "emit_embedding", label: "输出特征", type: "boolean" },
      { key: "max_faces", label: "最大人脸数", type: "number", step: "1" }
    ],
    tracker: [
      { key: "mode", label: "跟踪模式", type: "text" },
      { key: "per_class", label: "按类别跟踪", type: "boolean" },
      { key: "high_th", label: "高阈值", type: "number", step: "0.01" },
      { key: "low_th", label: "低阈值", type: "number", step: "0.01" },
      { key: "iou_th", label: "IOU 阈值", type: "number", step: "0.01" },
      { key: "max_age_ms", label: "最大保留毫秒", type: "number", step: "1" }
    ],
    logic_gate: [
      { key: "mode", label: "逻辑模式", type: "text" },
      { key: "debug", label: "调试输出", type: "boolean" },
      { key: "anchor_class", label: "锚点类别", type: "number", step: "1" },
      { key: "boots_class", label: "鞋靴类别", type: "number", step: "1" },
      { key: "violation_class", label: "违规类别", type: "number", step: "1" }
    ],
    osd: [
      { key: "draw_bbox", label: "绘制框", type: "boolean" },
      { key: "draw_text", label: "绘制文字", type: "boolean" },
      { key: "draw_face_det", label: "绘制人脸检测", type: "boolean" },
      { key: "draw_face_recog", label: "绘制人脸识别", type: "boolean" },
      { key: "line_width", label: "线宽", type: "number", step: "0.1" },
      { key: "font_scale", label: "字体缩放", type: "number", step: "0.1" }
    ],
    publish: [
      { key: "codec", label: "编码", type: "select", options: ["h264", "h265"] },
      { key: "fps", label: "输出帧率", type: "number", step: "1" },
      { key: "gop", label: "GOP", type: "number", step: "1" },
      { key: "bitrate_kbps", label: "码率 kbps", type: "number", step: "1" },
      { key: "use_mpp", label: "使用 MPP", type: "boolean" },
      { key: "use_ffmpeg_mux", label: "FFmpeg 封装", type: "boolean" }
    ],
    alarm: [
      { key: "eval_fps", label: "评估帧率", type: "number", step: "0.1" }
    ]
  };

  function catalogFromItems(items) {
    const out = {};
    (items || []).forEach((item) => {
      if (!item || !item.type || !item.defaults) return;
      out[item.type] = item;
      if (Array.isArray(item.params)) paramSchemas[item.type] = item.params;
    });
    return out;
  }

  let nodeCatalog = catalogFromItems(fallbackNodeTypes);

  const layout = (((doc.ui || {}).layout || {}).nodes) || {};
  const hasSavedLayout = Object.keys(layout).length > 0;
  const nodeSize = { w: 142, h: 48 };
  const layoutGap = { x: 190, y: 86 };
  const positions = {};
  let selectedNodeId = "";
  let selectedEdgeIndex = -1;
  let drag = null;

  function nodePosition(node, index) {
    const saved = layout[node.id] || {};
    return {
      x: Number.isFinite(saved.x) ? saved.x : 80 + (index % 4) * 220,
      y: Number.isFinite(saved.y) ? saved.y : 80 + Math.floor(index / 4) * 100
    };
  }

  nodes.forEach((node, index) => {
    positions[node.id] = nodePosition(node, index);
  });

  function edgeEndpoints(edge) {
    if (Array.isArray(edge)) return { from: edge[0], to: edge[1] };
    return { from: edge.from, to: edge.to };
  }

  function setEdgeEndpoint(edge, key, value) {
    if (Array.isArray(edge)) {
      edge[key === "from" ? 0 : 1] = value;
      return;
    }
    edge[key] = value;
  }

  function edgeExtras(edge) {
    if (Array.isArray(edge)) {
      const extra = edge[2];
      return extra && typeof extra === "object" && !Array.isArray(extra) ? extra : {};
    }
    const out = {};
    Object.entries(edge).forEach(([key, value]) => {
      if (!coreEdgeKeys.has(key)) out[key] = value;
    });
    return out;
  }

  function ensureObjectEdge(index) {
    const edge = edges[index];
    if (!Array.isArray(edge)) return edge;
    const next = { from: edge[0], to: edge[1] };
    edges[index] = next;
    return next;
  }

  function clear(el) {
    while (el.firstChild) el.removeChild(el.firstChild);
  }

  function svgEl(name, attrs) {
    const el = document.createElementNS("http://www.w3.org/2000/svg", name);
    Object.entries(attrs || {}).forEach(([key, value]) => el.setAttribute(key, String(value)));
    return el;
  }

  function iconLabel(icon, type) {
    const icons = {
      camera: "IN",
      file: "FI",
      adjust: "PR",
      "scan-face": "FD",
      face: "FD",
      "face-id": "FR",
      target: "AI",
      shoe: "SH",
      route: "TR",
      branch: "LG",
      merge: "EF",
      region: "RG",
      activity: "AC",
      filter: "PO",
      overlay: "OS",
      broadcast: "PB",
      database: "DB",
      bell: "AL",
      gate: "GT",
      server: "SV"
    };
    return icons[icon] || String(type || "?").slice(0, 2).toUpperCase();
  }

  function renderPalette() {
    if (!paletteList) return;
    clear(paletteList);
    const groups = {};
    Object.values(nodeCatalog).forEach((item) => {
      const key = item.category || "其他";
      groups[key] = groups[key] || [];
      groups[key].push(item);
    });
    Object.keys(groups).forEach((category) => {
      const group = document.createElement("div");
      group.className = "graph-node-palette-group";
      const title = document.createElement("div");
      title.className = "graph-node-palette-category";
      title.textContent = category;
      group.appendChild(title);
      groups[category].forEach((item) => {
        const button = document.createElement("button");
        button.type = "button";
        button.className = "graph-node-palette";
        button.dataset.nodeType = item.type;
        button.title = item.description || item.label || item.type;
        const icon = document.createElement("span");
        icon.className = "graph-node-palette-icon";
        icon.textContent = iconLabel(item.icon, item.type);
        const text = document.createElement("span");
        text.className = "graph-node-palette-text";
        const label = document.createElement("span");
        label.textContent = item.label || item.type;
        const type = document.createElement("small");
        type.textContent = item.type;
        text.appendChild(label);
        text.appendChild(type);
        button.appendChild(icon);
        button.appendChild(text);
        group.appendChild(button);
      });
      paletteList.appendChild(group);
    });
  }

  async function loadNodeCatalog() {
    if (!paletteList || !paletteList.dataset.catalogUrl || !window.fetch) return;
    try {
      const res = await fetch(paletteList.dataset.catalogUrl, { headers: { Accept: "application/json" } });
      if (!res.ok) return;
      const payload = await res.json();
      if (!payload || !Array.isArray(payload.items) || payload.items.length === 0) return;
      nodeCatalog = catalogFromItems(payload.items);
      renderPalette();
      render();
      if (selectedNodeId) selectNode(selectedNodeId);
    } catch (err) {
      // Keep the offline catalog available when no device agent is reachable.
    }
  }

  function nodeOptions(select, excludedId) {
    clear(select);
    nodes.forEach((node) => {
      if (node.id === excludedId) return;
      const option = document.createElement("option");
      option.value = node.id;
      option.textContent = node.id;
      select.appendChild(option);
    });
  }

  function anchorPoints(fromId, toId) {
    const a = positions[fromId];
    const b = positions[toId];
    if (!a || !b) return null;
    const dx = b.x - a.x;
    const dy = b.y - a.y;
    if (Math.abs(dy) >= Math.abs(dx)) {
      if (dy >= 0) {
        return {
          from: { x: a.x + nodeSize.w / 2, y: a.y + nodeSize.h },
          to: { x: b.x + nodeSize.w / 2, y: b.y },
          vertical: true
        };
      }
      return {
        from: { x: a.x + nodeSize.w / 2, y: a.y },
        to: { x: b.x + nodeSize.w / 2, y: b.y + nodeSize.h },
        vertical: true
      };
    }
    if (dx >= 0) {
      return {
        from: { x: a.x + nodeSize.w, y: a.y + nodeSize.h / 2 },
        to: { x: b.x, y: b.y + nodeSize.h / 2 },
        vertical: false
      };
    }
    return {
      from: { x: a.x, y: a.y + nodeSize.h / 2 },
      to: { x: b.x + nodeSize.w, y: b.y + nodeSize.h / 2 },
      vertical: false
    };
  }

  function edgePathData(fromId, toId) {
    const anchors = anchorPoints(fromId, toId);
    if (!anchors) return "";
    const a = anchors.from;
    const b = anchors.to;
    if (anchors.vertical) {
      const midY = (a.y + b.y) / 2;
      return `M ${a.x} ${a.y} C ${a.x} ${midY}, ${b.x} ${midY}, ${b.x} ${b.y}`;
    }
    const midX = (a.x + b.x) / 2;
    return `M ${a.x} ${a.y} C ${midX} ${a.y}, ${midX} ${b.y}, ${b.x} ${b.y}`;
  }

  function updateCanvasSize() {
    const padding = 90;
    let maxX = 0;
    let maxY = 0;
    nodes.forEach((node) => {
      const p = positions[node.id];
      if (!p) return;
      maxX = Math.max(maxX, p.x + nodeSize.w);
      maxY = Math.max(maxY, p.y + nodeSize.h);
    });
    const contentWidth = Math.max(720, maxX + padding);
    const contentHeight = Math.max(620, maxY + padding);
    const viewportWidth = canvasWrap ? Math.max(720, canvasWrap.clientWidth - 2) : 720;
    svg.setAttribute("width", String(Math.max(viewportWidth, contentWidth)));
    svg.setAttribute("height", String(contentHeight));
    svg.style.width = `${Math.max(viewportWidth, contentWidth)}px`;
    svg.style.height = `${contentHeight}px`;
    svg.setAttribute("viewBox", `0 0 ${Math.max(viewportWidth, contentWidth)} ${contentHeight}`);
  }

  function render() {
    updateCanvasSize();
    clear(svg);
    edges.forEach((edge, index) => {
      const ep = edgeEndpoints(edge);
      const d = edgePathData(ep.from, ep.to);
      if (!d) return;
      const hit = svgEl("path", { class: "graph-edge-hit", d: d, "data-edge-index": index });
      const path = svgEl("path", {
        class: "graph-edge" + (index === selectedEdgeIndex ? " selected" : ""),
        d: d,
        "data-edge-index": index
      });
      hit.addEventListener("click", () => selectEdge(index));
      path.addEventListener("click", () => selectEdge(index));
      svg.appendChild(hit);
      svg.appendChild(path);
    });

    nodes.forEach((node) => {
      const p = positions[node.id] || { x: 80, y: 80 };
      const g = svgEl("g", {
        class: "graph-node" + (node.id === selectedNodeId ? " selected" : ""),
        transform: `translate(${p.x}, ${p.y})`,
        "data-node-id": node.id
      });
      g.appendChild(svgEl("rect", { width: nodeSize.w, height: nodeSize.h, rx: 7 }));
      const spec = nodeCatalog[node.type] || {};
      const svgTitle = svgEl("title");
      svgTitle.textContent = spec.description || node.type || node.id || "";
      g.appendChild(svgTitle);
      const icon = svgEl("text", { x: 12, y: 19, class: "graph-node-icon" });
      icon.textContent = iconLabel(spec.icon, node.type);
      g.appendChild(icon);
      const title = svgEl("text", { x: 36, y: 19 });
      title.textContent = node.id || "-";
      g.appendChild(title);
      const meta = svgEl("text", { x: 36, y: 37, class: "graph-node-type" });
      meta.textContent = spec.label || node.type || "-";
      g.appendChild(meta);
      g.addEventListener("click", () => selectNode(node.id));
      g.addEventListener("pointerdown", (event) => startDrag(event, node));
      svg.appendChild(g);
    });
  }

  function showPanel(kind) {
    empty.hidden = kind !== "empty";
    nodeForm.hidden = kind !== "node";
    edgeForm.hidden = kind !== "edge";
  }

  function nodeExtras(node) {
    const schemaKeys = new Set((paramSchemas[node.type] || []).map((field) => field.key));
    const out = {};
    Object.entries(node).forEach(([key, value]) => {
      if (!coreNodeKeys.has(key) && !schemaKeys.has(key)) out[key] = value;
    });
    return out;
  }

  function fieldValueForInput(value) {
    if (value === undefined || value === null) return "";
    if (typeof value === "object") return JSON.stringify(value);
    return String(value);
  }

  function parseTypedValue(field, value) {
    if (field.type === "boolean") return value === "true";
    if (field.type === "number") {
      if (String(value).trim() === "") return undefined;
      const num = Number(value);
      return Number.isFinite(num) ? num : undefined;
    }
    if (String(value).trim() === "") return undefined;
    return value;
  }

  function createTypedField(field, value) {
    const label = document.createElement("label");
    const caption = document.createElement("span");
    caption.textContent = field.label;
    label.appendChild(caption);
    let input;
    if (field.type === "boolean") {
      input = document.createElement("select");
      [["true", "启用"], ["false", "停用"]].forEach(([val, text]) => {
        const option = document.createElement("option");
        option.value = val;
        option.textContent = text;
        input.appendChild(option);
      });
      input.value = value === false ? "false" : "true";
    } else if (field.type === "select") {
      input = document.createElement("select");
      (field.options || []).forEach((item) => {
        const option = document.createElement("option");
        option.value = item;
        option.textContent = item;
        input.appendChild(option);
      });
      if (value !== undefined) input.value = String(value);
    } else {
      input = document.createElement("input");
      input.type = field.type || "text";
      if (field.step) input.step = field.step;
      if (field.placeholder) input.placeholder = field.placeholder;
      input.value = fieldValueForInput(value);
    }
    input.dataset.paramKey = field.key;
    input.dataset.paramType = field.type || "text";
    label.appendChild(input);
    return label;
  }

  function renderTypedParamFields(node) {
    clear(typedParamFields);
    (paramSchemas[node.type] || []).forEach((field) => {
      typedParamFields.appendChild(createTypedField(field, node[field.key]));
    });
  }

  function applyTypedParamFields(node) {
    (paramSchemas[node.type] || []).forEach((field) => {
      const input = typedParamFields.querySelector(`[data-param-key="${field.key}"]`);
      if (!input) return;
      const parsed = parseTypedValue(field, input.value);
      if (parsed === undefined) {
        delete node[field.key];
      } else {
        node[field.key] = parsed;
      }
    });
  }

  function selectNode(id) {
    selectedNodeId = id;
    selectedEdgeIndex = -1;
    const node = nodes.find((item) => item.id === id);
    if (!node) return;
    showPanel("node");
    nodeForm.elements.id.value = node.id || "";
    nodeForm.elements.type.value = node.type || "";
    nodeForm.elements.role.value = node.role || "filter";
    nodeForm.elements.enable.value = node.enable === false ? "false" : "true";
    renderTypedParamFields(node);
    nodeForm.elements.params_json.value = JSON.stringify(nodeExtras(node), null, 2);
    nodeOptions(connectTarget, node.id);
    render();
  }

  function selectEdge(index) {
    selectedNodeId = "";
    selectedEdgeIndex = index;
    const edge = edges[index];
    if (!edge) return;
    const ep = edgeEndpoints(edge);
    showPanel("edge");
    nodeOptions(edgeForm.elements.from, "");
    nodeOptions(edgeForm.elements.to, "");
    edgeForm.elements.from.value = ep.from || "";
    edgeForm.elements.to.value = ep.to || "";
    edgeForm.elements.params_json.value = JSON.stringify(edgeExtras(edge), null, 2);
    render();
  }

  function parseJSONField(text, fallback) {
    const raw = text.trim();
    if (raw === "") return {};
    const parsed = JSON.parse(raw);
    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return fallback;
    return parsed;
  }

  function applyNodeExtras(node) {
    applyTypedParamFields(node);
    const extras = parseJSONField(nodeForm.elements.params_json.value, {});
    Object.keys(node).forEach((key) => {
      if (!coreNodeKeys.has(key) && !(paramSchemas[node.type] || []).some((field) => field.key === key)) delete node[key];
    });
    Object.assign(node, extras);
  }

  function applyEdgeExtras(index) {
    const edge = ensureObjectEdge(index);
    const extras = parseJSONField(edgeForm.elements.params_json.value, {});
    Object.keys(edge).forEach((key) => {
      if (!coreEdgeKeys.has(key)) delete edge[key];
    });
    Object.assign(edge, extras);
  }

  function bindForms() {
    if (!nodeForm.dataset.bound) {
      nodeForm.dataset.bound = "true";
      nodeForm.addEventListener("input", function (event) {
        if (event.target && event.target.dataset && event.target.dataset.paramKey) {
          const node = nodes.find((item) => item.id === selectedNodeId);
          if (node) applyTypedParamFields(node);
          return;
        }
        if (event.target && event.target.name === "params_json") return;
        const node = nodes.find((item) => item.id === selectedNodeId);
        if (!node) return;
        const oldId = node.id;
        const nextId = nodeForm.elements.id.value.trim();
        node.role = nodeForm.elements.role.value;
        node.enable = nodeForm.elements.enable.value === "true";
        if (nextId && nextId !== oldId && !nodes.some((item) => item !== node && item.id === nextId)) {
          node.id = nextId;
          positions[nextId] = positions[oldId] || { x: 80, y: 80 };
          delete positions[oldId];
          edges.forEach((edge) => {
            if (edgeEndpoints(edge).from === oldId) setEdgeEndpoint(edge, "from", nextId);
            if (edgeEndpoints(edge).to === oldId) setEdgeEndpoint(edge, "to", nextId);
          });
          selectedNodeId = nextId;
          nodeOptions(connectTarget, node.id);
        }
        render();
      });
      nodeForm.elements.params_json.addEventListener("change", function () {
        const node = nodes.find((item) => item.id === selectedNodeId);
        if (!node) return;
        try {
          applyNodeExtras(node);
          this.setCustomValidity("");
        } catch (err) {
          this.setCustomValidity("参数必须是 JSON 对象");
          this.reportValidity();
        }
      });
    }

    if (!edgeForm.dataset.bound) {
      edgeForm.dataset.bound = "true";
      edgeForm.addEventListener("input", function (event) {
        if (event.target && event.target.name === "params_json") return;
        const edge = edges[selectedEdgeIndex];
        if (!edge) return;
        setEdgeEndpoint(edge, "from", edgeForm.elements.from.value);
        setEdgeEndpoint(edge, "to", edgeForm.elements.to.value);
        render();
      });
      edgeForm.elements.params_json.addEventListener("change", function () {
        if (selectedEdgeIndex < 0) return;
        try {
          applyEdgeExtras(selectedEdgeIndex);
          this.setCustomValidity("");
        } catch (err) {
          this.setCustomValidity("参数必须是 JSON 对象");
          this.reportValidity();
        }
      });
    }
  }

  function startDrag(event, node) {
    const p = positions[node.id] || { x: 80, y: 80 };
    drag = { id: node.id, startX: event.clientX, startY: event.clientY, x: p.x, y: p.y };
    selectedNodeId = node.id;
    selectNode(node.id);
    event.preventDefault();
  }

  function uniqueNodeId(base) {
    let out = base;
    let i = 2;
    while (nodes.some((node) => node.id === out)) {
      out = `${base}_${i}`;
      i += 1;
    }
    return out;
  }

  function hasEdge(from, to) {
    return edges.some((edge) => {
      const ep = edgeEndpoints(edge);
      return ep.from === from && ep.to === to;
    });
  }

  function autoLayout() {
    const ids = nodes.map((node) => node.id).filter(Boolean);
    const viewportWidth = canvasWrap ? Math.max(720, canvasWrap.clientWidth - 2) : 720;
    const indegree = {};
    const outgoing = {};
    ids.forEach((id) => {
      indegree[id] = 0;
      outgoing[id] = [];
    });
    edges.forEach((edge) => {
      const ep = edgeEndpoints(edge);
      if (!(ep.from in indegree) || !(ep.to in indegree)) return;
      outgoing[ep.from].push(ep.to);
      indegree[ep.to] += 1;
    });

    const queue = ids.filter((id) => indegree[id] === 0);
    const level = {};
    queue.forEach((id) => { level[id] = 0; });
    for (let i = 0; i < queue.length; i += 1) {
      const id = queue[i];
      outgoing[id].forEach((next) => {
        level[next] = Math.max(level[next] || 0, (level[id] || 0) + 1);
        indegree[next] -= 1;
        if (indegree[next] === 0) queue.push(next);
      });
    }
    ids.forEach((id, index) => {
      if (level[id] === undefined) level[id] = index;
    });

    const groups = {};
    ids.forEach((id) => {
      const key = level[id];
      groups[key] = groups[key] || [];
      groups[key].push(id);
    });
    Object.keys(groups).map(Number).sort((a, b) => a - b).forEach((levelNum) => {
      const group = groups[levelNum];
      const totalWidth = nodeSize.w + Math.max(0, group.length - 1) * layoutGap.x;
      const startX = Math.max(60, (viewportWidth - totalWidth) / 2);
      group.forEach((id, index) => {
        positions[id] = {
          x: startX + index * layoutGap.x,
          y: 70 + levelNum * layoutGap.y
        };
      });
    });
    render();
  }

  if (paletteList) {
    paletteList.addEventListener("click", function (event) {
      const item = event.target.closest(".graph-node-palette");
      if (!item || !paletteList.contains(item)) return;
      const spec = nodeCatalog[item.dataset.nodeType];
      if (!spec) return;
      const node = JSON.parse(JSON.stringify(spec.defaults));
      node.id = uniqueNodeId(node.id);
      nodes.push(node);
      positions[node.id] = { x: 100 + nodes.length * 20, y: 100 + nodes.length * 20 };
      selectNode(node.id);
    });
  }

  svg.addEventListener("pointermove", function (event) {
    if (!drag) return;
    positions[drag.id] = {
      x: drag.x + event.clientX - drag.startX,
      y: drag.y + event.clientY - drag.startY
    };
    render();
  });

  svg.addEventListener("pointerup", function () {
    drag = null;
  });

  if (connectBtn) {
    connectBtn.addEventListener("click", function () {
      const target = connectTarget.value;
      if (!selectedNodeId || !target || hasEdge(selectedNodeId, target)) return;
      edges.push({ from: selectedNodeId, to: target });
      selectedEdgeIndex = edges.length - 1;
      selectEdge(selectedEdgeIndex);
    });
  }

  if (deleteNodeBtn) {
    deleteNodeBtn.addEventListener("click", function () {
      if (!selectedNodeId) return;
      const index = nodes.findIndex((node) => node.id === selectedNodeId);
      if (index >= 0) nodes.splice(index, 1);
      for (let i = edges.length - 1; i >= 0; i -= 1) {
        const ep = edgeEndpoints(edges[i]);
        if (ep.from === selectedNodeId || ep.to === selectedNodeId) edges.splice(i, 1);
      }
      delete positions[selectedNodeId];
      selectedNodeId = "";
      showPanel("empty");
      render();
    });
  }

  if (deleteEdgeBtn) {
    deleteEdgeBtn.addEventListener("click", function () {
      if (selectedEdgeIndex < 0) return;
      edges.splice(selectedEdgeIndex, 1);
      selectedEdgeIndex = -1;
      showPanel("empty");
      render();
    });
  }

  if (autoLayoutBtn) {
    autoLayoutBtn.addEventListener("click", autoLayout);
  }

  if (saveForm) {
    saveForm.addEventListener("submit", function (event) {
      try {
        if (!nodeForm.hidden && selectedNodeId) {
          const node = nodes.find((item) => item.id === selectedNodeId);
          if (node) applyNodeExtras(node);
        }
        if (!edgeForm.hidden && selectedEdgeIndex >= 0) {
          applyEdgeExtras(selectedEdgeIndex);
        }
        doc.ui = doc.ui || {};
        doc.ui.layout = doc.ui.layout || {};
        doc.ui.layout.version = 1;
        doc.ui.layout.nodes = positions;
        saveForm.elements.json.value = JSON.stringify(doc);
      } catch (err) {
        event.preventDefault();
        window.alert(err && err.message ? err.message : "参数 JSON 格式不正确");
      }
    });
  }

  bindForms();
  renderPalette();
  loadNodeCatalog();
  if (!hasSavedLayout && nodes.length > 0) {
    autoLayout();
  } else {
    render();
  }
})();
