"use client"

import { useState, useRef, useEffect, useMemo, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import {
  servers as mockServers,
  greTunnels as mockGreTunnels,
  type GreTunnel,
  type Server,
  type ServerStatus,
  type ServerType,
} from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import {
  buildGreMapEdges,
  buildServerResourceMap,
  buildWanJhEdges,
  computeNetworkMapLayout,
  NETWORK_MAP_H,
  NETWORK_MAP_LAYOUT_REVISION,
  NETWORK_MAP_PIPELINE_Y,
  NETWORK_MAP_W,
  findServerByGreRemote,
  greSourceWanIndexOnMap,
  greTunnelProbe,
  placeServiceNodes,
  type GreMapEdge,
  type WanJhEdge,
} from "@/lib/network-map-layout"
import {
  collectGreEndpointHostnames,
  greResolvedMapFromApi,
  isIpv4String,
  normalizeGreEndpointAddr,
} from "@/lib/gre-endpoint-resolve"
import {
  assignSpeedProbesToGreTunnels,
  assignSpeedProbesToWanJhEdges,
  canonicalGreServerPairKey,
  greOuterSummaryLine,
  ifaceNameForGreOuterIp,
  mergeGreMetricsWithSpeedProbe,
  wanJhEdgeMapKey,
  type GreSpeedProbeSnapshot,
} from "@/lib/map-gre-speed-probe"
import {
  formatNetflowDir,
  formatNetflowRate,
  hopHasRate,
  matchNetflowForGreEdge,
  matchNetflowForWan,
  type MatchedNetflowHop,
} from "@/lib/map-netflow-hops"
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/status-badge"
import { StatusDot } from "@/components/status-dot"
import {
  DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon, RefreshCwIcon,
  ZoomInIcon, ZoomOutIcon, Maximize2Icon, SearchIcon,
  LayersIcon, MapIcon, HomeIcon, TerminalIcon, ShieldIcon,
  CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { Flag } from "@/components/flag"

// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─

// ─── Backend → frontend (как /servers) ───────────────────────────────────────

interface BackendServerRow {
  id: number
  name: string
  host: string
  port: number
  useSsl: boolean
  verifySsl: boolean
  username: string
  password: string
  type: ServerType
  site: string
  country: string
  asn: string
  comment: string
  enabled: boolean
  lanSubnet?: string
  wanUplinks?: Array<{
    id: string
    name: string
    isp: string
    iface: string
    ip: string
    maxDl: number
    maxUl: number
  }>
  status: "online" | "offline" | null
  latency: number | null
  os: string | null
  model: string | null
  sessions: number
  /** Снапшот последнего poll (как в разделе «Серверы») */
  uptime?: string | null
  cpuLoad?: number | null
  freeMemory?: number | null
  totalMemory?: number | null
  polledAt?: string | null
  createdAt: string
  updatedAt: string
}

function mapBackendRowToServer(s: BackendServerRow): Server {
  return {
    id: String(s.id),
    name: s.name || s.host,
    host: s.host,
    type: s.type,
    site: s.site,
    country: s.country,
    asn: s.asn,
    model: s.model ?? "—",
    os: s.os ?? "—",
    enabled: s.enabled,
    status: (s.status === null ? "offline" : s.status) as Server["status"],
    latency: s.latency != null ? Math.round(s.latency) : null,
    sessions: s.sessions ?? 0,
    comment: s.comment || undefined,
    lanSubnet: s.lanSubnet || undefined,
    wanUplinks: Array.isArray(s.wanUplinks) && s.wanUplinks.length ? s.wanUplinks : undefined,
    uptime: s.uptime ?? undefined,
    cpuLoad: s.cpuLoad ?? undefined,
    freeMemory: s.freeMemory ?? undefined,
    totalMemory: s.totalMemory ?? undefined,
    polledAt: s.polledAt ?? undefined,
  }
}

interface ApiGreTunnelRow {
  id: string
  name: string
  serverId: string
  localAddress: string
  remoteAddress: string
  localInnerIp: string
  remoteInnerIp: string
  poolId: string
  ipsec: null
  mtu: number
  keepaliveInterval: number
  keepaliveRetries: number
  dscp: "inherit" | number
  clampTcpMss: boolean
  allowFastPath: boolean
  comment: string
  enabled: boolean
  status: "up" | "down" | "degraded"
}

function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
  return {
    id: t.id,
    name: t.name,
    serverId: String(t.serverId),
    localAddress: t.localAddress,
    remoteAddress: t.remoteAddress,
    localInnerIp: t.localInnerIp,
    remoteInnerIp: t.remoteInnerIp,
    poolId: t.poolId || "live",
    ipsec: null,
    mtu: t.mtu,
    keepaliveInterval: t.keepaliveInterval,
    keepaliveRetries: t.keepaliveRetries,
    dscp: t.dscp,
    clampTcpMss: t.clampTcpMss,
    allowFastPath: t.allowFastPath,
    comment: t.comment,
    enabled: t.enabled,
    status: t.status,
  }
}

function makeApiFetch(backendUrl: string) {
  return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
    return requestJson<T>(backendUrl, path, init)
  }
}

function fmtMB(mb: number): string {
  if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
  return `${mb} МБ`
}
function fmtUptime(sec: number): string {
  const d = Math.floor(sec / 86400)
  const h = Math.floor((sec % 86400) / 3600)
  const m = Math.floor((sec % 3600) / 60)
  if (d > 0) return `${d}д ${h}ч`
  if (h > 0) return `${h}ч ${m}м`
  return `${m}м`
}
function resBarColor(pct: number): string {
  if (pct >= 85) return "bg-red-500"
  if (pct >= 70) return "bg-amber-500"
  return "bg-emerald-500"
}
function resPctColor(pct: number): string {
  if (pct >= 85) return "text-red-400"
  if (pct >= 70) return "text-amber-400"
  return "text-emerald-400"
}

function MiniBar({ pct }: { pct: number }) {
  return (
    <div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
      <div
        className={cn("h-full rounded-full transition-all duration-700", resBarColor(pct))}
        style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
      />
    </div>
  )
}

function Sparkline({ history }: { history: number[] }) {
  const max = Math.max(...history, 1)
  const W = 80, H = 24
  const pts = history.map((v, i) => {
    const x = (i / (history.length - 1)) * W
    const y = H - (v / max) * H
    return `${x},${y}`
  }).join(" ")
  return (
    <svg width={W} height={H} className="shrink-0">
      <polyline points={pts} fill="none" stroke="currentColor" strokeWidth="1.2"
        className="text-emerald-500/60" strokeLinejoin="round" strokeLinecap="round" />
    </svg>
  )
}

// ─── Canvas dimensions ────────────────────────────────────────────────────────

const W = NETWORK_MAP_W
const H = NETWORK_MAP_H
const ZOOM_MIN = 0.2
const ZOOM_MAX = 6

// ─── Visual config ────────────────────────────────────────────────────────────

const STATUS_STYLE = {
  online:   { fill: "#0f2d1f", stroke: "#4ade80", text: "#4ade80", glow: "rgba(74,222,128,0.15)"  },
  degraded: { fill: "#2d1e06", stroke: "#fbbf24", text: "#fbbf24", glow: "rgba(251,191,36,0.15)"  },
  offline:  { fill: "#2d0f0f", stroke: "#f87171", text: "#f87171", glow: "transparent"             },
}

const TYPE_STYLE: Record<ServerType, { label: string; fill: string; r: number }> = {
  // HR — визуальный корень топологии: крупнее и с более сильным акцентом.
  "home-router": { label: "HR", fill: "#16a34a", r: 40 },
  // JH — агрегирующий слой по центру.
  "jump-host":   { label: "JH", fill: "#7c3aed", r: 34 },
  // EN — крайние edge-узлы, менее акцентные.
  "exit-node":   { label: "EN", fill: "#0369a1", r: 30 },
}

const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"]

const TUNNEL_STYLE = {
  up:       { stroke: "#4ade80", opacity: 0.5,  dotOpacity: 0.85 },
  degraded: { stroke: "#fbbf24", opacity: 0.4,  dotOpacity: 0.7  },
  down:     { stroke: "#f87171", opacity: 0.15, dotOpacity: 0    },
}

const TYPE_LABELS: Record<ServerType, string> = {
  "jump-host":   "JumpHost",
  "exit-node":   "Exit Node",
  "home-router": "Home Router",
}

const MOCK_MAP_SERVICES: FlowMapService[] = [
  { id: "svc:google", label: "Google", category: "Веб", bytes: 22_000_000, bps: 8_800_000, share: 0.22 },
  { id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 14_000_000, bps: 5_600_000, share: 0.14 },
  { id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 },
]

const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
  { fromId: "srv2", toId: "svc:google", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
  { fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
  { fromId: "srv2", toId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
  { fromId: "srv3", toId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
  { fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
]

function serviceSharePct(share: number): string {
  return `${Math.round(share * 100)}%`
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

function pingColor(ms: number | null) {
  if (ms == null) return "#f87171"
  if (ms < 15) return "#4ade80"
  if (ms < 50) return "#fbbf24"
  return "#fb923c"
}
function latencyColor(ms: number | null) {
  if (ms == null) return "#f87171"
  if (ms > 60)   return "#fbbf24"
  return "#4ade80"
}
function shortName(name: string) {
  return name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-")
}

function greStatusToServerStatus(s: "up" | "down" | "degraded"): ServerStatus {
  if (s === "up") return "online"
  if (s === "degraded") return "degraded"
  return "offline"
}

/**
 * Позиция бейджа на отрезке: не только середина, а доля t + сдвиг по нормали.
 * GRE и PingBadge (WAN→JH) используют разные t и знак нормали — меньше наложений.
 */
function edgeBadgePosition(
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  t: number,
  normalPx: number,
): { mx: number; my: number } {
  const tc = Math.min(0.82, Math.max(0.18, t))
  const px = x1 + (x2 - x1) * tc
  const py = y1 + (y2 - y1) * tc
  const dx = x2 - x1
  const dy = y2 - y1
  const len = Math.hypot(dx, dy) || 1
  const nx = -dy / len
  const ny = dx / len
  return { mx: px + nx * normalPx, my: py + ny * normalPx }
}

// ─── Filter ───────────────────────────────────────────────────────────────────

type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router"

function greEdgeKey(e: GreMapEdge): string {
  const la = (e.tunnel.localAddress ?? "").trim()
  const ra = (e.tunnel.remoteAddress ?? "").trim()
  return `${e.fromServer.id}|${e.toServer.id}|${e.tunnel.id}|${la}|${ra}`
}

// ─── SVG sub-components ───────────────────────────────────────────────────────

/** Бейдж на ребре WAN → JumpHost (не GRE): сумма latency домашнего узла и JH — те же поля, что в разделе Серверы. */
function PingBadge({ mx, my, ping, dl, ul, color }: {
  mx: number; my: number
  ping: number | null; dl?: number | null; ul?: number | null
  color: string
}) {
  const hasSpeed = dl != null && dl > 0
  const h = hasSpeed ? 36 : 20
  return (
    <g transform={`translate(${mx},${my})`}>
      <title>
        WAN → JumpHost: latency (из каталога) дом + latency JH, без отдельного ping по ребру. Не RTT по GRE.
      </title>
      <rect x="-30" y={-h / 2} width="60" height={h} rx="4"
        fill="#060d1a" stroke={color} strokeWidth="0.7" opacity="0.92" />
      <text textAnchor="middle" y={hasSpeed ? "-3" : "4"}
        fontSize="8.5" fontWeight="600" fill={color} fontFamily="ui-monospace,monospace">
        {ping == null ? "—" : `${ping} мс`}
      </text>
      {hasSpeed && (
        <text textAnchor="middle" y="10" fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace">
          {`↓${dl} ↑${ul}`}
        </text>
      )}
    </g>
  )
}

/** Метрики на GRE-ребре: компактно; подробности — в tooltip (`title`). */
function GreEdgeMetricBadge({
  mx,
  my,
  pingMs,
  dl,
  ul,
  onOpen,
  rttFromMonitor,
  throughputFromMonitor,
  outerSummary,
}: {
  mx: number
  my: number
  pingMs: number | null
  dl: number | null
  ul: number | null
  onOpen: (e: React.MouseEvent<SVGElement>) => void
  /** Данные из Мониторинг → скорость (uptime speed-probes) */
  rttFromMonitor?: boolean
  throughputFromMonitor?: boolean
  /** Внешние IP GRE и WAN — только в подсказке при наведении */
  outerSummary?: string
}) {
  const hasSpeed =
    dl != null &&
    ul != null &&
    (throughputFromMonitor || dl > 0 || ul > 0)
  const bw = 62
  const bh = 24 + (hasSpeed ? 14 : 0)
  const v1 = pingMs == null ? "—" : `${pingMs} мс`
  const hint =
    (outerSummary ? `${outerSummary}. ` : "") +
    (rttFromMonitor
      ? "RTT — ping/BT из «Мониторинг → скорость». "
      : "RTT — модель по данным туннеля. ") +
    (throughputFromMonitor ? "Строка ниже — TX/RX с пробы. " : "") +
    "Клик — панель справа."
  return (
    <g
      transform={`translate(${mx},${my})`}
      style={{ cursor: "pointer" }}
      onPointerDown={(e) => { e.stopPropagation() }}
      onClick={(e) => { e.stopPropagation(); onOpen(e) }}
    >
      <title>{hint}</title>
      <rect
        x={-bw / 2}
        y={-bh / 2}
        width={bw}
        height={bh}
        rx="6"
        fill="rgba(6,13,26,0.94)"
        stroke="#fbbf24"
        strokeWidth="1.15"
      />
      <text
        textAnchor="middle"
        y={hasSpeed ? "-5" : "4"}
        fontFamily="ui-monospace,monospace"
      >
        <tspan fill="#fcd34d" fontSize="9.5" fontWeight="700">{v1}</tspan>
      </text>
      {hasSpeed && (
        <text textAnchor="middle" y="11" fontFamily="ui-monospace,monospace">
          <tspan fill="#38bdf8" fontSize="7.5" fontWeight="600">{`↓${dl} ↑${ul}`}</tspan>
        </text>
      )}
    </g>
  )
}

/** Живой поток NetFlow (не ёмкость канала / не BT). */
function NetflowRateBadge({
  mx,
  my,
  hop,
  onOpen,
}: {
  mx: number
  my: number
  hop: MatchedNetflowHop
  onOpen?: (e: React.MouseEvent<SVGElement>) => void
}) {
  const showDir = hop.bpsFwd > 0 && hop.bpsRev > 0
  const bw = showDir ? 86 : 72
  const bh = showDir ? 32 : 20
  return (
    <g
      transform={`translate(${mx},${my})`}
      style={{ cursor: onOpen ? "pointer" : "default" }}
      onPointerDown={(e) => { e.stopPropagation() }}
      onClick={(e) => { e.stopPropagation(); onOpen?.(e) }}
    >
      <title>
        Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
      </title>
      <rect
        x={-bw / 2}
        y={-bh / 2}
        width={bw}
        height={bh}
        rx="6"
        fill="rgba(6,13,26,0.94)"
        stroke="#34d399"
        strokeWidth="1.15"
      />
      <text textAnchor="middle" y={showDir ? "-4" : "4"} fontFamily="ui-monospace,monospace">
        <tspan fill="#6ee7b7" fontSize="8" fontWeight="700">{formatNetflowRate(hop)}</tspan>
      </text>
      {showDir && (
        <text textAnchor="middle" y="10" fontFamily="ui-monospace,monospace">
          <tspan fill="#34d399" fontSize="6.5" fontWeight="600">{formatNetflowDir(hop)}</tspan>
        </text>
      )}
    </g>
  )
}

function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
  const ss = STATUS_STYLE[n.status]
  const ts = TYPE_STYLE[n.type]
  const lc = latencyColor(n.latency)
  const ox = n.x + ts.r + 14
  const oy = n.y - 32
  const lines = [
    n.name,
    `${TYPE_LABELS[n.type]} · ${n.site}`,
    n.latency != null ? `${n.latency} мс` : "Офлайн",
    n.model ?? "",
  ].filter(Boolean)
  const bw = 148, bh = lines.length * 14 + 14
  return (
    <g transform={`translate(${ox},${oy})`} style={{ pointerEvents: "none" }}>
      <rect x="0" y="0" width={bw} height={bh} rx="6"
        fill="#0a1628" stroke={ss.stroke} strokeWidth="0.8" opacity="0.97" />
      {/* colour accent line */}
      <rect x="0" y="0" width="3" height={bh} rx="2" fill={ts.fill} />
      {lines.map((l, i) => (
        <text key={i}
          x="10" y={12 + i * 14}
          fontSize={i === 0 ? "9.5" : "8"} fontWeight={i === 0 ? "700" : "400"}
          fill={i === 2 ? lc : i === 0 ? "#f1f5f9" : "#94a3b8"}
          fontFamily="ui-monospace,monospace">
          {l}
        </text>
      ))}
    </g>
  )
}

function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: {
  n: Server & { x: number; y: number }
  isSel: boolean; isVis: boolean; isDragged: boolean
  /** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */
  hideCatalogLatency?: boolean
  onClick: () => void
  onDblClick: () => void
  onHoverChange: (hov: boolean) => void
  onContextMenu: (e: React.MouseEvent) => void
  onMouseDown: (e: React.MouseEvent) => void
}) {
  const ss = STATUS_STYLE[n.status]
  const ts = TYPE_STYLE[n.type]

  return (
    <g transform={`translate(${n.x},${n.y})`}
      style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
      opacity={isVis ? 1 : 0.07}
      onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
      onClick={e => { e.stopPropagation(); onClick() }}
      onDoubleClick={e => { e.stopPropagation(); onDblClick() }}
      onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }}
      onMouseEnter={() => onHoverChange(true)}
      onMouseLeave={() => onHoverChange(false)}
    >
      {n.status === "online" && isVis && (
        <circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
          <animate attributeName="r" values={`${ts.r+10};${ts.r+18};${ts.r+10}`} dur="3s" repeatCount="indefinite" />
          <animate attributeName="opacity" values="0.8;0.15;0.8" dur="3s" repeatCount="indefinite" />
        </circle>
      )}
      {n.status === "degraded" && isVis && (
        <circle r={ts.r + 12} fill={ss.glow} />
      )}
      {isSel && (
        <circle r={ts.r + 10} fill="none" stroke="rgba(255,255,255,0.45)" strokeWidth="1.5" strokeDasharray="4 3" />
      )}
      <circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} />
      <g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
        <circle r="10" fill={ts.fill} />
        <text textAnchor="middle" y="4" fontSize="6.5" fontWeight="bold" fill="#fff"
          fontFamily="system-ui,sans-serif">{ts.label}</text>
      </g>
      {n.country?.trim() ? (
        <foreignObject
          x={-110}
          y={-22}
          width={220}
          height={28}
          style={{ overflow: "visible", pointerEvents: "none" }}
        >
          {/* Внутри foreignObject Tailwind не всегда применяется к тексту — явные стили, без lineHeight:0 (иначе подпись пропадает). */}
          <div
            style={{
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              gap: 6,
              width: "100%",
              height: "100%",
              fontFamily: "ui-monospace, monospace",
            }}
            {...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}
          >
            <span style={{ flexShrink: 0, lineHeight: 0 }}>
              <Flag code={n.country.trim()} size={ts.r > 32 ? 18 : 16} />
            </span>
            <span
              style={{
                maxWidth: 170,
                overflow: "hidden",
                textOverflow: "ellipsis",
                whiteSpace: "nowrap",
                fontWeight: 700,
                fontSize: ts.r > 32 ? 13 : 11,
                color: "#f1f5f9",
                lineHeight: 1.25,
              }}
            >
              {n.site}
            </span>
          </div>
        </foreignObject>
      ) : (
        <text textAnchor="middle" y="-4" fontSize={ts.r > 32 ? "13" : "11"} fontWeight="700"
          fill="#f1f5f9" fontFamily="ui-monospace,monospace">{n.site}</text>
      )}
      {!hideCatalogLatency && (
        <text textAnchor="middle" y="12" fontSize="8.5" fill={latencyColor(n.latency)} fontFamily="ui-monospace,monospace">
          {n.latency == null ? "офлайн" : `${n.latency} мс`}
        </text>
      )}
      {n.type === "home-router" && (
        <text textAnchor="middle" y={hideCatalogLatency ? 15 : ts.r + 18} fontSize="8.5" fill="#94a3b8"
          fontFamily="ui-monospace,monospace">{shortName(n.name)}</text>
      )}
    </g>
  )
}

function ServiceNode({
  label,
  share,
  x,
  y,
  isSel,
  isVis,
  isDragged,
  onClick,
  onMouseDown,
}: {
  label: string
  share: number
  x: number
  y: number
  isSel: boolean
  isVis: boolean
  isDragged: boolean
  onClick: () => void
  onMouseDown: (e: React.MouseEvent) => void
}) {
  const bw = 86
  const bh = 58
  return (
    <g
      transform={`translate(${x},${y})`}
      style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
      opacity={isVis ? 1 : 0.08}
      onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
      onClick={(e) => { e.stopPropagation(); onClick() }}
    >
      <title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
      {isSel && (
        <rect
          x={-bw / 2 - 6}
          y={-bh / 2 - 6}
          width={bw + 12}
          height={bh + 12}
          rx="12"
          fill="none"
          stroke="rgba(56,189,248,0.55)"
          strokeWidth="1.5"
          strokeDasharray="4 3"
        />
      )}
      <rect
        x={-bw / 2}
        y={-bh / 2}
        width={bw}
        height={bh}
        rx="10"
        fill="#08202c"
        stroke="#22d3ee"
        strokeWidth={isSel ? 2.2 : 1.4}
      />
      <g transform="translate(-11,-24)" pointerEvents="none">
        <ServiceBrandIcon label={label} size={22} />
      </g>
      <text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
        {label}
      </text>
      <text textAnchor="middle" y="25" fontSize="7.5" fill="#67e8f9" fontFamily="ui-monospace,monospace">
        {serviceSharePct(share)}
      </text>
    </g>
  )
}

function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
  x: number; y: number
  wan: { name: string; isp: string; maxDl: number; maxUl: number }
  color: string; active: boolean; isSel: boolean; isDragged: boolean
  onSelect: () => void
  onMouseDown: (e: React.MouseEvent) => void
}) {
  const r = 18
  return (
    <g transform={`translate(${x},${y})`}
      style={{ cursor: isDragged ? "grabbing" : "grab" }}
      onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
      onClick={e => { e.stopPropagation(); onSelect() }}>
      {active && (
        <circle r={r + 6} fill={color} opacity="0.12">
          <animate attributeName="opacity" values="0.12;0.04;0.12" dur="2.5s" repeatCount="indefinite" />
        </circle>
      )}
      {isSel && (
        <circle r={r + 8} fill="none" stroke={color} strokeWidth="1.2" strokeDasharray="3 2" opacity="0.6" />
      )}
      <circle r={r} fill={`${color}1a`} stroke={color}
        strokeWidth={active ? 2 : 1.2} opacity={active ? 1 : 0.65} />
      <g opacity={active ? 0.9 : 0.5}>
        <path d="M -5 2 Q 0 -5 5 2"   fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
        <path d="M -8 5 Q 0 -10 8 5"  fill="none" stroke={color} strokeWidth="1"   strokeLinecap="round" opacity="0.6" />
        <circle cx="0" cy="4" r="2" fill={color} />
      </g>
      <text textAnchor="middle" y={r + 14} fontSize="8" fontWeight="700"
        fill={color} fontFamily="ui-monospace,monospace">{wan.name}</text>
      <text textAnchor="middle" y={r + 24} fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace">
        {wan.isp.length > 10 ? wan.isp.slice(0, 9) + "…" : wan.isp}
      </text>
      <text textAnchor="middle" y={r + 34} fontSize="6.5" fill="#475569" fontFamily="ui-monospace,monospace">
        ↓{wan.maxDl} ↑{wan.maxUl}
      </text>
    </g>
  )
}

// ─── Context menu ─────────────────────────────────────────────────────────────

interface CtxMenu { x: number; y: number; server: Server }

function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void }) {
  const items = [
    {
      icon: <TerminalIcon className="size-3" />,
      label: "Открыть терминал",
      href: `/terminal?host=${menu.server.host}`,
    },
    {
      icon: <ShieldIcon className="size-3" />,
      label: "Firewall",
      href: `/firewall?server=${menu.server.id}`,
    },
    {
      icon: <CableIcon className="size-3" />,
      label: "GRE-туннели",
      href: `/gre?server=${menu.server.id}`,
    },
    {
      icon: <ActivityIcon className="size-3" />,
      label: "Трафик",
      href: `/traffic?server=${menu.server.id}`,
    },
  ]

  return (
    <>
      {/* backdrop */}
      <div className="fixed inset-0 z-40" onClick={onClose} />
      <div
        className="fixed z-50 min-w-[190px] rounded-lg border border-border bg-popover shadow-xl py-1 overflow-hidden"
        style={{ left: menu.x, top: menu.y }}>
        {/* header */}
        <div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 mb-1">
          <span className={cn(
            "size-2 rounded-full",
            menu.server.status === "online"   ? "bg-emerald-400" :
            menu.server.status === "degraded" ? "bg-amber-400"   : "bg-red-400",
          )} />
          <span className="text-xs font-mono font-semibold truncate">{menu.server.name}</span>
          <span className="ml-auto text-[10px] font-mono text-muted-foreground">{menu.server.host}</span>
        </div>

        {items.map(item => (
          <a
            key={item.label}
            href={item.href}
            onClick={onClose}
            className="flex items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-muted transition-colors cursor-pointer">
            <span className="text-muted-foreground">{item.icon}</span>
            {item.label}
            <ExternalLinkIcon className="size-2.5 ml-auto text-muted-foreground/50" />
          </a>
        ))}

        <div className="border-t border-border/60 mt-1 pt-1">
          <button
            onClick={() => { navigator.clipboard.writeText(menu.server.host); onClose() }}
            className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-muted transition-colors">
            <span className="text-muted-foreground"><CopyIcon className="size-3" /></span>
            Скопировать IP
          </button>
        </div>
      </div>
    </>
  )
}

// ─── Minimap ──────────────────────────────────────────────────────────────────

const MM_W = 172, MM_H = 94

function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
  pan: { x: number; y: number }; zoom: number
  nodes: (Server & { x: number; y: number })[]
  greEdges: GreMapEdge[]
  satPos: Record<string, { x: number; y: number }[]>
  wanJhEdges: WanJhEdge[]
  homeRouters: Server[]
  servicePos: Record<string, { x: number; y: number }>
  onClose: () => void
  onPan: (x: number, y: number) => void
}) {
  const vpW = Math.min(W, W / zoom)
  const vpH = Math.min(H, H / zoom)

  function handleClick(e: React.MouseEvent<SVGSVGElement>) {
    e.stopPropagation()
    const rect = e.currentTarget.getBoundingClientRect()
    const svgX = (e.clientX - rect.left) / rect.width  * W
    const svgY = (e.clientY - rect.top)  / rect.height * H
    onPan(svgX - vpW / 2, svgY - vpH / 2)
  }

  return (
    <div className="absolute bottom-16 right-4 z-20 rounded-lg overflow-hidden
      border border-white/10 bg-[#060d1a]/90 backdrop-blur-sm shadow-xl"
      style={{ width: MM_W, height: MM_H }}>
      <svg width={MM_W} height={MM_H} viewBox={`0 0 ${W} ${H}`}
        style={{ display: "block", cursor: "crosshair" }} onClick={handleClick}>
        <rect width={W} height={H} fill="#060d1a" />
        {/* simplified edges */}
        {greEdges.map((e) => (
          <line key={greEdgeKey(e)} x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
            stroke={TUNNEL_STYLE[e.tunnel.status].stroke} strokeWidth="5" opacity="0.25" />
        ))}
        {/* WAN edges */}
        {wanJhEdges.map((edge) => {
          const sat = satPos[edge.homeId]?.[edge.wanIdx]
          const jh  = nodes.find(n => n.id === edge.jhId)
          if (!sat || !jh) return null
          const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}`
          return <line key={edgeKey} x1={sat.x} y1={sat.y} x2={jh.x} y2={jh.y}
            stroke={WAN_COLORS[edge.wanIdx]} strokeWidth="3" opacity="0.25" />
        })}
        {/* nodes */}
        {nodes.map(n => (
          <circle key={n.id} cx={n.x} cy={n.y}
            r={TYPE_STYLE[n.type].r * 0.65}
            fill={TYPE_STYLE[n.type].fill}
            stroke={STATUS_STYLE[n.status].stroke}
            strokeWidth="3"
            opacity="0.85"
          />
        ))}
        {/* WAN satellites */}
        {homeRouters.flatMap(r =>
          (satPos[r.id] ?? []).map((p, i) => (
            <circle key={`${r.id}-${i}`} cx={p.x} cy={p.y} r="10"
              fill={WAN_COLORS[i] + "33"} stroke={WAN_COLORS[i]} strokeWidth="3" opacity="0.7" />
          ))
        )}
        {Object.entries(servicePos).map(([id, p]) => (
          <rect key={id} x={p.x - 14} y={p.y - 10} width="28" height="20" rx="4"
            fill="#08202c" stroke="#22d3ee" strokeWidth="3" opacity="0.85" />
        ))}
        {/* viewport rect */}
        <rect x={pan.x} y={pan.y} width={W / zoom} height={H / zoom}
          fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.6)" strokeWidth="6" rx="6" />
      </svg>
      <button
        onClick={e => { e.stopPropagation(); onClose() }}
        className="absolute top-1 right-1 text-white/30 hover:text-white/80 transition-colors leading-none">
        <XIcon className="size-2.5" />
      </button>
      <span className="absolute bottom-1 left-2 text-[7px] font-mono text-white/20 pointer-events-none">minimap</span>
    </div>
  )
}

// ─── Zoom controls overlay ────────────────────────────────────────────────────

function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: {
  zoom: number
  onZoomIn: () => void
  onZoomOut: () => void
  onFit: () => void
}) {
  return (
    <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-0
      rounded-xl border border-white/10 bg-black/75 backdrop-blur-md shadow-xl overflow-hidden">
      <button onClick={onZoomOut}
        className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
        title="Уменьшить (-)">
        <ZoomOutIcon className="size-3.5" />
      </button>
      <span className="px-3 py-2 text-xs font-mono text-white/70 min-w-[52px] text-center border-x border-white/10 select-none">
        {Math.round(zoom * 100)}%
      </span>
      <button onClick={onZoomIn}
        className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
        title="Увеличить (+)">
        <ZoomInIcon className="size-3.5" />
      </button>
      <div className="w-px h-5 bg-white/10" />
      <button onClick={onFit}
        className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
        title="Вписать (F / 0)">
        <Maximize2Icon className="size-3.5" />
      </button>
    </div>
  )
}

// ════════════════════════════════════════════════════════════════════════════

export default function NetworkMapPage() {
  const { mode, backendUrl } = useDataSource()
  const useLiveData = mode === "live"
  const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])

  const [mapServers, setMapServers] = useState<Server[]>([])
  const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
  const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
  const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
  const [mapServices, setMapServices] = useState<FlowMapService[]>([])
  const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
  const [mapSharePct, setMapSharePct] = useState(5)
  /** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
  const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
  const [dataError, setDataError] = useState<string | null>(null)
  const [dataLoading, setDataLoading] = useState(false)

  const greResolvedMap = useMemo(
    () => new Map(Object.entries(greResolvedIpv4ByHost)) as ReadonlyMap<string, string>,
    [greResolvedIpv4ByHost],
  )

  const loadLive = useCallback(async () => {
    if (!useLiveData) return
    setDataLoading(true)
    setDataError(null)
    try {
      const [rows, fr, spRes] = await Promise.all([
        apiFetch<BackendServerRow[]>("/api/servers"),
        apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels").catch(() => ({ tunnels: [] })),
        apiFetch<{
          probes?: Array<{
            id: string
            srcServerId: string
            dstServerId: string
            enabled?: boolean
            srcInterface?: string
            dstInterface?: string
            lastTxAvgMbps?: number | null
            lastRxAvgMbps?: number | null
            lastPingRttMs?: number | null
          }>
        }>("/api/uptime/speed-probes").catch(() => ({ probes: [] })),
      ])
      setMapServers(rows.map(mapBackendRowToServer))
      const mappedTunnels = (fr.tunnels ?? []).map(apiGreToGreTunnel)
      setMapGreTunnels(mappedTunnels)
      setSpeedProbes(
        (spRes.probes ?? []).map((p) => ({
          id: p.id,
          srcServerId: String(p.srcServerId),
          dstServerId: String(p.dstServerId),
          enabled: p.enabled !== false,
          srcInterface: p.srcInterface || undefined,
          dstInterface: p.dstInterface || undefined,
          lastTxAvgMbps: p.lastTxAvgMbps,
          lastRxAvgMbps: p.lastRxAvgMbps,
          lastPingRttMs: p.lastPingRttMs,
        })),
      )
      const hostSet = new Set<string>(collectGreEndpointHostnames(mappedTunnels))
      for (const row of rows) {
        if (!Array.isArray(row.wanUplinks)) continue
        for (const w of row.wanUplinks) {
          const raw = (w.ip ?? "").trim()
          if (!raw) continue
          const n = normalizeGreEndpointAddr(raw)
          if (!n || n === "0.0.0.0") continue
          if (!isIpv4String(n)) hostSet.add(n.toLowerCase())
        }
      }
      const hostnames = [...hostSet]
      if (hostnames.length > 0) {
        try {
          const res = await apiFetch<{ results: Record<string, string | null> }>(
            "/api/network/resolve-hosts",
            {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ hosts: hostnames }),
            },
          )
          setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(res.results)))
        } catch {
          setGreResolvedIpv4ByHost({})
        }
      } else {
        setGreResolvedIpv4ByHost({})
      }
    } catch (e) {
      setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
    } finally {
      setDataLoading(false)
    }
  }, [apiFetch, useLiveData])

  useEffect(() => {
    if (!useLiveData) {
      queueMicrotask(() => {
        setMapServers(mockServers)
        setMapGreTunnels(mockGreTunnels)
        setSpeedProbes([])
        setGreResolvedIpv4ByHost({})
        setMapHops([])
        setMapServices(MOCK_MAP_SERVICES)
        setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
        setMapSharePct(5)
        setDataError(null)
      })
      return
    }
    queueMicrotask(() => {
      void loadLive()
    })
  }, [useLiveData, loadLive])

  const autoLayout = useMemo(
    () => computeNetworkMapLayout(mapServers, mapGreTunnels, greResolvedMap),
    [mapServers, mapGreTunnels, greResolvedMap],
  )
  const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers])
  const srvResMap = useMemo(() => buildServerResourceMap(mapServers), [mapServers])
  const homeRouters = useMemo(
    () => mapServers.filter((s) => s.type === "home-router"),
    [mapServers],
  )

  // ── View state ──────────────────────────────────────────────────────────────
  const [pan,        setPan]        = useState({ x: 0, y: 0 })
  const [zoom,       setZoom]       = useState(1)
  const [isDragging, setIsDragging] = useState(false)

  // ── Interaction ─────────────────────────────────────────────────────────────
  const [selected,   setSelected]   = useState<Server | null>(null)
  const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
  const [selWanIdx,  setSelWanIdx]  = useState<number | null>(null)
  const [hoveredId,  setHoveredId]  = useState<string | null>(null)

  // ── Node positions (overrides POS defaults) ─────────────────────────────────
  const [nodePositions, setNodePositions] = useState<Record<string, { x: number; y: number }>>({})
  const [satPositions,  setSatPositions]  = useState<Record<string, { x: number; y: number }[]>>({})
  const [servicePositions, setServicePositions] = useState<Record<string, { x: number; y: number }>>({})

  /** После обновления алгоритма раскладки (см. NETWORK_MAP_LAYOUT_REVISION) сбрасываем drag, иначе старые координаты «перебивают» computeNetworkMapLayout. */
  useEffect(() => {
    if (typeof window === "undefined") return
    try {
      const k = "mm-network-map-layout-rev"
      if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) {
        // Важно: без явного сброса старые drag-координаты остаются в state
        // и перекрывают новый авто-лейаут на live-данных.
        setNodePositions({})
        setSatPositions({})
        setServicePositions({})
        sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
      }
    } catch {
      /* private mode / disabled storage */
    }
  }, [])

  // ── Context menu ────────────────────────────────────────────────────────────
  const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)

  const [selectedGreEdge, setSelectedGreEdge] = useState<GreMapEdge | null>(null)

  // ── Toggles ─────────────────────────────────────────────────────────────────
  const [filter,         setFilter]         = useState<FilterKey>("all")
  const [search,         setSearch]         = useState("")
  const [showPingBadges, setShowPingBadges] = useState(true)
  const [showNetflow,     setShowNetflow]     = useState(true)
  const [showServices,    setShowServices]    = useState(true)
  const [showAnimDots,   setShowAnimDots]   = useState(true)
  const [showMinimap,    setShowMinimap]    = useState(true)
  const [showHints,      setShowHints]      = useState(false)
  const [showLayers,     setShowLayers]     = useState(false)

  useEffect(() => {
    if (!useLiveData) {
      queueMicrotask(() => {
        setMapHops([])
        setMapServices(MOCK_MAP_SERVICES)
        setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
        setMapSharePct(5)
      })
      return
    }
    if (!showNetflow && !showServices) {
      queueMicrotask(() => {
        setMapHops([])
        setMapServices([])
        setMapServiceEdges([])
      })
      return
    }
    let cancelled = false
    let ac: AbortController | null = null
    const tick = () => {
      ac?.abort()
      ac = new AbortController()
      apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal })
        .then((res) => {
          if (cancelled) return
          setMapHops(res.hops ?? [])
          setMapServices(res.services ?? [])
          setMapServiceEdges(res.serviceEdges ?? [])
          if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
        })
        .catch((err: unknown) => {
          if (cancelled) return
          const name = err instanceof Error ? err.name : ""
          if (name === "AbortError") return
        })
    }
    tick()
    const id = window.setInterval(tick, 4000)
    return () => {
      cancelled = true
      ac?.abort()
      window.clearInterval(id)
    }
  }, [useLiveData, showNetflow, showServices, apiFetch])

  const effectiveSatPos = useMemo(() => {
    const out: Record<string, { x: number; y: number }[]> = {}
    mapServers
      .filter((s) => s.type === "home-router")
      .forEach((r) => {
        const defaults = autoLayout.wanSatPos[r.id] ?? []
        const user = satPositions[r.id]
        if (user && user.length === defaults.length) {
          out[r.id] = defaults.map((def, i) => user[i] ?? def)
        } else {
          out[r.id] = defaults
        }
      })
    return out
  }, [mapServers, autoLayout.wanSatPos, satPositions])

  const nodePosById = useMemo(
    () =>
      Object.fromEntries(
        mapServers.map((s) => {
          const pos =
            nodePositions[s.id] ??
            autoLayout.nodePos[s.id] ??
            { x: W / 2, y: NETWORK_MAP_PIPELINE_Y }
          return [s.id, pos] as const
        }),
      ),
    [mapServers, nodePositions, autoLayout.nodePos],
  )

  const greEdges = useMemo(
    () =>
      buildGreMapEdges(mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap),
    [mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap],
  )

  /** Speed-пробы на сегментах WAN→JH (тот же формат бейджа, что на GRE JH↔EN). */
  const speedProbeByWanJhKey = useMemo(
    () => assignSpeedProbesToWanJhEdges(wanJhEdges, mapServers, speedProbes),
    [wanJhEdges, mapServers, speedProbes],
  )

  /**
   * WAN→JH: не дублировать линию спутник→пир, если уже есть GRE с того же WAN к тому же узлу
   * (JH или exit — иначе «второй» бейдж speed посередине сегмента).
   */
  const suppressWanJhPingBadge = useMemo(() => {
    const s = new Set<string>()
    for (const g of greEdges) {
      if (g.fromServer.type !== "home-router") continue
      if (g.toServer.type === "home-router") continue
      const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
      if (widx == null) continue
      s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
    }
    return s
  }, [greEdges, greResolvedMap])

  /** Концы GRE: не дублировать «N мс» из каталога внутри круга, если показаны бейджи по туннелям. */
  const hideCatalogLatencyByNodeId = useMemo(() => {
    if (!showPingBadges) return new Set<string>()
    const s = new Set<string>()
    for (const e of greEdges) {
      s.add(e.fromServer.id)
      s.add(e.toServer.id)
    }
    return s
  }, [greEdges, showPingBadges])

  /** Одна speed-проба не может быть назначена двум GRE между одной парой узлов (RT vs MTS и т.д.). */
  const speedProbeByTunnelId = useMemo(
    () =>
      assignSpeedProbesToGreTunnels(
        greEdges.map((e) => ({
          tunnel: { ...e.tunnel, id: greEdgeKey(e) },
          fromServer: e.fromServer,
          toServer: e.toServer,
        })),
        speedProbes,
        greResolvedMap,
      ),
    [greEdges, speedProbes, greResolvedMap],
  )

  /** WAN→JH: не показывать GreEdgeMetricBadge со speed, если те же метрики уже на GRE (один бейдж на сегмент). */
  const suppressWanJhSpeedBadgeDuplicate = useMemo(() => {
    const s = new Set<string>()
    for (const g of greEdges) {
      if (g.fromServer.type !== "home-router") continue
      if (g.toServer.type === "home-router") continue
      const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
      if (widx == null) continue
      const spGre = speedProbeByTunnelId.get(greEdgeKey(g))
      const merged = mergeGreMetricsWithSpeedProbe(spGre, greTunnelProbe(g.tunnel))
      if (!merged.hasSpeedMonitor) continue
      s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
    }
    return s
  }, [greEdges, speedProbeByTunnelId, greResolvedMap])

  /** WAN→JH: скрываем саму линию, если тот же отрезок уже отрисован GRE (одна связь на карте). */
  const suppressWanJhLineDuplicate = useMemo(() => {
    const s = new Set<string>()
    for (const g of greEdges) {
      if (g.fromServer.type !== "home-router") continue
      if (g.toServer.type === "home-router") continue
      const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
      if (widx == null) continue
      s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
    }
    return s
  }, [greEdges, greResolvedMap])

  const visibleWanJhEdges = useMemo(
    () => wanJhEdges.filter((e) => !suppressWanJhLineDuplicate.has(wanJhEdgeMapKey(e))),
    [wanJhEdges, suppressWanJhLineDuplicate],
  )

  /** Один бейдж на совпадающий отрезок карты (два туннеля в БД HR→MSK и MSK→HR — одна линия). */
  const greTunnelIdsWithMapBadge = useMemo(() => {
    const edgeByTunnel = new Map(greEdges.map((e) => [greEdgeKey(e), e] as const))
    const geomKey = (e: GreMapEdge) => {
      const a = `${Math.round(e.from.x)},${Math.round(e.from.y)}`
      const b = `${Math.round(e.to.x)},${Math.round(e.to.y)}`
      return a <= b ? `${a}|${b}` : `${b}|${a}`
    }
    const rank = (tid: string): number => {
      const e = edgeByTunnel.get(tid)
      if (!e) return -1
      const sp = speedProbeByTunnelId.get(tid)
      const m = mergeGreMetricsWithSpeedProbe(sp, greTunnelProbe(e.tunnel))
      let r = m.hasSpeedMonitor ? 1000 : 0
      r += m.pingMs ?? 0
      return r
    }
    const winners = new Map<string, string>()
    for (const e of greEdges) {
      const key = geomKey(e)
      const cur = winners.get(key)
      const edgeId = greEdgeKey(e)
      if (!cur || rank(edgeId) > rank(cur) || (rank(edgeId) === rank(cur) && edgeId.localeCompare(cur) < 0)) {
        winners.set(key, edgeId)
      }
    }
    return new Set(winners.values())
  }, [greEdges, speedProbeByTunnelId])

  /** Несколько GRE между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */
  const greBadgeStaggerByTunnelId = useMemo(() => {
    const pairOrder = new Map<string, number>()
    const out = new Map<string, number>()
    for (const ge of greEdges) {
      const k = canonicalGreServerPairKey(ge.fromServer, ge.toServer)
      const lane = pairOrder.get(k) ?? 0
      out.set(greEdgeKey(ge), lane)
      pairOrder.set(k, lane + 1)
    }
    return out
  }, [greEdges])

  const netflowByGreKey = useMemo(() => {
    const m = new Map<string, MatchedNetflowHop>()
    if (!showNetflow) return m
    for (const e of greEdges) {
      const hop = matchNetflowForGreEdge(e, mapHops)
      if (hopHasRate(hop)) m.set(greEdgeKey(e), hop)
    }
    return m
  }, [greEdges, mapHops, showNetflow])

  const netflowByWanKey = useMemo(() => {
    const m = new Map<string, MatchedNetflowHop>()
    if (!showNetflow) return m
    for (const home of homeRouters) {
      for (const [wIdx, wan] of (home.wanUplinks ?? []).entries()) {
        const hop = matchNetflowForWan(home.id, wan.iface, mapHops)
        if (!hopHasRate(hop)) continue
        m.set(`${home.id}\t${wIdx}`, hop)
        for (const e of wanJhEdges) {
          if (e.homeId === home.id && e.wanIdx === wIdx) m.set(wanJhEdgeMapKey(e), hop)
        }
      }
    }
    return m
  }, [homeRouters, wanJhEdges, mapHops, showNetflow])

  const visibleMapServices = showServices ? mapServices : []
  const visibleServiceEdges = showServices ? mapServiceEdges.filter((e) =>
    visibleMapServices.some((s) => s.id === e.toId),
  ) : []

  const nodes = mapServers
    .map((s) => ({ ...s, ...nodePosById[s.id]! }))
    // Визуальный приоритет: HR поверх JH, JH поверх EN.
    .sort((a, b) => {
      const weight = (t: ServerType) =>
        t === "home-router" ? 3 :
        t === "jump-host"   ? 2 :
        1
      return weight(a.type) - weight(b.type)
    })
  const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))

  const autoServicePos = placeServiceNodes(
    visibleMapServices.map((s) => s.id),
    mapServers
      .filter((s) => s.type === "exit-node")
      .map((s) => nodePosById[s.id])
      .filter((p): p is { x: number; y: number } => Boolean(p)),
  )
  const servicePosById = Object.fromEntries(
    visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoServicePos[s.id]!]),
  )

  // ── Refs ─────────────────────────────────────────────────────────────────────
  const svgRef  = useRef<SVGSVGElement>(null)

  // Canvas pan drag
  const dragRef = useRef<{
    startX: number; startY: number
    panX: number; panY: number
    moved: boolean
  } | null>(null)

  // Node / WAN-sat drag
  type NodeDrag =
    | { kind: "server";  nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
    | { kind: "wan-sat"; homeId: string; wanIdx: number; startX: number; startY: number; origX: number; origY: number; moved: boolean }
    | { kind: "service"; svcId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
  const nodeDragRef     = useRef<NodeDrag | null>(null)
  const suppressClickRef = useRef(false)
  const [draggedNodeId,  setDraggedNodeId]  = useState<string | null>(null)
  const [draggedSatKey,  setDraggedSatKey]  = useState<string | null>(null) // `${homeId}-${wanIdx}`
  const [draggedSvcId,   setDraggedSvcId]   = useState<string | null>(null)

  const zoomRef = useRef(zoom)
  const panRef  = useRef(pan)
  zoomRef.current = zoom
  panRef.current  = pan

  // ── SVG export ───────────────────────────────────────────────────────────
  function exportSvg() {
    const svg = svgRef.current
    if (!svg) return
    // Clone with full viewBox (reset pan/zoom)
    const clone = svg.cloneNode(true) as SVGSVGElement
    clone.setAttribute("viewBox", `0 0 ${W} ${H}`)
    clone.setAttribute("width", String(W))
    clone.setAttribute("height", String(H))
    const blob = new Blob(
      [`<?xml version="1.0" encoding="UTF-8"?>\n`, clone.outerHTML],
      { type: "image/svg+xml;charset=utf-8" },
    )
    const url = URL.createObjectURL(blob)
    const a = document.createElement("a")
    a.href = url
    a.download = `network-map-${new Date().toISOString().slice(0, 10)}.svg`
    a.click()
    URL.revokeObjectURL(url)
  }

  // ── Non-passive wheel zoom ────────────────────────────────────────────────
  useEffect(() => {
    const svg = svgRef.current
    if (!svg) return
    const handler = (e: WheelEvent) => {
      e.preventDefault()
      const z    = zoomRef.current
      const p    = panRef.current
      const rect = svg.getBoundingClientRect()
      const svgX = p.x + (e.clientX - rect.left) / rect.width  * (W / z)
      const svgY = p.y + (e.clientY - rect.top)  / rect.height * (H / z)
      // trackpad pinch sends ctrlKey, use smaller step
      const step = e.ctrlKey ? 1.06 : 1.14
      const factor  = e.deltaY < 0 ? step : 1 / step
      const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * factor))
      setPan({
        x: svgX - (e.clientX - rect.left) / rect.width  * (W / newZoom),
        y: svgY - (e.clientY - rect.top)  / rect.height * (H / newZoom),
      })
      setZoom(newZoom)
    }
    svg.addEventListener("wheel", handler, { passive: false })
    return () => svg.removeEventListener("wheel", handler)
  }, [])

  // ── Keyboard shortcuts ────────────────────────────────────────────────────
  useEffect(() => {
    function onKey(e: KeyboardEvent) {
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
      if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
      if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
      if (e.key === "-")                  applyZoomCenter(1 / 1.25)
      if (e.key === "0" || e.key.toLowerCase() === "f") fitView()
      if (e.key.toLowerCase() === "m")   setShowMinimap(v => !v)
      if (e.key.toLowerCase() === "p")   setShowPingBadges(v => !v)
    }
    window.addEventListener("keydown", onKey)
    return () => window.removeEventListener("keydown", onKey)
  }, [zoom, pan]) // eslint-disable-line react-hooks/exhaustive-deps -- applyZoomCenter/fitView ниже; handler обновляется через [zoom, pan]

  // ── Zoom helpers ──────────────────────────────────────────────────────────
  function applyZoomCenter(factor: number) {
    const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
    setPan(p => ({
      x: p.x + (W / zoom - W / newZoom) / 2,
      y: p.y + (H / zoom - H / newZoom) / 2,
    }))
    setZoom(newZoom)
  }

  function fitView() {
    setPan({ x: 0, y: 0 })
    setZoom(1)
  }

  function focusNode(x: number, y: number) {
    const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
    setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
    setZoom(newZoom)
  }

  // ── Drag ─────────────────────────────────────────────────────────────────
  function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
    // Only pan on background clicks (node mousedown stops propagation)
    dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false }
    setIsDragging(true)
  }

  function onSvgMouseMove(e: React.MouseEvent<SVGSVGElement>) {
    const nd = nodeDragRef.current
    if (nd) {
      const dx = e.clientX - nd.startX
      const dy = e.clientY - nd.startY
      if (Math.abs(dx) > 2 || Math.abs(dy) > 2) nd.moved = true
      const rect = e.currentTarget.getBoundingClientRect()
      const svgDx = dx * (W / zoom) / rect.width
      const svgDy = dy * (H / zoom) / rect.height
      const nx = nd.origX + svgDx
      const ny = nd.origY + svgDy

      if (nd.kind === "server") {
        setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } }))
      } else if (nd.kind === "service") {
        setServicePositions(prev => ({ ...prev, [nd.svcId]: { x: nx, y: ny } }))
      } else {
        setSatPositions(prev => {
          const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))]
          arr[nd.wanIdx] = { x: nx, y: ny }
          return { ...prev, [nd.homeId]: arr }
        })
      }
      return
    }

    if (!dragRef.current) return
    const dx = e.clientX - dragRef.current.startX
    const dy = e.clientY - dragRef.current.startY
    if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragRef.current.moved = true
    const rect = e.currentTarget.getBoundingClientRect()
    setPan({
      x: dragRef.current.panX - dx * (W / zoom / rect.width),
      y: dragRef.current.panY - dy * (H / zoom / rect.height),
    })
  }

  function finishNodeDrag() {
    if (!nodeDragRef.current) return false
    suppressClickRef.current = nodeDragRef.current.moved
    nodeDragRef.current = null
    setDraggedNodeId(null)
    setDraggedSatKey(null)
    setDraggedSvcId(null)
    return true
  }

  function onSvgMouseUp() {
    if (finishNodeDrag()) return
    const moved = dragRef.current?.moved ?? false
    dragRef.current = null
    setIsDragging(false)
    if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
  }

  // ── Node drag start ──────────────────────────────────────────────────────
  function onNodeMouseDown(e: React.MouseEvent, n: Server & { x: number; y: number }) {
    nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false }
    setDraggedNodeId(n.id)
  }
  function onSatMouseDown(e: React.MouseEvent, homeId: string, wanIdx: number, x: number, y: number) {
    nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
    setDraggedSatKey(`${homeId}-${wanIdx}`)
  }
  function onServiceMouseDown(e: React.MouseEvent, svcId: string, x: number, y: number) {
    nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
    setDraggedSvcId(svcId)
  }

  // ── Visibility / search ──────────────────────────────────────────────────
  const sq = search.toLowerCase().trim()
  const matchesSearch = (n: typeof nodes[0]) =>
    !sq ||
    n.name.toLowerCase().includes(sq) ||
    n.site.toLowerCase().includes(sq) ||
    n.model?.toLowerCase().includes(sq) ||
    n.os?.toLowerCase().includes(sq)

  const isVisible = (n: typeof nodes[0]) => {
    if (!matchesSearch(n)) return false
    if (filter === "all") return true
    if (filter === "online" || filter === "degraded" || filter === "offline") return n.status === filter
    return n.type === filter
  }

  // ── Side panel ────────────────────────────────────────────────────────────
  function selectServer(s: Server) {
    setSelectedGreEdge(null)
    setSelectedService(null)
    setSelected(prev => prev?.id === s.id ? null : s)
    setSelWanIdx(null)
    setHoveredId(null)
  }
  function selectService(svc: FlowMapService) {
    setSelectedGreEdge(null)
    setSelected(null)
    setSelWanIdx(null)
    setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
  }
  function selectWan(s: Server, wanIdx: number) {
    setSelectedGreEdge(null)
    setSelectedService(null)
    setSelected(s)
    setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
  }

  function openWanJhSpeedDetail(ev: React.MouseEvent<SVGElement>, edge: WanJhEdge) {
    ev.stopPropagation()
    const home = mapServers.find((s) => s.id === edge.homeId)
    if (!home) return
    setSelectedGreEdge(null)
    setSelectedService(null)
    setSelected(home)
    setSelWanIdx(edge.wanIdx)
    setHoveredId(null)
  }

  const connectedTunnels = selected
    ? mapGreTunnels.filter((t) => {
        if (t.serverId === selected.id) return true
        const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
        if (peer?.id === selected.id) return true
        const selectedHost = normalizeGreEndpointAddr(selected.host)
        const selectedWanIps = (selected.wanUplinks ?? [])
          .map((w) => normalizeGreEndpointAddr(w.ip))
          .filter(Boolean)
        const remote = normalizeGreEndpointAddr(t.remoteAddress)
        const local = normalizeGreEndpointAddr(t.localAddress)
        return (
          (selectedHost.length > 0 && (remote === selectedHost || local === selectedHost)) ||
          selectedWanIps.includes(remote) ||
          selectedWanIps.includes(local)
        )
      })
    : []
  const grePrimaryByGroupKey = useMemo(() => {
    if (!selected) return new Map<string, string>()
    const group = new Map<string, GreTunnel[]>()
    const score = (t: GreTunnel): number => {
      const statusScore = t.status === "up" ? 3 : t.status === "degraded" ? 2 : 1
      const enabledScore = t.enabled ? 10 : 0
      return enabledScore + statusScore
    }
    for (const t of connectedTunnels) {
      const peer =
        t.serverId === selected.id
          ? findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
          : mapServers.find((s) => s.id === t.serverId)
      const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress)
      const arr = group.get(groupKey) ?? []
      arr.push(t)
      group.set(groupKey, arr)
    }
    const primaryByKey = new Map<string, string>()
    for (const [groupKey, tunnels] of group.entries()) {
      const sorted = [...tunnels].sort((a, b) => {
        const d = score(b) - score(a)
        if (d !== 0) return d
        return a.id.localeCompare(b.id)
      })
      const first = sorted[0]
      if (first) primaryByKey.set(groupKey, first.id)
    }
    return primaryByKey
  }, [connectedTunnels, selected, mapServers, greResolvedMap])

  const hoveredNode = hoveredId ? nodes.find(n => n.id === hoveredId) : null

  const filterBtns: { value: FilterKey; label: string }[] = [
    { value: "all",         label: "Все"        },
    { value: "online",      label: "Онлайн"     },
    { value: "degraded",    label: "Внимание"   },
    { value: "offline",     label: "Оффлайн"    },
    { value: "jump-host",   label: "JumpHost"   },
    { value: "exit-node",   label: "Exit Node"  },
    { value: "home-router", label: "Home Router"},
  ]

  // ═════════════════════════════════════════════════════════════════════════

  return (
    <div className="flex flex-col h-full">
      <PageHeader
        crumbs={[{ label: "Обзор" }, { label: "Карта сети" }]}
        actions={
          <>
            {useLiveData && (
              <Button variant="outline" size="sm" onClick={() => void loadLive()} disabled={dataLoading}>
                <RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
                Обновить
              </Button>
            )}
            <Button variant="outline" size="sm" onClick={exportSvg}>
              <DownloadIcon className="size-4" />Экспорт SVG
            </Button>
          </>
        }
      />

      {/* ── Toolbar ── */}
      <div className="flex items-center gap-2 px-3 py-2 border-b bg-background/60 shrink-0 flex-wrap">

        {/* Filter pills */}
        <div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
          {filterBtns.map(b => (
            <button key={b.value} onClick={() => setFilter(b.value)}
              className={cn(
                "px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
                filter === b.value
                  ? "bg-background text-foreground shadow-sm"
                  : "text-muted-foreground hover:text-foreground",
              )}>
              {b.label}
            </button>
          ))}
        </div>

        {/* Search */}
        <div className="relative">
          <SearchIcon className="absolute left-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground pointer-events-none" />
          <input
            value={search}
            onChange={e => setSearch(e.target.value)}
            placeholder="Поиск узла…"
            className="h-7 pl-6 pr-3 text-xs rounded-md border border-border bg-muted/40
              placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring w-44"
          />
          {search && (
            <button onClick={() => setSearch("")}
              className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
              <XIcon className="size-3" />
            </button>
          )}
        </div>

        {/* Layers dropdown */}
        <div className="relative">
          <button
            onClick={() => setShowLayers(v => !v)}
            className={cn(
              "flex items-center gap-1.5 h-7 px-2.5 text-xs rounded-md border transition-colors",
              showLayers
                ? "bg-muted text-foreground border-border"
                : "border-border/60 text-muted-foreground hover:text-foreground hover:border-border",
            )}>
            <LayersIcon className="size-3" />Слои
          </button>
          {showLayers && (
            <div className="absolute top-9 left-0 z-30 rounded-lg border border-border bg-popover shadow-xl p-2 flex flex-col gap-0.5 min-w-[170px]"
              onMouseLeave={() => setShowLayers(false)}>
              {([
                { key: "showPingBadges", label: "Ping-значки",      val: showPingBadges, set: setShowPingBadges, hint: "P" },
                { key: "showNetflow",    label: "NetFlow",          val: showNetflow,    set: setShowNetflow,    hint: "" },
                { key: "showServices",   label: "Сервисы",          val: showServices,   set: setShowServices,   hint: "" },
                { key: "showAnimDots",   label: "Анимация трафика", val: showAnimDots,   set: setShowAnimDots,   hint: "" },
                { key: "showMinimap",    label: "Минимап",          val: showMinimap,    set: setShowMinimap,    hint: "M" },
                { key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
              ] as const).map(item => (
                <button key={item.key}
                  onClick={() => item.set((v: boolean) => !v)}
                  className={cn(
                    "flex items-center justify-between px-3 py-1.5 rounded-md text-xs transition-colors",
                    item.val ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
                  )}>
                  <span>{item.label}</span>
                  <span className="flex items-center gap-2">
                    {item.hint && (
                      <kbd className="text-[9px] font-mono px-1 py-0.5 rounded border border-border bg-muted/40">
                        {item.hint}
                      </kbd>
                    )}
                    <span className={cn(
                      "size-2 rounded-full",
                      item.val ? "bg-emerald-400" : "bg-muted-foreground/30",
                    )} />
                  </span>
                </button>
              ))}
              <p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
                {mapSharePct > 0
                  ? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow`
                  : "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}
              </p>
              {(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && (
                <div className="border-t border-border/50 mt-1 pt-1">
                  <button
                    onClick={() => { setNodePositions({}); setSatPositions({}); setServicePositions({}) }}
                    className="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs
                      text-amber-400 hover:bg-amber-500/10 transition-colors">
                    ↺ Сбросить расположение
                  </button>
                </div>
              )}
            </div>
          )}
        </div>

        {/* Stats */}
        <div className="ml-auto flex items-center gap-4 text-xs text-muted-foreground flex-wrap justify-end">
          <span className="text-[10px] border border-border rounded px-1.5 py-0.5">
            {useLiveData ? "Живые данные · API" : "Демо · mock"}
          </span>
          {dataError && (
            <span className="text-[10px] text-destructive max-w-[220px] truncate" title={dataError}>
              {dataError}
            </span>
          )}
          <span>
            <span className="font-semibold text-emerald-400">{mapServers.filter(s => s.status === "online").length}</span> онлайн
          </span>
          <span>
            <span className="font-semibold text-amber-400">{mapServers.filter(s => s.status === "degraded").length}</span> с проблемами
          </span>
          <span>
            <span className="font-semibold text-red-400">{mapServers.filter(s => s.status === "offline").length}</span> оффлайн
          </span>
          <span title="Линии на карте / всего туннелей в данных">
            <span className="font-semibold text-cyan-400">{greEdges.length}</span>
            <span className="text-muted-foreground">/</span>
            <span className="font-semibold text-foreground">{mapGreTunnels.length}</span>
            {" "}GRE
          </span>
        </div>
      </div>

      {/* ── Main area ── */}
      <div className="flex flex-1 overflow-hidden">

        {/* ── Canvas ── */}
        <div className="flex-1 relative overflow-hidden bg-[#060d1a] select-none">
          <svg
            ref={svgRef}
            viewBox={`${pan.x} ${pan.y} ${W / zoom} ${H / zoom}`}
            style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none" }}
            onMouseDown={e => { e.preventDefault(); onSvgMouseDown(e) }}
            onMouseMove={onSvgMouseMove}
            onMouseUp={onSvgMouseUp}
            onMouseLeave={() => {
              finishNodeDrag()
              dragRef.current = null; setIsDragging(false); setHoveredId(null)
            }}
          >
            <defs>
              <pattern id="map-dots" width="22" height="22" patternUnits="userSpaceOnUse">
                <circle cx="1" cy="1" r="0.8" fill="rgba(148,163,184,0.08)" />
              </pattern>
              {WAN_COLORS.map((_, i) => (
                <filter key={i} id={`glow-wan-${i}`} x="-80%" y="-80%" width="260%" height="260%">
                  <feGaussianBlur stdDeviation="3" result="blur" />
                  <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
                </filter>
              ))}
            </defs>

            {/* Background grid */}
            <rect width={W} height={H} fill="url(#map-dots)" />

            {/* ── GRE edges ── */}
            {greEdges.map((e, i) => {
              const edgeId = greEdgeKey(e)
              const ts     = TUNNEL_STYLE[e.tunnel.status]
              const fromN  = nodeById[e.fromServer.id]
              const toN    = nodeById[e.toServer.id]
              const dimmed =
                filter !== "all" &&
                fromN && toN &&
                !isVisible(fromN) &&
                !isVisible(toN)
              const baseProbe = greTunnelProbe(e.tunnel)
              const spGre      = speedProbeByTunnelId.get(edgeId)
              const merged     = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
              const rttFromMonitor = spGre?.lastPingRttMs != null
              const throughputFromMonitor =
                spGre?.lastTxAvgMbps != null || spGre?.lastRxAvgMbps != null
              const badgeLane = greBadgeStaggerByTunnelId.get(edgeId) ?? 0
              const tBadge = Math.min(0.78, Math.max(0.22, 0.34 + badgeLane * 0.052))
              const normalPx =
                (badgeLane % 2 === 0 ? 1 : -1) * (16 + badgeLane * 22)
              const { mx, my } = edgeBadgePosition(
                e.from.x,
                e.from.y,
                e.to.x,
                e.to.y,
                tBadge,
                normalPx,
              )
              const flowHop = netflowByGreKey.get(edgeId)
              const flowPos = edgeBadgePosition(
                e.from.x,
                e.from.y,
                e.to.x,
                e.to.y,
                tBadge,
                -normalPx - (normalPx === 0 ? 22 : 0),
              )
              function openGreDetail(ev: React.MouseEvent<SVGElement>) {
                ev.stopPropagation()
                setSelectedGreEdge(e)
                setSelected(null)
                setSelectedService(null)
                setSelWanIdx(null)
              }
              return (
                <g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
                  <line
                    x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
                    stroke={ts.stroke} strokeWidth={hopHasRate(flowHop) ? 2.6 : 1.5}
                    strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
                    opacity={ts.opacity}
                  />
                  <line
                    x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
                    stroke="#00000000"
                    strokeWidth={14}
                    strokeLinecap="round"
                    style={{ cursor: "pointer" }}
                    onPointerDown={(ev) => { ev.stopPropagation() }}
                    onClick={openGreDetail}
                  >
                    <title>GRE: подробнее</title>
                  </line>
                  {showAnimDots && ts.dotOpacity > 0 && (
                    <circle r="3" fill={ts.stroke} opacity={ts.dotOpacity} pointerEvents="none">
                      <animateMotion dur={`${2.4 + (i % 5) * 0.35}s`} repeatCount="indefinite"
                        path={`M ${e.from.x} ${e.from.y} L ${e.to.x} ${e.to.y}`} />
                    </circle>
                  )}
                  {showPingBadges && greTunnelIdsWithMapBadge.has(edgeId) && (
                    <GreEdgeMetricBadge
                      mx={mx}
                      my={my}
                      pingMs={merged.pingMs}
                      dl={merged.dlMbps}
                      ul={merged.ulMbps}
                      onOpen={openGreDetail}
                      rttFromMonitor={rttFromMonitor}
                      throughputFromMonitor={throughputFromMonitor}
                      outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
                    />
                  )}
                  {showNetflow && hopHasRate(flowHop) && (
                    <NetflowRateBadge
                      mx={flowPos.mx}
                      my={flowPos.my}
                      hop={flowHop}
                      onOpen={openGreDetail}
                    />
                  )}
                </g>
              )
            })}

            {/* ── Home Router → WAN satellite connectors ── */}
            {homeRouters.flatMap(router => {
              const rNode = nodes.find(n => n.id === router.id)
              if (!rNode) return []
              return (router.wanUplinks ?? []).map((wan, wIdx) => {
                const sat   = effectiveSatPos[router.id]?.[wIdx]
                const color = WAN_COLORS[wIdx] ?? "#888"
                const vis   = filter === "all" || filter === "home-router" || filter === "online"
                if (!sat) return null
                return (
                  <g key={`${router.id}-conn-${wIdx}`} opacity={vis ? 1 : 0.06} style={{ transition: "opacity 0.3s" }}>
                    <line x1={rNode.x} y1={rNode.y} x2={sat.x} y2={sat.y}
                      stroke={color} strokeWidth="2" opacity="0.55" />
                  </g>
                )
              }).filter(Boolean)
            })}

            {/* ── WAN→JH edges ── */}
            {visibleWanJhEdges.map((edge) => {
              const jh     = nodeById[edge.jhId]
              const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
              if (!jh || !satPos) return null
              const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}`
              const color = WAN_COLORS[edge.wanIdx] ?? "#888"
              const vis   = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
              const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17)
              const flowPos = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.38, 18)
              const isHL  = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
              const wanFlow = netflowByWanKey.get(wanJhEdgeMapKey(edge))
              return (
                <g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
                  style={{ transition: "opacity 0.3s" }}>
                  <line
                    x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
                    stroke={color}
                    strokeWidth={edge.active ? (hopHasRate(wanFlow) ? 2.8 : 2) : 1.2}
                    strokeDasharray={edge.active ? "none" : "5 4"}
                    opacity={edge.active ? 0.7 : 0.4}
                    filter={isHL ? `url(#glow-wan-${edge.wanIdx})` : undefined}
                  />
                  {showAnimDots && edge.active && (
                    <circle r="3.5" fill={color} opacity="0.85">
                      <animateMotion dur={`${1.8 + (edge.wanIdx % 5) * 0.3}s`} repeatCount="indefinite"
                        path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
                    </circle>
                  )}
                  {showPingBadges && (() => {
                    const kj = wanJhEdgeMapKey(edge)
                    const sp = speedProbeByWanJhKey.get(kj)
                    const merged = mergeGreMetricsWithSpeedProbe(sp, {
                      pingMs: edge.pingMs,
                      dlMbps: null,
                      ulMbps: null,
                    })
                    const hasMonitor = merged.hasSpeedMonitor
                    if (!hasMonitor && suppressWanJhPingBadge.has(kj)) return null
                    if (hasMonitor && suppressWanJhSpeedBadgeDuplicate.has(kj)) return null
                    if (hasMonitor) {
                      return (
                        <GreEdgeMetricBadge
                          mx={mx}
                          my={my}
                          pingMs={merged.pingMs}
                          dl={merged.dlMbps}
                          ul={merged.ulMbps}
                          onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
                          rttFromMonitor={sp?.lastPingRttMs != null}
                          throughputFromMonitor={
                            sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null
                          }
                        />
                      )
                    }
                    return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
                  })()}
                  {showNetflow && hopHasRate(wanFlow) && (
                    <NetflowRateBadge
                      mx={flowPos.mx}
                      my={flowPos.my}
                      hop={wanFlow}
                      onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
                    />
                  )}
                </g>
              )
            })}

            {/* ── EN → destination services ── */}
            {visibleServiceEdges.map((edge) => {
              const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
              const to = servicePosById[edge.toId]
              if (!from || !to) return null
              const hop: MatchedNetflowHop = {
                bytes: edge.bytes,
                bps: edge.bps,
                bpsFwd: edge.bpsFwd,
                bpsRev: edge.bpsRev,
              }
              const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16)
              const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId
              const svc = visibleMapServices.find((s) => s.id === edge.toId)
              const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
              const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
              const pathTitle = `${clientLabel} → ${enName} → ${svc?.label ?? edge.toId}`
              return (
                <g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
                  <title>{pathTitle}</title>
                  <line
                    x1={from.x} y1={from.y} x2={to.x} y2={to.y}
                    stroke="#22d3ee"
                    strokeWidth={hopHasRate(hop) ? 2.2 : 1.3}
                    strokeDasharray="5 5"
                    opacity="0.7"
                  />
                  {showAnimDots && hopHasRate(hop) && (
                    <circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
                      <animateMotion dur="2.6s" repeatCount="indefinite"
                        path={`M ${from.x} ${from.y} L ${to.x} ${to.y}`} />
                    </circle>
                  )}
                  {hopHasRate(hop) && (
                    <NetflowRateBadge
                      mx={mx}
                      my={my}
                      hop={hop}
                      onOpen={(ev) => {
                        ev.stopPropagation()
                        const svc = visibleMapServices.find((s) => s.id === edge.toId)
                        if (svc) selectService(svc)
                      }}
                    />
                  )}
                </g>
              )
            })}

            {/* ── Server nodes ── */}
            {nodes.map(n => (
              <ServerNode
                key={n.id} n={n}
                isSel={selected?.id === n.id}
                isVis={isVisible(n)}
                isDragged={draggedNodeId === n.id}
                hideCatalogLatency={hideCatalogLatencyByNodeId.has(n.id)}
                onClick={() => {
                  if (suppressClickRef.current) { suppressClickRef.current = false; return }
                  selectServer(n)
                }}
                onDblClick={() => { selectServer(n); focusNode(n.x, n.y) }}
                onHoverChange={h => setHoveredId(h ? n.id : null)}
                onContextMenu={e => setCtxMenu({ x: e.clientX, y: e.clientY, server: n })}
                onMouseDown={e => onNodeMouseDown(e, n)}
              />
            ))}

            {/* ── WAN satellite nodes ── */}
            {homeRouters.flatMap(router => {
              const vis = filter === "all" || filter === "home-router" || filter === "online"
              return (router.wanUplinks ?? []).map((wan, wIdx) => {
                const sat = effectiveSatPos[router.id]?.[wIdx]
                if (!sat) return null
                const color    = WAN_COLORS[wIdx] ?? "#888"
                const isActive = wanJhEdges.filter(e => e.homeId === router.id && e.wanIdx === wIdx).some(e => e.active)
                const isSatSel = selected?.id === router.id && selWanIdx === wIdx
                const satKey   = `${router.id}-${wIdx}`
                return (
                  <g key={`${router.id}-wan-${wIdx}`} opacity={vis ? 1 : 0.06} style={{ transition: "opacity 0.3s" }}>
                    <WanSatNode
                      x={sat.x} y={sat.y} wan={wan} color={color}
                      active={isActive} isSel={isSatSel}
                      isDragged={draggedSatKey === satKey}
                      onSelect={() => {
                        if (suppressClickRef.current) { suppressClickRef.current = false; return }
                        selectWan(router, wIdx)
                      }}
                      onMouseDown={e => onSatMouseDown(e, router.id, wIdx, sat.x, sat.y)}
                    />
                  </g>
                )
              }).filter(Boolean)
            })}

            {visibleMapServices.map((svc) => {
              const pos = servicePosById[svc.id]
              if (!pos) return null
              return (
                <ServiceNode
                  key={svc.id}
                  label={svc.label}
                  share={svc.share}
                  x={pos.x}
                  y={pos.y}
                  isSel={selectedService?.id === svc.id}
                  isVis
                  isDragged={draggedSvcId === svc.id}
                  onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
                  onClick={() => {
                    if (suppressClickRef.current) { suppressClickRef.current = false; return }
                    selectService(svc)
                  }}
                />
              )
            })}

            {/* ── Hover tooltip ── */}
            {hoveredNode && !isDragging && (
              <SvgTooltip n={hoveredNode} />
            )}

            {/* ── Legend (viewport-fixed) ── */}
            <g transform={`translate(${pan.x + 14}, ${pan.y + 14})`}>
              <rect width="140" height="250" rx="8"
                fill="rgba(6,13,26,0.88)" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
              <text x="10" y="22" fontSize="8" fontWeight="700" fill="#64748b"
                fontFamily="system-ui" letterSpacing="0.08em">ЛЕГЕНДА</text>

              {(["online","degraded","offline"] as const).map((s, i) => (
                <g key={s} transform={`translate(10, ${34 + i * 18})`}>
                  <circle cx="5" cy="0" r="4.5" fill={STATUS_STYLE[s].stroke} opacity="0.85" />
                  <text x="16" y="4" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">
                    {s === "online" ? "Онлайн" : s === "degraded" ? "Внимание" : "Оффлайн"}
                  </text>
                </g>
              ))}

              <line x1="10" y1="100" x2="130" y2="100" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />

              {[
                { fill: "#7c3aed", label: "JH", text: "JumpHost"    },
                { fill: "#0369a1", label: "EN", text: "Exit Node"   },
                { fill: "#16a34a", label: "HR", text: "Home Router" },
              ].map((t, i) => (
                <g key={t.label} transform={`translate(10, ${110 + i * 18})`}>
                  <rect width="14" height="14" rx="3" fill={t.fill} />
                  <text x="5.5" y="11" fontSize="6" fontWeight="bold" fill="#fff"
                    textAnchor="middle" fontFamily="system-ui">{t.label}</text>
                  <text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">{t.text}</text>
                </g>
              ))}

              <g transform="translate(10, 164)">
                <rect width="14" height="14" rx="4" fill="#08202c" stroke="#22d3ee" strokeWidth="1.2" />
                <text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">Сервис</text>
              </g>

              <line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />

              <text x="10" y="200" fontSize="7.5" fontWeight="700" fill="#475569"
                fontFamily="system-ui" letterSpacing="0.05em">WAN АПЛИНКИ</text>
              {WAN_COLORS.slice(0, 2).map((c, i) => (
                <g key={i} transform={`translate(10, ${210 + i * 14})`}>
                  <circle cx="5" cy="4" r="4" fill={c} opacity="0.9" />
                  <text x="16" y="8" fontSize="8" fill="#94a3b8" fontFamily="ui-monospace,monospace">
                    WAN{i + 1}
                  </text>
                  <line x1="38" y1="4" x2="60" y2="4" stroke={c}
                    strokeWidth={i === 0 ? 2 : 1.2} strokeDasharray={i === 0 ? "none" : "4 3"} opacity="0.8" />
                  <text x="66" y="8" fontSize="7.5" fill="#475569" fontFamily="system-ui">
                    {i === 0 ? "primary" : "backup"}
                  </text>
                </g>
              ))}
            </g>

            {/* ── Keyboard hints (viewport-fixed) ── */}
            {showHints && (
              <g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + H / zoom - 14})`}>
                {[
                  ["Scroll",   "zoom"],
                  ["+/-",      "zoom in/out"],
                  ["F / 0",    "вписать"],
                  ["M",        "minimap"],
                  ["P",        "ping"],
                  ["Esc",      "снять выбор"],
                  ["2× клик", "навигация"],
                ].reverse().map(([k, l], i) => (
                  <g key={k} transform={`translate(0, ${-(i * 16)})`}>
                    <rect x="-120" y="-12" width="120" height="14" rx="3"
                      fill="rgba(6,13,26,0.82)" />
                    <text x="-60" y="-1" textAnchor="middle" fontSize="7.5"
                      fill="#64748b" fontFamily="system-ui">
                      <tspan fontWeight="600" fill="#94a3b8">{k}</tspan>  {l}
                    </text>
                  </g>
                ))}
              </g>
            )}
          </svg>

          {/* ── Zoom controls ── */}
          <ZoomControls
            zoom={zoom}
            onZoomIn={() => applyZoomCenter(1.3)}
            onZoomOut={() => applyZoomCenter(1 / 1.3)}
            onFit={fitView}
          />

          {/* ── Minimap ── */}
          {showMinimap && (
            <Minimap
              pan={pan} zoom={zoom}
              nodes={nodes}
              greEdges={greEdges}
              satPos={effectiveSatPos}
              wanJhEdges={visibleWanJhEdges}
              homeRouters={homeRouters}
              servicePos={servicePosById}
              onClose={() => setShowMinimap(false)}
              onPan={(x, y) => setPan({ x, y })}
            />
          )}

          {/* ── "Show minimap" button when hidden ── */}
          {!showMinimap && (
            <button
              onClick={() => setShowMinimap(true)}
              className="absolute bottom-16 right-4 z-20 p-2 rounded-lg border border-white/10
                bg-black/70 backdrop-blur-sm text-white/40 hover:text-white/80 transition-colors"
              title="Показать миникарту (M)">
              <MapIcon className="size-3.5" />
            </button>
          )}

          {/* ── Search result count ── */}
          {sq && (
            <div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 px-3 py-1.5 rounded-full
              border border-white/10 bg-black/75 backdrop-blur-sm text-xs font-mono text-white/70">
              {nodes.filter(isVisible).length === 0
                ? "Ничего не найдено"
                : `Найдено: ${nodes.filter(isVisible).length} узл${nodes.filter(isVisible).length === 1 ? "" : nodes.filter(isVisible).length < 5 ? "а" : "ов"}`}
            </div>
          )}
        </div>

        {/* ── Side panel (узел или выбранное GRE-ребро) ── */}
        {(selectedGreEdge || selected || selectedService) && (
          <div className="border-l flex flex-col overflow-hidden shrink-0 bg-background" style={{ width: 300 }}>
            {selectedGreEdge ? (
              <>
                <div className="flex items-start gap-2 px-4 py-3 border-b">
                  <div className="flex-1 min-w-0">
                    <p className="font-mono font-semibold text-sm truncate">{selectedGreEdge.tunnel.name}</p>
                    <p className="text-xs text-muted-foreground font-mono mt-0.5 leading-snug truncate">
                      GRE · {selectedGreEdge.fromServer.name} ({TYPE_LABELS[selectedGreEdge.fromServer.type]}) ↔{" "}
                      {selectedGreEdge.toServer.name} ({TYPE_LABELS[selectedGreEdge.toServer.type]})
                    </p>
                  </div>
                  <div className="flex items-center gap-1 mt-0.5">
                    <button
                      type="button"
                      onClick={() => {
                        const mx = (selectedGreEdge.from.x + selectedGreEdge.to.x) / 2
                        const my = (selectedGreEdge.from.y + selectedGreEdge.to.y) / 2
                        focusNode(mx, my)
                      }}
                      className="text-muted-foreground hover:text-foreground transition-colors"
                      title="Навести на карте">
                      <HomeIcon className="size-3.5" />
                    </button>
                    <button
                      type="button"
                      onClick={() => setSelectedGreEdge(null)}
                      className="text-muted-foreground hover:text-foreground transition-colors">
                      <XIcon className="size-4" />
                    </button>
                  </div>
                </div>
                <div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-5">
                  {(() => {
                    const edge = selectedGreEdge
                    const t = edge.tunnel
                    const selectedEdgeId = greEdgeKey(edge)
                    const localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap)
                    const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap)
                    const baseProbe = greTunnelProbe(t)
                    const spGr = speedProbeByTunnelId.get(selectedEdgeId)
                    const merged = mergeGreMetricsWithSpeedProbe(spGr, baseProbe)
                    const rttMon = spGr?.lastPingRttMs != null
                    const bwMon  = spGr?.lastTxAvgMbps != null || spGr?.lastRxAvgMbps != null
                    const ts = TUNNEL_STYLE[t.status]
                    const tunnelStatusLabel =
                      t.status === "up" ? "Работает" : t.status === "degraded" ? "Деградация" : "Недоступен"
                    const tunnelStatusBadge =
                      t.status === "up"
                        ? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)]"
                        : t.status === "degraded"
                          ? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)]"
                          : "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)]"
                    return (
                      <>
                        <div className="flex flex-wrap gap-2">
                          <span
                            className={cn(
                              "inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium",
                              tunnelStatusBadge,
                            )}
                          >
                            <StatusDot status={greStatusToServerStatus(t.status)} />
                            {tunnelStatusLabel}
                          </span>
                          <span
                            className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium
                              bg-amber-950/40 text-amber-400 border border-amber-500/20"
                          >
                            GRE-туннель
                          </span>
                        </div>

                        <div>
                          <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
                            Метрики на карте
                          </p>
                          <div className="flex flex-col gap-0">
                            <div className="flex items-center justify-between py-2 border-b border-border/50">
                              <span className="text-xs text-muted-foreground">
                                {rttMon ? "Мониторинг (ping/BT)" : "Модель RTT"}
                              </span>
                              <span
                                className="text-xs font-mono font-semibold"
                                style={{ color: pingColor(merged.pingMs) }}
                              >
                                {merged.pingMs == null ? "—" : `${merged.pingMs} мс`}
                              </span>
                            </div>
                            <div className="flex items-center justify-between py-2 border-b border-border/50">
                              <span className="text-xs text-muted-foreground">
                                {bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"}
                              </span>
                              <span className="text-xs font-mono font-semibold text-sky-400">
                                {merged.dlMbps != null && merged.ulMbps != null
                                  ? `↓${merged.dlMbps} ↑${merged.ulMbps}`
                                  : "—"}
                              </span>
                            </div>
                            <div className="flex items-center justify-between py-2 border-b border-border/50">
                              <span className="text-xs text-muted-foreground">Поток (NetFlow)</span>
                              <span className="text-xs font-mono font-semibold text-emerald-400">
                                {(() => {
                                  const hop = netflowByGreKey.get(selectedEdgeId)
                                  if (!hopHasRate(hop)) return "—"
                                  return hop.bpsFwd > 0 && hop.bpsRev > 0
                                    ? formatNetflowDir(hop)
                                    : formatNetflowRate(hop)
                                })()}
                              </span>
                            </div>
                            <p className="text-[10px] text-muted-foreground pt-2 leading-snug">
                              {merged.hasSpeedMonitor
                                ? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. "
                                : "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "}
                              Поток — живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
                            </p>
                          </div>
                        </div>

                        <div className="flex flex-col gap-0">
                          <div className="flex items-center justify-between py-2 border-b border-border/50">
                            <span className="text-xs text-muted-foreground">Статус туннеля</span>
                            <span className="text-xs font-mono font-medium" style={{ color: ts.stroke }}>{t.status}</span>
                          </div>
                          <div className="flex items-center justify-between py-2 border-b border-border/50">
                            <span className="text-xs text-muted-foreground">На узле</span>
                            <span className="text-xs font-mono font-medium">{edge.fromServer.name}</span>
                          </div>
                          <div className="flex items-center justify-between py-2 border-b border-border/50">
                            <span className="text-xs text-muted-foreground">Пир (каталог)</span>
                            <span className="text-xs font-mono font-medium">{edge.toServer.name}</span>
                          </div>
                          <div className="flex items-center justify-between py-2 border-b border-border/50">
                            <span className="text-xs text-muted-foreground">Включён</span>
                            <span className="text-xs font-mono font-medium">{t.enabled ? "да" : "нет"}</span>
                          </div>
                          <div className="flex items-center justify-between py-2">
                            <span className="text-xs text-muted-foreground">IPsec</span>
                            <span className="text-xs font-mono font-medium flex items-center gap-1 justify-end">
                              {t.ipsec ? (
                                <><LockIcon className="size-3 text-emerald-400 shrink-0" /> да</>
                              ) : (
                                <><LockOpenIcon className="size-3 shrink-0" /> нет</>
                              )}
                            </span>
                          </div>
                        </div>

                        <div>
                          <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
                            Адреса и параметры
                          </p>
                          <div className="flex flex-col gap-0">
                            <div className="flex items-center justify-between py-2 border-b border-border/50">
                              <span className="text-xs text-muted-foreground">Local outer</span>
                              <span className="text-xs font-mono font-medium text-right break-all max-w-[62%]">
                                {t.localAddress || "—"}
                              </span>
                            </div>
                            {localWanName && (
                              <div className="flex items-center justify-between py-2 border-b border-border/50">
                                <span className="text-[10px] text-muted-foreground">WAN на узле</span>
                                <span className="text-[10px] font-mono font-medium">{localWanName}</span>
                              </div>
                            )}
                            <div className="flex items-center justify-between py-2 border-b border-border/50">
                              <span className="text-xs text-muted-foreground">Remote outer</span>
                              <span className="text-xs font-mono font-medium text-right break-all max-w-[62%]">
                                {t.remoteAddress || "—"}
                              </span>
                            </div>
                            {remoteWanName && (
                              <div className="flex items-center justify-between py-2 border-b border-border/50">
                                <span className="text-[10px] text-muted-foreground">WAN у пира</span>
                                <span className="text-[10px] font-mono font-medium">{remoteWanName}</span>
                              </div>
                            )}
                            <div className="flex items-center justify-between py-2 border-b border-border/50">
                              <span className="text-xs text-muted-foreground">Inner</span>
                              <span className="text-xs font-mono font-medium text-right">
                                {t.localInnerIp} ↔ {t.remoteInnerIp}
                              </span>
                            </div>
                            <div className="flex items-center justify-between py-2">
                              <span className="text-xs text-muted-foreground">MTU / Keepalive</span>
                              <span className="text-xs font-mono font-medium">
                                {t.mtu} /{" "}
                                {t.keepaliveInterval === 0 ? "выкл." : `${t.keepaliveInterval}s×${t.keepaliveRetries}`}
                              </span>
                            </div>
                          </div>
                        </div>

                        <Link
                          href="/gre"
                          className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-dashed border-border/60
                            text-xs text-muted-foreground hover:text-foreground hover:border-border transition-colors"
                        >
                          Открыть раздел GRE-туннелей
                        </Link>
                      </>
                    )
                  })()}
                </div>
              </>
            ) : selectedService ? (
              <>
                <div className="flex items-start gap-2 px-4 py-3 border-b">
                  <div className="mt-0.5">
                    <ServiceBrandIcon label={selectedService.label} size={22} />
                  </div>
                  <div className="flex-1 min-w-0">
                    <p className="font-mono font-semibold text-sm truncate">{selectedService.label}</p>
                    <p className="text-xs text-muted-foreground mt-0.5">
                      Конечный сервис · {selectedService.category}
                    </p>
                  </div>
                  <button
                    type="button"
                    onClick={() => setSelectedService(null)}
                    className="text-muted-foreground hover:text-foreground transition-colors"
                  >
                    <XIcon className="size-4" />
                  </button>
                </div>
                <div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-4">
                  <div className="flex flex-col gap-0">
                    <div className="flex items-center justify-between py-2 border-b border-border/50">
                      <span className="text-xs text-muted-foreground">Доля окна</span>
                      <span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(selectedService.share)}</span>
                    </div>
                    <div className="flex items-center justify-between py-2 border-b border-border/50">
                      <span className="text-xs text-muted-foreground">Скорость</span>
                      <span className="text-xs font-mono font-medium">
                        {formatNetflowRate({
                          bytes: selectedService.bytes,
                          bps: selectedService.bps,
                          bpsFwd: selectedService.bps,
                          bpsRev: 0,
                        })}
                      </span>
                    </div>
                  </div>
                  <div>
                    <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
                    <div className="flex flex-col gap-1.5">
                      {visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
                        const src = mapServers.find((s) => s.id === e.fromId)
                        return (
                          <div key={`${e.fromId}|${e.toId}`} className="flex items-center justify-between text-xs">
                            <span className="font-mono truncate">{src?.name ?? e.fromId}</span>
                            <span className="font-mono text-emerald-400 tabular-nums">
                              {formatNetflowRate({ bytes: e.bytes, bps: e.bps, bpsFwd: e.bpsFwd, bpsRev: e.bpsRev })}
                            </span>
                          </div>
                        )
                      })}
                    </div>
                  </div>
                  <div>
                    <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Клиенты</p>
                    <div className="flex flex-col gap-1.5">
                      {(() => {
                        const names = new Map<string, string>()
                        for (const e of visibleServiceEdges.filter((x) => x.toId === selectedService.id)) {
                          if (e.clients?.length) {
                            for (const c of e.clients) names.set(c.id, c.name)
                          } else if (e.clientName) {
                            names.set(e.clientId ?? e.clientName, e.clientName)
                          }
                        }
                        if (names.size === 0) {
                          return <p className="text-xs text-muted-foreground">—</p>
                        }
                        return [...names.values()].map((name) => (
                          <p key={name} className="text-xs font-mono truncate">{name}</p>
                        ))
                      })()}
                    </div>
                  </div>
                </div>
              </>
            ) : selected ? (
              <>
            <div className="flex items-start gap-2 px-4 py-3 border-b">
              <div className="flex-1 min-w-0">
                <p className="font-mono font-semibold text-sm truncate">{selected.name}</p>
                <p className="text-xs text-muted-foreground font-mono mt-0.5">{selected.host}</p>
              </div>
              <div className="flex items-center gap-1 mt-0.5">
                <button
                  onClick={() => { const n = nodes.find(x => x.id === selected.id); if (n) focusNode(n.x, n.y) }}
                  className="text-muted-foreground hover:text-foreground transition-colors"
                  title="Навести на карте">
                  <HomeIcon className="size-3.5" />
                </button>
                <button onClick={() => { setSelected(null); setSelWanIdx(null) }}
                  className="text-muted-foreground hover:text-foreground transition-colors">
                  <XIcon className="size-4" />
                </button>
              </div>
            </div>

            <div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-5">
              {/* Badges */}
              <div className="flex flex-wrap gap-2">
                <StatusBadge status={selected.status} />
                <span className={cn(
                  "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
                  selected.type === "jump-host"   ? "bg-violet-950/40 text-violet-400" :
                  selected.type === "home-router" ? "bg-emerald-950/40 text-emerald-400" :
                  "bg-sky-950/40 text-sky-400",
                )}>
                  {TYPE_STYLE[selected.type].label} · {TYPE_LABELS[selected.type]}
                </span>
              </div>

              {/* Info rows */}
              <div className="flex flex-col gap-0">
                {[
                  ["Площадка",  selected.site],
                  ["Модель",    selected.model],
                  ["RouterOS",  selected.os],
                  ...(selected.type !== "home-router"
                    ? [["ASN", selected.asn], ["Сессий", String(selected.sessions)]]
                    : [["LAN", selected.lanSubnet ?? "—"]]),
                ].map(([k, v]) => (
                  <div key={k} className="flex items-center justify-between py-2 border-b border-border/50 last:border-0">
                    <span className="text-xs text-muted-foreground">{k}</span>
                    <span className="text-xs font-mono font-medium">{v}</span>
                  </div>
                ))}
                <div className="flex items-center justify-between py-2">
                  <span className="text-xs text-muted-foreground">Задержка</span>
                  <span className="text-xs font-mono font-semibold" style={{ color: latencyColor(selected.latency) }}>
                    {selected.latency == null ? "— офлайн" : `${selected.latency} мс`}
                  </span>
                </div>
              </div>

              {/* WAN uplinks */}
              {selected.type === "home-router" && selected.wanUplinks && (
                <div>
                  <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
                    WAN-аплинки ({selected.wanUplinks.length})
                  </p>
                  <div className="flex flex-col gap-2">
                    {selected.wanUplinks.map((wan, wIdx) => {
                      const color    = WAN_COLORS[wIdx] ?? "#888"
                      const myEdges  = wanJhEdges.filter(e => e.homeId === selected.id && e.wanIdx === wIdx)
                      const isActive = myEdges.some(e => e.active)
                      const isSel    = selWanIdx === wIdx
                      return (
                        <div key={wan.id}
                          onClick={() => setSelWanIdx(prev => prev === wIdx ? null : wIdx)}
                          className={cn(
                            "rounded-lg border p-3 cursor-pointer transition-all",
                            isSel ? "bg-muted/60" : "bg-muted/20 hover:bg-muted/40",
                          )}
                          style={{ borderColor: isSel ? color : undefined }}>
                          <div className="flex items-center gap-2 mb-2">
                            <WifiIcon className="size-3.5 shrink-0" style={{ color }} />
                            <span className="font-mono text-xs font-bold" style={{ color }}>{wan.name}</span>
                            <span className={cn(
                              "ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full",
                              isActive ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
                            )}>
                              {isActive ? "primary" : "backup"}
                            </span>
                          </div>
                          <div className="flex flex-col gap-1">
                            {[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip],
                              ["Канал", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => (
                              <div key={k} className="flex items-center justify-between">
                                <span className="text-[10px] text-muted-foreground">{k}</span>
                                <span className="text-[10px] font-mono">{v}</span>
                              </div>
                            ))}
                            {(() => {
                              const hop = netflowByWanKey.get(`${selected.id}\t${wIdx}`)
                              if (!hopHasRate(hop)) return null
                              return (
                                <div className="flex items-center justify-between">
                                  <span className="text-[10px] text-muted-foreground">Поток</span>
                                  <span className="text-[10px] font-mono text-emerald-400">
                                    {hop.bpsFwd > 0 && hop.bpsRev > 0
                                      ? formatNetflowDir(hop)
                                      : formatNetflowRate(hop)}
                                  </span>
                                </div>
                              )
                            })()}
                          </div>
                          {myEdges.length > 0 && (
                            <div className="mt-2 pt-2 border-t border-border/40">
                              <p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5">
                                Подключения к JH
                              </p>
                              {myEdges.map((e) => {
                                const jh = mapServers.find(s => s.id === e.jhId)
                                const edgeKey = `${e.homeId}-${e.wanIdx}-${e.jhId}`
                                return (
                                  <div key={edgeKey} className="flex items-center justify-between py-0.5">
                                    <span className="text-[10px] font-mono text-muted-foreground truncate">
                                      → {jh?.name ?? e.jhId}
                                    </span>
                                    <span className="text-[10px] font-mono font-semibold shrink-0 ml-2"
                                      style={{ color: pingColor(e.pingMs) }}>
                                      {e.pingMs == null ? "—" : `${e.pingMs} мс`}
                                    </span>
                                  </div>
                                )
                              })}
                            </div>
                          )}
                        </div>
                      )
                    })}
                  </div>
                </div>
              )}

              {/* GRE tunnels */}
              {connectedTunnels.length > 0 && (
                <div>
                  <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
                    GRE-туннели ({connectedTunnels.length})
                  </p>
                  <div className="flex flex-col gap-2">
                    {connectedTunnels.map(t => {
                      const ts    = TUNNEL_STYLE[t.status]
                      const peer: Server | undefined =
                        t.serverId === selected.id
                          ? findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
                          : mapServers.find((s) => s.id === t.serverId)
                      const fromServer = mapServers.find((s) => s.id === t.serverId)
                      const toServer   = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
                      const tunnelPanelKey =
                        fromServer && toServer
                          ? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}`
                          : `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}`
                      const baseProbe  = greTunnelProbe(t)
                      const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress)
                      const isPrimary = grePrimaryByGroupKey.get(groupKey) === t.id
                      const spGre =
                        fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
                      const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
                      const pc     = pingColor(merged.pingMs)
                      const greFlow = netflowByGreKey.get(tunnelPanelKey)
                      const showMetrics =
                        merged.pingMs != null ||
                        (merged.dlMbps != null && merged.ulMbps != null) ||
                        hopHasRate(greFlow)
                      return (
                        <div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
                          <div className="flex items-center justify-between mb-1">
                            <span className="text-xs font-mono font-medium">{t.name}</span>
                            <div className="flex items-center gap-2">
                              <span className={cn(
                                "text-[10px] font-medium px-1.5 py-0.5 rounded-full",
                                isPrimary ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
                              )}>
                                {isPrimary ? "primary" : "backup"}
                              </span>
                              <span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
                                {t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
                              </span>
                            </div>
                          </div>
                          <div className="flex items-center justify-between mb-1">
                            <span className="text-[10px] text-muted-foreground font-mono truncate max-w-[200px]">
                              → {peer ? `${peer.name} · ${peer.site}` : t.remoteAddress}
                            </span>
                            {t.ipsec
                              ? <span className="text-[10px] text-emerald-400 flex items-center gap-0.5"><LockIcon className="size-2.5" />IPsec</span>
                              : <span className="text-[10px] text-muted-foreground flex items-center gap-0.5"><LockOpenIcon className="size-2.5" />Plain</span>}
                          </div>
                          {showMetrics && (
                            <div className="flex items-center justify-between pt-1 border-t border-border/30">
                              <span className="text-[9px] text-muted-foreground">
                                {merged.hasSpeedMonitor ? "Мониторинг (ping / TX·RX)" : "Модель ping / speed"}
                              </span>
                              <span className="text-[10px] font-mono" style={{ color: pc }}>
                                {merged.pingMs != null ? `${merged.pingMs} мс` : "—"}
                                {merged.dlMbps != null && merged.ulMbps != null && (
                                  <span className={cn(
                                    "text-[9px] ml-1.5",
                                    merged.hasSpeedMonitor ? "text-sky-400/90" : "text-muted-foreground",
                                  )}>
                                    ↓{merged.dlMbps} ↑{merged.ulMbps}
                                  </span>
                                )}
                                {hopHasRate(greFlow) && (
                                  <span className="text-[9px] ml-1.5 text-emerald-400">
                                    {formatNetflowRate(greFlow)}
                                  </span>
                                )}
                              </span>
                            </div>
                          )}
                        </div>
                      )
                    })}
                  </div>
                </div>
              )}

              {/* Resources */}
              {(() => {
                const res = srvResMap[selected.id]
                if (!res) return null
                const cpuPct = res.cpu
                const ramPct = Math.round(res.ramUsed / res.ramTotal * 100)
                const hddPct = Math.round(res.hddUsed / res.hddTotal * 100)
                return (
                  <div>
                    <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">
                      Ресурсы
                    </p>
                    {!res.fromPoll && (
                      <p className="text-[9px] text-muted-foreground mb-3 leading-snug">
                        Нет снапшота опроса — показаны демо-значения. Включите опрос серверов или откройте «Серверы».
                      </p>
                    )}
                    {res.fromPoll && (
                      <p className="text-[9px] text-muted-foreground mb-3 leading-snug">
                        CPU, RAM и uptime — с последнего poll MikroTik. Диск в опросе не передаётся, шкала условная.
                      </p>
                    )}

                    {/* CPU with sparkline */}
                    <div className="mb-3">
                      <div className="flex items-center justify-between mb-1">
                        <span className="text-[10px] text-muted-foreground">CPU</span>
                        <span className={cn("text-[10px] font-mono font-semibold", resPctColor(cpuPct))}>
                          {cpuPct}%
                        </span>
                      </div>
                      <div className="flex items-center gap-2">
                        <MiniBar pct={cpuPct} />
                        <Sparkline history={res.cpuHistory} />
                      </div>
                    </div>

                    {/* RAM */}
                    <div className="mb-3">
                      <div className="flex items-center justify-between mb-1">
                        <span className="text-[10px] text-muted-foreground">RAM</span>
                        <span className={cn("text-[10px] font-mono font-semibold", resPctColor(ramPct))}>
                          {fmtMB(res.ramUsed)} / {fmtMB(res.ramTotal)}
                        </span>
                      </div>
                      <MiniBar pct={ramPct} />
                    </div>

                    {/* HDD (нет в GET /api/servers — всегда условная шкала) */}
                    <div className="mb-3">
                      <div className="flex items-center justify-between mb-1">
                        <span className="text-[10px] text-muted-foreground">
                          HDD
                          {res.fromPoll && (
                            <span className="text-[8px] font-normal text-muted-foreground/70 ml-1">(оценка)</span>
                          )}
                        </span>
                        <span className="text-[10px] font-mono text-muted-foreground">
                          {fmtMB(res.hddUsed)} / {fmtMB(res.hddTotal)}
                        </span>
                      </div>
                      <MiniBar pct={hddPct} />
                    </div>

                    {/* Misc metrics */}
                    <div className="flex flex-col gap-0">
                      <div className="flex items-center justify-between py-1.5 border-b border-border/40">
                        <span className="text-[10px] text-muted-foreground">Uptime</span>
                        <span className="text-[10px] font-mono">{fmtUptime(res.uptimeSeconds)}</span>
                      </div>
                      {res.temp != null && (
                        <div className="flex items-center justify-between py-1.5 border-b border-border/40">
                          <span className="text-[10px] text-muted-foreground">Температура</span>
                          <span className={cn(
                            "text-[10px] font-mono font-semibold",
                            res.temp >= 70 ? "text-red-400" : res.temp >= 55 ? "text-amber-400" : "text-emerald-400",
                          )}>
                            {res.temp}°C
                          </span>
                        </div>
                      )}
                      <div className="flex items-center justify-between py-1.5">
                        <span className="text-[10px] text-muted-foreground">Плата</span>
                        <span className="text-[10px] font-mono">{selected.model || "—"}</span>
                      </div>
                    </div>
                  </div>
                )
              })()}

              {/* Navigate hint */}
              <button
                onClick={() => { const n = nodes.find(x => x.id === selected.id); if (n) focusNode(n.x, n.y) }}
                className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-dashed border-border/60
                  text-xs text-muted-foreground hover:text-foreground hover:border-border transition-colors">
                <HomeIcon className="size-3" />
                Навести карту на узел
              </button>
            </div>
              </>
            ) : null}
          </div>
        )}
      </div>

      {/* ── Context menu ── */}
      {ctxMenu && <ContextMenu menu={ctxMenu} onClose={() => setCtxMenu(null)} />}
    </div>
  )
}
