<script lang="ts">
  import { onMount, onDestroy } from 'svelte'
  import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
  import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
  import { liveEvents, subscribeEvents } from '$lib/stores/events'
  import { typeToCategory, type Category } from '$lib/categories'
  import { Skeleton } from '$lib/components/ui/skeleton'

  export interface GraphInfo {
    allNodeTypes: string[]
    allRelTypes: string[]
    relColors: Map<string, string>
    visibleCount: number
    truncated: boolean
    zoomPct: number
  }

  let {
    category,
    selectedSlug = null,
    onSelect,
    root = $bindable(''),
    depth,
    search,
    reloadToken,
    resetToken,
    activeNodeTypes = $bindable(new Set<string>()),
    activeRelTypes = $bindable(new Set<string>()),
    info = $bindable<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
  }: {
    category: Category
    selectedSlug?: string | null
    onSelect: (slug: string | null) => void
    root?: string
    depth: number
    search: string
    // Bumped by the parent toolbar to request a data reload / view reset —
    // these controls live in the shared page toolbar (not squeezed inside
    // this resizable pane), so they can't call load()/resetView() directly.
    reloadToken: number
    resetToken: number
    activeNodeTypes?: Set<string>
    activeRelTypes?: Set<string>
    info?: GraphInfo
  } = $props()

  interface Node extends Entity {
    x?: number
    y?: number
    vx?: number
    vy?: number
    fx?: number | null
    fy?: number | null
    degree: number
  }
  interface Link {
    source: string | Node
    target: string | Node
    type: string
  }

  let graph = $state<GraphView | null>(null)
  let loading = $state(true)
  let nodes = $state<Node[]>([])
  let links = $state<Link[]>([])
  let sim: Simulation<Node, Link> | null = null

  // type → browsing category, so the graph can be scoped client-side (the
  // graph endpoint itself has no category/domain param). Value is undefined
  // for types deliberately excluded from every category (e.g. execution/
  // check/task — see categories.ts); the key is still present so inCategory
  // can tell "excluded on purpose" apart from "not in the ontology at all."
  let typeCategory = $state<Map<string, Category | undefined>>(new Map())

  let hoveredId = $state<string | null>(null)

  // viewport transform: translate(x, y) scale(k)
  let view = $state({ x: 0, y: 0, k: 1 })
  let svgEl = $state<SVGSVGElement | null>(null)

  const width = 1200
  const height = 800

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

  const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
  const relColorByType = $derived.by(() => {
    const map = new Map<string, string>()
    const types = Array.from(new Set(links.map((l) => l.type))).sort()
    types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
    return map
  })

  function relColor(type: string): string {
    return relColorByType.get(type) ?? '#30363d'
  }

  function markerId(type: string): string {
    return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
  }

  function endpoint(end: string | Node): Node | undefined {
    return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
  }
  function endpointId(end: string | Node): string {
    return typeof end === 'object' ? end.id : end
  }

  // Node belongs to the active category? Types the ontology never returned
  // at all fall back to visible (so a missing entry never blanks the
  // graph); types the ontology returned but categories.ts deliberately
  // excludes (key present, value undefined) do not.
  function inCategory(type: string): boolean {
    if (!typeCategory.has(type)) return true
    return typeCategory.get(type) === category
  }

  // Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
  // default init spreads those via a spiral centered on the ORIGIN — not
  // (width/2, height/2) — while the x/y centering forces below are
  // deliberately weak (0.04, so they don't fight the link/collide layout).
  // Together that meant the cluster could settle noticeably off-origin
  // instead of centered. Fixed by explicitly fitting the viewport to the
  // node bounding box once the simulation settles, rather than relying on
  // the force balance to land on center by itself.
  function fitToView() {
    const placed = nodes.filter((n) => n.x != null && n.y != null)
    if (!placed.length) return
    const xs = placed.map((n) => n.x as number)
    const ys = placed.map((n) => n.y as number)
    const minX = Math.min(...xs)
    const maxX = Math.max(...xs)
    const minY = Math.min(...ys)
    const maxY = Math.max(...ys)
    const pad = 70
    const bw = Math.max(maxX - minX, 1)
    const bh = Math.max(maxY - minY, 1)
    const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
    const cx = (minX + maxX) / 2
    const cy = (minY + maxY) / 2
    view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
  }

  // fit=false for passive background reloads (live entity/relationship
  // events) — those shouldn't yank the view out from under someone
  // actively panning/zooming. Fresh loads (mount, root/depth change,
  // reset, re-root) default to fit=true.
  async function load(fit = true) {
    loading = true
    graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
    loading = false
    if (!graph) return

    const byId = new Map(nodes.map((n) => [n.id, n]))
    const degree = new Map<string, number>()
    const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
    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)
    }

    nodes = graph.nodes.map((n) => {
      const prev = byId.get(n.id)
      return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
    })
    links = graph.edges.map((e) => ({
      source: idBySlug.get(e.source) ?? e.source,
      target: idBySlug.get(e.target) ?? e.target,
      type: e.type
    }))

    // Default the node/edge-type toggles to the types present in the active category.
    activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
    activeRelTypes = new Set(links.map((l) => l.type))

    sim?.stop()
    sim = forceSimulation(nodes)
      .force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
      .force('charge', forceManyBody().strength(-240).distanceMax(400))
      .force('center', forceCenter(width / 2, height / 2))
      .force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
      .force('x', forceX(width / 2).strength(0.04))
      .force('y', forceY(height / 2).strength(0.04))
      .velocityDecay(0.32)
      .alphaDecay(0.035)
      .on('tick', () => {
        nodes = [...nodes]
      })
      .on('end', () => {
        if (fit) fitToView()
      })
  }

  onMount(() => {
    fetchEntityTypes().then((types) => {
      typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
      // Re-derive the active node types now that category membership is known.
      activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
    })
    load()
    const unsubscribe = subscribeEvents()
    return () => {
      unsubscribe()
      sim?.stop()
    }
  })

  onDestroy(() => sim?.stop())

  // When the category perspective changes, reset the node-type toggles to it.
  $effect(() => {
    category
    activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
  })

  $effect(() => {
    const ev = $liveEvents[0]
    if (!ev) return
    if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
      load(false)
    }
  })

  // Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
  // typing freely doesn't refetch, only a committed change (Enter/blur in the
  // parent's inputs, or the Reset button) bumps the token.
  let lastReloadToken = $state(0)
  $effect(() => {
    if (reloadToken !== lastReloadToken) {
      lastReloadToken = reloadToken
      load()
    }
  })

  let lastResetToken = $state(0)
  $effect(() => {
    if (resetToken !== lastResetToken) {
      lastResetToken = resetToken
      view = { x: 0, y: 0, k: 1 }
      load()
    }
  })

  function selectNode(node: Node) {
    onSelect(node.slug)
  }

  function rerootTo(node: Node) {
    root = node.slug
    load()
  }

  function nodeColor(node: Node): string {
    const h = graph?.health?.[node.id]
    return h ? healthColor[h] : '#58a6ff'
  }

  function nodeRadius(node: Node): number {
    return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
  }

  // Only offer node-type toggles that live in the active category.
  const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
  const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())

  // Publish status/legend info up to the parent toolbar.
  $effect(() => {
    info = {
      allNodeTypes,
      allRelTypes,
      relColors: relColorByType,
      visibleCount: visibleNodeIds.size,
      truncated: !!graph?.truncated,
      zoomPct: Math.round(view.k * 100)
    }
  })

  const matchedIds = $derived.by(() => {
    if (!search.trim()) return null
    const q = search.trim().toLowerCase()
    return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
  })

  // Focus = in the active category AND its node-type toggle is on — these
  // are what the category tab is "about."
  const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).map((n) => n.id)))

  // Real infra relationships mostly cross category lines (a service sits on
  // a network, uses storage, runs on an lxc — different categories under
  // this taxonomy). Hard-hiding any edge whose other end isn't in-category
  // left focus nodes looking like disconnected dots. Rooted views (the user
  // is exploring out from one entity) pull in 1-hop neighbors of any
  // category, dimmed, so the edges — and what they connect to — stay
  // visible. Unscoped "browse the whole category" views (no root) skip
  // this: with ~50 focus nodes that touch nearly everything, 1-hop
  // expansion floods in most of the graph (measured: 417 of 479 total
  // entities for an unrooted Fleet view) — worse than the isolated-dot
  // problem it was meant to fix. There, same-category-only edges stay.
  const neighborNodeIds = $derived.by(() => {
    const neighbors = new Set<string>()
    if (!root.trim()) return neighbors
    for (const l of links) {
      if (!activeRelTypes.has(l.type)) continue
      const s = endpointId(l.source)
      const t = endpointId(l.target)
      if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
      else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
    }
    return neighbors
  })

  const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))

  const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)

  const adjacency = $derived.by(() => {
    const adj = new Map<string, Set<string>>()
    for (const l of links) {
      const s = endpointId(l.source)
      const t = endpointId(l.target)
      if (!adj.has(s)) adj.set(s, new Set())
      if (!adj.has(t)) adj.set(t, new Set())
      adj.get(s)!.add(t)
      adj.get(t)!.add(s)
    }
    return adj
  })

  const focusIds = $derived.by(() => {
    const focus = hoveredId ?? selectedId
    if (!focus) return null
    const set = new Set<string>([focus])
    for (const n of adjacency.get(focus) ?? []) set.add(n)
    return set
  })

  function nodeOpacity(node: Node): number {
    const base = focusNodeIds.has(node.id) ? 1 : 0.4
    if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
    if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
    return base
  }

  function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
    const s = endpointId(link.source)
    const t = endpointId(link.target)
    const focus = hoveredId ?? selectedId
    if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
    if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
    return { opacity: 0.45, emphasized: false }
  }

  // ─── pan / zoom / drag ───────────────────────────────────────────────

  function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
    const rect = svgEl!.getBoundingClientRect()
    return {
      x: ((clientX - rect.left) / rect.width) * width,
      y: ((clientY - rect.top) / rect.height) * height
    }
  }

  function toWorld(clientX: number, clientY: number): { x: number; y: number } {
    const p = toViewBox(clientX, clientY)
    return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
  }

  function onWheel(e: WheelEvent) {
    e.preventDefault()
    const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
    const k = Math.min(6, Math.max(0.25, view.k * factor))
    const p = toViewBox(e.clientX, e.clientY)
    const wx = (p.x - view.x) / view.k
    const wy = (p.y - view.y) / view.k
    view = { k, x: p.x - wx * k, y: p.y - wy * k }
  }

  let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
  let dragState: { node: Node; moved: boolean } | null = null

  function onBackgroundPointerDown(e: PointerEvent) {
    if (dragState) return
    ;(e.currentTarget as Element).setPointerCapture(e.pointerId)
    const p = toViewBox(e.clientX, e.clientY)
    panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
  }

  function onNodePointerDown(e: PointerEvent, node: Node) {
    e.stopPropagation()
    ;(e.currentTarget as Element).setPointerCapture(e.pointerId)
    dragState = { node, moved: false }
    sim?.alphaTarget(0.25).restart()
  }

  function onPointerMove(e: PointerEvent) {
    if (dragState) {
      const w = toWorld(e.clientX, e.clientY)
      dragState.node.fx = w.x
      dragState.node.fy = w.y
      dragState.moved = true
      return
    }
    if (panState) {
      const p = toViewBox(e.clientX, e.clientY)
      const dx = p.x - panState.startX
      const dy = p.y - panState.startY
      if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
      view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
    }
  }

  function onPointerUp(e: PointerEvent) {
    if (dragState) {
      const { node, moved } = dragState
      node.fx = null
      node.fy = null
      sim?.alphaTarget(0)
      dragState = null
      if (!moved) selectNode(node)
      return
    }
    if (panState && !panState.moved) {
      // Plain click on empty background (not a drag-pan) — clear selection.
      onSelect(null)
    }
    panState = null
  }
</script>

{#if loading && !nodes.length}
  <Skeleton class="h-full min-h-0" />
{:else}
  <div class="relative h-full min-h-0 overflow-hidden rounded-lg border">
    <svg
      bind:this={svgEl}
      viewBox="0 0 {width} {height}"
      preserveAspectRatio="xMidYMid slice"
      class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
      role="application"
      aria-label="Entity graph"
      onwheel={onWheel}
      onpointerdown={onBackgroundPointerDown}
      onpointermove={onPointerMove}
      onpointerup={onPointerUp}
      onpointercancel={onPointerUp}
    >
      <defs>
        <pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
          <circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
        </pattern>
        {#each allRelTypes as type}
          <marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
            <path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
          </marker>
        {/each}
      </defs>
      <rect x="0" y="0" width={width} height={height} fill="url(#dot-grid)" />
      <g transform="translate({view.x},{view.y}) scale({view.k})">
        <g>
          {#each links as link}
            {@const s = endpoint(link.source)}
            {@const t = endpoint(link.target)}
            {#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
              {@const vs = linkVisualState(link)}
              {@const dx = t.x - s.x}
              {@const dy = t.y - s.y}
              {@const len = Math.max(Math.hypot(dx, dy), 1)}
              {@const tr = nodeRadius(t) + 3}
              {@const ex = t.x - (dx / len) * tr}
              {@const ey = t.y - (dy / len) * tr}
              <line
                x1={s.x}
                y1={s.y}
                x2={ex}
                y2={ey}
                stroke={relColor(link.type)}
                stroke-width={vs.emphasized ? 2 : 1.2}
                opacity={vs.opacity}
                marker-end="url(#{markerId(link.type)})"
              >
                <title>{link.type}</title>
              </line>
              {#if vs.emphasized && view.k >= 0.7}
                <text
                  x={(s.x + ex) / 2}
                  y={(s.y + ey) / 2 - 4}
                  text-anchor="middle"
                  font-size={10 / view.k}
                  fill={relColor(link.type)}
                  opacity="0.95"
                  paint-order="stroke"
                  stroke="var(--background)"
                  stroke-width={3 / view.k}
                >
                  {link.type}
                </text>
              {/if}
            {/if}
          {/each}
        </g>
        <g>
          {#each nodes as node (node.id)}
            {#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
              {@const r = nodeRadius(node)}
              {@const op = nodeOpacity(node)}
              {@const isFocus = hoveredId === node.id || selectedId === node.id}
              {@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
              <g
                transform="translate({node.x},{node.y})"
                opacity={op}
                class="cursor-pointer"
                role="button"
                tabindex="0"
                onpointerdown={(e) => onNodePointerDown(e, node)}
                onpointerenter={() => (hoveredId = node.id)}
                onpointerleave={() => (hoveredId = null)}
                onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
                ondblclick={() => rerootTo(node)}
              >
                {#if isFocus || isMatch}
                  <circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
                {/if}
                <circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? 'var(--foreground)' : 'var(--background)'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
                {#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
                  <text
                    y={r + 12}
                    text-anchor="middle"
                    font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
                    fill={isFocus ? 'var(--foreground)' : 'var(--muted-foreground)'}
                    paint-order="stroke"
                    stroke="var(--background)"
                    stroke-width={3 / view.k}
                    class="pointer-events-none select-none"
                  >
                    {node.slug}
                  </text>
                {/if}
              </g>
            {/if}
          {/each}
        </g>
      </g>
    </svg>
    <div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
      scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
    </div>
  </div>
{/if}
