import type { CategoryNode, WeightDimMeta } from '../types/category'
import type { DemandsByCategory } from '../types/demand'

export interface CategoryTreeExportPayload {
  nodes: CategoryNode[]
  dims: WeightDimMeta[]
  bizDt: string | null
  demandsByCategory: DemandsByCategory
}

/** Escape text for embedding inside HTML (not attribute). */
function escHtml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

/** Safely embed JSON in a <script type="application/json"> block. */
function embedJson(data: unknown): string {
  return JSON.stringify(data)
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026')
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029')
}

function buildFilename(bizDt: string | null): string {
  const stamp = bizDt || new Date().toISOString().slice(0, 10)
  return `category-tree-${stamp}.html`
}

/**
 * Build a standalone HTML snapshot of the category tree page.
 * Looks and behaves like the live page; all data is embedded (offline).
 */
export function buildCategoryTreeHtml(payload: CategoryTreeExportPayload): string {
  const dataJson = embedJson({
    nodes: payload.nodes,
    dims: payload.dims,
    bizDt: payload.bizDt,
    demandsByCategory: payload.demandsByCategory,
  })

  const bizLabel = payload.bizDt
    ? `biz_dt ${escHtml(payload.bizDt)} · avg`
    : '暂无权重数据'
  const bizClass = payload.bizDt ? 'biz-dt' : 'biz-dt warn'

  return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>全局分类树${payload.bizDt ? ` · ${escHtml(payload.bizDt)}` : ''}（导出）</title>
  <style>
${EXPORT_CSS}
  </style>
</head>
<body>
  <div class="app-shell">
    <nav class="nav">
      <span class="brand">SupplyAgent</span>
      <span class="nav-badge">导出快照</span>
      <span class="current">全局分类树</span>
    </nav>
    <main class="main">
      <div class="category-tree" id="app" style="--col-width:180px;--col-gap:36px">
        <header class="toolbar">
          <div class="title-row">
            <h1>全局分类树</h1>
            <span class="${bizClass}">${bizLabel}</span>
          </div>
          <div class="controls">
            <label class="control">
              <span>展开到第</span>
              <select id="depth-select"></select>
              <span>层</span>
            </label>
            <button type="button" class="btn" id="expand-all">全部展开</button>
            <span class="hint" id="hint"></span>
          </div>
        </header>
        <div class="dim-bar">
          <div class="tabs" id="tabs" role="tablist" aria-label="热度维度"></div>
          <div class="legend" id="legend" hidden aria-hidden="true">
            <span class="legend-label">冷</span>
            <div class="legend-bar"></div>
            <span class="legend-label">热</span>
          </div>
        </div>
        <div id="empty" class="empty" hidden></div>
        <div id="tree-panel" class="tree-panel" hidden>
          <div class="tree-scroll-inner">
            <div class="level-headers" id="level-headers"></div>
            <div class="forest" id="forest"></div>
          </div>
        </div>
      </div>
    </main>
  </div>

  <div id="drawer-overlay" class="overlay" hidden>
    <aside class="drawer" role="dialog" aria-label="需求词列表">
      <header class="drawer-header">
        <div>
          <h2 id="drawer-title">分类节点</h2>
          <p class="sub" id="drawer-sub"></p>
        </div>
        <button type="button" class="close" id="drawer-close" title="关闭">×</button>
      </header>
      <div class="drawer-body" id="drawer-body"></div>
    </aside>
  </div>

  <script id="export-data" type="application/json">${dataJson}</script>
  <script>
${EXPORT_JS}
  </script>
</body>
</html>`
}

export function downloadCategoryTreeHtml(payload: CategoryTreeExportPayload): void {
  const html = buildCategoryTreeHtml(payload)
  const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = buildFilename(payload.bizDt)
  document.body.appendChild(a)
  a.click()
  a.remove()
  URL.revokeObjectURL(url)
}

const EXPORT_CSS = `
:root {
  font-family: 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
  line-height: 1.5;
  font-weight: 400;
  color: #0f172a;
  background: #f1f5f9;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
}
* { box-sizing: border-box; }
html, body {
  margin: 0;
  min-width: 320px;
  height: 100%;
  overflow: hidden;
}
body {
  background:
    radial-gradient(ellipse 80% 50% at 0% 0%, #e0e7ff 0%, transparent 55%),
    radial-gradient(ellipse 60% 40% at 100% 0%, #dbeafe 0%, transparent 50%),
    #f1f5f9;
}
button, select { font-family: inherit; }

.app-shell {
  display: flex;
  flex-direction: column;
  height: 100vh;
  overflow: hidden;
}
.nav {
  display: flex;
  align-items: center;
  gap: 20px;
  padding: 0 28px;
  height: 48px;
  flex-shrink: 0;
  border-bottom: 1px solid #e2e8f0;
  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(8px);
}
.brand {
  font-weight: 700;
  font-size: 15px;
  color: #0f172a;
  letter-spacing: -0.02em;
}
.nav-badge {
  display: inline-flex;
  align-items: center;
  height: 24px;
  padding: 0 10px;
  border-radius: 999px;
  background: #eef2ff;
  color: #4338ca;
  font-size: 12px;
  font-weight: 600;
}
.current {
  margin-left: auto;
  font-size: 12px;
  color: #94a3b8;
}
.main {
  flex: 1;
  min-height: 0;
  padding: 20px 28px 24px;
  box-sizing: border-box;
  overflow: hidden;
}

.category-tree {
  display: flex;
  flex-direction: column;
  gap: 12px;
  height: 100%;
  min-height: 0;
  max-width: 100%;
}
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px 24px;
  padding-bottom: 12px;
  border-bottom: 1px solid #e2e8f0;
  flex-shrink: 0;
}
.title-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 10px 14px;
}
.toolbar h1 {
  margin: 0;
  font-size: 22px;
  font-weight: 700;
  color: #0f172a;
  letter-spacing: -0.02em;
}
.biz-dt {
  font-size: 12px;
  color: #64748b;
  font-variant-numeric: tabular-nums;
}
.biz-dt.warn { color: #b45309; }
.controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 14px;
}
.control {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 14px;
  color: #334155;
}
.control select {
  height: 32px;
  padding: 0 8px;
  border: 1px solid #cbd5e1;
  border-radius: 6px;
  background: #fff;
  font-size: 14px;
  color: #0f172a;
}
.btn {
  height: 32px;
  padding: 0 12px;
  border: 1px solid #334155;
  border-radius: 6px;
  background: #0f172a;
  color: #fff;
  font-size: 13px;
  cursor: pointer;
}
.btn:hover { background: #1e293b; }
.hint { font-size: 12px; color: #94a3b8; }

.dim-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px 16px;
  flex-shrink: 0;
}
.tabs {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 4px;
  border: 1px solid #e2e8f0;
  border-radius: 10px;
  background: #f8fafc;
}
.tab {
  height: 32px;
  padding: 0 12px;
  border: 1px solid transparent;
  border-radius: 7px;
  background: transparent;
  color: #475569;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
}
.tab:hover { color: #0f172a; background: #fff; }
.tab.active {
  background: #0f172a;
  border-color: #0f172a;
  color: #fff;
}
.legend {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
.legend[hidden] { display: none !important; }
.legend-label { font-size: 11px; color: #94a3b8; }
.legend-bar {
  width: 120px;
  height: 10px;
  border-radius: 999px;
  border: 1px solid rgba(15, 23, 42, 0.08);
  background: linear-gradient(
    90deg,
    rgb(239, 68, 68) 0%,
    rgb(255, 255, 255) 50%,
    rgb(34, 197, 94) 100%
  );
}

.tree-panel {
  flex: 1;
  min-height: 0;
  min-width: 0;
  width: 100%;
  overflow: scroll;
  overscroll-behavior: contain;
  border: 1px solid #e2e8f0;
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.55);
  cursor: grab;
  scrollbar-gutter: stable;
}
.tree-panel[hidden], .empty[hidden] { display: none !important; }
.tree-panel.is-panning {
  cursor: grabbing;
  user-select: none;
}
.tree-panel::-webkit-scrollbar { width: 12px; height: 12px; }
.tree-panel::-webkit-scrollbar-thumb {
  background: #94a3b8;
  border-radius: 8px;
  border: 2px solid transparent;
  background-clip: content-box;
}
.tree-panel::-webkit-scrollbar-track {
  background: #e2e8f0;
  border-radius: 8px;
}
.tree-scroll-inner {
  display: inline-block;
  min-width: 100%;
  padding: 8px 12px 24px;
  vertical-align: top;
}
.level-headers {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  position: sticky;
  top: 0;
  z-index: 2;
  background: rgba(248, 250, 252, 0.96);
  backdrop-filter: blur(6px);
  border-bottom: 1px solid #e2e8f0;
  margin-bottom: 12px;
  padding: 8px 0;
  width: max-content;
  min-width: 100%;
}
.level-header {
  width: var(--col-width, 180px);
  flex-shrink: 0;
  margin-right: var(--col-gap, 36px);
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  color: #475569;
  letter-spacing: 0.02em;
}
.level-header:last-child { margin-right: 0; }
.forest {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 16px;
  width: max-content;
  min-width: 100%;
}
.empty {
  padding: 48px;
  text-align: center;
  color: #94a3b8;
  font-size: 15px;
}

.tree-node {
  --line-y: 20px;
  --line-color: #cbd5e1;
  --gap: var(--col-gap, 36px);
  display: flex;
  flex-direction: row;
  align-items: flex-start;
}
.node-card {
  display: flex;
  align-items: center;
  gap: 8px;
  width: var(--col-width, 180px);
  min-height: 40px;
  padding: 8px 10px;
  border: 1px solid #d8dee6;
  border-radius: 8px;
  box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
  flex-shrink: 0;
  box-sizing: border-box;
}
.node-card.muted { opacity: 0.72; }
.toggle {
  width: 22px;
  height: 22px;
  border: 1px solid rgba(15, 23, 42, 0.18);
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.65);
  color: #334155;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  flex-shrink: 0;
  padding: 0;
}
.toggle:hover {
  border-color: #64748b;
  background: #fff;
}
.toggle-spacer { width: 22px; flex-shrink: 0; }
.node-main {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: flex-start;
  gap: 4px;
}
.node-text {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.node-name {
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  word-break: break-word;
}
.node-weight {
  font-size: 11px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  opacity: 0.85;
  letter-spacing: 0.01em;
}
.inspect {
  flex-shrink: 0;
  width: 24px;
  height: 24px;
  margin-top: -1px;
  border: none;
  border-radius: 4px;
  background: transparent;
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
  padding: 0;
}
.inspect:hover { background: rgba(15, 23, 42, 0.08); }
.children {
  --half-gap: calc(var(--gap) / 2);
  --sibling-gap: 10px;
  display: flex;
  flex-direction: column;
  list-style: none;
  margin: 0;
  padding: 0 0 0 var(--gap);
  position: relative;
}
.children::before {
  content: '';
  position: absolute;
  left: 0;
  top: var(--line-y);
  width: var(--half-gap);
  border-top: 2px solid var(--line-color);
}
.child { position: relative; }
.child:not(:last-child) { padding-bottom: var(--sibling-gap); }
.child::before {
  content: '';
  position: absolute;
  top: var(--line-y);
  left: 0;
  width: var(--half-gap);
  margin-left: calc(-1 * var(--half-gap));
  border-top: 2px solid var(--line-color);
}
.child::after {
  content: '';
  position: absolute;
  left: 0;
  margin-left: calc(-1 * var(--half-gap));
  border-left: 2px solid var(--line-color);
}
.child:not(:first-child):not(:last-child)::after { top: 0; bottom: 0; }
.child:first-child:not(:last-child)::after { top: var(--line-y); bottom: 0; }
.child:last-child:not(:first-child)::after { top: 0; height: var(--line-y); }
.child:only-child::after { display: none; }

.overlay {
  position: fixed;
  inset: 0;
  z-index: 1000;
  background: rgba(15, 23, 42, 0.28);
  display: flex;
  justify-content: flex-end;
}
.overlay[hidden] { display: none !important; }
.drawer {
  width: min(480px, 100vw);
  height: 100%;
  background: #fff;
  box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
  display: flex;
  flex-direction: column;
}
.drawer-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  padding: 20px 20px 16px;
  border-bottom: 1px solid #e2e8f0;
}
.drawer-header h2 {
  margin: 0;
  font-size: 18px;
  color: #0f172a;
  word-break: break-word;
}
.sub {
  margin: 4px 0 0;
  font-size: 12px;
  color: #94a3b8;
}
.close {
  width: 32px;
  height: 32px;
  border: none;
  border-radius: 6px;
  background: transparent;
  color: #64748b;
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  flex-shrink: 0;
}
.close:hover { background: #f1f5f9; color: #0f172a; }
.drawer-body {
  flex: 1;
  overflow: auto;
  padding: 0 0 24px;
}
.drawer-body table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}
.drawer-body th,
.drawer-body td {
  padding: 10px 16px;
  text-align: left;
  vertical-align: top;
  border-bottom: 1px solid #eef2f7;
  color: #0f172a;
  word-break: break-word;
}
.drawer-body th {
  position: sticky;
  top: 0;
  background: #f8fafc;
  font-size: 12px;
  font-weight: 600;
  color: #64748b;
  letter-spacing: 0.02em;
}
.drawer-body .col-name { width: 30%; }
.drawer-body .col-grade { width: 12%; }
.drawer-body .col-reason { width: 58%; }
.drawer-body tbody tr:hover td { background: #f8fafc; }
.drawer-empty {
  padding: 48px 20px;
  text-align: center;
  color: #94a3b8;
  font-size: 14px;
}
`

const EXPORT_JS = `
(function () {
  const FULL_TREE_KEY = 'full';
  const DEFAULT_DEPTH = 3;
  const DEFAULT_DIMS = [
    { key: 'total_score', label: '全局热度' },
    { key: 'ext_pop', label: '外部热度' },
    { key: 'plat_sust_pop', label: '平台持续热度' },
    { key: 'plat_ly_pop', label: '去年同期热度' },
    { key: 'recent_pop', label: '近期热度' },
    { key: 'real_rov_7d', label: 'ROV相对差(7日)' },
    { key: 'real_vov_7d', label: 'VOV相对差(7日)' },
  ];

  const raw = JSON.parse(document.getElementById('export-data').textContent);
  const allNodes = raw.nodes || [];
  const dims = (raw.dims && raw.dims.length) ? raw.dims : DEFAULT_DIMS;
  const demandsByCategory = raw.demandsByCategory || {};

  const state = {
    activeTab: FULL_TREE_KEY,
    expandDepth: DEFAULT_DEPTH,
    expandedMap: {},
    collapsedMap: {},
  };

  const els = {
    tabs: document.getElementById('tabs'),
    legend: document.getElementById('legend'),
    depthSelect: document.getElementById('depth-select'),
    expandAll: document.getElementById('expand-all'),
    hint: document.getElementById('hint'),
    empty: document.getElementById('empty'),
    treePanel: document.getElementById('tree-panel'),
    levelHeaders: document.getElementById('level-headers'),
    forest: document.getElementById('forest'),
    overlay: document.getElementById('drawer-overlay'),
    drawerTitle: document.getElementById('drawer-title'),
    drawerSub: document.getElementById('drawer-sub'),
    drawerBody: document.getElementById('drawer-body'),
    drawerClose: document.getElementById('drawer-close'),
  };

  function esc(text) {
    return String(text == null ? '' : text)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  function maxTreeDepth(nodes, depth) {
    depth = depth || 1;
    if (!nodes.length) return 0;
    let max = depth;
    for (const node of nodes) {
      if (node.children && node.children.length) {
        max = Math.max(max, maxTreeDepth(node.children, depth + 1));
      }
    }
    return max;
  }

  function filterTreeByDim(nodes, dim) {
    const out = [];
    for (const node of nodes) {
      const children = filterTreeByDim(node.children || [], dim);
      const hasData = ((node.counts && node.counts[dim]) || 0) > 0;
      if (hasData || children.length) {
        out.push(Object.assign({}, node, { children: children }));
      }
    }
    return out;
  }

  function collectWeights(nodes, dim, out) {
    out = out || [];
    for (const node of nodes) {
      const w = node.weights && node.weights[dim];
      const c = (node.counts && node.counts[dim]) || 0;
      if (typeof w === 'number' && c > 0) out.push(w);
      if (node.children && node.children.length) collectWeights(node.children, dim, out);
    }
    return out;
  }

  function buildWeightScale(values) {
    if (!values.length) return [];
    return values.slice().sort(function (a, b) { return a - b; });
  }

  function weightHeatT(weight, sortedScale) {
    if (!sortedScale.length) return null;
    const n = sortedScale.length;
    let lo = 0, hi = n;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (sortedScale[mid] < weight) lo = mid + 1;
      else hi = mid;
    }
    if (n === 1) return 1;
    return Math.min(1, Math.max(0, lo / n));
  }

  function weightHeatTLinear(weight, sortedScale) {
    if (!sortedScale.length) return null;
    const min = sortedScale[0];
    const max = sortedScale[sortedScale.length - 1];
    if (max === min) return 0.5;
    return Math.min(1, Math.max(0, (weight - min) / (max - min)));
  }

  function scoreHeatT(dim, weight, sortedScale) {
    if (dim === 'total_score') {
      return weightHeatTLinear(weight, sortedScale);
    }
    return weightHeatT(weight, sortedScale);
  }

  function heatColor(t) {
    if (t == null) return '#f1f5f9';
    const clamp = Math.min(1, Math.max(0, t));
    if (clamp <= 0.5) {
      const u = clamp / 0.5;
      const r = Math.round(239 + (255 - 239) * u);
      const g = Math.round(68 + (255 - 68) * u);
      const b = Math.round(68 + (255 - 68) * u);
      return 'rgb(' + r + ', ' + g + ', ' + b + ')';
    }
    const u = (clamp - 0.5) / 0.5;
    const r = Math.round(255 + (34 - 255) * u);
    const g = Math.round(255 + (197 - 255) * u);
    const b = Math.round(255 + (94 - 255) * u);
    return 'rgb(' + r + ', ' + g + ', ' + b + ')';
  }

  function heatTextColor(t) {
    if (t == null) return '#64748b';
    return '#0f172a';
  }

  function formatAvgScore(avg, count) {
    if (count != null && count <= 0) return '—';
    if (avg == null || Number.isNaN(avg)) return '—';
    if (avg >= 100) return avg.toFixed(0);
    if (avg >= 1) return avg.toFixed(1);
    return avg.toFixed(2);
  }

  function formatWeightScore(dim, score, count) {
    if (count != null && count <= 0) return '—';
    if (score == null || Number.isNaN(score)) return '—';
    if (dim === 'total_score') return score.toFixed(2);
    return formatAvgScore(score, count);
  }

  function isFullTree() { return state.activeTab === FULL_TREE_KEY; }
  function activeDim() { return isFullTree() ? null : state.activeTab; }

  function displayNodes() {
    const dim = activeDim();
    if (!dim) return allNodes;
    return filterTreeByDim(allNodes, dim);
  }

  function weightScale() {
    const dim = activeDim();
    if (!dim) return [];
    return buildWeightScale(collectWeights(displayNodes(), dim));
  }

  function resetExpandState() {
    state.expandedMap = {};
    state.collapsedMap = {};
  }

  function findDepth(nodes, id, depth) {
    for (const node of nodes) {
      if (node.id === id) return depth;
      const found = findDepth(node.children || [], id, depth + 1);
      if (found != null) return found;
    }
    return null;
  }

  function isExpanded(node, depth) {
    if (!node.children || !node.children.length) return false;
    if (state.collapsedMap[node.id]) return false;
    if (state.expandedMap[node.id]) return true;
    return depth < state.expandDepth;
  }

  function toggleNode(id) {
    const wasCollapsed = !!state.collapsedMap[id];
    const wasExpanded = !!state.expandedMap[id];
    const nextExpanded = Object.assign({}, state.expandedMap);
    const nextCollapsed = Object.assign({}, state.collapsedMap);
    delete nextExpanded[id];
    delete nextCollapsed[id];

    if (wasCollapsed) {
      nextExpanded[id] = true;
    } else if (wasExpanded) {
      nextCollapsed[id] = true;
    } else {
      const depth = findDepth(displayNodes(), id, 1);
      const autoOpen = depth != null && depth < state.expandDepth;
      if (autoOpen) nextCollapsed[id] = true;
      else nextExpanded[id] = true;
    }
    state.expandedMap = nextExpanded;
    state.collapsedMap = nextCollapsed;
    renderTree();
  }

  function renderNode(node, depth, scale, dim) {
    const hasChildren = !!(node.children && node.children.length);
    const expanded = isExpanded(node, depth);
    const demands = demandsByCategory[node.id] || [];
    const weight = dim ? (node.weights && node.weights[dim] != null ? node.weights[dim] : null) : null;
    const count = dim ? ((node.counts && node.counts[dim]) || 0) : 0;
    let heatT = null;
    if (dim && count > 0 && weight != null) heatT = scoreHeatT(dim, weight, scale);
    const bg = heatColor(heatT);
    const color = heatTextColor(heatT);
    const borderColor = heatT == null ? '#d8dee6' : 'rgba(15, 23, 42, 0.12)';
    const muted = dim != null && heatT == null ? ' muted' : '';
    const openCls = expanded ? ' open' : '';
    const leafCls = hasChildren ? '' : ' leaf';
    const name = node.name || '(未命名)';
    let title;
    if (!dim) title = name;
    else if (count > 0 && dim === 'total_score') {
      title = '全局热度=' + weight + ' 覆盖维度=' + count + '/4';
    } else if (count > 0) title = 'avg=' + weight + ' count=' + count;
    else title = '无数据';

    let html = '<div class="tree-node">';
    html += '<div class="node-card' + leafCls + openCls + muted + '" style="background:' + bg +
      ';color:' + color + ';border-color:' + borderColor + '" title="' + esc(title) + '">';
    if (hasChildren) {
      html += '<button class="toggle" type="button" data-toggle="' + node.id +
        '" aria-expanded="' + expanded + '" title="' + (expanded ? '收起' : '展开') + '">' +
        (expanded ? '−' : '+') + '</button>';
    } else {
      html += '<span class="toggle-spacer"></span>';
    }
    html += '<div class="node-main"><div class="node-text">';
    html += '<span class="node-name">' + esc(name) + '</span>';
    if (dim) {
      html += '<span class="node-weight">' + esc(formatWeightScore(dim, weight, count)) + '</span>';
    }
    html += '</div>';
    if (demands.length) {
      html += '<button class="inspect" type="button" data-inspect="' + node.id +
        '" data-name="' + esc(name) + '" title="查看需求词">🔍</button>';
    }
    html += '</div></div>';

    if (hasChildren && expanded) {
      html += '<ul class="children">';
      for (const child of node.children) {
        html += '<li class="child">' + renderNode(child, depth + 1, scale, dim) + '</li>';
      }
      html += '</ul>';
    }
    html += '</div>';
    return html;
  }

  function headerLevels(treeMax) {
    let count = Math.max(state.expandDepth, 1);
    if (Object.keys(state.expandedMap).length > 0) {
      count = Math.max(count, treeMax);
    }
    if (treeMax > 0) count = Math.min(count, treeMax);
    const levels = [];
    for (let i = 1; i <= count; i++) levels.push(i);
    return levels;
  }

  function renderTabs() {
    const tabs = [{ key: FULL_TREE_KEY, label: '完整全局树' }].concat(
      dims.map(function (d) { return { key: d.key, label: d.label }; })
    );
    els.tabs.innerHTML = tabs.map(function (t) {
      const active = state.activeTab === t.key ? ' active' : '';
      return '<button type="button" role="tab" class="tab' + active +
        '" data-tab="' + esc(t.key) + '" aria-selected="' + (state.activeTab === t.key) + '">' +
        esc(t.label) + '</button>';
    }).join('');
  }

  function renderDepthOptions(treeMax) {
    const max = Math.max(treeMax, DEFAULT_DEPTH);
    let html = '';
    for (let n = 1; n <= max; n++) {
      html += '<option value="' + n + '"' + (n === state.expandDepth ? ' selected' : '') + '>' + n + '</option>';
    }
    els.depthSelect.innerHTML = html;
  }

  function renderTree() {
    const nodes = displayNodes();
    const dim = activeDim();
    const scale = weightScale();
    const treeMax = maxTreeDepth(nodes);

    renderTabs();
    renderDepthOptions(treeMax);
    els.legend.hidden = isFullTree();
    els.hint.textContent = '共 ' + treeMax + ' 层 · 空白处按住拖动可平移 · Shift+滚轮左右滚';

    if (!nodes.length) {
      els.empty.hidden = false;
      els.treePanel.hidden = true;
      els.empty.textContent = isFullTree() ? '暂无分类数据' : '该维度暂无有数据的节点';
      return;
    }

    els.empty.hidden = true;
    els.treePanel.hidden = false;
    els.levelHeaders.innerHTML = headerLevels(treeMax).map(function (n) {
      return '<div class="level-header">第 ' + n + ' 层</div>';
    }).join('');
    els.forest.innerHTML = nodes.map(function (node) {
      return renderNode(node, 1, scale, dim);
    }).join('');
  }

  function openDrawer(categoryId, categoryName) {
    const items = demandsByCategory[categoryId] || [];
    els.drawerTitle.textContent = categoryName || '分类节点';
    els.drawerSub.textContent = '共 ' + items.length + ' 条需求词';
    if (!items.length) {
      els.drawerBody.innerHTML = '<div class="drawer-empty">该节点暂无需求词</div>';
    } else {
      els.drawerBody.innerHTML =
        '<table><thead><tr><th class="col-name">需求词</th><th class="col-grade">等级</th><th class="col-reason">原因</th></tr></thead><tbody>' +
        items.map(function (item) {
          return '<tr><td>' + esc(item.demand_name || '—') + '</td><td>' + esc(item.grade || '—') + '</td><td>' + esc(item.reason || '—') + '</td></tr>';
        }).join('') +
        '</tbody></table>';
    }
    els.overlay.hidden = false;
  }

  function closeDrawer() {
    els.overlay.hidden = true;
  }

  els.tabs.addEventListener('click', function (e) {
    const btn = e.target.closest('[data-tab]');
    if (!btn) return;
    state.activeTab = btn.getAttribute('data-tab');
    state.expandDepth = DEFAULT_DEPTH;
    resetExpandState();
    renderTree();
  });

  els.depthSelect.addEventListener('change', function () {
    state.expandDepth = Number(els.depthSelect.value) || DEFAULT_DEPTH;
    resetExpandState();
    renderTree();
  });

  els.expandAll.addEventListener('click', function () {
    state.expandDepth = maxTreeDepth(displayNodes()) || 1;
    resetExpandState();
    renderTree();
  });

  els.forest.addEventListener('click', function (e) {
    const toggle = e.target.closest('[data-toggle]');
    if (toggle) {
      e.stopPropagation();
      toggleNode(Number(toggle.getAttribute('data-toggle')));
      return;
    }
    const inspect = e.target.closest('[data-inspect]');
    if (inspect) {
      e.stopPropagation();
      openDrawer(
        Number(inspect.getAttribute('data-inspect')),
        inspect.getAttribute('data-name') || '(未命名)'
      );
    }
  });

  els.drawerClose.addEventListener('click', closeDrawer);
  els.overlay.addEventListener('click', function (e) {
    if (e.target === els.overlay) closeDrawer();
  });

  // Pan
  let isPanning = false, panStartX = 0, panStartY = 0, panScrollLeft = 0, panScrollTop = 0;
  function onPanMove(e) {
    if (!isPanning) return;
    els.treePanel.scrollLeft = panScrollLeft - (e.clientX - panStartX);
    els.treePanel.scrollTop = panScrollTop - (e.clientY - panStartY);
  }
  function onPanEnd() {
    isPanning = false;
    els.treePanel.classList.remove('is-panning');
    window.removeEventListener('mousemove', onPanMove);
    window.removeEventListener('mouseup', onPanEnd);
  }
  els.treePanel.addEventListener('mousedown', function (e) {
    if (e.button !== 0) return;
    if (e.target.closest('button, select, a, input')) return;
    isPanning = true;
    panStartX = e.clientX;
    panStartY = e.clientY;
    panScrollLeft = els.treePanel.scrollLeft;
    panScrollTop = els.treePanel.scrollTop;
    els.treePanel.classList.add('is-panning');
    window.addEventListener('mousemove', onPanMove);
    window.addEventListener('mouseup', onPanEnd);
  });

  renderTree();
})();
`
