<script lang="ts">
  import { onMount } from 'svelte'
  import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
  import { fetchGraph, type Health } from '$lib/api'
  import { getTheme } from '$lib/stores/theme.svelte'

  // Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
  // host places this behind the page with pointer-events:none, so it never
  // steals clicks. The "alive" feeling comes entirely from the camera (slow
  // autonomous drift + mouse parallax + per-node depth), NOT from a live force
  // sim — we warm the layout up once, freeze it, then just pan a static field.

  interface SimNode {
    id: string
    slug: string
    degree: number
    z: number // depth in [0,1] for parallax
    x?: number
    y?: number
    fx?: number | null
    fy?: number | null
  }
  interface SimLink {
    source: string | SimNode
    target: string | SimNode
  }

  let host = $state<HTMLDivElement | null>(null)
  let canvas = $state<HTMLCanvasElement | null>(null)

  let nodes: SimNode[] = []
  let links: SimLink[] = []
  let health: Record<string, Health> = {}

  // World bounds the layout is centered in; camera pans within.
  const WORLD = 1400
  const MAX_NODES = 260

  const healthColor: Record<Health, string> = {
    healthy: '#3fb950',
    degraded: '#d29922',
    down: '#f85149',
    unknown: '#8b949e'
  }

  function nodeRadius(n: SimNode): number {
    return 3 + Math.min(Math.sqrt(n.degree) * 1.4, 7)
  }

  async function loadGraph() {
    const graph = await fetchGraph({ depth: 3, includeStatus: true })
    if (!graph) return
    health = graph.health ?? {}

    // degree by id, edges reference slugs
    const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
    const degree = new Map<string, number>()
    for (const e of graph.edges) {
      const s = idBySlug.get(e.source) ?? e.source
      const t = idBySlug.get(e.target) ?? e.target
      degree.set(s, (degree.get(s) ?? 0) + 1)
      degree.set(t, (degree.get(t) ?? 0) + 1)
    }

    let all: SimNode[] = graph.nodes.map((n) => ({
      id: n.id,
      slug: n.slug,
      degree: degree.get(n.id) ?? 0,
      z: Math.random()
    }))
    // Cap to the most-connected nodes so large graphs stay cheap.
    if (all.length > MAX_NODES) {
      all = [...all].sort((a, b) => b.degree - a.degree).slice(0, MAX_NODES)
    }
    const keep = new Set(all.map((n) => n.id))
    nodes = all
    links = graph.edges
      .map((e) => ({ source: idBySlug.get(e.source) ?? e.source, target: idBySlug.get(e.target) ?? e.target }))
      .filter((l) => keep.has(l.source as string) && keep.has(l.target as string))

    warmUpLayout()
  }

  // Run the sim to a settled state without rendering each tick, then freeze.
  function warmUpLayout() {
    const sim: Simulation<SimNode, SimLink> = forceSimulation(nodes)
      .force('link', forceLink<SimNode, SimLink>(links).id((n) => n.id).distance(60).strength(0.5))
      .force('charge', forceManyBody().strength(-140).distanceMax(360))
      .force('center', forceCenter(0, 0))
      .force('collide', forceCollide<SimNode>((n) => nodeRadius(n) + 6))
      .stop()
    const ticks = Math.min(400, Math.max(120, nodes.length * 2))
    for (let i = 0; i < ticks; i++) sim.tick()
    sim.stop()
  }

  // ─── camera + render loop ───────────────────────────────────────────────

  let cam = { x: 0, y: 0 } // eased mouse-parallax offset
  let targetCam = { x: 0, y: 0 }
  let timer: ReturnType<typeof setTimeout> | 0 = 0
  let dpr = 1
  let w = 0
  let h = 0
  let dotCanvas: HTMLCanvasElement | null = null
  let lastDotDark: boolean | null = null

  function drawDots(dark: boolean) {
    if (!dotCanvas) {
      dotCanvas = document.createElement('canvas')
    }
    dotCanvas.width = Math.round(w * dpr)
    dotCanvas.height = Math.round(h * dpr)
    const dctx = dotCanvas.getContext('2d')!
    dctx.setTransform(dpr, 0, 0, dpr, 0, 0)
    dctx.clearRect(0, 0, w, h)
    dctx.fillStyle = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
    const spacing = 12
    for (let x = spacing; x < w; x += spacing) {
      for (let y = spacing; y < h; y += spacing) {
        dctx.beginPath()
        dctx.arc(x, y, 0.7, 0, Math.PI * 2)
        dctx.fill()
      }
    }
  }

  function onPointerMove(e: PointerEvent) {
    if (!host) return
    const rect = host.getBoundingClientRect()
    const nx = (e.clientX - rect.left) / rect.width - 0.5 // -0.5..0.5
    const ny = (e.clientY - rect.top) / rect.height - 0.5
    targetCam = { x: -nx * 90, y: -ny * 90 } // small parallax nudge
  }

  function resize() {
    if (!host || !canvas) return
    dpr = Math.min(window.devicePixelRatio || 1, 2)
    w = host.clientWidth
    h = host.clientHeight
    canvas.width = Math.round(w * dpr)
    canvas.height = Math.round(h * dpr)
    dotCanvas = null // force redraw on next frame
  }

  function colorForNode(n: SimNode): string {
    return healthColor[health[n.id] ?? 'unknown']
  }

  // Driven by setTimeout rather than requestAnimationFrame: some embedding
  // contexts (iframed previews, backgrounded-but-visible panes) report
  // document.hidden = true and browsers fully suspend rAF callbacks there,
  // which would freeze this canvas forever. setTimeout keeps ticking
  // regardless, and ~30fps is plenty for a slow ambient drift.
  function draw(t: number) {
    timer = setTimeout(() => draw(performance.now()), 33)
    if (!canvas) return
    const ctx = canvas.getContext('2d')
    if (!ctx) return

    // ease parallax toward target
    cam.x += (targetCam.x - cam.x) * 0.05
    cam.y += (targetCam.y - cam.y) * 0.05

    // autonomous drift (Lissajous pan + breathing zoom)
    const ts = t / 1000
    const driftX = Math.sin(ts * 0.05) * 70 + Math.sin(ts * 0.017) * 40
    const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
    const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03

    const dark = getTheme() !== 'light'
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
    ctx.clearRect(0, 0, w, h)

    if (lastDotDark !== dark) { dotCanvas = null; lastDotDark = dark }
    if (!dotCanvas) drawDots(dark)
    ctx.drawImage(dotCanvas!, 0, 0)

    const cx = w / 2
    const cy = h / 2

    // project a world point to screen, applying per-depth parallax
    function project(px: number, py: number, z: number) {
      const par = 0.5 + z // nearer nodes (higher z) move more
      const ox = (driftX + cam.x) * par
      const oy = (driftY + cam.y) * par
      return { x: cx + (px + ox) * zoom, y: cy + (py + oy) * zoom }
    }

    // edges
    ctx.lineWidth = 1
    ctx.strokeStyle = dark ? 'rgba(140,175,230,0.28)' : 'rgba(60,90,140,0.22)'
    ctx.beginPath()
    for (const l of links) {
      const s = l.source as SimNode
      const tg = l.target as SimNode
      if (s.x == null || tg.x == null) continue
      const z = (s.z + tg.z) / 2
      const a = project(s.x, s.y!, z)
      const b = project(tg.x, tg.y!, z)
      const dx = b.x - a.x
      const dy = b.y - a.y
      const len = Math.max(Math.hypot(dx, dy), 1)
      const curve = Math.min(len * 0.15, 40)
      const mx = (a.x + b.x) / 2 - (dy / len) * curve
      const my = (a.y + b.y) / 2 + (dx / len) * curve
      ctx.moveTo(a.x, a.y)
      ctx.quadraticCurveTo(mx, my, b.x, b.y)
    }
    ctx.stroke()

    // nodes (glow via radial gradient, cheap enough at this count)
    for (const n of nodes) {
      if (n.x == null || n.y == null) continue
      const p = project(n.x, n.y, n.z)
      const r = nodeRadius(n) * zoom * (0.7 + n.z * 0.6)
      const col = colorForNode(n)
      const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3.2)
      glow.addColorStop(0, hexA(col, dark ? 0.45 : 0.32))
      glow.addColorStop(1, hexA(col, 0))
      ctx.fillStyle = glow
      ctx.beginPath()
      ctx.arc(p.x, p.y, r * 3.2, 0, Math.PI * 2)
      ctx.fill()
      ctx.fillStyle = hexA(col, dark ? 0.7 : 0.55)
      ctx.beginPath()
      ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
      ctx.fill()
    }

    // legibility scrim: dim only the center band where the UI sits, taper to
    // ~nothing at the edges so the graph (and its connections) stay visible
    // in the margins instead of being crushed everywhere equally.
    const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
    const base = dark ? '13,17,23' : '255,255,255'
    scrim.addColorStop(0, `rgba(${base},0.68)`)
    scrim.addColorStop(0.45, `rgba(${base},0.32)`)
    scrim.addColorStop(1, `rgba(${base},0.02)`)
    ctx.fillStyle = scrim
    ctx.fillRect(0, 0, w, h)
  }

  // "#rrggbb" + alpha -> rgba()
  function hexA(hex: string, a: number): string {
    const n = parseInt(hex.slice(1), 16)
    return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
  }

  onMount(() => {
    loadGraph()
    resize()
    const ro = new ResizeObserver(resize)
    if (host) ro.observe(host)
    window.addEventListener('pointermove', onPointerMove)
    timer = setTimeout(() => draw(performance.now()), 33)
    return () => {
      clearTimeout(timer)
      ro.disconnect()
      window.removeEventListener('pointermove', onPointerMove)
    }
  })
</script>

<div bind:this={host} class="pointer-events-none absolute inset-0 overflow-hidden">
  <canvas bind:this={canvas} class="h-full w-full"></canvas>
</div>
