/* ==============================================
   Color system — role-driven
   漏洞 = 发现的安全弱点（RCE/弱口令/未授权等）
   过程 = 渗透测试操作（利用/横向移动/维权/后渗透等）
   Priority: dead → objective → vuln → process → user_info → neutral
   ============================================== */
const typeNames = {
  objective: '测试目标',
  host: '主机',
  service: '服务',
  vulnerability: '漏洞',
  credential: '凭据',
  note: '备注',
  entry_point: '入口',
  attack_step: '攻击步骤',
  impact: '影响',
  exfiltration: '数据外传',
  privilege_escalation: '权限提升',
  asset: '资产',
};

function nodeColor(nodeType, props) {
  const nt = (nodeType||'').toLowerCase();
  const v = (typeof props==='object' && props!==null) ? props.verdict : undefined;
  const src = (typeof props==='object' && props!==null) ? props.source : undefined;

  // 1. 死路 — 攻击尝试失败，优先覆盖一切类型
  if (v === 'failed') return 'dead';

  // 2. 目标 — 测试目标 / 攻击入口
  if (nt==='objective'||nt==='entry_point') return 'objective';

  // 3. 漏洞 — 发现的安全弱点（RCE/弱口令/未授权/提权漏洞等）
  if (nt==='vulnerability'||nt==='privilege_escalation') return 'vuln';

  // 4. 信息 — 用户直接提供的非目标信息（优先于过程）
  if (src==='user') return 'user_info';

  // 5. 过程 — 渗透测试操作（利用/横向移动/上传shell/维权/信息收集/后渗透等）
  if (nt==='exploitation'||nt==='exfiltration'||nt==='impact'||nt==='attack_step'||nt==='reconnaissance'||v==='success') return 'process';

  // 6. 默认 — 未归类节点
  return 'neutral';
}

/* ── Icon generator: SVG symbol inside corner dot ── */
function cornerIcon(nodeType, verdict, source) {
  const nt = (nodeType||'').toLowerCase();
  const v = verdict || '';
  const stroke = 'white';
  const sw = '1.6';

  // Dead end → ✗ cross
  if (v === 'failed') {
    return '<line x1="4" y1="4" x2="12" y2="12" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linecap="round"/>'+
           '<line x1="12" y1="4" x2="4" y2="12" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linecap="round"/>';
  }
  // Entry point → ◆ diamond
  if (nt === 'objective' || nt === 'entry_point') {
    return '<polygon points="8,2 14,8 8,14 2,8" fill="none" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linejoin="round"/>';
  }
  // Vulnerability → 🚩 flag（漏洞永远是旗，不受 verdict 影响）
  if (nt==='vulnerability'||nt==='privilege_escalation') {
    return '<line x1="8" y1="2.5" x2="8" y2="14" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linecap="round"/>'+
           '<polyline points="8,2.5 13,4.5 8,6.5" fill="none" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linecap="round" stroke-linejoin="round"/>';
  }
  // User info → 👤 person
  if (source === 'user') {
    return '<circle cx="8" cy="5" r="2.2" fill="none" stroke="'+stroke+'" stroke-width="'+sw+'"/>'+
           '<path d="M3 14 Q3 8.5 8 8.5 Q13 8.5 13 14" fill="none" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linecap="round"/>';
  }
  // Confirmed exploit → ✓ checkmark（过程节点 + verdict:success）
  if (v === 'success') {
    return '<polyline points="3,8.5 6.5,11.5 13,4.5" fill="none" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linecap="round" stroke-linejoin="round"/>';
  }
  // Process / action → ▶ play arrow
  if (nt==='exploitation'||nt==='exfiltration'||nt==='impact'||nt==='attack_step'||nt==='reconnaissance') {
    return '<polygon points="4,2.5 4,13.5 13,8" fill="none" stroke="'+stroke+'" stroke-width="'+sw+'" stroke-linecap="round" stroke-linejoin="round"/>';
  }
  // Neutral → ● dot
  return '<circle cx="8" cy="8" r="2.5" fill="'+stroke+'"/>';
}

const colorLegend = [
  { cls:'vuln',      label:'漏洞', desc:'发现的安全弱点（RCE/弱口令/未授权/提权漏洞等）' },
  { cls:'process',   label:'过程', desc:'渗透测试操作（漏洞利用/横向移动/维权/后渗透等）' },
  { cls:'objective', label:'目标', desc:'测试目标 / 攻击入口' },
  { cls:'dead',      label:'死路', desc:'攻击尝试失败' },
  { cls:'user_info', label:'信息', desc:'用户直接提供的非目标信息' },
  { cls:'neutral',   label:'未归类', desc:'尚未确定性质的节点' },
];

/* ==============================================
   Legend toggle
   ============================================== */
let legendOpen = false;
function toggleLegend() {
  legendOpen = !legendOpen;
  document.getElementById('legend-body').classList.toggle('visible', legendOpen);
  document.getElementById('legend-toggle').classList.toggle('open', legendOpen);
}

/* ==============================================
   Empty state
   ============================================== */
function updateEmptyState() {
  const el = document.getElementById('empty-state');
  if (!currentProject) {
    el.style.display = 'block';
  } else {
    el.style.display = 'none';
  }
}

/* ==============================================
   Panel
   ============================================== */
function closePanel() {
  document.getElementById('node-panel').classList.remove('open');
}

function exportPNG() {
  if (!cy) return;
  const png = cy.png({ full: true, scale: 4, bg: '#0f0f13', output: 'blob' });
  const url = URL.createObjectURL(png);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'ariadne-graph.png';
  a.click();
  URL.revokeObjectURL(url);
}

function exportReport() {
  if (!cy) return;
  const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
  let md = '# 渗透测试报告\n\n';
  md += '生成时间：' + now + '\n\n---\n\n';
  const nodes = cy.nodes();
  nodes.forEach(n => {
    const props = n.data('properties') || {};
    const typeNames = {objective:'测试目标',vulnerability:'漏洞',exploitation:'漏洞利用',attack_step:'攻击步骤',exfiltration:'数据外传',impact:'影响评估',host:'主机',service:'服务',credential:'凭据',finding:'发现',entry_point:'攻击入口',reconnaissance:'信息收集',privilege_escalation:'提权漏洞'};
    md += '## ' + (n.data('label')||'(无标签)') + '\n\n';
    md += '**类型**: ' + (typeNames[n.data('node_type')]||n.data('node_type')||'未知') + '\n';
    if (props.verdict) md += '  **结果**: ' + ({success:'✓ 成功',failed:'✗ 失败'}[props.verdict]||props.verdict) + '\n';
    if (props.evidence) md += '  **证据**: ' + props.evidence + '\n';
    if (typeof props.content === 'string' && props.content.trim()) {
      md += '\n' + props.content + '\n';
    }
    md += '\n---\n\n';
  });
  const blob = new Blob([md], {type:'text/markdown'});
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'pentest-report-'+now.slice(0,10)+'.md';
  a.click();
  URL.revokeObjectURL(a.href);
}

function showCtxMenu(x, y, items) {
  const menu = document.getElementById('ctx-menu');
  const deleteEl = document.getElementById('ctx-delete-edge');
  const editEl = document.getElementById('ctx-edit-node');
  deleteEl.style.display = items.includes('delete-edge') ? '' : 'none';
  editEl.style.display = items.includes('edit-node') ? '' : 'none';
  menu.style.left = x + 'px';
  menu.style.top = y + 'px';
  menu.style.display = '';
  menu._items = items;
}

// Context menu item: delete edge
document.getElementById('ctx-delete-edge').addEventListener('click', async () => {
  const edgeId = window._ctxEdgeId;
  if (!edgeId || !currentProject) return;
  await authFetch(API+'/projects/'+currentProject+'/edges/'+edgeId, {method:'DELETE'});
  const edge = cy.getElementById(edgeId);
  if (edge.length) cy.remove(edge);
  toast('连线已删除');
  document.getElementById('ctx-menu').style.display = 'none';
});

// Context menu item: edit node
document.getElementById('ctx-edit-node').addEventListener('click', () => {
  editNode();
  document.getElementById('ctx-menu').style.display = 'none';
});

function dbg(s) { console.log(s); }

/* ==============================================
   Init
   ============================================== */
function init() {
  dbg('init: cytoscape=' + typeof cytoscape + ' dagre=' + typeof dagre);

  // Build legend body (always present, just hidden)
  const lb = document.getElementById('legend-body');
  lb.innerHTML = '<div style="font-weight:600;color:var(--text-primary);margin-bottom:3px;font-size:12px">颜色含义</div>' +
    colorLegend.map(c =>
      '<div class="legend-row"><span class="legend-dot '+c.cls+'"></span><b>'+c.label+'</b> — '+c.desc+'</div>'
    ).join('');

  try {
    cy = cytoscape({
      container: document.getElementById('cy'),
      style: [
        /* ── Base node ── */
        { selector:'node', style: {
          'shape': 'round-rectangle',
          'width': (ele) => {
            const label = ele.data('label') || '';
            let w = 0;
            for (const ch of label) { w += /[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/.test(ch) ? 13.5 : 7.2; }
            return Math.max(90, w + 24);
          },
          'height': (ele) => {
            const label = ele.data('label') || '';
            let tw = 0;
            for (const ch of label) { tw += /[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/.test(ch) ? 13.5 : 7.2; }
            const nodeW = Math.max(90, tw + 24);
            const textMaxW = nodeW - 12;
            const lines = Math.max(1, Math.ceil(tw / textMaxW));
            return 16 * lines + 14;
          },
          'padding': '8px',
          'background-color': '#1a1a1e',
          'background-image': (ele) => {
            const colorMap = {
              'vuln':      '#d44c4c',
              'process':   '#0ea5a9',
              'objective': '#5c9cd4',
              'dead':      '#5c5c6e',
              'user_info': '#8a6cd4',
              'neutral':   '#3a3a4c',
            };
            const status = ele.data('status') || 'neutral';
            const c = colorMap[status] || colorMap['neutral'];
            const icon = cornerIcon(ele.data('node_type'), ele.data('verdict'), ele.data('source'));
            const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7.5" fill="'+c+'"/>'+icon+'</svg>';
            return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
          },
          'background-width': '14px',
          'background-height': '14px',
          'background-position-x': '5px',
          'background-position-y': '5px',
          'background-fit': 'none',
          'border-color': (ele) => {
            const colors = {vuln:'#d44c4c', process:'#0ea5a9', objective:'#4a8cc4', dead:'#4a4a56', user_info:'#7a5cc4', neutral:'#3a3a4c'};
            return colors[ele.data('status')] || colors['neutral'];
          },
          'border-width': 2.5,
          'label': 'data(label)',
          'color': '#e4e4ec',
          'font-size': 13,
          'font-family': '-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",sans-serif',
          'font-weight': 600,
          'text-valign': 'center',
          'text-halign': 'center',
          'text-wrap': 'wrap',
          'text-max-width': (ele) => {
            const label = ele.data('label') || '';
            let tw = 0;
            for (const ch of label) { tw += /[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/.test(ch) ? 13.5 : 7.2; }
            return Math.max(75, tw + 6);
          },
          'text-background-color': 'transparent',
          'transition-property': 'background-color,border-color,border-width',
          'transition-duration': '0.25s',
        }},
        /* ── Base edge ── */
        { selector:'edge', style: {
          'width': 1.8,
          'line-color': '#3a3a4c',
          'target-arrow-color': '#3a3a4c',
          'target-arrow-shape': 'triangle',
          'curve-style': 'bezier',
          'arrow-scale': 1.1,
          'line-style': 'solid',
          'label': 'data(label)',
          'color': '#6a6a7c',
          'font-size': 11,
          'font-family': '-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",sans-serif',
          'text-background-color': '#0e0e16',
          'text-background-opacity': 0.85,
          'text-background-padding': '4px',
          'text-background-shape': 'round-rectangle',
          'text-rotation': 'autorotate',
          'text-margin-y': -6,
          'opacity': 0.75,
          'transition-property': 'opacity,line-color,target-arrow-color',
          'transition-duration': '0.2s',
        }},
        /* ── Edge hover ── */
        { selector:'edge:hover', style: {
          'opacity': 1,
          'line-color': '#c8960c',
          'target-arrow-color': '#c8960c',
          'color': '#c8a028',
          'transition-duration': '0.15s',
        }},
        /* ── Node hover ── */
        { selector:'node:hover', style: {
          'border-width': 3.5,
          'overlay-opacity': 0.05,
          'overlay-color': '#c8960c',
          'overlay-padding': 6,
        }},
        /* ── Color: vuln (漏洞) ── */
        { selector:'node[status="vuln"]', style: {
          'background-color': '#1a1014',
          'border-color': '#d44c4c',
        }},
        /* ── Color: process (过程) ── */
        { selector:'node[status="process"]', style: {
          'background-color': '#0e1a19',
          'border-color': '#0ea5a9',
        }},
        /* ── Color: dead (死路) ── */
        { selector:'node[status="dead"]', style: {
          'background-color': '#18181d',
          'border-color': '#5c5c6e',
        }},
        /* ── Color: objective (目标) ── */
        { selector:'node[status="objective"]', style: {
          'background-color': '#101a28',
          'border-color': '#5c9cd4',
        }},
        /* ── Color: user_info (信息) ── */
        { selector:'node[status="user_info"]', style: {
          'background-color': '#1a1528',
          'border-color': '#8a6cd4',
        }},
        /* ── Color: neutral (未归类) ── */
        { selector:'node[status="neutral"]', style: {
          'background-color': '#1a1a22',
          'border-color': '#3a3a4c',
        }},
        /* ── Evidence: confirmed (solid) ── */
        { selector:'node[evidence="confirmed"]', style: {
          'border-style': 'solid',
        }},
        /* ── Evidence: unverified (dashed) ── */
        { selector:'node[evidence="unverified"]', style: {
          'border-style': 'dashed',
        }},
        /* ── Evidence: hypothesis (dotted) ── */
        { selector:'node[evidence="hypothesis"]', style: {
          'border-style': 'dotted',
        }},
      ],
      layout: { name:'dagre', rankDir:'LR', spacingFactor:1.15, nodeSep:50, edgeSep:30, rankSep:140 },
      wheelSensitivity: 0.3,
    });
    dbg('init: cy created, nodes=' + cy.nodes().length);
  } catch(e) { dbg('cytoscape init FAILED: ' + e.message); return; }

  cy.on('tap', 'node', e => {
    const n = e.target;
    const props = (typeof n.data('properties')==='object' && n.data('properties')!==null) ? n.data('properties') : {};
    const panel = document.getElementById('node-panel');

    /* ── Header row: type pill + evidence pill ── */
    const nodeType = n.data('node_type')||'note';
    panel.querySelector('.np-type-pill').textContent = typeNames[nodeType] || nodeType;

    const ev = n.data('evidence') || props.evidence || 'unverified';
    const evLabels = {confirmed:'已确认',unverified:'未验证',hypothesis:'推测'};
    const evPill = panel.querySelector('.np-evidence-pill');
    evPill.textContent = evLabels[ev] || ev;
    evPill.className = 'np-evidence-pill ' + ev;

    /* ── Title area: status icon + title ── */
    const verdict = n.data('verdict') || props.verdict || '';
    const verdictIcons = {success:'✓',failed:'✗'};
    const iconEl = panel.querySelector('.np-status-icon');
    if (verdictIcons[verdict]) {
      iconEl.className = 'np-status-icon ' + verdict;
      iconEl.textContent = verdictIcons[verdict];
    } else {
      iconEl.className = 'np-status-icon none';
      iconEl.textContent = '';
    }

    const rawLabel = n.data('label')||'';
    panel.querySelector('.np-title').textContent = rawLabel;

    /* ── Content ── */
    const md = (typeof props.content === 'string') ? props.content : '';
    const contentEl = panel.querySelector('.np-content');
    if (!md.trim()) {
      contentEl.innerHTML = '<div class="np-empty"><div class="np-empty-icon">📝</div><p class="np-empty-title">尚未添加详细说明</p><p class="np-empty-hint">使用 CLI 的 <code>--content</code> 参数为节点添加 Markdown 文档</p></div>';
    } else {
      const html = marked && marked.parse ? marked.parse(md) : (md.replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>'));
      contentEl.innerHTML = html;

      if (typeof hljs !== 'undefined') {
        contentEl.querySelectorAll('pre code').forEach(block => {
          hljs.highlightElement(block);
        });
      }
    }

    /* ── Footer tags ── */
    const tagsEl = document.getElementById('np-tags');
    tagsEl.innerHTML = '';
    const verdictLabels = {success:'✓ 成功',failed:'✗ 失败'};
    const phaseLabels = {recon:'🔍 侦察',vuln:'💥 漏洞发现',exploit:'⚡ 漏洞利用',post:'🔄 后渗透',result:'📋 结果'};

    const typeTag = document.createElement('span');
    typeTag.className = 'np-tag';
    typeTag.textContent = typeNames[nodeType] || nodeType;
    tagsEl.appendChild(typeTag);

    if (props.verdict) {
      const span = document.createElement('span');
      span.className = 'np-tag ' + ({success:'verdict-success',failed:'verdict-failed'}[props.verdict]||'');
      span.textContent = verdictLabels[props.verdict] || props.verdict;
      tagsEl.appendChild(span);
    }
    if (props.phase) {
      const span = document.createElement('span');
      span.className = 'np-tag';
      span.textContent = phaseLabels[props.phase] || props.phase;
      tagsEl.appendChild(span);
    }
    if (props.source && props.source !== 'user') {
      const span = document.createElement('span');
      span.className = 'np-tag';
      span.textContent = '🤖 AI生成';
      tagsEl.appendChild(span);
    }
    if (props.target && typeof props.target === 'string') {
      const span = document.createElement('span');
      span.className = 'np-tag';
      span.textContent = '🎯 ' + props.target;
      tagsEl.appendChild(span);
    }

    // Timestamp
    const createdAt = n.data('created_at');
    if (createdAt) {
      const d = new Date(createdAt);
      const ts = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')+' '
                +String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0');
      const tsEl = document.getElementById('np-timestamp');
      if (tsEl) tsEl.textContent = '🕐 ' + ts;
    }

    // Store for editing
    window._currentNodeId = n.id();
    window._currentNodeLabel = rawLabel;
    window._currentNodeContent = md;
    window._currentNodeType = nodeType;
    window._currentNodeVerdict = props.verdict || '';
    window._currentNodeEvidence = props.evidence || '';

    // Render existing screenshots
    const shotsEl = document.getElementById('np-screenshots');
    shotsEl.innerHTML = '';
    if (Array.isArray(props.screenshots)) {
      renderScreenshots(props.screenshots);
    }
    document.getElementById('np-edit-btn').style.display = '';
    document.getElementById('np-edit-actions').style.display = 'none';
    document.getElementById('np-edit-fields').style.display = 'none';
    document.querySelector('.np-content').style.display = '';
    document.getElementById('np-content-edit').style.display = 'none';
    document.querySelector('.np-title').style.display = '';
    document.getElementById('np-title-input').style.display = 'none';

    panel.classList.add('open');
    // Load evidence attachments for this node
    loadEvidence(n.id());
    if (e.originalEvent) e.originalEvent.stopPropagation();
  });
  cy.on('tap', e => {
    if (e.target === cy) closePanel();
  });

  // Right-click context menu on edges
  cy.on('cxttap', 'edge', e => {
    e.originalEvent.preventDefault();
    const edge = e.target;
    window._ctxEdgeId = edge.id();
    showCtxMenu(e.originalEvent.clientX, e.originalEvent.clientY, ['delete-edge']);
  });

  // Right-click context menu on panel
  document.getElementById('node-panel').addEventListener('contextmenu', e => {
    if (window._currentNodeId) {
      e.preventDefault();
      showCtxMenu(e.clientX, e.clientY, ['edit-node']);
    }
  });

  // Node drag — save position to backend on release
  cy.on('dragfree', 'node', e => {
    const node = e.target;
    const pos = node.position();
    saveNodePosition(currentProject, node.id(), pos.x, pos.y);
    dbg('drag: saved position for ' + node.id().slice(0,8) + ' x=' + pos.x.toFixed(0) + ' y=' + pos.y.toFixed(0));
  });

  // Box selection: double-click background → drag to select nodes
  let selecting = false, selStart = null;
  const selBox = document.getElementById('sel-box');
  const cyDiv = document.getElementById('cy');

  cy.on('dblclick', e => {
    if (e.target !== cy) return;
    selecting = true;
    selStart = null;
    cy.panningEnabled(false);
    cyDiv.style.cursor = 'crosshair';
    dbg('sel: double-click — draw rectangle to select nodes');
  });

  cy.on('mousedown', e => {
    if (!selecting || e.target !== cy) return;
    selStart = { x: e.originalEvent.clientX, y: e.originalEvent.clientY };
    selBox.style.display = 'block';
    selBox.style.left = selStart.x + 'px';
    selBox.style.top = selStart.y + 'px';
    selBox.style.width = '0px';
    selBox.style.height = '0px';
    e.originalEvent.preventDefault();
    e.originalEvent.stopPropagation();
  });

  cy.on('mousemove', e => {
    if (!selStart) return;
    const mx = e.originalEvent.clientX, my = e.originalEvent.clientY;
    const x = Math.min(selStart.x, mx), y = Math.min(selStart.y, my);
    const w = Math.abs(mx - selStart.x), h = Math.abs(my - selStart.y);
    selBox.style.left = x + 'px';
    selBox.style.top = y + 'px';
    selBox.style.width = w + 'px';
    selBox.style.height = h + 'px';
  });

  cy.on('mouseup', e => {
    if (!selStart) return;
    const ex = e.originalEvent.clientX, ey = e.originalEvent.clientY;
    const x1 = Math.min(selStart.x, ex), x2 = Math.max(selStart.x, ex);
    const y1 = Math.min(selStart.y, ey), y2 = Math.max(selStart.y, ey);

    // Find nodes within the selection rectangle
    let count = 0;
    cy.nodes().forEach(n => {
      const rp = n.renderedPosition();
      if (rp.x >= x1 && rp.x <= x2 && rp.y >= y1 && rp.y <= y2) {
        n.select();
        count++;
      }
    });

    // Reset
    selStart = null;
    selBox.style.display = 'none';
    selecting = false;
    cy.panningEnabled(true);
    cyDiv.style.cursor = '';
    if (count > 0) dbg('sel: selected ' + count + ' nodes');
  });

  // Escape to cancel selection
  document.addEventListener('keydown', e => {
    if (e.key === 'Escape' && selecting) {
      selecting = false;
      selStart = null;
      selBox.style.display = 'none';
      cy.panningEnabled(true);
      cyDiv.style.cursor = '';
      dbg('sel: cancelled');
    }
  });

  // Hide context menu on any click
  document.addEventListener('click', () => {
  document.getElementById('ctx-menu').style.display = 'none';
});

// Screenshot upload via drop
  const uploadZone = document.getElementById('np-upload');
  uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('drag-over'); });
  uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));
  uploadZone.addEventListener('drop', async e => {
    e.preventDefault();
    uploadZone.classList.remove('drag-over');
    const files = e.dataTransfer.files;
    if (!files.length) return;
    await uploadFiles(files);
  });
  uploadZone.addEventListener('click', () => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/*';
    input.multiple = true;
    input.onchange = () => { if (input.files) uploadFiles(input.files); };
    input.click();
  });

  async function uploadFiles(files) {
    const id = window._currentNodeId;
    if (!id || !currentProject) { toast('请先选择节点'); return; }
    const form = new FormData();
    for (const f of files) form.append('file', f);
    try {
      const res = await authFetch(API+'/projects/'+currentProject+'/nodes/'+id+'/upload', { method:'POST', body: form });
      const j = await res.json();
      if (j.success) { toast('已上传 '+j.data.length+' 张截图'); renderScreenshots(j.data); }
      else toast('上传失败: '+(j.error||''));
    } catch(e) { toast('上传失败: '+e.message); }
  }

  function renderScreenshots(newFiles) {
    const el = document.getElementById('np-screenshots');
    if (newFiles) {
      newFiles.forEach(fn => {
        if (el.querySelector('[data-file="'+fn+'"]')) return;
        const img = document.createElement('img');
        img.className = 'np-shot';
        img.src = '/static/uploads/' + fn;
        img.setAttribute('data-file', fn);
        img.title = '点击查看大图';
        img.onclick = () => window.open('/static/uploads/'+fn, '_blank');
        el.appendChild(img);
      });
    }
  }

  loadProjects()
    .then(() => {
      updateEmptyState();
      connectWs();
    })
    .catch(e => {
      updateEmptyState();
      if (!getApiKey() || e.message.includes('API key')) showAuthOverlay(e.message);
    });
}

/* ==============================================
   Graph loading
   ============================================== */
async function saveNodePosition(pid, nid, x, y) {
  try {
    await authFetch(API+'/projects/'+pid+'/nodes/'+nid, {
      method:'PUT',
      headers:{'Content-Type':'application/json'},
      body:JSON.stringify({position:{x,y}})
    });
  } catch(e) { dbg('save position failed: '+e.message); }
}

async function loadGraph() {
  if (!currentProject) {
    cy.elements().remove();
    document.getElementById('stat-nodes').textContent = '0';
    document.getElementById('stat-edges').textContent = '0';
    updateEmptyState();
    return;
  }
  updateEmptyState();

  // Save zoom/pan before clearing — only if not first load after project switch
  const hasNodes = cy.nodes().length > 0;
  const zoomBefore = (savedZoom !== null && hasNodes) ? cy.zoom() : null;
  const panBefore = (savedZoom !== null && hasNodes) ? cy.pan() : null;

  const [nr,er] = await Promise.all([
    authFetch(API+'/projects/'+currentProject+'/nodes'),
    authFetch(API+'/projects/'+currentProject+'/edges')
  ]);
  const nodesBody = await nr.json();
  const edgesBody = await er.json();
  if (!nodesBody.success || !edgesBody.success) {
    const error = nodesBody.error || edgesBody.error || '加载图谱失败';
    if (nr.status === 401 || er.status === 401) showAuthOverlay(error);
    throw new Error(error);
  }
  const nodes = nodesBody.data||[];
  const edges = edgesBody.data||[];

  // Collect saved positions from nodes that have them
  const savedPositions = {};
  nodes.forEach(n => {
    if (n.position && n.position.x != null && n.position.y != null) {
      savedPositions[n.id] = { x: n.position.x, y: n.position.y };
    }
  });

  cy.elements().remove();
  nodes.forEach(n => {
    const props = (typeof n.properties==='object' && n.properties!==null) ? n.properties : {};
    const st = nodeColor(n.node_type, n.properties);
    try {
      cy.add({group:'nodes',data:{id:n.id,label:n.label,node_type:n.node_type,status:st,verdict:props.verdict||'',evidence:props.evidence||'unverified',source:props.source||'user',properties:n.properties,created_at:n.created_at}});
    } catch(e) { dbg('add node FAILED: '+e.message+' id='+n.id); }
  });
  edges.forEach(e => {
    try { cy.add({group:'edges',data:{id:e.id,source:e.source_node_id,target:e.target_node_id,label:e.label||''}}); }
    catch(e) { dbg('add edge FAILED: '+e.message); }
  });
  dbg('loadGraph: cy has '+cy.nodes().length+' nodes, '+cy.edges().length+' edges');
  try {
    cy.one('layoutstop', () => {
      // Restore user-saved node positions
      Object.entries(savedPositions).forEach(([id, pos]) => {
        const el = cy.getElementById(id);
        if (el.length) el.position(pos);
      });
      if (zoomBefore !== null) {
        cy.zoom(zoomBefore);
        cy.pan(panBefore);
      } else {
        cy.fit(undefined, 80);
        if (cy.zoom() > 2.5) cy.zoom(2.5);
      }
      savedZoom = cy.zoom();
      savedPan = cy.pan();
      dbg('loadGraph: layout ok zoom=' + savedZoom.toFixed(2) + ' restored=' + Object.keys(savedPositions).length + ' positions');
    });
    cy.layout({
      name:'elk',
      elk: {
        algorithm: 'layered',
        'elk.direction': 'RIGHT',
        'elk.spacing.nodeNode': '60',
        'elk.layered.spacing.nodeNodeBetweenLayers': '120',
        'elk.layered.spacing.edgeNodeBetweenLayers': '40',
        'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
        'elk.layered.thoroughness': '8',
      },
      nodeDimensionsIncludeLabels: true,
      fit: false,
      animate: false,
    }).run();
  } catch(e) { dbg('layout FAILED: '+e.message); }
  document.getElementById('stat-nodes').textContent = nodes.length;
  document.getElementById('stat-edges').textContent = edges.length;
}

document.addEventListener('DOMContentLoaded', init);
