// graph.jsx — Public Graph page.
// Data-driven via picks model (slot + paths). GraphStage owns rendering; this page owns
// layout, picks state, selection, and the right-side drawer.

const { useEffect: useEffectG, useState: useStateG, useMemo: useMemoG } = React;

const LS_INTERESTS_NEW = "nodeidea.kg.interests.v1";
const LS_INTERESTS_OLD = "nodeidea.graph.interests.v1";

// Slot color for display chips / panel swatches. Order matches COLOR_SLOTS in interest-modal.
const SLOT_SWATCH = [
  "linear-gradient(135deg,#5E6AD2,#7C8CFF)", // Linear Purple
  "linear-gradient(135deg,#2FAE8F,#6BC9B1)", // Mint Teal
  "linear-gradient(135deg,#C68A28,#F2B45A)", // Amber Mist
];

// Migrate old layer_2 string[] format → new pick objects (best-effort, one pick).
function migrateLegacyInterests() {
  try {
    const raw = localStorage.getItem(LS_INTERESTS_OLD);
    if (!raw) return null;
    const arr = JSON.parse(raw);
    if (!Array.isArray(arr) || arr.length === 0) return null;
    // Wrap each old layer_2 string as a pick under physical_sciences (best guess; user
    // will see the picks and can re-pick if wrong).
    const guessTilePath = (name) => {
      const map = {
        mathematics: "physical_sciences.mathematics",
        computer_science: "physical_sciences.computer_science",
        physics: "physical_sciences.physics",
        chemistry: "physical_sciences.chemistry",
        biology: "life_sciences.biology",
        statistics: "physical_sciences.statistics",
      };
      return map[name] || null;
    };
    const picks = arr
      .slice(0, 3)
      .map((name, i) => {
        const path = guessTilePath(name);
        if (!path) return null;
        return { slot: i, tile_id: path, paths: [path], label_en: name, label_zh: "" };
      })
      .filter(Boolean);
    return picks.length ? picks : null;
  } catch { return null; }
}

function loadInitialPicks() {
  try {
    const raw = localStorage.getItem(LS_INTERESTS_NEW);
    if (raw) {
      const arr = JSON.parse(raw);
      if (Array.isArray(arr) && arr.length) return arr;
    }
  } catch {}
  // Try migrating from old key.
  const migrated = migrateLegacyInterests();
  if (migrated && migrated.length) {
    try { localStorage.setItem(LS_INTERESTS_NEW, JSON.stringify(migrated)); } catch {}
    return migrated;
  }
  return null;
}

function GraphPage({ tweaks }) {
  const [tipStep, setTipStep] = useStateG(tweaks.onboarding ? 1 : 0);
  const [picks, setPicks] = useStateG(loadInitialPicks);
  const [pickerOpen, setPickerOpen] = useStateG(false);
  const [selectedNode, setSelectedNode] = useStateG(null);
  const [graphData, setGraphData] = useStateG({ nodes: [], links: [] });
  // Client-side prune. Re-cleared whenever picks change (fresh subgraph → fresh filter).
  const [excludedPaths, setExcludedPaths] = useStateG(new Set());
  // Which pick's filter flyout is currently open (or null).
  const [flyoutPickId, setFlyoutPickId] = useStateG(null);
  // Compose-bar search state (3-stage cascade)
  const [composeQuery, setComposeQuery] = useStateG("");
  const [composeFocused, setComposeFocused] = useStateG(false);
  const [cascadeHits, setCascadeHits] = useStateG({ local: [], text: [], vector: [], loading: false, stage: null });
  // Annexed nodes/edges — nodes the user pulled into the view via out-of-view search hit.
  // Persists for the session; cleared when picks change (fresh subgraph → fresh annexes).
  const [annexed, setAnnexed] = useStateG({ nodes: [], links: [] });
  // Amber path highlight (relationship / prereqs). Set<string> of canonical link keys
  // ("minId|maxId") + Set<string> of node ids. Cleared by 8-second TTL.
  const [highlight, setHighlight] = useStateG({ nodeIds: new Set(), linkKeys: new Set() });
  // Toast message for intent feedback (e.g. "→ 关系: A → B,3 跳"). Auto-clears on 4s.
  const [intentToast, setIntentToast] = useStateG(null);
  // QA streaming state. { question, answer, loading, error, citations, abort } | null
  const [qa, setQa] = useStateG(null);

  // Drawer DETAIL section is lazy-loaded on selection (get_kg_interest_view doesn't return it).
  const [detail, setDetail] = useStateG({ loading: false, id: null, data: null });

  const needPick = !picks || picks.length === 0;

  function commitPicks(arr) {
    setPicks(arr);
    try { localStorage.setItem(LS_INTERESTS_NEW, JSON.stringify(arr)); } catch {}
    setPickerOpen(false);
    setSelectedNode(null);
    setExcludedPaths(new Set());
    setFlyoutPickId(null);
    setAnnexed({ nodes: [], links: [] });
    setHighlight({ nodeIds: new Set(), linkKeys: new Set() });
    setIntentToast(null);
  }

  const onStageData = (g) => setGraphData({ nodes: g.nodes, links: g.links });
  const stats = { nodes: graphData.nodes.length, edges: graphData.links.length };

  // Taxonomy subtree for current picks — fetched once per picks change, then split
  // by pick and handed to PickFlyout when one is open.
  const [taxonomyTree, setTaxonomyTree] = useStateG(null);
  useEffectG(() => {
    if (!picks || picks.length === 0) { setTaxonomyTree(null); return; }
    let cancelled = false;
    setTaxonomyTree(null);
    window.graphRepo.fetchTaxonomyForPicks(picks)
      .then(rows => { if (!cancelled) setTaxonomyTree(rows || []); })
      .catch(e => { if (!cancelled) console.warn("[GraphPage] taxonomy load failed", e); });
    return () => { cancelled = true; };
  }, [(picks || []).map(p => p.tile_id).sort().join("|")]);

  // Group taxonomy rows by the most-specific pick they belong under.
  const taxonomyByPick = useMemoG(() => {
    const m = new Map();
    (picks || []).forEach(p => m.set(p.tile_id, null));   // null = still loading
    if (!taxonomyTree) return m;
    (picks || []).forEach(p => m.set(p.tile_id, []));     // [] = loaded but maybe empty
    for (const r of taxonomyTree) {
      let bestPick = null;
      let bestLen = -1;
      for (const p of (picks || [])) {
        if (r.path === p.tile_id || r.path.startsWith(p.tile_id + ".")) {
          if (p.tile_id.length > bestLen) { bestLen = p.tile_id.length; bestPick = p.tile_id; }
        }
      }
      if (bestPick) m.get(bestPick).push(r);
    }
    for (const arr of m.values()) {
      if (Array.isArray(arr)) arr.sort((a, b) => a.path.localeCompare(b.path));
    }
    return m;
  }, [taxonomyTree, (picks || []).map(p => p.tile_id).sort().join("|")]);

  // Build adjacency for ADJACENT chips
  const adjacency = useMemoG(() => {
    const byId = new Map();
    graphData.nodes.forEach(n => byId.set(n.id, n));
    const adj = new Map();
    function push(a, b) {
      if (!adj.has(a)) adj.set(a, []);
      const nb = byId.get(b);
      if (nb) adj.get(a).push(nb);
    }
    graphData.links.forEach(l => {
      const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
      const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
      push(s, t); push(t, s);
    });
    return { byId, adj };
  }, [graphData.nodes, graphData.links]);

  const neighbors = selectedNode
    ? (adjacency.adj.get(selectedNode.id) || []).slice(0, 12)
    : [];

  function selectById(id) {
    const fresh = adjacency.byId.get(id);
    if (fresh) setSelectedNode(fresh);
  }

  // Debounced search cascade. Cancellable so back-to-back keystrokes don't pile up.
  useEffectG(() => {
    const q = composeQuery.trim();
    if (!q) {
      setCascadeHits({ local: [], text: [], vector: [], loading: false, stage: null });
      return;
    }
    let cancelled = false;
    // Stage A is sync — show instantly while we wait the debounce.
    const localImmediate = window.searchCascade.searchLocal(q, graphData.nodes, 8)
      .map(h => window.searchCascade.normalizeHit(h, "local", new Set(graphData.nodes.map(n => n.id))));
    setCascadeHits({ local: localImmediate, text: [], vector: [], loading: true, stage: "local" });

    const t = setTimeout(() => {
      window.searchCascade.cascade(q, graphData.nodes, { limit: 8 }, (update) => {
        if (cancelled) return;
        setCascadeHits(prev => {
          const next = { ...prev };
          next[update.stage] = update.hits;
          next.stage = update.stage;
          if (update.stage === "vector") next.loading = false;
          return next;
        });
      }).catch(e => {
        if (cancelled) return;
        console.warn("[search] cascade failed", e);
        setCascadeHits(prev => ({ ...prev, loading: false }));
      });
    }, 260);
    return () => { cancelled = true; clearTimeout(t); };
  }, [composeQuery, graphData.nodes]);

  // Handle a result click. Open the drawer IMMEDIATELY (synchronously, regardless of
  // in/out of view) so the user always sees feedback; async enrich for out-of-view nodes
  // that need detail/source from a separate fetch. Camera focus is handled by GraphStage's
  // selectedNodeId effect (single source of truth — works for 3D clicks and search clicks).
  //
  // CRITICAL: don't tear down the dropdown synchronously. If we set composeFocused=false
  // in this handler, React commits and unmounts the dropdown BEFORE mouseup → click fires
  // → mouseup falls through to the 3D canvas → ForceGraph3D's onBackgroundClick fires →
  // setSelectedNode(null) → drawer flashes and disappears. setTimeout defers teardown
  // until after the event cycle completes.
  function handleSearchHit(hit) {
    if (!hit || !hit.id) return;

    // Re-check view at click time (graphData may have changed since cascade ran).
    const fresh = graphData.nodes.find(n => n.id === hit.id);
    if (fresh) {
      setSelectedNode(fresh);
      setTimeout(() => { setComposeQuery(""); setComposeFocused(false); }, 50);
      return;
    }

    // Out of view — open preview drawer with what we have, then async-enrich.
    const preview = {
      id: hit.id,
      name: hit.name || "",
      nameEn: hit.nameEn || "",
      nameZh: hit.name || "",
      summary: "",
      summaryEn: "",
      summaryZh: "",
      detail: "",
      detailEn: "",
      detailZh: "",
      source: "",
      sourceUrls: [],
      domainPath: hit.domainPath || "",
      layer1: null, layer2: null, layer3: null, layer4: null, layer5: null,
      pathDepth: (hit.domainPath || "").split(".").length,
      pickSlot: 0,
      kgDegree: 0,
      shape: "sphere",
      size: 2,
      isSynthetic: false,
      isMine: false,
      contributorCount: 0,
      clusterSize: 0,
      aliasCount: 1,
      _outOfView: true,
    };
    setSelectedNode(preview);

    window.graphRepo.fetchNodeDetails(hit.id).then(d => {
      if (!d) return;
      setSelectedNode(prev => (prev && prev.id === hit.id) ? {
        ...prev,
        summary: d.summary_zh || d.summary_en || prev.summary,
        summaryEn: d.summary_en || prev.summaryEn,
        summaryZh: d.summary_zh || prev.summaryZh,
        detail: d.detail_zh || d.detail_en || prev.detail,
        detailEn: d.detail_en || prev.detailEn,
        detailZh: d.detail_zh || prev.detailZh,
        source: d.source || prev.source,
        sourceUrls: Array.isArray(d.source_urls) ? d.source_urls : prev.sourceUrls,
      } : prev);
    }).catch(e => console.warn("[handleSearchHit] enrich failed", e));

    // Annex the node + its bridge edges into the live view.
    const viewIds = graphData.nodes.map(n => n.id);
    console.info("[annex] starting for", hit.id, "viewIds:", viewIds.length);
    window.graphRepo.annexNode(hit.id, viewIds, { minCosine: 0.65, edgeLimit: 50 })
      .then(annex => {
        if (!annex || !annex.node) { console.warn("[annex] no node returned"); return; }
        // Mark visually distinct: node gets emissive boost, bridge edges get amber tint
        // (so you can actually trace which existing nodes the new one connects to).
        annex.node.isAnnexed = true;
        for (const l of annex.links) l.isAnnexBridge = true;
        console.info("[annex] bridge edges:", annex.links.length);

        // Position the new node:
        //   - if it has connected neighbors with positions, place at their centroid
        //   - else, place visibly OUTSIDE the existing cluster (shifted right) so the
        //     user can actually see it land. Origin-spawning made it invisible inside
        //     the dense central cloud.
        const connected = new Set();
        for (const l of annex.links) {
          const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
          const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
          if (s === annex.node.id) connected.add(t);
          else if (t === annex.node.id) connected.add(s);
        }
        let cx = 0, cy = 0, cz = 0, c = 0;
        for (const n of graphData.nodes) {
          if (connected.has(n.id) && typeof n.x === "number") {
            cx += n.x; cy += n.y; cz += n.z; c++;
          }
        }
        if (c > 0) {
          annex.node.x = cx / c;
          annex.node.y = cy / c;
          annex.node.z = cz / c;
          console.info("[annex] placed at neighbor centroid; bridges:", annex.links.length);
        } else {
          // Compute the existing cloud's bounding-sphere right edge and place there.
          let avgX = 0, avgY = 0, avgZ = 0, nc = 0;
          let maxR = 0;
          for (const n of graphData.nodes) {
            if (typeof n.x === "number") {
              avgX += n.x; avgY += n.y; avgZ += n.z; nc++;
            }
          }
          if (nc > 0) {
            avgX /= nc; avgY /= nc; avgZ /= nc;
            for (const n of graphData.nodes) {
              if (typeof n.x === "number") {
                const r = Math.hypot(n.x - avgX, n.y - avgY, n.z - avgZ);
                if (r > maxR) maxR = r;
              }
            }
          }
          const offset = maxR > 0 ? maxR + 60 : 200;
          annex.node.x = avgX + offset;
          annex.node.y = avgY;
          annex.node.z = avgZ;
          console.info("[annex] no bridge edges; placed at cluster edge (+", offset.toFixed(0), ")");
        }
        setAnnexed(prev => {
          // Dedupe by id (nodes) and by src|dst|kind (edges)
          if (prev.nodes.some(n => n.id === annex.node.id)) return prev;
          const linkKey = (l) => {
            const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
            const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
            return s + "|" + t + "|" + (l.relationType || "");
          };
          const seen = new Set(prev.links.map(linkKey));
          const newLinks = annex.links.filter(l => !seen.has(linkKey(l)));
          return { nodes: [...prev.nodes, annex.node], links: [...prev.links, ...newLinks] };
        });
        // Refresh selectedNode reference with the annexed node so drawer loses _outOfView
        // (it's now in-view) and ADJACENT can recompute against the merged graph.
        setSelectedNode(prev => (prev && prev.id === annex.node.id) ? annex.node : prev);
      })
      .catch(e => console.warn("[handleSearchHit] annex failed", e));

    // Defer dropdown teardown — same reason as in-view branch.
    setTimeout(() => { setComposeQuery(""); setComposeFocused(false); }, 50);
  }

  // 8-second TTL for the amber path highlight.
  useEffectG(() => {
    if (!highlight || highlight.linkKeys.size === 0) return;
    const t = setTimeout(() => setHighlight({ nodeIds: new Set(), linkKeys: new Set() }), 8000);
    return () => clearTimeout(t);
  }, [highlight]);

  // 4-second TTL for the intent feedback toast.
  useEffectG(() => {
    if (!intentToast) return;
    const t = setTimeout(() => setIntentToast(null), 4000);
    return () => clearTimeout(t);
  }, [intentToast]);

  // Compute highlight from a {nodes:[id...], links:[link...]} path.
  function applyPathHighlight(path, label) {
    if (!path || !path.nodes || path.nodes.length === 0) {
      setIntentToast({ kind: "warn", text: label + ":未找到路径" });
      return;
    }
    const nodeIds = new Set(path.nodes);
    const linkKeys = new Set();
    for (const l of (path.links || [])) {
      if (!l) continue;
      linkKeys.add(window.graphTraverse.linkKey(l));
    }
    setHighlight({ nodeIds, linkKeys });
    setIntentToast({ kind: "ok", text: label });
    // Focus the camera on the first node of the path via existing selectedNode mechanism.
    const firstId = path.nodes[0];
    const fresh = graphData.nodes.find(n => n.id === firstId);
    if (fresh) setSelectedNode(fresh);
  }

  // Place an annex node at the centroid of its bridge neighbors (or the cloud
  // edge if it has none) and merge it into state. Shared by relationship
  // resolution and the existing out-of-view search-hit flow.
  function mergeAnnex(annex) {
    if (!annex || !annex.node) return null;
    annex.node.isAnnexed = true;
    for (const l of annex.links) l.isAnnexBridge = true;

    const connected = new Set();
    for (const l of annex.links) {
      const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
      const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
      if (s === annex.node.id) connected.add(t);
      else if (t === annex.node.id) connected.add(s);
    }
    let cx = 0, cy = 0, cz = 0, c = 0;
    for (const n of graphData.nodes) {
      if (connected.has(n.id) && typeof n.x === "number") {
        cx += n.x; cy += n.y; cz += n.z; c++;
      }
    }
    if (c > 0) {
      annex.node.x = cx / c; annex.node.y = cy / c; annex.node.z = cz / c;
    } else {
      let avgX = 0, avgY = 0, avgZ = 0, nc = 0, maxR = 0;
      for (const n of graphData.nodes) {
        if (typeof n.x === "number") { avgX += n.x; avgY += n.y; avgZ += n.z; nc++; }
      }
      if (nc > 0) {
        avgX /= nc; avgY /= nc; avgZ /= nc;
        for (const n of graphData.nodes) {
          if (typeof n.x === "number") {
            const r = Math.hypot(n.x - avgX, n.y - avgY, n.z - avgZ);
            if (r > maxR) maxR = r;
          }
        }
      }
      const offset = maxR > 0 ? maxR + 60 : 200;
      annex.node.x = avgX + offset; annex.node.y = avgY; annex.node.z = avgZ;
    }
    setAnnexed(prev => {
      if (prev.nodes.some(n => n.id === annex.node.id)) return prev;
      const linkKey = (l) => {
        const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
        const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
        return s + "|" + t + "|" + (l.relationType || "");
      };
      const seen = new Set(prev.links.map(linkKey));
      const newLinks = annex.links.filter(l => !seen.has(linkKey(l)));
      return { nodes: [...prev.nodes, annex.node], links: [...prev.links, ...newLinks] };
    });
    return annex.node;
  }

  // Resolve a name string to a node currently in graphData. Walks:
  //   (a) in-view fuzzy name match
  //   (b) Postgres text search → annex the top hit if found
  // Returns a node object (with x/y/z eventually filled by d3-force) or null.
  async function resolveOrAnnexNode(name) {
    if (!name) return null;
    const inView = window.graphTraverse.findNodeByName(graphData, name);
    if (inView) return inView;
    try {
      const hits = await window.graphRepo.searchByText(name, 5);
      if (!hits || hits.length === 0) return null;
      // Pick the first hit whose title best matches; fall back to top.
      const top = hits[0];
      const viewIds = graphData.nodes.map(n => n.id);
      const annex = await window.graphRepo.annexNode(top.id, viewIds, { minCosine: 0.55, edgeLimit: 50 });
      if (!annex || !annex.node) return null;
      return mergeAnnex(annex);
    } catch (e) {
      console.warn("[resolveOrAnnexNode] failed", e);
      return null;
    }
  }

  // Build a compact "graph context" for the QA prompt. Returns { contextStr,
  // aliasMap } where aliasMap maps short ids ("n1","n2",...) → uuid. We hand
  // short aliases to the LLM (cheaper than 36-char UUIDs) and post-resolve
  // them to real node ids in the chat panel.
  //
  // NB: context is only built when a node is focused. Without focus, an
  // open-ended QA gets an empty context — the system prompt tells the model
  // to answer from general knowledge in that case.
  function buildQAContext() {
    const focused = selectedNode;
    if (!focused) return { contextStr: "", aliasMap: {} };

    const aliasMap = {};
    let counter = 0;
    const aliasFor = (uuid) => {
      counter += 1;
      const a = "n" + counter;
      aliasMap[a] = uuid;
      return a;
    };

    const byId = new Map(graphData.nodes.map(n => [n.id, n]));
    const idOf = (end) => (end && typeof end === "object") ? end.id : end;

    const degree = new Map();
    for (const l of graphData.links) {
      const s = idOf(l.source), t = idOf(l.target);
      degree.set(s, (degree.get(s) || 0) + 1);
      degree.set(t, (degree.get(t) || 0) + 1);
    }

    const oneHop = [];
    for (const l of graphData.links) {
      const s = idOf(l.source), t = idOf(l.target);
      if (s === focused.id && t !== focused.id) oneHop.push(t);
      else if (t === focused.id && s !== focused.id) oneHop.push(s);
    }
    const oneHopSet = new Set(oneHop);

    const lines = [];
    lines.push("聚焦节点:");
    lines.push(`- [[${aliasFor(focused.id)}]] ${focused.name || focused.nameEn} · ${focused.domainPath || ""}`);
    lines.push("");

    if (oneHop.length) {
      lines.push("一阶邻居:");
      for (const id of oneHop.slice(0, 20)) {
        const n = byId.get(id);
        if (n) lines.push(`- [[${aliasFor(n.id)}]] ${n.name || n.nameEn}`);
      }
      lines.push("");
    }

    const sortedHubs = [...degree.entries()].sort((a, b) => b[1] - a[1]);
    const hubLines = [];
    for (const [id, d] of sortedHubs) {
      if (id === focused.id) continue;
      if (oneHopSet.has(id)) continue;
      const n = byId.get(id);
      if (!n) continue;
      hubLines.push(`- [[${aliasFor(n.id)}]] ${n.name || n.nameEn} (邻居数=${d})`);
      if (hubLines.length >= 14) break;
    }
    if (hubLines.length) {
      lines.push("当前图谱中度数较高的相关节点:");
      lines.push(...hubLines);
    }

    return { contextStr: lines.join("\n"), aliasMap };
  }

  // Run the QA flow — opens the chat panel, streams the LLM reply, parses
  // [[uuid]] tokens (handled by GraphChatPanel) for clickable chips.
  async function runQA(question) {
    // Cancel any in-flight stream.
    setQa(prev => {
      if (prev && prev.abort) { try { prev.abort.abort(); } catch {} }
      return prev;
    });

    const { contextStr, aliasMap } = buildQAContext();
    const hasContext = contextStr && contextStr.length > 0;
    const sys = hasContext
      ? (
          "你是 Nodeidea 知识图谱助手。基于【图谱节点列表】用中文清晰、简洁地回答用户问题。\n\n" +
          "**重要规则**:\n" +
          "1. 提到的概念若出现在下方节点列表中,必须用列表给你的短编号 `[[n1]]`、`[[n2]]` 标注。不要发明编号、不要用 UUID。\n" +
          "2. 没出现在列表里的概念照常写,不要乱标。\n" +
          "3. 回答尽量分 2-4 段,每段 50-150 字,不要罗列大量 bullet。\n" +
          "4. 如果列表与问题无关,直接基于通识回答,不要硬塞编号。\n\n" +
          "【图谱节点列表】\n" + contextStr + "\n"
        )
      : (
          "你是 Nodeidea 知识图谱助手。当前用户没有聚焦任何节点,直接基于通识用中文清晰、简洁回答。\n" +
          "回答尽量分 2-4 段,每段 50-150 字,不要罗列大量 bullet,不要使用任何 `[[...]]` 标注。"
        );

    const ctl = new AbortController();
    setQa({ question, answer: "", loading: true, error: null, citations: [], aliasMap, abort: ctl });

    // Identity guard: state may have been replaced by a newer runQA; only
    // mutate qa when prev.abort still points to *our* AbortController.
    const ifActive = (prev, mutator) => (prev && prev.abort === ctl) ? mutator(prev) : prev;

    try {
      await window.chatStream.streamChat(
        {
          messages: [
            { role: "system", content: sys },
            { role: "user", content: question },
          ],
          temperature: 0.4,
          maxTokens: 900,
          signal: ctl.signal,
        },
        (delta) => setQa(prev => ifActive(prev, p => ({ ...p, answer: (p.answer || "") + delta }))),
        ({ citations, usage, model, region, elapsedMs }) => setQa(prev => ifActive(prev, p => ({
          ...p, loading: false, citations: citations || [],
          usage: usage || null, model, region, elapsedMs,
        }))),
      );
    } catch (e) {
      const isAbort = e && (e.name === "AbortError" || /aborted/i.test(String(e.message || "")));
      setQa(prev => ifActive(prev, p => ({
        ...p,
        loading: false,
        error: isAbort ? "已取消" : ("LLM 调用失败:" + String(e.message || e)),
      })));
    }
  }

  // Main intent dispatcher for the compose bar (Enter / Send).
  async function handleComposeSubmit() {
    const q = composeQuery.trim();
    if (!q) return;

    // Rule layer is synchronous — try it first so explicit templates don't
    // pay the LLM cost. If rule misses, classify() calls /chat with a 3.5s
    // hard timeout and a strict JSON-output prompt for both intent + entity
    // extraction.
    const ruled = window.intentRouter.classifyByRules(q);
    let cls = ruled;
    if (ruled.source !== "rule") {
      setIntentToast({ kind: "info", text: "解析意图中…" });
      try {
        cls = await window.intentRouter.classify(q, { enableLLM: true, timeoutMs: 3500 });
      } catch (e) {
        console.warn("[intent] classify failed", e);
        cls = ruled;
      }
    }
    console.info("[intent]", cls);

    if (cls.intent === "relationship") {
      setIntentToast({ kind: "info", text: `解析关系:${cls.args.src} ↔ ${cls.args.dst}…` });
      const [src, dst] = await Promise.all([
        resolveOrAnnexNode(cls.args.src),
        resolveOrAnnexNode(cls.args.dst),
      ]);
      if (!src || !dst) {
        const missing = !src ? cls.args.src : cls.args.dst;
        setIntentToast({ kind: "warn", text: `"${missing}" 在数据库中没找到` });
        return;
      }
      // Annexed nodes are merged into `annexed` state — but graphData (the
      // page's view of the stage) only updates after the stage's onDataLoaded
      // re-fires. For path-finding we need the merged set NOW. Compose a
      // local merged graph from current state.
      const liveLinks = [...graphData.links];
      const liveNodes = [...graphData.nodes];
      if (!graphData.nodes.some(n => n.id === src.id)) liveNodes.push(src);
      if (!graphData.nodes.some(n => n.id === dst.id)) liveNodes.push(dst);
      // Pull annex links that aren't already in graphData.links.
      const knownKey = new Set(liveLinks.map(l => {
        const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
        const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
        return s + "|" + t + "|" + (l.relationType || "");
      }));
      for (const l of annexed.links) {
        const s = (l.source && typeof l.source === "object") ? l.source.id : l.source;
        const t = (l.target && typeof l.target === "object") ? l.target.id : l.target;
        const k = s + "|" + t + "|" + (l.relationType || "");
        if (!knownKey.has(k)) liveLinks.push(l);
      }
      const path = window.graphTraverse.shortestPath({ nodes: liveNodes, links: liveLinks }, src.id, dst.id);
      if (!path) {
        setIntentToast({ kind: "warn", text: `${src.name} ↔ ${dst.name}:当前子图内不连通(可继续把相邻概念拉入图谱)` });
        return;
      }
      applyPathHighlight(path, `${src.name} → ${dst.name} · ${path.links.length} 跳`);
      setComposeQuery("");
      setComposeFocused(false);
      return;
    }

    if (cls.intent === "prereqs") {
      setIntentToast({ kind: "info", text: `解析前置:${cls.args.target}…` });
      const target = await resolveOrAnnexNode(cls.args.target);
      if (!target) {
        setIntentToast({ kind: "warn", text: `"${cls.args.target}" 在数据库中没找到` });
        return;
      }
      const chain = window.graphTraverse.prereqsOf(graphData, target.id);
      if (!chain) {
        setIntentToast({ kind: "warn", text: `${target.name}:前置链需要 hypernym 边(当前视图未包含)` });
        return;
      }
      applyPathHighlight(chain, `${target.name} 前置链 · ${chain.links.length} 级`);
      setComposeQuery("");
      setComposeFocused(false);
      return;
    }

    if (cls.intent === "qa") {
      // 自由问答 → /chat SSE 流式;答案中 [[uuid]] 会渲染为可点 chip。
      setIntentToast(null);
      setComposeQuery("");
      setComposeFocused(false);
      runQA(cls.args.body || q);
      return;
    }

    // Default: search — take the top cascade hit.
    const first = (cascadeHits.local && cascadeHits.local[0])
      || (cascadeHits.text && cascadeHits.text[0])
      || (cascadeHits.vector && cascadeHits.vector[0]);
    if (first) handleSearchHit(first);
  }

  // Lazy-load DETAIL whenever selectedNode changes.
  useEffectG(() => {
    if (!selectedNode) { setDetail({ loading: false, id: null, data: null }); return; }
    if (detail.id === selectedNode.id) return;
    let cancelled = false;
    setDetail({ loading: true, id: selectedNode.id, data: null });
    window.graphRepo.fetchNodeDetails(selectedNode.id)
      .then(data => {
        if (cancelled) return;
        setDetail({ loading: false, id: selectedNode.id, data });
      })
      .catch(() => {
        if (cancelled) return;
        setDetail({ loading: false, id: selectedNode.id, data: null });
      });
    return () => { cancelled = true; };
  }, [selectedNode && selectedNode.id]);

  // ESC clears selection (modal handles its own dismissal)
  useEffectG(() => {
    const h = (e) => {
      if (e.key !== "Escape") return;
      if (pickerOpen) { setPickerOpen(false); return; }
      setSelectedNode(null);
    };
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, [pickerOpen]);

  // Render breadcrumb path: last segment is the leaf, others are ancestors.
  function renderBreadcrumb(domainPath) {
    const segs = (domainPath || "").split(".").filter(Boolean);
    return segs.map((seg, i) => (
      <React.Fragment key={i}>
        {i > 0 && <span className="sep">›</span>}
        <span style={{ color: i === segs.length - 1 ? "var(--text-soft)" : undefined }}>{seg}</span>
      </React.Fragment>
    ));
  }

  const detailText = detail.data
    ? (detail.data.detail_zh || detail.data.detail_en || "")
    : "";
  const sourceUrls = detail.data && Array.isArray(detail.data.source_urls)
    ? detail.data.source_urls
    : (selectedNode && selectedNode.sourceUrls) || [];
  const sourceName = (detail.data && detail.data.source) || (selectedNode && selectedNode.source) || "";

  return (
    <div className="graph-page page-fade">
      <div className="graph-canvas">
        <div className="star-bg" />
        {picks && picks.length > 0 && (
          <window.GraphStage
            picks={picks}
            maxNodes={800}
            perNodeK={5}
            minCosine={0.65}
            excludedPaths={excludedPaths}
            annexedNodes={annexed.nodes}
            annexedLinks={annexed.links}
            highlightedLinkKeys={highlight.linkKeys}
            highlightedNodeIds={highlight.nodeIds}
            selectedNodeId={selectedNode ? selectedNode.id : null}
            onSelectNode={setSelectedNode}
            onDataLoaded={onStageData}
          />
        )}
      </div>

      {/* Stats badge */}
      <div className="mem-badge">
        <div className="icn"><Ic.Brain /></div>
        <span>当前子图 <strong>{stats.nodes}</strong> 节点 · {stats.edges} 边</span>
      </div>

      {/* Domain panel (left) — picks with slot color */}
      <div className="float-panel domain-panel">
        <div className="panel-section">
          <div className="panel-row active"><span style={{ width: 14, display: "inline-grid", placeItems: "center" }}><Ic.Globe /></span>图谱探索</div>
          <div className="panel-row"><span style={{ width: 14, display: "inline-grid", placeItems: "center" }}><Ic.Book /></span>文库 <span className="count">412</span></div>
          <div className="panel-row"><span style={{ width: 14, display: "inline-grid", placeItems: "center" }}><Ic.Globe /></span>公开图谱</div>
        </div>
        <div className="panel-section">
          <div className="panel-section-title">
            领域 (picks)
            <span style={{ display: "flex", gap: 8, alignItems: "center" }}>
              {excludedPaths.size > 0 && (
                <button onClick={() => setExcludedPaths(new Set())} title="清空过滤">重置过滤</button>
              )}
              <button onClick={() => setPickerOpen(true)}>编辑</button>
            </span>
          </div>
          {(picks || []).map((p, idx) => {
            const rows = taxonomyByPick.get(p.tile_id);
            const count = Array.isArray(rows) ? rows.length : 0;
            return (
              <window.PickChip
                key={p.tile_id}
                pick={p}
                slot={typeof p.slot === "number" ? p.slot : idx}
                count={count}
                active={flyoutPickId === p.tile_id}
                onClick={() => setFlyoutPickId(prev => prev === p.tile_id ? null : p.tile_id)}
              />
            );
          })}
          {(!picks || picks.length === 0) && (
            <div style={{ padding: "8px 14px", fontSize: 12, color: "var(--text-faint)" }}>
              尚未选择领域
            </div>
          )}
        </div>
      </div>

      {/* Onboarding coachmark */}
      {tipStep === 1 && picks && (
        <div className="tip-coach" style={{ top: 76 }}>
          <span className="step-pill">01 / 03</span>
          <span>从上方搜索框开始,或直接点击任一节点。</span>
          <button onClick={() => setTipStep(0)}>跳过</button>
          <button className="next" onClick={() => setTipStep(2)}>下一步 →</button>
        </div>
      )}

      {/* Detail drawer (right) — selectedNode-driven */}
      {selectedNode && (
        <div className="float-panel detail-drawer">
          <div className="drawer-head">
            <div className="breadcrumb">
              <span className="scope-pill">PUBLIC</span>
              {selectedNode._outOfView && (
                <span className="scope-pill" style={{ background: "rgba(242,180,90,0.18)", color: "var(--accent-hi)", borderColor: "rgba(242,180,90,0.35)" }}>
                  外部预览
                </span>
              )}
              {renderBreadcrumb(selectedNode.domainPath)}
            </div>
            <div className="title">{selectedNode.name || selectedNode.nameEn}</div>
            <div className="actions">
              <button
                className="btn btn-sm btn-quiet"
                onClick={() => runQA(`请基于聚焦节点"${selectedNode.name || selectedNode.nameEn}"展开讲讲它是什么、与一阶邻居的关系。`)}
              >
                <Ic.Plus /> 从这里提问
              </button>
              <button className="btn btn-sm btn-quiet"><Ic.Share /> 分享</button>
              <button className="btn btn-sm btn-quiet" style={{ marginLeft: "auto" }} onClick={() => setSelectedNode(null)}><Ic.More /></button>
            </div>
          </div>
          <div className="drawer-body">
            <div className="drawer-section">
              <div className="drawer-section-head"><span className="dot"/>摘要 · SUMMARY</div>
              <p>{selectedNode.summary || selectedNode.summaryEn || "暂无摘要。"}</p>
            </div>

            <div className="drawer-section">
              <div className="drawer-section-head">
                <span className="dot" style={{ background: "var(--accent-data)" }}/>
                详细 · DETAIL
                {detail.loading && detail.id === selectedNode.id && (
                  <span style={{ marginLeft: "auto", fontSize: 11, color: "var(--text-faint)" }}>加载中…</span>
                )}
              </div>
              {detail.loading && detail.id === selectedNode.id ? (
                <p style={{ color: "var(--text-faint)", fontSize: 12 }}>正在拉取长文…</p>
              ) : detailText ? (
                detailText.split(/\n\s*\n/).filter(Boolean).map((para, i) => (
                  <p key={i}>{para}</p>
                ))
              ) : (
                <p style={{ color: "var(--text-faint)", fontSize: 12 }}>暂无长文。</p>
              )}
            </div>

            <div className="drawer-section">
              <div className="drawer-section-head">
                <span className="dot" style={{ background: "var(--accent-hi)" }}/>
                思维链 · REASONING
                <span style={{ marginLeft: "auto", color: "var(--text-faint)", fontFamily: "var(--font-mono)" }}>0</span>
              </div>
              <p style={{ color: "var(--text-dim)", fontSize: 12.5 }}>
                暂无公开的思维链。上传笔记时选择 ✨ AI Boost 即可贡献你的推理过程。
              </p>
            </div>

            <div className="drawer-section">
              <div className="drawer-section-head"><span className="dot" style={{ background: "var(--accent-fresh)" }}/>相邻节点 · ADJACENT</div>
              <div style={{ fontSize: 11, color: "var(--text-faint)", marginBottom: 8 }}>
                PUBLIC · {neighbors.length} 个 1-hop 邻居{(adjacency.adj.get(selectedNode.id) || []).length > 12 ? "(已截前 12)" : ""}
              </div>
              {neighbors.length === 0 ? (
                <p style={{ color: "var(--text-faint)", fontSize: 12 }}>
                  在当前有界子图内未发现相邻节点。
                </p>
              ) : (
                <div className="adj-chips">
                  {neighbors.map(nb => (
                    <span
                      key={nb.id}
                      className="adj-chip"
                      onClick={() => selectById(nb.id)}
                      title={nb.domainPath}
                    >
                      <span className="marker"/>{nb.name || nb.nameEn}
                    </span>
                  ))}
                </div>
              )}
            </div>

            <div className="drawer-section">
              <div className="drawer-section-head"><span className="dot" style={{ background: "var(--accent-2)" }}/>来源 · SOURCES</div>
              {sourceName || (sourceUrls && sourceUrls.length) ? (
                <div className="source-card">
                  <div className="name">{sourceName || "unknown"}</div>
                  {(!sourceUrls || sourceUrls.length === 0) ? (
                    <div className="url" style={{ color: "var(--text-faint)" }}>无 URL</div>
                  ) : (
                    sourceUrls.slice(0, 4).map((u, i) => (
                      <a key={i} className="url" href={u} target="_blank" rel="noreferrer">{u}</a>
                    ))
                  )}
                </div>
              ) : (
                <p style={{ color: "var(--text-faint)", fontSize: 12 }}>无来源记录。</p>
              )}
            </div>

            <div className="drawer-section">
              <div className="drawer-section-head"><span className="dot" style={{ background: "var(--text-faint)" }}/>META</div>
              <p style={{ fontSize: 11.5, color: "var(--text-dim)" }}>
                slot = {selectedNode.pickSlot} · pathDepth = {selectedNode.pathDepth} ·
                kg_degree = {selectedNode.kgDegree} · cluster_size = {selectedNode.clusterSize}
                {selectedNode.isSynthetic ? " · synthetic" : ""}
                {selectedNode.isMine ? " · mine" : ""}
              </p>
              <p style={{ fontSize: 10.5, color: "var(--text-faint)", fontFamily: "var(--font-mono)" }}>
                {selectedNode.id}
              </p>
            </div>
          </div>
          <div className="drawer-chat">
            <div className="input-shell">
              <span className="spin"/>
              <input
                placeholder={`在 ${selectedNode.name || "this node"} 上下文中提问…`}
                onKeyDown={(e) => {
                  if (e.key === "Enter") {
                    const v = e.currentTarget.value.trim();
                    if (!v) return;
                    e.currentTarget.value = "";
                    runQA(v);
                  }
                }}
              />
              <kbd className="kbd">⌘ ↵</kbd>
            </div>
          </div>
        </div>
      )}

      {/* PickFlyout — right-side filter for the active pick (only one at a time) */}
      {flyoutPickId && (() => {
        const p = (picks || []).find(x => x.tile_id === flyoutPickId);
        if (!p) return null;
        const slot = typeof p.slot === "number" ? p.slot : 0;
        return (
          <window.PickFlyout
            pick={p}
            slot={slot}
            rows={taxonomyByPick.get(p.tile_id)}
            excludedPaths={excludedPaths}
            onExcludedChange={setExcludedPaths}
            onClose={() => setFlyoutPickId(null)}
          />
        );
      })()}

      {/* MiniMap lives inside GraphStage so it has access to camera + node positions */}

      {/* Search results dropdown — sits above the compose bar */}
      {composeFocused && composeQuery.trim().length > 0 && (
        <div className="compose-results" onMouseDown={(e) => e.preventDefault()}>
          {(() => {
            const local = cascadeHits.local || [];
            const text = (cascadeHits.text || []).filter(h => !local.some(l => l.id === h.id));
            const vec = (cascadeHits.vector || []).filter(h => !local.some(l => l.id === h.id) && !text.some(t => t.id === h.id));
            const empty = local.length === 0 && text.length === 0 && vec.length === 0;

            const renderRow = (h, k) => (
              <div
                key={k + ":" + h.id}
                className={"compose-result" + (h.inView ? " in-view" : "")}
                // onPointerDown + setPointerCapture nails the entire pointerdown→up→click
                // sequence to this element, even if the cursor moves or the dropdown
                // is in the process of unmounting — events can't leak to the 3D canvas
                // below (which would otherwise fire onBackgroundClick and clear selection).
                onPointerDown={(e) => {
                  try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
                  e.preventDefault();
                  handleSearchHit(h);
                }}
              >
                <span className={"badge badge-" + h.kind}>
                  {h.kind === "local" ? "图内" : h.kind === "text" ? "全库" : "近邻"}
                </span>
                <div className="info">
                  <div className="title">{h.name || h.nameEn || "(unnamed)"}</div>
                  <div className="path">{h.domainPath}</div>
                </div>
                <span className="score" title="score / similarity">
                  {typeof h.score === "number"
                    ? (h.kind === "vector" ? h.score.toFixed(3) : Math.round(h.score))
                    : ""}
                </span>
              </div>
            );

            return (
              <>
                {local.length > 0 && (
                  <>
                    <div className="compose-section">本图谱内 · IN-VIEW ({local.length})</div>
                    {local.map(h => renderRow(h, "L"))}
                  </>
                )}
                {text.length > 0 && (
                  <>
                    <div className="compose-section">
                      全库 · POSTGREST ({text.length})
                      {cascadeHits.loading && cascadeHits.stage === "local" && <span className="loading">·载入中…</span>}
                    </div>
                    {text.map(h => renderRow(h, "T"))}
                  </>
                )}
                {vec.length > 0 && (
                  <>
                    <div className="compose-section">语义近邻 · VECTOR ({vec.length})</div>
                    {vec.map(h => renderRow(h, "V"))}
                  </>
                )}
                {empty && !cascadeHits.loading && (
                  <div className="compose-empty">无匹配。尝试别的关键词,或换更模糊的描述。</div>
                )}
                {empty && cascadeHits.loading && (
                  <div className="compose-empty">搜索中…</div>
                )}
              </>
            );
          })()}
        </div>
      )}

      {/* Intent toast — sits just above the compose bar, auto-clears on 4s.
          Suppressed while the QA panel is open (they share the same screen slot). */}
      {intentToast && !qa && (
        <div className={"intent-toast intent-toast-" + intentToast.kind}>
          <span className="intent-dot" />
          <span>{intentToast.text}</span>
        </div>
      )}

      {/* QA streaming panel (sits where the toast would, but bigger). */}
      {qa && (
        <window.GraphChatPanel
          qa={qa}
          graphData={graphData}
          onClose={() => {
            if (qa.abort) { try { qa.abort.abort(); } catch {} }
            setQa(null);
          }}
          onChipClick={selectById}
        />
      )}

      {/* Bottom compose */}
      <div className="compose-bar">
        <button className="icbtn" title="截图"><Ic.Camera /></button>
        <button className="icbtn" title="图片"><Ic.Image /></button>
        <button className="icbtn" title="文件"><Ic.Folder /></button>
        <input
          placeholder="搜节点 / 提问 (Enter 取第一结果)"
          value={composeQuery}
          onChange={(e) => setComposeQuery(e.target.value)}
          onFocus={() => setComposeFocused(true)}
          onBlur={() => setTimeout(() => setComposeFocused(false), 150)}
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              e.preventDefault();
              handleComposeSubmit();
            } else if (e.key === "Escape") {
              setComposeQuery("");
              setComposeFocused(false);
            }
          }}
        />
        <button className="icbtn" title="语音"><Ic.Audio /></button>
        <button
          className="icbtn send"
          title="发送"
          onClick={handleComposeSubmit}
        >
          <Ic.Send />
        </button>
      </div>

      {/* InterestModal — mandatory on first visit, optional via Edit */}
      {(needPick || pickerOpen) && (
        <window.InterestModal
          initialPicks={picks || []}
          onConfirm={commitPicks}
          onCancel={pickerOpen && picks && picks.length ? () => setPickerOpen(false) : null}
        />
      )}
    </div>
  );
}

window.GraphPage = GraphPage;
