import { Component, Suspense, lazy, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import {
  forceCenter,
  forceCollide,
  forceLink,
  forceManyBody,
  forceSimulation,
  type Simulation,
  type SimulationNodeDatum,
  type SimulationLinkDatum,
} from 'd3-force'
import { useDictionaries, dictionaryDependentsQuery, recordsQuery } from '@/api/queries'
import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
import { GlobeLoader, LoadingBlock, QueryErrorState } from '@/ui'
import { cn } from '@/lib/utils'

// 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не
// попадают в default chunk. Vite автоматически создаёт отдельный chunk для
// dynamic import. Suspense fallback показывается во время загрузки модуля.
const DictionaryGraph3D = lazy(() => import('./DictionaryGraph3D'))

type GraphMode = '2d' | '3d'

/**
 * Scoped error boundary для 3D Suspense — three.js'у нужен WebGL, и если
 * браузер его не поддерживает (corp VM, headless без --use-gl=*, hardware
 * accel выключен) — `WebGLRenderer` бросает ошибку в render-цикле. Без
 * этого ErrorBoundary fallback'a ошибка bubble'ит до AppErrorBoundary и
 * вся page становится "Что-то пошло не так". Здесь мы её ловим локально,
 * показываем friendly fallback + предлагаем вернуться в 2D режим.
 *
 * `onError` callback позволяет parent auto-reset'ить mode → '2d', чтобы
 * пользователь не залипал на ошибочном состоянии. Recovery через
 * componentDidUpdate (resetKey changes → state.hasError = false).
 */
class Graph3DErrorBoundary extends Component<
  { children: ReactNode; onError: () => void; resetKey: number; fallback: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false }
  static getDerivedStateFromError() {
    return { hasError: true }
  }
  componentDidCatch() {
    // Auto-notify parent (will switch mode → '2d') чтобы next render не
    // пытался монтировать 3D снова. Defer через setTimeout — синхронный
    // setState из catch ломает React reconciliation.
    setTimeout(() => this.props.onError(), 0)
  }
  componentDidUpdate(prev: { resetKey: number }) {
    if (prev.resetKey !== this.props.resetKey && this.state.hasError) {
      this.setState({ hasError: false })
    }
  }
  render() {
    if (this.state.hasError) return this.props.fallback
    return this.props.children
  }
}

/**
 * Граф связей справочников — d3-force network diagram.
 *
 * Nodes: справочники (color по bundle, size по recordCount).
 * Edges: incoming FK refs (от useDictionaryDependents).
 *
 * Layout: d3-force simulation
 *  - forceLink — keeps connected nodes близко
 *  - forceManyBody (charge) — отталкивает несоседних узлов
 *  - forceCenter — anchored к (w/2, h/2)
 *  - forceCollide — prevent overlap node circles
 *
 * Interaction:
 *  - Hover node → highlight node + connected edges + neighbors
 *  - Click node → navigate /dictionaries/$name
 *  - Drag node — temp pin position, click empty space → unpin all
 *
 * Performance: 40+ nodes SVG rendering OK. Above ~200 — switch на canvas.
 */

type GraphNode = SimulationNodeDatum & {
  id: string
  name: string
  displayName: string
  bundle: string
  scope: string
  recordCount: number
}

type GraphLink = SimulationLinkDatum<GraphNode> & {
  sourceField: string
  targetField: string
}

const BUNDLE_COLORS: Record<string, string> = {
  cuod: '#b05a2e',     // accent (terracotta)
  geo: '#4f7038',      // green
  comms: '#a8762a',    // warn
  delivery: '#3a2818', // navy
  default: '#52483d',  // ink-2
}

// colorForBundle helper больше не используется — карточки рендерят
// planet-orbs через radial gradients (defs id=planet-{bundle}), а легенда
// читает BUNDLE_COLORS напрямую через Object.entries.

export function DictionaryGraph() {
  const dictsQuery = useDictionaries()
  const dicts = dictsQuery.data ?? []
  // localStorage persist чтобы preference сохранился между переходами.
  // SSR-safe guard на window — vitest setup использует jsdom где window есть,
  // но defensive чтобы не упасть на SSR в будущем.
  const [mode, setMode] = useState<GraphMode>(() => {
    if (typeof window === 'undefined') return '2d'
    return (localStorage.getItem('graph.mode') as GraphMode) === '3d' ? '3d' : '2d'
  })
  useEffect(() => {
    if (typeof window !== 'undefined') localStorage.setItem('graph.mode', mode)
  }, [mode])

  // Fetch dependents для всех dicts параллельно (cached между mounts).
  const dependentsResults = useQueries({
    queries: dicts.map((d) => ({
      ...dictionaryDependentsQuery(d.name),
      enabled: Boolean(d.name),
    })),
  })

  const dependentsByDict = useMemo(() => {
    const m = new Map<string, SchemaDependent[]>()
    dicts.forEach((d, i) => {
      m.set(d.name, dependentsResults[i]?.data ?? [])
    })
    return m
  }, [dicts, dependentsResults])

  // Records counts per dict — backend /dictionaries listing НЕ возвращает
  // recordCount field (см. catalog/dictionaries.index.tsx, тот же подход).
  // Fetch records per dict через useQueries; TanStack Query кэширует между
  // catalog ↔ graph переходами, так что если user уже видел catalog —
  // counts уже в cache, мгновенно. Иначе 37 параллельных queries на mount.
  const recordsResults = useQueries({
    queries: dicts.map((d) => ({
      ...recordsQuery(d.name, 'PUBLIC,INTERNAL,RESTRICTED'),
      enabled: Boolean(d.name),
    })),
  })

  const recordCountByDict = useMemo(() => {
    const m = new Map<string, number>()
    dicts.forEach((d, i) => {
      const records = recordsResults[i]?.data
      if (Array.isArray(records)) m.set(d.name, records.length)
    })
    return m
  }, [dicts, recordsResults])

  const allDependentsLoading = dependentsResults.some((r) => r.isLoading)
  const allRecordsLoading = recordsResults.some((r) => r.isLoading)

  if (dictsQuery.isLoading) {
    return <LoadingBlock size="lg" label="Загрузка справочников…" />
  }

  if (dictsQuery.error) {
    return <QueryErrorState error={dictsQuery.error} onRetry={() => dictsQuery.refetch()} />
  }

  if (dicts.length === 0) {
    return <div className="text-body text-mute">Справочники не найдены.</div>
  }

  return (
    <GraphCanvas
      dicts={dicts}
      dependentsByDict={dependentsByDict}
      recordCountByDict={recordCountByDict}
      depsLoading={allDependentsLoading || allRecordsLoading}
      mode={mode}
      onModeChange={setMode}
    />
  )
}

function GraphCanvas({
  dicts,
  dependentsByDict,
  recordCountByDict,
  depsLoading,
  mode,
  onModeChange,
}: {
  dicts: DictionaryDefinition[]
  dependentsByDict: Map<string, SchemaDependent[]>
  recordCountByDict: Map<string, number>
  depsLoading: boolean
  mode: GraphMode
  onModeChange: (m: GraphMode) => void
}) {
  // WebGL-fallback banner — показывается несколько секунд после авто-возврата
  // в 2D из-за ошибки 3D renderer'а (нет WebGL / hardware accel выключено).
  const [webglFallbackNotice, setWebglFallbackNotice] = useState(false)
  const navigate = useNavigate()
  const wrapperRef = useRef<HTMLDivElement>(null)
  const svgRef = useRef<SVGSVGElement>(null)
  const [size, setSize] = useState({ width: 800, height: 600 })
  const [hoveredId, setHoveredId] = useState<string | null>(null)
  // Zoom/pan state — управляется wheel events + mouse drag на empty space.
  // Transform применяется к <g> wrapper'у, эффективно scaling всех children.
  const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
  const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null)
  // Force-rerender tick. Simulation .on('tick') увеличивает это значение,
  // React видит state change → перерисовывает SVG с новыми node.x/y. Не
  // читаем value напрямую, только инкрементируем — eslint complains, поэтому
  // используем void.
  const [, setTick] = useState(0)
  const simulationRef = useRef<Simulation<GraphNode, GraphLink> | null>(null)
  const nodesRef = useRef<GraphNode[]>([])
  const linksRef = useRef<GraphLink[]>([])

  // Build initial graph data. recordCount приходит из per-dict useRecords
  // (см. parent component DictionaryGraph) — backend /dictionaries listing
  // не отдаёт count, поэтому fetch'аем records и берём .length.
  const { nodes, links } = useMemo(() => {
    const nodes: GraphNode[] = dicts.map((d) => ({
      id: d.name,
      name: d.name,
      displayName: d.displayName ?? d.name,
      bundle: d.bundle,
      scope: d.scope,
      recordCount: recordCountByDict.get(d.name) ?? d.recordCount ?? 0,
    }))

    const links: GraphLink[] = []
    dicts.forEach((target) => {
      const deps = dependentsByDict.get(target.name) ?? []
      deps.forEach((dep) => {
        // Edge: source dict → target dict (source has FK к target).
        if (nodes.find((n) => n.id === dep.sourceDict)) {
          links.push({
            source: dep.sourceDict,
            target: target.name,
            sourceField: dep.sourceField,
            targetField: dep.targetField,
          })
        }
      })
    })

    return { nodes, links }
  }, [dicts, dependentsByDict, recordCountByDict])

  // Responsive sizing.
  useEffect(() => {
    const el = wrapperRef.current
    if (!el) return
    const ro = new ResizeObserver(() => {
      setSize({
        width: el.clientWidth,
        height: Math.max(el.clientHeight, 500),
      })
    })
    ro.observe(el)
    return () => ro.disconnect()
  }, [])

  // Init simulation when nodes/links change.
  useEffect(() => {
    if (nodes.length === 0) return
    nodesRef.current = nodes.map((n) => ({ ...n }))
    linksRef.current = links.map((l) => ({ ...l }))

    // Force tuning под card geometry (W=200, H=56).
    //  - link distance 180 — расстояние центров двух соединённых cards;
    //    с шириной 200 это лёгкое касание краёв (визуально связные).
    //  - charge -540 — умеренное repulsion для 200px nodes; orphan nodes
    //    рассыпаются вокруг центра, не убегают за viewport.
    //  - collide radius 110 — halfW 100 + 10px padding.
    const sim = forceSimulation<GraphNode>(nodesRef.current)
      .force(
        'link',
        forceLink<GraphNode, GraphLink>(linksRef.current)
          .id((d) => d.id)
          .distance(180)
          .strength(0.4),
      )
      .force('charge', forceManyBody().strength(-540))
      .force('center', forceCenter(size.width / 2, size.height / 2))
      .force('collide', forceCollide<GraphNode>().radius(110).strength(0.9))
      .alpha(1)
      .alphaDecay(0.025)
      .on('tick', () => setTick((t) => (t + 1) % 1_000_000))

    simulationRef.current = sim
    return () => {
      sim.stop()
    }
  }, [nodes, links, size.width, size.height])

  // Update center force on resize without re-init.
  useEffect(() => {
    const sim = simulationRef.current
    if (!sim) return
    sim.force('center', forceCenter(size.width / 2, size.height / 2))
    sim.alpha(0.3).restart()
  }, [size])

  const connectedIds = useMemo(() => {
    if (!hoveredId) return new Set<string>()
    const out = new Set<string>([hoveredId])
    linksRef.current.forEach((l) => {
      const s = typeof l.source === 'string' ? l.source : (l.source as GraphNode).id
      const t = typeof l.target === 'string' ? l.target : (l.target as GraphNode).id
      if (s === hoveredId) out.add(t)
      if (t === hoveredId) out.add(s)
    })
    return out
  }, [hoveredId])

  // Космос — фон graph container'а: глубокий радиальный градиент от deep
  // navy в центре к чёрному по краям + starfield 120 точек поверх. Stars
  // мемоизированы через useMemo чтобы случайные позиции были stable между
  // re-renders'ами (иначе звёзды бы мерцали при каждом tick simulation).
  // Звёзды distribuируются на 2000×2000 виртуальном poле — больше viewport,
  // чтобы pan'инг показывал новые звёзды а не пустоту по краям.
  const starfield = useMemo(() => {
    const pts: Array<{ x: number; y: number; r: number; o: number }> = []
    const seed = 137
    let s = seed
    const rand = () => {
      // Простой LCG — детерминированный без библиотеки seedrandom.
      s = (s * 1103515245 + 12345) & 0x7fffffff
      return s / 0x7fffffff
    }
    for (let i = 0; i < 140; i++) {
      const isLarge = rand() > 0.85
      pts.push({
        x: rand() * 2400 - 200,
        y: rand() * 1800 - 200,
        r: isLarge ? 1.2 + rand() * 0.8 : 0.4 + rand() * 0.6,
        o: 0.3 + rand() * 0.6,
      })
    }
    return pts
  }, [])

  return (
    <div
      ref={wrapperRef}
      className="relative w-full h-[calc(100vh-220px)] min-h-[500px] rounded-lg border border-line overflow-hidden"
      style={{
        // Космос — radial gradient deep navy → near-black, плюс subtle
        // accent halo в центре (намёк на star/sun без отвлекающего диска).
        background:
          'radial-gradient(ellipse at 40% 35%, #1a1742 0%, #0c0a23 45%, #060512 90%)',
      }}
    >
      {depsLoading && (
        <div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
          <div className="flex flex-col items-center gap-3 bg-[#1c1840]/85 backdrop-blur border border-[#3d3863] rounded-lg px-6 py-5 shadow-2xl">
            <GlobeLoader size={56} />
            <span className="text-mono text-cap uppercase tracking-wider text-[#a09bc8]">
              Считаем планеты и связи…
            </span>
          </div>
        </div>
      )}
      {/* Controls overlay — top-right. Stack из 2D/3D toggle + zoom (только в 2D).
        * Космос-стиль: dark glass panels с violet-tint border, accent (warm
        * orange) для active state. */}
      <div className="absolute top-2 right-2 z-10 flex flex-col items-end gap-1">
        {/* 2D / 3D mode toggle */}
        <div className="inline-flex items-center rounded-md border border-[#3d3863] bg-[#1c1840]/85 backdrop-blur p-0.5 shadow-lg">
          <button
            type="button"
            onClick={() => onModeChange('2d')}
            aria-pressed={mode === '2d'}
            title="2D режим — SVG force-directed layout"
            className={cn(
              'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
              mode === '2d'
                ? 'bg-[#ff9a64] text-[#1c1840]'
                : 'text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40',
            )}
          >
            2D
          </button>
          <button
            type="button"
            onClick={() => onModeChange('3d')}
            aria-pressed={mode === '3d'}
            title="3D режим — WebGL three.js (camera orbit)"
            className={cn(
              'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
              mode === '3d'
                ? 'bg-[#ff9a64] text-[#1c1840]'
                : 'text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40',
            )}
          >
            3D
          </button>
        </div>

        {/* Zoom controls — только в 2D, в 3D camera orbit имеет свой wheel-zoom. */}
        {mode === '2d' && (
          <div className="inline-flex items-center gap-1 rounded-md border border-[#3d3863] bg-[#1c1840]/85 backdrop-blur p-0.5 shadow-lg">
            <button
              type="button"
              onClick={() =>
                setView((v) => ({ ...v, zoom: Math.min(4, v.zoom * 1.25) }))
              }
              aria-label="Zoom in"
              title="Zoom in (+)"
              className="size-7 inline-flex items-center justify-center text-[#c5bff0] hover:text-[#f0eaff] hover:bg-[#3d3863]/40 rounded-sm transition-colors"
            >
              +
            </button>
            <button
              type="button"
              onClick={() =>
                setView((v) => ({ ...v, zoom: Math.max(0.2, v.zoom / 1.25) }))
              }
              aria-label="Zoom out"
              title="Zoom out (−)"
              className="size-7 inline-flex items-center justify-center text-[#c5bff0] hover:text-[#f0eaff] hover:bg-[#3d3863]/40 rounded-sm transition-colors"
            >
              −
            </button>
            <button
              type="button"
              onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
              aria-label="Reset view"
              title="Reset (1:1)"
              className="h-7 px-2 inline-flex items-center justify-center text-cap text-[#a09bc8] hover:text-[#f0eaff] hover:bg-[#3d3863]/40 rounded-sm transition-colors"
            >
              1:1
            </button>
            <span className="px-1 text-mono text-cap text-[#a09bc8] tabular-nums">
              {Math.round(view.zoom * 100)}%
            </span>
          </div>
        )}
      </div>

      {mode === '3d' && (
        <Graph3DErrorBoundary
          resetKey={size.width + size.height}
          onError={() => {
            // WebGL undefined / hardware accel off → switch back в 2D.
            // Banner показывается ~5s, потом сам исчезает.
            onModeChange('2d')
            setWebglFallbackNotice(true)
            setTimeout(() => setWebglFallbackNotice(false), 5000)
          }}
          fallback={
            <div className="absolute inset-0 flex items-center justify-center p-6">
              <div className="text-center max-w-md">
                <p className="text-title-md text-[#f0eaff] mb-2">3D-режим недоступен</p>
                <p className="text-cell text-[#a09bc8]">
                  Браузер не смог инициализировать WebGL. Возможно отключено
                  аппаратное ускорение (chrome://gpu) или используется среда без
                  поддержки 3D. Возврат к 2D-режиму…
                </p>
              </div>
            </div>
          }
        >
          <Suspense
            fallback={
              <div className="absolute inset-0 flex items-center justify-center">
                <LoadingBlock size="lg" label="Загрузка 3D-движка…" />
              </div>
            }
          >
            <DictionaryGraph3D
              dicts={dicts}
              dependentsByDict={dependentsByDict}
              recordCountByDict={recordCountByDict}
              width={size.width}
              height={size.height}
            />
          </Suspense>
        </Graph3DErrorBoundary>
      )}

      {webglFallbackNotice && (
        <div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 max-w-md bg-[#3d2614]/85 border border-[#ff9a64] text-[#ffd5b0] text-cell rounded-md px-3 py-2 shadow-lg backdrop-blur">
          3D недоступен в этом браузере — переключено на 2D.
        </div>
      )}

      {mode === '2d' && <svg
        ref={svgRef}
        width={size.width}
        height={size.height}
        viewBox={`0 0 ${size.width} ${size.height}`}
        className="block touch-none"
        style={{ cursor: panStartRef.current ? 'grabbing' : 'grab' }}
        onWheel={(e) => {
          // Zoom toward cursor — scale factor based on wheel delta.
          // Negative deltaY (scroll up) = zoom in.
          e.preventDefault()
          const rect = e.currentTarget.getBoundingClientRect()
          const mx = e.clientX - rect.left
          const my = e.clientY - rect.top
          setView((v) => {
            const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
            const newZoom = Math.min(4, Math.max(0.2, v.zoom * factor))
            // Anchor zoom вокруг cursor: viewport position под cursor должна
            // остаться той же относительно world coordinates.
            const worldX = (mx - v.panX) / v.zoom
            const worldY = (my - v.panY) / v.zoom
            return {
              zoom: newZoom,
              panX: mx - worldX * newZoom,
              panY: my - worldY * newZoom,
            }
          })
        }}
        onMouseDown={(e) => {
          // Pan только если клик НЕ на ноде. Узлы свои onMouseDown handlers.
          if ((e.target as SVGElement).tagName === 'svg') {
            panStartRef.current = {
              x: e.clientX,
              y: e.clientY,
              panX: view.panX,
              panY: view.panY,
            }
          }
        }}
        onMouseMove={(e) => {
          // Захватываем start snapshot СИНХРОННО — setView updater запускается
          // асинхронно, и к тому моменту panStartRef.current может стать null
          // (mouseup/mouseleave). Раньше `panStartRef.current!.panX + dx`
          // внутри updater'а падал с "Cannot read properties of null (reading
          // 'panX')". Локальная const живёт в closure → стабильна.
          const start = panStartRef.current
          if (!start) return
          const dx = e.clientX - start.x
          const dy = e.clientY - start.y
          setView((v) => ({
            ...v,
            panX: start.panX + dx,
            panY: start.panY + dy,
          }))
        }}
        onMouseUp={() => {
          panStartRef.current = null
        }}
        onMouseLeave={() => {
          panStartRef.current = null
        }}
      >
        <defs>
          <marker
            id="arrow"
            viewBox="0 0 10 10"
            refX="20"
            refY="5"
            markerWidth="6"
            markerHeight="6"
            orient="auto"
          >
            <path d="M 0 0 L 10 5 L 0 10 z" fill="#7a76b8" opacity="0.6" />
          </marker>
          <marker
            id="arrow-hi"
            viewBox="0 0 10 10"
            refX="20"
            refY="5"
            markerWidth="7"
            markerHeight="7"
            orient="auto"
          >
            <path d="M 0 0 L 10 5 L 0 10 z" fill="#ff9a64" />
          </marker>

          {/* Planet glow filter — soft blur halo вокруг планет-орбов.
            * stdDeviation = размер размытия в SVG-юнитах. Layered:
            * blur copy сначала, потом source поверх. */}
          <filter id="planet-glow" x="-50%" y="-50%" width="200%" height="200%">
            <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
            <feMerge>
              <feMergeNode in="blur" />
              <feMergeNode in="SourceGraphic" />
            </feMerge>
          </filter>

          {/* Per-bundle radial gradient — даёт 3D-сферу feel (lit на
            * top-left, shadow на bottom-right). Цвета: lit edge = brighter
            * mix of bundle + white, base = bundle, shadow = darker mix.
            * Gradient'ы определены статически для всех бандлов. */}
          <radialGradient id="planet-cuod" cx="35%" cy="30%" r="70%">
            <stop offset="0%" stopColor="#ffc8a3" />
            <stop offset="40%" stopColor="#d97757" />
            <stop offset="100%" stopColor="#5c2810" />
          </radialGradient>
          <radialGradient id="planet-geo" cx="35%" cy="30%" r="70%">
            <stop offset="0%" stopColor="#a8c47a" />
            <stop offset="40%" stopColor="#5b8044" />
            <stop offset="100%" stopColor="#22341a" />
          </radialGradient>
          <radialGradient id="planet-comms" cx="35%" cy="30%" r="70%">
            <stop offset="0%" stopColor="#e6c97a" />
            <stop offset="40%" stopColor="#b8902f" />
            <stop offset="100%" stopColor="#4a3a10" />
          </radialGradient>
          <radialGradient id="planet-delivery" cx="35%" cy="30%" r="70%">
            <stop offset="0%" stopColor="#8a6e5a" />
            <stop offset="40%" stopColor="#4a3324" />
            <stop offset="100%" stopColor="#1a0e08" />
          </radialGradient>
          <radialGradient id="planet-default" cx="35%" cy="30%" r="70%">
            <stop offset="0%" stopColor="#9d93a6" />
            <stop offset="40%" stopColor="#5e5560" />
            <stop offset="100%" stopColor="#2a242a" />
          </radialGradient>

          {/* Edge glow — мягкий blur halo вокруг линий связей для космо-вайба.
            * stdDeviation=2 — лёгкое размытие; merge с source чтобы исходная
            * линия оставалась видимой поверх halo. */}
          <filter id="edge-glow" x="-50%" y="-50%" width="200%" height="200%">
            <feGaussianBlur in="SourceGraphic" stdDeviation="1.6" result="blur" />
            <feMerge>
              <feMergeNode in="blur" />
              <feMergeNode in="SourceGraphic" />
            </feMerge>
          </filter>

          {/* Edge gradient — обычная связь идёт от cool violet к warm
            * stardust, hovered связь — accent throughout. Это даёт
            * direction-сigг'нал даже без arrow markers. */}
          <linearGradient id="edge-stroke" x1="0%" y1="0%" x2="100%" y2="0%">
            <stop offset="0%" stopColor="#5b569b" stopOpacity="0.85" />
            <stop offset="100%" stopColor="#a09bc8" stopOpacity="0.55" />
          </linearGradient>
          <linearGradient id="edge-stroke-hi" x1="0%" y1="0%" x2="100%" y2="0%">
            <stop offset="0%" stopColor="#ff9a64" stopOpacity="1" />
            <stop offset="100%" stopColor="#ffd5b0" stopOpacity="0.9" />
          </linearGradient>
        </defs>

        {/* Starfield — рендерится первым (под всем), не пересчитывается
          * при ticks simulation (useMemo seeded LCG для стабильных позиций).
          * Position поверх pan/zoom group? Нет — звёзды должны оставаться
          * "fixed" в космосе относительно world, поэтому рендерим ВНУТРИ
          * zoom group (двигаются вместе с graph nodes). */}

        {/* Zoom/pan transform — все nodes/edges рендерятся внутри <g>. */}
        <g transform={`translate(${view.panX} ${view.panY}) scale(${view.zoom})`}>
        {/* Starfield — звёзды в space layer, под edges/nodes */}
        <g pointerEvents="none">
          {starfield.map((st, i) => (
            <circle
              key={i}
              cx={st.x}
              cy={st.y}
              r={st.r}
              fill="#e6e6ff"
              opacity={st.o}
            />
          ))}
        </g>

        {/* Edges (под nodes) — космические "потоки" между планетами.
          * Стиль:
          *  - Default: cool violet → mute violet gradient stroke с лёгким
          *    glow filter + dashed pattern (orbital track feel).
          *  - Highlighted: accent (warm orange) с brighter glow, particles
          *    ставим на 2D через stroke-dashoffset CSS animation (см.
          *    @keyframes edge-flow в styles.css).
          *  - Dimmed (не connected): opacity 0.12, no glow (фокус на
          *    выделенной цепи). */}
        {linksRef.current.map((l, i) => {
          const s = l.source as GraphNode
          const t = l.target as GraphNode
          if (!s.x || !s.y || !t.x || !t.y) return null
          const highlighted =
            hoveredId &&
            (s.id === hoveredId || t.id === hoveredId)
          const dimmed = hoveredId && !highlighted
          return (
            <line
              key={i}
              x1={s.x}
              y1={s.y}
              x2={t.x}
              y2={t.y}
              stroke={
                highlighted
                  ? 'url(#edge-stroke-hi)'
                  : dimmed
                    ? '#3d3863'
                    : 'url(#edge-stroke)'
              }
              strokeWidth={highlighted ? 1.8 : 1}
              strokeDasharray={highlighted ? '4 3' : '2 4'}
              opacity={dimmed ? 0.15 : highlighted ? 0.95 : 0.55}
              filter={dimmed ? undefined : 'url(#edge-glow)'}
              markerEnd={highlighted ? 'url(#arrow-hi)' : 'url(#arrow)'}
              className={highlighted ? 'graph-edge-flow' : undefined}
            />
          )
        })}

        {/* Nodes — планет-карточки: glowing orb (radial gradient sphere) +
          * dark glass card с title/count/subtitle (HTML inside foreignObject
          * → нативный text-overflow:ellipsis, никаких manual char-caps).
          * Layout: row1 = title (full width with ellipsis), row2 = subtitle
          * слева | record count справа. Раньше title и count были в одном
          * row → длинные имена дрезались за count и за edge карточки. */}
        {nodesRef.current.map((n) => {
          const dim = hoveredId && !connectedIds.has(n.id)
          const focused = hoveredId === n.id
          // Card 200×56 — расширил с 180 чтобы reserve area для самой
          // большой планеты (orbR_max=18) не съедала text content.
          const W = 200
          const H = 56
          const halfW = W / 2
          const halfH = H / 2
          // Planet radius шкалируется по record count: sqrt чтобы 5→14
          // записей давали заметную разницу (не log который сжал бы их в
          // 2-3px diff). Min 7 (0 записей — планета всё ещё видна), max
          // 18 (cap чтобы не разорвать card layout).
          //  records=0   → 7px
          //  records=5   → 10.1px
          //  records=14  → 12.2px
          //  records=50  → 16.9px
          //  records≥100 → 18px (cap)
          const orbR = Math.min(18, Math.max(7, 7 + Math.sqrt(n.recordCount) * 1.4))
          // Orb center остаётся в фиксированном x для consistent left margin —
          // меняется только радиус (планета "растёт" наружу).
          const orbCx = -halfW + 26
          const bundleKey = n.bundle in BUNDLE_COLORS ? n.bundle : 'default'
          const planetGradId = `planet-${bundleKey}`
          // Content area стартует за reserved orb area (фиксированно для
          // max orb size), чтобы title не дёргался когда счётчики
          // обновляются из useRecords.
          const contentX = orbCx + 18 + 8 // max orbR (18) + padding 8
          const contentW = halfW - contentX - 8
          const recordText = n.recordCount > 0 ? `${n.recordCount} зап` : '—'
          return (
            <g
              key={n.id}
              transform={`translate(${n.x ?? 0},${n.y ?? 0})`}
              className="cursor-pointer"
              opacity={dim ? 0.25 : 1}
              onMouseEnter={() => setHoveredId(n.id)}
              onMouseLeave={() => setHoveredId(null)}
              onClick={() => navigate({ to: '/dictionaries/$name', params: { name: n.name } })}
            >
              {/* Card body — dark glass с violet-ish border, focused → accent border + glow */}
              <rect
                x={-halfW}
                y={-halfH}
                width={W}
                height={H}
                rx={8}
                fill="#1c1840"
                fillOpacity={0.85}
                stroke={focused ? '#ff9a64' : '#3d3863'}
                strokeWidth={focused ? 1.5 : 1}
                className={focused ? 'drop-shadow-[0_0_12px_rgba(255,154,100,0.5)]' : ''}
              />
              {/* Planet orb с glow filter + radial gradient (lit top-left).
                * Размер orbR пропорционален recordCount (sqrt scale, 7-18px),
                * specular highlight тоже шкалируется чтобы выглядеть как
                * планета а не точка. */}
              <g filter="url(#planet-glow)">
                <circle
                  cx={orbCx}
                  cy={0}
                  r={orbR}
                  fill={`url(#${planetGradId})`}
                />
                <ellipse
                  cx={orbCx - orbR * 0.3}
                  cy={-orbR * 0.3}
                  rx={orbR * 0.22}
                  ry={orbR * 0.14}
                  fill="#ffffff"
                  opacity={0.4}
                />
              </g>
              {/* foreignObject — HTML inside SVG для нативного truncate.
                * pointerEvents=none чтобы родительский <g> ловил mouse events. */}
              <foreignObject
                x={contentX}
                y={-halfH + 5}
                width={contentW}
                height={H - 10}
                style={{ pointerEvents: 'none' }}
              >
                <div
                  style={{
                    height: '100%',
                    display: 'flex',
                    flexDirection: 'column',
                    justifyContent: 'space-between',
                    overflow: 'hidden',
                    fontFamily: 'ui-sans-serif, system-ui, sans-serif',
                  }}
                >
                  {/* Title — full width, ellipsis на overflow */}
                  <div
                    style={{
                      fontSize: '12px',
                      fontWeight: 600,
                      color: '#f0eaff',
                      whiteSpace: 'nowrap',
                      overflow: 'hidden',
                      textOverflow: 'ellipsis',
                    }}
                    title={n.displayName}
                  >
                    {n.displayName}
                  </div>
                  {/* Subtitle row: name · bundle (left, ellipsis) | count (right, no shrink) */}
                  <div
                    style={{
                      display: 'flex',
                      alignItems: 'baseline',
                      justifyContent: 'space-between',
                      gap: '6px',
                      fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
                    }}
                  >
                    <span
                      style={{
                        fontSize: '10px',
                        color: '#a09bc8',
                        whiteSpace: 'nowrap',
                        overflow: 'hidden',
                        textOverflow: 'ellipsis',
                        minWidth: 0,
                        flex: 1,
                      }}
                      title={`${n.name} · ${n.bundle}`}
                    >
                      {n.name} · {n.bundle}
                    </span>
                    <span
                      style={{
                        fontSize: '11px',
                        color: n.recordCount > 0 ? '#ff9a64' : '#7a76b8',
                        fontWeight: n.recordCount > 0 ? 600 : 400,
                        whiteSpace: 'nowrap',
                        flexShrink: 0,
                      }}
                    >
                      {recordText}
                    </span>
                  </div>
                </div>
              </foreignObject>
            </g>
          )
        })}
        </g>
        {/* === end zoom/pan transform === */}
      </svg>}
      {/* Legend — space-themed panel с planet-orb swatches */}
      <div className="absolute bottom-3 left-3 flex flex-col gap-1 bg-[#1c1840]/85 backdrop-blur px-3 py-2 rounded-md border border-[#3d3863] text-cell shadow-lg">
        <div className="font-display uppercase tracking-wider text-[#a09bc8] mb-0.5">Bundle</div>
        {Object.entries(BUNDLE_COLORS)
          .filter(([k]) => k !== 'default')
          .map(([bundle, color]) => (
            <div key={bundle} className="flex items-center gap-1.5">
              <span
                className="size-2.5 rounded-full"
                style={{
                  background: color,
                  boxShadow: `0 0 4px ${color}aa`,
                }}
              />
              <span className="text-[#c5bff0] font-mono">{bundle}</span>
            </div>
          ))}
      </div>
    </div>
  )
}
