import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
  forceSimulation,
  forceLink,
  forceManyBody,
  forceCenter,
  forceCollide,
  type SimulationNodeDatum,
  type SimulationLinkDatum,
} from "d3-force";
import {
  buildEdgesFromStocks,
  fetchThemeGraph,
  type ThemeGraph,
  type GraphStock,
} from "@/lib/theme-api";
import { getStockBoard } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils";
import { ArrowLeft, RefreshCw, Flame, TrendingUp, Target, Map as MapIcon, Search, List as ListIcon, Share2 } from "lucide-react";

export const Route = createFileRoute("/hot-map")({
  component: HotMapPage,
});

/* ============================================================
   排序维度：涨幅榜 / 热度榜
   ============================================================ */
const MODES: { key: 1 | 4; label: string }[] = [
  { key: 1, label: "涨幅" },
  { key: 4, label: "热度" },
];

/* 图视图股票节点上限：按覆盖题材数降序保留最高穿透度的核心股 */
const MAX_STOCK_NODES = 800;

/* 题材节点半径范围：按题材涨幅绝对值平方根映射（涨得越猛球越大，小涨幅区分更明显） */
const THEME_MIN_RADIUS = 4;
const THEME_MAX_RADIUS = 22;

/* 视图切换：关系图 / 核心股列表 */
type ViewMode = "graph" | "list";

function HotMapPage() {
  const [mode, setMode] = useState<1 | 4>(1);
  const [view, setView] = useState<ViewMode>("graph");
  const isCoarse = useMemo(
    () => typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches,
    [],
  );

  const { data: graph, isLoading, isFetching, isError, refetch } = useQuery({
    queryKey: ["themeGraph", mode],
    queryFn: () => fetchThemeGraph(mode, 50),
    staleTime: 60_000,
    retry: false,
  });

  return (
    <div className="h-[100dvh] flex flex-col bg-background">
      {/* ── 顶栏 ── */}
      <header className="shrink-0 z-10 bg-background/95 backdrop-blur border-b">
        <div className="max-w-6xl mx-auto px-4 py-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5">
          <div className="flex items-center gap-3 shrink-0">
            <Link to="/themes" className="hover:opacity-70 transition-opacity shrink-0">
              <ArrowLeft className="h-5 w-5" />
            </Link>
            <h1 className="text-base font-semibold whitespace-nowrap">热点穿透</h1>
          </div>
          <div className="flex flex-wrap items-center justify-end gap-1.5">
            {/* 涨幅/热度切换 */}
            <div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
              {MODES.map((m) => (
                <button
                  key={m.key}
                  onClick={() => setMode(m.key)}
                  className={`px-2.5 py-1 flex items-center gap-1 transition-colors whitespace-nowrap ${
                    mode === m.key
                      ? "bg-primary text-primary-foreground"
                      : "text-muted-foreground hover:text-foreground"
                  }`}
                >
                  {m.key === 4 ? <Flame className="h-3 w-3" /> : <TrendingUp className="h-3 w-3" />}
                  {m.label}榜
                </button>
              ))}
            </div>
            {/* 图 / 列表切换 */}
            <div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
              <button
                onClick={() => setView("graph")}
                className={`px-2.5 py-1 flex items-center gap-1 transition-colors whitespace-nowrap ${
                  view === "graph"
                    ? "bg-primary text-primary-foreground"
                    : "text-muted-foreground hover:text-foreground"
                }`}
              >
                <Share2 className="h-3 w-3" />
                关系图
              </button>
              <button
                onClick={() => setView("list")}
                className={`px-2.5 py-1 flex items-center gap-1 transition-colors whitespace-nowrap ${
                  view === "list"
                    ? "bg-primary text-primary-foreground"
                    : "text-muted-foreground hover:text-foreground"
                }`}
              >
                <ListIcon className="h-3 w-3" />
                核心股
              </button>
            </div>
            <button
              onClick={() => refetch()}
              className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
              title="刷新"
            >
              <RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
            </button>
            <Link
              to="/core-stocks"
              className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity whitespace-nowrap"
            >
              <Flame className="h-3.5 w-3.5" />
              热点股
            </Link>
          </div>
        </div>

        {/* 统计条 */}
        {graph?.stats && !isLoading && (
          <div className="max-w-6xl mx-auto px-4 pb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted-foreground">
            <span className="inline-flex items-center gap-1 whitespace-nowrap">
              <MapIcon className="h-3 w-3 text-blue-500" /> 题材 {graph.stats.themeCount}
            </span>
            <span className="inline-flex items-center gap-1 whitespace-nowrap">
              <Target className="h-3 w-3 text-slate-400" /> 股票 {graph.stats.stockCount}
            </span>
            <span className="inline-flex items-center gap-1 text-red-500 whitespace-nowrap">
              <Search className="h-3 w-3" /> 穿透核心股 {graph.stats.coreCount}
              <span className="text-muted-foreground/70">(覆盖≥2个题材)</span>
            </span>
          </div>
        )}
      </header>

      {/* ── 图主体 ── */}
      <main className="flex-1 relative min-h-0">
        {isLoading ? (
          <div className="absolute inset-0 flex items-center justify-center">
            <p className="text-sm text-muted-foreground">正在构建关系网络…</p>
          </div>
        ) : isError || !graph ? (
          <div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
            <p className="text-sm text-muted-foreground">数据加载失败</p>
            <button onClick={() => refetch()} className="text-xs text-primary hover:underline">
              点击重试
            </button>
          </div>
        ) : graph.themes.length === 0 || graph.stocks.length === 0 ? (
          <div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
            暂无题材数据
          </div>
        ) : view === "list" ? (
          <CoreStockList stocks={graph.stocks} />
        ) : (
          <>
            <HotMapGraph graph={graph} />
            {/* 图例（触屏紧凑横排） */}
            <Legend maxCover={graph.stats.maxCover} compact={isCoarse} />
            {/* 操作提示 */}
            <div className="absolute bottom-3 left-1/2 -translate-x-1/2 text-[10px] text-muted-foreground/70 bg-background/80 backdrop-blur px-2.5 py-1 rounded-full border whitespace-nowrap">
              {isCoarse
                ? "点击节点查看 · 再点跳转 · 双指缩放 · 拖拽平移"
                : "悬停查看 · 滚轮/捏合缩放 · 拖拽平移 · 点击跳转"}
            </div>
          </>
        )}
      </main>
    </div>
  );
}

/* ============================================================
   核心股列表：覆盖题材 > 2 的公司，按覆盖数降序
   ============================================================ */
function CoreStockList({ stocks }: { stocks: GraphStock[] }) {
  // 覆盖题材数 > 2（即 ≥3），接口已按 coverCount 降序、同覆盖按涨幅降序
  const core = stocks.filter((s) => s.coverCount > 2);
  const groupMax = core.length ? core[0].coverCount : 0;

  return (
    <div className="absolute inset-0 overflow-y-auto">
      <div className="max-w-6xl mx-auto px-4 py-3 pb-8 space-y-2">
        {/* 列表说明 */}
        <p className="text-[10px] text-muted-foreground px-1">
          覆盖 3 个及以上题材的核心股，按覆盖题材数降序（共 {core.length} 家）
        </p>

        {core.length === 0 ? (
          <div className="text-center py-16 text-sm text-muted-foreground">
            当前题材榜中暂无覆盖 3 个及以上题材的核心股
          </div>
        ) : (
          core.map((s) => {
            const board = getStockBoard(s.securityCode);
            const isPos = (s.f3 ?? 0) >= 0;
            return (
              <Link
                key={s.securityCode}
                to="/stock/$code"
                params={{ code: s.securityCode }}
                className="block"
              >
                <div className="rounded-xl border bg-card hover:shadow-md transition-shadow px-3 py-2.5">
                  {/* 首行：覆盖数徽章 + 名称 + 涨幅 */}
                  <div className="flex items-center gap-2">
                    <span
                      className={`shrink-0 inline-flex items-center justify-center rounded-full text-[10px] font-bold text-white min-w-[26px] h-[22px] px-1.5 ${
                        s.coverCount >= 7 ? "bg-red-700" : s.coverCount >= 5 ? "bg-red-500" : "bg-orange-500"
                      }`}
                    >
                      {s.coverCount}
                    </span>
                    <p className="font-medium text-sm truncate">{s.securityName}</p>
                    {board.label && (
                      <span className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none shrink-0 ${board.className}`}>
                        {board.label}
                      </span>
                    )}
                    <span className="text-[10px] text-muted-foreground font-mono">{s.securityCode}</span>
                    <span className={`ml-auto shrink-0 text-xs font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
                      {isPos ? "+" : ""}{(s.f3 ?? 0).toFixed(2)}%
                    </span>
                  </div>

                  {/* 次行：行业 + 现价 + 覆盖比例条 */}
                  <div className="flex items-center gap-2 mt-1.5 text-[10px] text-muted-foreground">
                    {s.f100 && (
                      <span className="bg-muted rounded px-1.5 py-0.5 shrink-0">{s.f100}</span>
                    )}
                    {s.f2 != null && <span className="shrink-0 tabular-nums">现价 {s.f2.toFixed(2)}</span>}
                    <span className="shrink-0 tabular-nums">{s.themeCodes?.length ?? s.coverCount} 个题材</span>
                    <div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
                      <div
                        className={`h-full rounded-full ${s.coverCount >= 7 ? "bg-red-700" : s.coverCount >= 5 ? "bg-red-500" : "bg-orange-500"}`}
                        style={{ width: `${(s.coverCount / Math.max(groupMax, 1)) * 100}%` }}
                      />
                    </div>
                  </div>
                </div>
              </Link>
            );
          })
        )}
      </div>
    </div>
  );
}

/* ============================================================
   力导向图组件
   ============================================================ */

interface SimNode extends SimulationNodeDatum {
  id: string;
  type: "theme" | "stock";
  name: string;
  radius: number;
  // stock 专属
  coverCount?: number;
  f3?: number | null;
  f2?: number | null;
  f62?: number | null;
  f100?: string;
  themeCodes?: string[];
  code?: string;
  // theme 专属
  bf3?: number | null; // 题材涨幅
}

interface SimEdge extends SimulationLinkDatum<SimNode> {
  source: string;
  target: string;
}

type ViewState = { x: number; y: number; k: number };

/* 指针手势状态机：pending（可升级 pan）/ pan / pinch */
type Gesture =
  | { kind: "none" }
  | { kind: "pending"; id: number; sx: number; sy: number; v0: ViewState }
  | { kind: "pan"; id: number; sx: number; sy: number; v0: ViewState }
  | { kind: "pinch"; ids: [number, number]; d0: number; mid0: { x: number; y: number }; v0: ViewState };

/* ============================================================
   Canvas 绘制工具（世界坐标输入，由调用方设置 transform）
   ============================================================ */

const TAU = Math.PI * 2;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y);
const mid = (a: { x: number; y: number }, b: { x: number; y: number }) => ({
  x: (a.x + b.x) / 2,
  y: (a.y + b.y) / 2,
});

/** 白色描边 + 填色的文本（等价 SVG 的 paint-order: stroke） */
function haloText(
  ctx: CanvasRenderingContext2D,
  text: string,
  x: number,
  y: number,
  fontSize: number,
  color: string,
  weight = 600,
) {
  ctx.font = `${weight} ${fontSize}px system-ui, -apple-system, sans-serif`;
  ctx.textAlign = "center";
  ctx.textBaseline = "alphabetic";
  ctx.lineJoin = "round";
  ctx.strokeStyle = "rgba(255,255,255,0.95)";
  ctx.lineWidth = 3;
  ctx.strokeText(text, x, y);
  ctx.fillStyle = color;
  ctx.fillText(text, x, y);
}

/** 边：按「高亮 / 正常 / 淡出」三档批量绘制，避免逐边切换 context 状态 */
function drawEdges(ctx: CanvasRenderingContext2D, edges: SimEdge[], activeSet: Set<string> | null) {
  ctx.lineWidth = 0.8;
  ctx.lineCap = "round";
  ctx.lineJoin = "round";
  if (activeSet) {
    // 高亮邻居边（两端都在邻居集）
    ctx.strokeStyle = "#60a5fa";
    ctx.globalAlpha = 0.85;
    ctx.beginPath();
    for (const e of edges) {
      const s = e.source as unknown as SimNode;
      const t = e.target as unknown as SimNode;
      if (typeof s !== "object" || typeof t !== "object") continue;
      if (activeSet.has(s.id) && activeSet.has(t.id)) {
        ctx.moveTo(s.x ?? 0, s.y ?? 0);
        ctx.lineTo(t.x ?? 0, t.y ?? 0);
      }
    }
    ctx.stroke();
  }
  // 正常 / 淡出边
  ctx.strokeStyle = "#3b82f6";
  ctx.globalAlpha = activeSet ? 0.03 : 0.18;
  ctx.beginPath();
  for (const e of edges) {
    const s = e.source as unknown as SimNode;
    const t = e.target as unknown as SimNode;
    if (typeof s !== "object" || typeof t !== "object") continue;
    if (activeSet && activeSet.has(s.id) && activeSet.has(t.id)) continue;
    ctx.moveTo(s.x ?? 0, s.y ?? 0);
    ctx.lineTo(t.x ?? 0, t.y ?? 0);
  }
  ctx.stroke();
  ctx.globalAlpha = 1;
}

/** 股票节点：覆盖数分级颜色 + 外圈淡填充 + 描边 + 内实心 */
function drawStockNodes(
  ctx: CanvasRenderingContext2D,
  nodes: SimNode[],
  activeSet: Set<string> | null,
  activeId: string | null,
) {
  for (const n of nodes) {
    if (n.type !== "stock") continue;
    const cover = n.coverCount ?? 1;
    const isActive = activeId === n.id;
    const dim = activeSet ? !activeSet.has(n.id) : false;
    const alpha = dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1;
    const r = isActive ? n.radius + 3 : n.radius;
    const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
    const x = n.x ?? 0;
    const y = n.y ?? 0;
    // 外圈淡填充
    ctx.beginPath();
    ctx.arc(x, y, r, 0, TAU);
    ctx.fillStyle = fill;
    ctx.globalAlpha = 0.35 * alpha;
    ctx.fill();
    // 描边
    ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
    ctx.lineWidth = cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5;
    ctx.globalAlpha = alpha;
    ctx.stroke();
    // 内实心
    ctx.beginPath();
    ctx.arc(x, y, n.radius, 0, TAU);
    ctx.globalAlpha = (cover >= 2 ? 0.9 : 0.55) * alpha;
    ctx.fill();
    // 选中外环
    if (isActive) {
      ctx.beginPath();
      ctx.arc(x, y, n.radius + 3, 0, TAU);
      ctx.globalAlpha = alpha;
      ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
      ctx.lineWidth = 1;
      ctx.stroke();
    }
  }
  ctx.globalAlpha = 1;
}

/** 题材节点：涨幅正蓝负绿，大小按涨幅绝对值映射 */
function drawThemeNodes(
  ctx: CanvasRenderingContext2D,
  nodes: SimNode[],
  activeSet: Set<string> | null,
  activeId: string | null,
) {
  for (const n of nodes) {
    if (n.type !== "theme") continue;
    const isActive = activeId === n.id;
    const dim = activeSet ? !activeSet.has(n.id) : false;
    const r = isActive ? n.radius + 3 : n.radius;
    const isPos = (n.bf3 ?? 0) >= 0;
    ctx.beginPath();
    ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU);
    ctx.fillStyle = isActive ? (isPos ? "#2563eb" : "#16a34a") : isPos ? "#3b82f6" : "#22c55e";
    ctx.globalAlpha = dim ? 0.15 : 1;
    ctx.fill();
    ctx.strokeStyle = isPos ? "#1d4ed8" : "#15803d";
    ctx.lineWidth = 1.5;
    ctx.stroke();
  }
  ctx.globalAlpha = 1;
}

/** 活动节点名牌：股票名 + 板块标签 */
function drawActiveNodeLabel(ctx: CanvasRenderingContext2D, activeNode: SimNode | null, isCoarse: boolean) {
  if (!activeNode) return;
  const x = activeNode.x ?? 0;
  const y = activeNode.y ?? 0;
  haloText(ctx, activeNode.name, x, y - activeNode.radius - 6, isCoarse ? 11 : 10, "#334155");
  if (activeNode.type === "stock" && activeNode.code) {
    const board = getStockBoard(activeNode.code);
    if (board.label) {
      const color = board.className.includes("red")
        ? "#dc2626"
        : board.className.includes("purple")
          ? "#9333ea"
          : "#ea580c";
      haloText(ctx, board.label, x, y - activeNode.radius - 18, isCoarse ? 9 : 8, color, 700);
    }
  }
}

/** 题材标签：放大（k>=0.95）或选中时显示，触屏截断 */
function drawThemeLabels(
  ctx: CanvasRenderingContext2D,
  nodes: SimNode[],
  k: number,
  isCoarse: boolean,
  activeId: string | null,
) {
  const showAll = k >= 0.95;
  for (const n of nodes) {
    if (n.type !== "theme") continue;
    if (!showAll && activeId !== n.id) continue;
    const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name;
    const labelColor = (n.bf3 ?? 0) >= 0 ? "#1e40af" : "#15803d";
    haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, labelColor);
  }
}

function HotMapGraph({ graph }: { graph: ThemeGraph }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const mainCanvasRef = useRef<HTMLCanvasElement>(null);
  const staticCanvasRef = useRef<HTMLCanvasElement | null>(null);
  const [size, setSize] = useState({ w: 800, h: 600 });
  const [hovered, setHovered] = useState<string | null>(null);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const navigate = useNavigate();

  /* 触屏检测：触屏无 hover，改用「tap 选中 → 底部浮层查看 → 按钮跳转」交互 */
  const isCoarse = useMemo(
    () => typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches,
    [],
  );

  /* 绘制/手势状态放 ref：拖拽平移期间每帧只有 rAF 重绘，零 React 重渲染 */
  const sizeRef = useRef({ w: 800, h: 600 });
  const viewRef = useRef<ViewState>({ x: 0, y: 0, k: 1 });
  const nodesRef = useRef<SimNode[]>([]);
  const edgesRef = useRef<SimEdge[]>([]);
  const stockByIdRef = useRef(new Map<string, GraphStock>());
  const activeIdRef = useRef<string | null>(null);
  const hoverNeighborsRef = useRef<Set<string> | null>(null);
  const activeNodeRef = useRef<SimNode | null>(null);
  const boundsRef = useRef({ minX: 0, minY: 0, w: 0, h: 0 });
  const staticReadyRef = useRef(false);
  const simRunningRef = useRef(false);
  const fitOnceRef = useRef(false);
  const rafRef = useRef(0);
  const pointersRef = useRef(new Map<number, { x: number; y: number }>());
  const gestureRef = useRef<Gesture>({ kind: "none" });

  /* rAF 合并调度：任何变化只触发一帧重绘 */
  const drawFrame = useCallback(() => {
    const canvas = mainCanvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const dpr = window.devicePixelRatio || 1;
    const v = viewRef.current;
    const { w, h } = sizeRef.current;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, w, h);
    ctx.translate(v.x, v.y);
    ctx.scale(v.k, v.k);

    const nodes = nodesRef.current;
    const edges = edgesRef.current;
    const activeId = activeIdRef.current;
    const activeSet = activeId ? hoverNeighborsRef.current : null;

    if (activeSet) {
      // 高亮态：全量重绘（邻居亮、非邻居淡出）
      drawEdges(ctx, edges, activeSet);
      drawStockNodes(ctx, nodes, activeSet, activeId);
      drawThemeNodes(ctx, nodes, activeSet, activeId);
      drawActiveNodeLabel(ctx, activeNodeRef.current, isCoarse);
    } else {
      const b = boundsRef.current;
      const sc = staticCanvasRef.current;
      if (staticReadyRef.current && sc && b.w > 0 && b.h > 0 && v.k < 1.5) {
        // 静止态：drawImage 静态层 + 动态层
        ctx.drawImage(sc, b.minX, b.minY, b.w, b.h);
      } else {
        // 模拟期或大比例放大：直接全量绘制（保证清晰）
        drawEdges(ctx, edges, null);
        drawStockNodes(ctx, nodes, null, null);
        drawThemeNodes(ctx, nodes, null, null);
      }
    }
    drawThemeLabels(ctx, nodes, v.k, isCoarse, activeId);
  }, [isCoarse]);

  const requestRender = useCallback(() => {
    if (rafRef.current === 0) {
      rafRef.current = requestAnimationFrame(() => {
        rafRef.current = 0;
        drawFrame();
      });
    }
  }, [drawFrame]);

  /* 静态离屏层：力收敛后一次性绘制全部边 + 节点底图 */
  const buildStaticLayer = useCallback(() => {
    const nodes = nodesRef.current;
    const edges = edgesRef.current;
    if (!nodes.length) return;
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const n of nodes) {
      const x = n.x ?? 0, y = n.y ?? 0;
      if (x < minX) minX = x;
      if (y < minY) minY = y;
      if (x > maxX) maxX = x;
      if (y > maxY) maxY = y;
    }
    if (maxX <= minX || maxY <= minY) return;
    const pad = 50;
    const minX2 = minX - pad, minY2 = minY - pad;
    const w = maxX - minX + pad * 2;
    const h = maxY - minY + pad * 2;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const maxDim = 4096; // 防超大包围盒在 3x dpr 下爆内存
    const c = Math.min(1, maxDim / Math.max(w, h));
    const sw = Math.max(1, Math.round(w * dpr * c));
    const sh = Math.max(1, Math.round(h * dpr * c));
    const sc = (staticCanvasRef.current ??= document.createElement("canvas"));
    sc.width = sw;
    sc.height = sh;
    const sctx = sc.getContext("2d");
    if (!sctx) return;
    sctx.setTransform(sw / w, 0, 0, sh / h, 0, 0);
    sctx.translate(-minX2, -minY2);
    drawEdges(sctx, edges, null);
    drawStockNodes(sctx, nodes, null, null);
    drawThemeNodes(sctx, nodes, null, null);
    staticReadyRef.current = true;
    boundsRef.current = { minX: minX2, minY: minY2, w, h };
  }, []);

  /* 自动/手动 fit：让整图适配视口 */
  const fitView = useCallback(() => {
    const nodes = nodesRef.current;
    if (!nodes.length) return;
    const xs = nodes.map((n) => n.x ?? 0);
    const ys = nodes.map((n) => n.y ?? 0);
    const bw = Math.max(...xs) - Math.min(...xs) + 100;
    const bh = Math.max(...ys) - Math.min(...ys) + 100;
    const { w, h } = sizeRef.current;
    if (bw <= 0 || bh <= 0) return;
    const k = Math.min(1.2, Math.max(0.25, Math.min(w / bw, h / bh) * 0.92));
    viewRef.current = {
      k,
      x: w / 2 - ((Math.min(...xs) + Math.max(...xs)) / 2) * k,
      y: h / 2 - ((Math.min(...ys) + Math.max(...ys)) / 2) * k,
    };
    requestRender();
  }, [requestRender]);

  /* 以屏幕坐标 (px,py) 为锚缩放 */
  const zoomAt = useCallback((px: number, py: number, targetK: number) => {
    const k = clamp(targetK, 0.3, 4);
    const v = viewRef.current;
    const kRatio = k / v.k;
    viewRef.current = { k, x: px - (px - v.x) * kRatio, y: py - (py - v.y) * kRatio };
    requestRender();
  }, [requestRender]);

  /* 监听容器尺寸：更新 canvas 物理尺寸（dpr）并触发重绘 */
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const update = () => {
      const w = el.clientWidth || 800;
      const h = el.clientHeight || 600;
      sizeRef.current = { w, h };
      setSize({ w, h });
      const canvas = mainCanvasRef.current;
      if (canvas) {
        const dpr = window.devicePixelRatio || 1;
        canvas.width = Math.max(1, Math.round(w * dpr));
        canvas.height = Math.max(1, Math.round(h * dpr));
      }
      requestRender();
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, [requestRender]);

  /* 节点过滤 + 边重建（后端不再下发 edges，由 stocks[].themeCodes 重建） */
  const { nodes, edges, stockById } = useMemo(() => {
    if (!graph) return { nodes: [] as SimNode[], edges: [] as SimEdge[], stockById: new Map<string, GraphStock>() };
    const kept = [...graph.stocks]
      .sort((a, b) => b.coverCount - a.coverCount || (b.f3 ?? 0) - (a.f3 ?? 0))
      .slice(0, MAX_STOCK_NODES);

    const stockNodes: SimNode[] = kept.map((s) => ({
      id: `s:${s.securityCode}`,
      type: "stock" as const,
      name: s.securityName,
      code: s.securityCode,
      coverCount: s.coverCount,
      f3: s.f3,
      f2: s.f2,
      f62: s.f62,
      f100: s.f100,
      themeCodes: s.themeCodes,
      radius: Math.min(18, 4 + s.coverCount * 1.3),
    }));
    // 题材涨幅绝对值作为球大小的归一化基准（兜底 ≥1 防除零）
    const maxAbsBf3 = Math.max(1, ...graph.themes.map((t) => Math.abs(t.bf3 ?? 0)));
    const themeNodes: SimNode[] = graph.themes.map((t) => {
      const bf3 = t.bf3 ?? 0;
      return {
        id: `t:${t.themeCode}`,
        type: "theme" as const,
        name: t.themeName,
        code: t.themeCode,
        coverCount: t.stockCount,
        bf3: t.bf3,
        // 平方根映射：同一个小涨幅区间内球径差异更大（线性映射被极端涨幅拉平均）
        radius: THEME_MIN_RADIUS + Math.sqrt(Math.abs(bf3) / maxAbsBf3) * (THEME_MAX_RADIUS - THEME_MIN_RADIUS),
      };
    });
    const edgeList: SimEdge[] = buildEdgesFromStocks(kept);

    const stockByIdMap = new Map<string, GraphStock>();
    kept.forEach((s) => stockByIdMap.set(`s:${s.securityCode}`, s));

    return { nodes: [...themeNodes, ...stockNodes], edges: edgeList, stockById: stockByIdMap };
  }, [graph]);

  /* 预计算相邻关系：nodeId -> Set<相邻nodeId> */
  const adjacency = useMemo(() => {
    const adj = new Map<string, Set<string>>();
    nodes.forEach((n) => adj.set(n.id, new Set()));
    edges.forEach((e) => {
      const s = typeof e.source === "object" ? (e.source as SimNode).id : e.source;
      const t = typeof e.target === "object" ? (e.target as SimNode).id : e.target;
      adj.get(s)?.add(t);
      adj.get(t)?.add(s);
    });
    return adj;
  }, [nodes, edges]);

  /* 激活节点：悬停（桌面）或选中（触屏） → 驱动高亮 */
  const activeId = hovered ?? selectedId;
  const hoverNeighbors = useMemo(() => {
    if (!activeId) return null;
    const set = new Set<string>();
    const direct = adjacency.get(activeId);
    direct?.forEach((id) => set.add(id));
    // 二级邻居：让题材的邻接股票再扩散一层
    direct?.forEach((id) => adjacency.get(id)?.forEach((nid) => set.add(nid)));
    set.add(activeId);
    return set;
  }, [activeId, adjacency]);

  const activeNode = activeId ? (nodes.find((n) => n.id === activeId) ?? null) : null;

  /* 渲染期镜像 ref，供 rAF / 绘制函数读取 */
  nodesRef.current = nodes;
  edgesRef.current = edges;
  stockByIdRef.current = stockById;

  /* 高亮状态同步给绘制层 */
  useEffect(() => {
    activeIdRef.current = activeId;
    hoverNeighborsRef.current = hoverNeighbors;
    activeNodeRef.current = activeNode;
    requestRender();
  }, [activeId, hoverNeighbors, activeNode, requestRender]);

  /* 力导向模拟：tick → rAF 合帧绘制，收敛后建静态层 */
  useEffect(() => {
    if (!nodes.length) return;
    const sim = forceSimulation<SimNode>(nodes)
      .alphaDecay(0.03)
      .force(
        "link",
        forceLink<SimNode, SimEdge>(edges)
          .id((d) => d.id)
          .distance((d) => {
            const s = d.source as unknown as SimNode;
            const t = d.target as unknown as SimNode;
            return s.type === "theme" && t.coverCount && t.coverCount >= 3 ? 60 : 40;
          })
          .strength(0.6),
      )
      .force("charge", forceManyBody<SimNode>().strength((d) => (d.type === "theme" ? -650 : -35)))
      .force("center", forceCenter(size.w / 2, size.h / 2))
      // 题材节点留出标签高度防文字重叠（标签在节点下方）
      .force("collide", forceCollide<SimNode>().radius((d) =>
        d.type === "theme" ? d.radius + (isCoarse ? 20 : 16) : d.radius + 4,
      ));

    simRunningRef.current = true;
    staticReadyRef.current = false;
    sim.on("tick", () => {
      // 收敛后一次性自动 fit（兜底，防 end 不触发）
      if (!fitOnceRef.current && sim.alpha() < 0.08) {
        fitView();
        fitOnceRef.current = true;
      }
      requestRender();
    });
    sim.on("end", () => {
      simRunningRef.current = false;
      buildStaticLayer();
      if (!fitOnceRef.current) {
        fitView();
        fitOnceRef.current = true;
      }
      requestRender();
    });

    return () => {
      sim.stop();
      simRunningRef.current = false;
      staticReadyRef.current = false;
      fitOnceRef.current = false;
      cancelAnimationFrame(rafRef.current);
      rafRef.current = 0;
    };
  }, [nodes, edges, size.w, size.h, isCoarse, requestRender, buildStaticLayer, fitView]);

  /* 滚轮：macOS 双指滚动=平移、捏合(ctrlKey)=缩放；非 macOS 滚轮=缩放 */
  useEffect(() => {
    const canvas = mainCanvasRef.current;
    if (!canvas) return;
    const onWheel = (e: WheelEvent) => {
      e.preventDefault();
      const rect = canvas.getBoundingClientRect();
      const px = e.clientX - rect.left;
      const py = e.clientY - rect.top;
      let dy = e.deltaY;
      if (e.deltaMode === 1) dy *= 16; // 行 → 像素
      else if (e.deltaMode === 2) dy *= rect.height; // 页 → 像素
      if (e.ctrlKey) {
        // 触控板捏合（浏览器以 ctrl+wheel 派发）→ 以光标为锚缩放
        zoomAt(px, py, viewRef.current.k * Math.exp(-dy * 0.01));
      } else if (/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)) {
        // macOS/iOS 非 ctrl：双指滚动 = 平移
        viewRef.current = { ...viewRef.current, x: viewRef.current.x - e.deltaX, y: viewRef.current.y - dy };
        requestRender();
      } else {
        // 非 macOS 滚轮 → 缩放
        zoomAt(px, py, viewRef.current.k * (dy < 0 ? 1.12 : 0.89));
      }
    };
    canvas.addEventListener("wheel", onWheel, { passive: false });
    return () => canvas.removeEventListener("wheel", onWheel);
  }, [zoomAt, requestRender]);

  /* 命中检测：屏幕坐标 → 世界坐标，触屏扩大命中半径 */
  const hitTest = useCallback((cssX: number, cssY: number): SimNode | null => {
    const v = viewRef.current;
    const wx = (cssX - v.x) / v.k;
    const wy = (cssY - v.y) / v.k;
    const pad = (isCoarse ? 22 : 12) / v.k;
    let best: SimNode | null = null;
    let bestD = Infinity;
    for (const n of nodesRef.current) {
      const dx = (n.x ?? 0) - wx;
      const dy = (n.y ?? 0) - wy;
      const d = Math.sqrt(dx * dx + dy * dy);
      if (d <= n.radius + pad && d < bestD) {
        bestD = d;
        best = n;
      }
    }
    return best;
  }, [isCoarse]);

  /* 节点跳转 */
  const navigateToNode = (node: SimNode) => {
    if (node.type === "stock" && node.code) navigate({ to: "/stock/$code", params: { code: node.code } });
    else if (node.type === "theme" && node.code) navigate({ to: "/theme/$code", params: { code: node.code } });
  };

  /* 节点点击：触屏第一次 tap 选中查看，再 tap 同一节点跳转；桌面直接跳转 */
  const handleTap = (node: SimNode) => {
    if (isCoarse) {
      if (selectedId === node.id) navigateToNode(node);
      else setSelectedId(node.id);
    } else {
      navigateToNode(node);
    }
  };

  /* 指针手势：pending（移动超阈值升级 pan）/ pan / pinch，区分 tap 与拖拽 */
  const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
    (e.currentTarget as HTMLCanvasElement).setPointerCapture(e.pointerId);
    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
    const ids = [...pointersRef.current.keys()];
    if (ids.length === 1) {
      gestureRef.current = {
        kind: "pending",
        id: e.pointerId,
        sx: e.clientX,
        sy: e.clientY,
        v0: { ...viewRef.current },
      };
      if (isCoarse) setSelectedId(null); // 触屏点空白先取消选中
    } else if (ids.length === 2) {
      const [a, b] = [pointersRef.current.get(ids[0])!, pointersRef.current.get(ids[1])!];
      gestureRef.current = {
        kind: "pinch",
        ids: [ids[0], ids[1]],
        d0: dist(a, b),
        mid0: mid(a, b),
        v0: { ...viewRef.current },
      };
    }
  };

  const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
    const g = gestureRef.current;
    if (g.kind === "pending" && Math.hypot(e.clientX - g.sx, e.clientY - g.sy) > 6) {
      gestureRef.current = { kind: "pan", id: g.id, sx: g.sx, sy: g.sy, v0: g.v0 };
    }
    if (g.kind === "pan") {
      const pt = pointersRef.current.get(g.id);
      if (pt) {
        viewRef.current = { ...g.v0, x: g.v0.x + (pt.x - g.sx), y: g.v0.y + (pt.y - g.sy) };
        requestRender();
      }
    } else if (g.kind === "pinch") {
      const a = pointersRef.current.get(g.ids[0]);
      const b = pointersRef.current.get(g.ids[1]);
      if (a && b) {
        const d = dist(a, b);
        const m = mid(a, b);
        const k = clamp(g.v0.k * (d / g.d0), 0.3, 4);
        const kRatio = k / g.v0.k;
        viewRef.current = {
          k,
          x: m.x - (g.mid0.x - g.v0.x) * kRatio,
          y: m.y - (g.mid0.y - g.v0.y) * kRatio,
        };
        requestRender();
      }
    } else if (g.kind === "none" && e.pointerType === "mouse" && !isCoarse) {
      // 桌面 hover 命中检测（触屏永不 setHovered，避免与选中冲突）
      const rect = e.currentTarget.getBoundingClientRect();
      const node = hitTest(e.clientX - rect.left, e.clientY - rect.top);
      setHovered(node?.id ?? null);
    }
  };

  const onPointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
    const g = gestureRef.current;
    pointersRef.current.delete(e.pointerId);
    if (g.kind === "pending" && g.id === e.pointerId) {
      // 无移动 → 判定为 tap
      const rect = e.currentTarget.getBoundingClientRect();
      const node = hitTest(g.sx - rect.left, g.sy - rect.top);
      if (node) handleTap(node);
      else if (isCoarse) setSelectedId(null);
    }
    // 双指抬起后剩一指 → 复位为 pending（继续可拖）
    const remaining = [...pointersRef.current.entries()];
    if (remaining.length === 1) {
      const [id, p] = remaining[0];
      gestureRef.current = { kind: "pending", id, sx: p.x, sy: p.y, v0: { ...viewRef.current } };
    } else {
      gestureRef.current = { kind: "none" };
    }
  };

  const onPointerLeave = (e: React.PointerEvent<HTMLCanvasElement>) => {
    if (e.pointerType === "mouse") setHovered(null);
    pointersRef.current.delete(e.pointerId);
  };

  return (
    <div ref={containerRef} className="absolute inset-0 overflow-hidden">
      <canvas
        ref={mainCanvasRef}
        className="w-full h-full touch-none select-none cursor-grab active:cursor-grabbing"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        onPointerLeave={onPointerLeave}
      />
      {/* 缩放控制：触屏/触控板兜底，点击不触发画布手势 */}
      <div
        className="absolute bottom-16 right-3 z-10 flex flex-col gap-1.5"
        onPointerDown={(e) => e.stopPropagation()}
      >
        <button
          onClick={() => zoomAt(size.w / 2, size.h / 2, viewRef.current.k * 1.4)}
          className="flex h-9 w-9 items-center justify-center rounded-full border bg-background/90 text-base font-semibold text-muted-foreground shadow backdrop-blur hover:text-foreground active:scale-95"
          aria-label="放大"
        >
          +
        </button>
        <button
          onClick={() => zoomAt(size.w / 2, size.h / 2, viewRef.current.k / 1.4)}
          className="flex h-9 w-9 items-center justify-center rounded-full border bg-background/90 text-base font-semibold text-muted-foreground shadow backdrop-blur hover:text-foreground active:scale-95"
          aria-label="缩小"
        >
          −
        </button>
        <button
          onClick={fitView}
          className="flex h-9 items-center justify-center rounded-full border bg-background/90 px-2 text-[11px] font-medium text-muted-foreground shadow backdrop-blur hover:text-foreground active:scale-95"
          aria-label="适应全图"
        >
          适应
        </button>
      </div>

      {/* 信息卡：桌面 hover 左上浮层 / 触屏选中底部浮层 */}
      {isCoarse ? (
        activeNode && (
          <BottomSheet
            node={activeNode}
            stockById={stockById}
            onClose={() => setSelectedId(null)}
            onGo={() => navigateToNode(activeNode)}
          />
        )
      ) : (
        activeNode && <InfoCard node={activeNode} stockById={stockById} />
      )}
    </div>
  );
}

/* ============================================================
   信息卡
   ============================================================ */
function InfoCard({ node, stockById }: { node: SimNode; stockById: Map<string, GraphStock> }) {
  const stock = stockById.get(node.id);
  const isPos = (node.f3 ?? 0) >= 0;
  return (
    <div className="absolute left-3 top-3 z-10 max-w-[220px] rounded-lg border bg-background/95 backdrop-blur p-3 shadow-lg space-y-1.5 text-sm">
      {node.type === "stock" ? (
        <>
          <p className="font-semibold flex items-center gap-1.5">
            {node.name}
            <span className="text-[10px] text-muted-foreground font-normal">{node.code}</span>
          </p>
          <div className="flex items-center gap-2 text-xs">
            <span className={`font-bold ${isPos ? "text-red-500" : "text-green-500"}`}>
              {isPos ? "+" : ""}{node.f3?.toFixed(2)}%
            </span>
            {node.f2 != null && <span className="text-muted-foreground">现价 {node.f2.toFixed(2)}</span>}
          </div>
          <p className="text-xs">
            <span className="text-red-500 font-semibold">覆盖 {node.coverCount} 个题材</span>
            {node.coverCount! >= 2 && <span className="text-[10px] text-orange-500 ml-1">· 穿透核心</span>}
          </p>
          {node.f62 != null && (
            <p className="text-[10px] text-muted-foreground">主力 {formatMoney(node.f62)}</p>
          )}
          {node.f100 && <p className="text-[10px] text-muted-foreground">行业：{node.f100}</p>}
          {stock?.themeCodes?.length ? (
            <p className="text-[10px] text-muted-foreground leading-relaxed pt-0.5 border-t border-border/40">
              所属：{stock.themeCodes.length > 3 ? stock.themeCodes.slice(0, 3).join("、") + ` 等${stock.themeCodes.length}个` : stock.themeCodes.join("、")}
            </p>
          ) : null}
        </>
      ) : (
        <>
          <p className="font-semibold">{node.name}</p>
          {node.bf3 != null && (
            <p className={`text-xs font-bold ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
              {node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
            </p>
          )}
          <p className="text-[10px] text-muted-foreground">代码 {node.code} · {node.coverCount} 只股票</p>
        </>
      )}
    </div>
  );
}

/* ============================================================
   底部浮层信息卡（触屏选中节点时展示）
   ============================================================ */
function BottomSheet({
  node,
  stockById,
  onClose,
  onGo,
}: {
  node: SimNode;
  stockById: Map<string, GraphStock>;
  onClose: () => void;
  onGo: () => void;
}) {
  const stock = stockById.get(node.id);
  const isPos = (node.f3 ?? 0) >= 0;
  return (
    <div className="absolute inset-x-0 bottom-0 z-20 rounded-t-xl border-t bg-background/95 backdrop-blur shadow-[0_-8px_30px_rgba(0,0,0,0.12)] px-4 pt-3 pb-[max(env(safe-area-inset-bottom),12px)]">
      {/* 顶部拖动条 */}
      <div className="mx-auto mb-2.5 h-1 w-10 rounded-full bg-muted" />
      <div className="flex items-start justify-between gap-3">
        <div className="min-w-0 flex-1">
          {node.type === "stock" ? (
            <>
              <p className="font-semibold flex items-center gap-1.5 text-sm">
                {node.name}
                <span className="text-[10px] text-muted-foreground font-normal">{node.code}</span>
              </p>
              <div className="flex items-center gap-2 text-xs mt-0.5">
                <span className={`font-bold ${isPos ? "text-red-500" : "text-green-500"}`}>
                  {isPos ? "+" : ""}{node.f3?.toFixed(2)}%
                </span>
                {node.f2 != null && <span className="text-muted-foreground">现价 {node.f2.toFixed(2)}</span>}
                {node.f62 != null && <span className="text-muted-foreground">主力 {formatMoney(node.f62)}</span>}
              </div>
              <p className="text-xs mt-1">
                <span className="text-red-500 font-semibold">覆盖 {node.coverCount} 个题材</span>
                {node.coverCount! >= 2 && (
                  <span className="text-[10px] text-orange-500 ml-1">· 穿透核心股</span>
                )}
                {node.f100 && <span className="text-[10px] text-muted-foreground ml-1">· {node.f100}</span>}
              </p>
              {stock?.themeCodes?.length ? (
                <p className="text-[10px] text-muted-foreground leading-relaxed mt-1">
                  所属：{stock.themeCodes.length > 4 ? stock.themeCodes.slice(0, 4).join("、") + ` 等${stock.themeCodes.length}个题材` : stock.themeCodes.join("、")}
                </p>
              ) : null}
            </>
          ) : (
            <>
              <p className="font-semibold text-sm">{node.name}</p>
              {node.bf3 != null && (
                <p className={`text-xs font-bold mt-0.5 ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
                  {node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
                </p>
              )}
              <p className="text-[10px] text-muted-foreground mt-0.5">
                代码 {node.code} · 覆盖 {node.coverCount} 只股票
              </p>
            </>
          )}
        </div>
        <button
          onClick={onClose}
          className="shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
          aria-label="关闭"
        >
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
            <path d="M18 6 6 18M6 6l12 12" />
          </svg>
        </button>
      </div>

      {/* 跳转按钮 */}
      <button
        onClick={onGo}
        className="mt-3 w-full rounded-lg bg-primary text-primary-foreground py-2.5 text-sm font-medium active:scale-[0.99] transition-transform"
      >
        查看{node.type === "stock" ? "股票" : "题材"}详情 →
      </button>
    </div>
  );
}

/* ============================================================
   图例（compact：触屏横向紧凑，避免挤占图区）
   ============================================================ */
function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
  const high = maxCover >= 5 ? 5 : 4;
  if (compact) {
    return (
      <div className="absolute left-3 top-2.5 z-10 flex items-center gap-2 rounded-full bg-background/85 backdrop-blur border px-2.5 py-1 text-[9px] text-muted-foreground">
        <span className="inline-flex items-center gap-1">
          <span className="inline-block rounded-full bg-orange-500" style={{ width: 8, height: 8 }} />
          3–4 个
        </span>
        <span className="inline-flex items-center gap-1">
          <span className="inline-block rounded-full bg-red-600" style={{ width: 11, height: 11 }} />
          ≥5 核心
        </span>
        <span className="inline-flex items-center gap-1">
          <span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} />
          题材涨
        </span>
        <span className="inline-flex items-center gap-1">
          <span className="inline-block rounded-full bg-green-500" style={{ width: 8, height: 8 }} />
          题材跌
        </span>
      </div>
    );
  }
  return (
    <div className="absolute right-3 top-3 z-10 rounded-lg border bg-background/90 backdrop-blur px-3 py-2 text-[10px] text-muted-foreground space-y-1.5">
      <p className="font-semibold text-foreground">图例</p>
      <div className="space-y-1">
        <div className="flex items-center gap-2">
          <span className="inline-block rounded-full bg-orange-500" style={{ width: 9, height: 9 }} />
          <span>覆盖 3–4 个题材</span>
        </div>
        <div className="flex items-center gap-2">
          <span className="inline-block rounded-full bg-red-600" style={{ width: 12, height: 12 }} />
          <span>覆盖 ≥{high} 个题材（核心股）</span>
        </div>
      </div>
      <div className="flex items-center gap-2 pt-1 border-t border-border/40">
        <span className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} />
        <span>题材 · 涨幅为正</span>
      </div>
      <div className="flex items-center gap-2">
        <span className="inline-block rounded-full bg-green-500" style={{ width: 10, height: 10 }} />
        <span>题材 · 涨幅为负</span>
      </div>
      <div className="text-[9px] text-muted-foreground pt-0.5">球大小随涨幅绝对值增大</div>
    </div>
  );
}
