<script lang="ts">
  import { onMount, onDestroy } from 'svelte'
  import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
  import { fetchGraph, fetchBlastRadius, type GraphView, type Entity, type BlastRadiusItem, type Health } from '$lib/api'
  import { liveEvents, subscribeEvents } from '$lib/stores/events'
  import { Input } from '$lib/components/ui/input'
  import { Button } from '$lib/components/ui/button'
  import { Badge } from '$lib/components/ui/badge'
  import * as Sheet from '$lib/components/ui/sheet'
  import { Skeleton } from '$lib/components/ui/skeleton'
  import EntitySheet from '$lib/components/EntitySheet.svelte'
  import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'

  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 root = $state('')
  let depth = $state(2)
  let nodes = $state<Node[]>([])
  let links = $state<Link[]>([])
  let selected = $state<Entity | null>(null)
  let blastRadius = $state<BlastRadiusItem[]>([])
  let sim: Simulation<Node, Link> | null = null

  let search = $state('')
  let activeNodeTypes = $state<Set<string>>(new Set())
  let activeRelTypes = $state<Set<string>>(new Set())
  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
  }

  async function load() {
    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
    }))

    activeNodeTypes = new Set(nodes.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]
      })
  }

  onMount(() => {
    load()
    const unsubscribe = subscribeEvents()
    return () => {
      unsubscribe()
      sim?.stop()
    }
  })

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

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

  async function selectNode(node: Node) {
    selected = node
    blastRadius = await fetchBlastRadius(node.id)
  }

  function rerootTo(node: Node) {
    root = node.slug
    selected = null
    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)
  }

  function toggleSet(set: Set<string>, value: string): Set<string> {
    const next = new Set(set)
    if (next.has(value)) next.delete(value)
    else next.add(value)
    return next
  }

  const allNodeTypes = $derived(Array.from(new Set(nodes.map((n) => n.type))).sort())
  const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())

  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))
  })

  const visibleNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))

  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 ?? selected?.id ?? null
    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 {
    if (matchedIds !== null) return matchedIds.has(node.id) ? 1 : 0.15
    if (focusIds !== null) return focusIds.has(node.id) ? 1 : 0.15
    return 1
  }

  function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
    const s = endpointId(link.source)
    const t = endpointId(link.target)
    const focus = hoveredId ?? selected?.id ?? null
    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: { startX: number; startY: number; viewX: number; viewY: number } | 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 }
  }

  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)
      view = { ...view, x: panState.viewX + (p.x - panState.startX), y: panState.viewY + (p.y - panState.startY) }
    }
  }

  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
    }
    panState = null
  }

  function resetView() {
    view = { x: 0, y: 0, k: 1 }
    root = ''
    search = ''
    load()
  }

  let entitySheetOpen = $state(false)
  let entitySheetSlug = $state<string | null>(null)

  function openEntityDetail(slug: string) {
    entitySheetSlug = slug
    entitySheetOpen = true
  }
</script>

<div class="flex h-full flex-col gap-3 p-4">
  <div class="flex flex-wrap items-center gap-2">
    <h1 class="mr-2 text-lg font-semibold">Graph</h1>
    <Input placeholder="Root entity…" bind:value={root} class="h-8 max-w-44 text-xs" onchange={load} />
    <Input type="number" min="1" max="5" bind:value={depth} class="h-8 w-16 text-xs" onchange={load} />
    <Input placeholder="Search / highlight…" bind:value={search} class="h-8 max-w-44 text-xs" />
    <Button variant="outline" size="sm" class="h-8" onclick={resetView}>
      <LocateFixedIcon class="mr-1 size-3.5" />
      Reset
    </Button>
    <div class="flex-1"></div>
    <span class="text-xs text-muted-foreground">
      {nodes.length} nodes · {links.length} edges{graph?.truncated ? ' · truncated' : ''} · {Math.round(view.k * 100)}%
    </span>
  </div>

  <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
    {#if allNodeTypes.length}
      <div class="flex flex-wrap items-center gap-1">
        <span class="text-[11px] uppercase tracking-wide text-muted-foreground">Nodes</span>
        {#each allNodeTypes as type}
          <button type="button" onclick={() => (activeNodeTypes = toggleSet(activeNodeTypes, type))}>
            <Badge variant={activeNodeTypes.has(type) ? 'default' : 'outline'} class="h-5 cursor-pointer px-1.5 text-[10px]">{type}</Badge>
          </button>
        {/each}
      </div>
    {/if}
    {#if allRelTypes.length}
      <div class="flex flex-wrap items-center gap-1">
        <span class="text-[11px] uppercase tracking-wide text-muted-foreground">Edges</span>
        {#each allRelTypes as type}
          <button type="button" onclick={() => (activeRelTypes = toggleSet(activeRelTypes, type))}>
            <Badge
              variant="outline"
              class="h-5 cursor-pointer px-1.5 text-[10px] {activeRelTypes.has(type) ? '' : 'opacity-35'}"
              style="border-color: {relColor(type)}; color: {relColor(type)};"
            >
              {type}
            </Badge>
          </button>
        {/each}
      </div>
    {/if}
  </div>

  {#if loading && !nodes.length}
    <Skeleton class="min-h-0 flex-1" />
  {:else}
    <div class="relative min-h-0 flex-1 overflow-hidden rounded-lg border bg-[radial-gradient(ellipse_at_center,rgba(88,166,255,0.04),transparent_70%)]">
      <svg
        bind:this={svgEl}
        viewBox="0 0 {width} {height}"
        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>
          {#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>
        <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 || selected?.id === 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 ? '#e6edf3' : '#0d1117'} 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 ? '#e6edf3' : '#8b949e'}
                      paint-order="stroke"
                      stroke="#0d1117"
                      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}
</div>

<Sheet.Root open={selected !== null} onOpenChange={(open) => { if (!open) selected = null }}>
  <Sheet.Content side="right" class="w-[420px] sm:max-w-[420px]">
    {#if selected}
      <Sheet.Header>
        <Sheet.Title class="font-mono text-sm">{selected.slug}</Sheet.Title>
        <Sheet.Description>{selected.type}{selected.state ? ` — ${selected.state}` : ''}</Sheet.Description>
      </Sheet.Header>
      <div class="flex flex-col gap-4 overflow-y-auto px-4 pb-4">
        <div class="flex gap-2">
          <Button variant="outline" size="sm" onclick={() => openEntityDetail(selected!.slug)}>
            View entity detail
          </Button>
          <Button variant="outline" size="sm" onclick={() => rerootTo(selected as Node)}>Re-root here</Button>
        </div>
        <div>
          <p class="mb-1 text-xs font-medium text-muted-foreground">Attributes</p>
          <pre class="overflow-x-auto rounded bg-muted p-2 text-xs">{JSON.stringify(selected.attributes, null, 2)}</pre>
        </div>
        <div>
          <p class="mb-1 text-xs font-medium text-muted-foreground">Relationships</p>
          <div class="flex flex-col gap-1">
            {#each links.filter((l) => endpointId(l.source) === selected!.id || endpointId(l.target) === selected!.id) as link}
              {@const sId = endpointId(link.source)}
              {@const other = endpoint(sId === selected!.id ? link.target : link.source)}
              <div class="flex items-center gap-1 text-xs">
                <Badge variant="outline" style="border-color: {relColor(link.type)}; color: {relColor(link.type)};">
                  {sId === selected!.id ? '→' : '←'} {link.type}
                </Badge>
                <span class="font-mono">{other?.slug ?? '—'}</span>
              </div>
            {:else}
              <p class="text-xs text-muted-foreground">No relationships in this view.</p>
            {/each}
          </div>
        </div>
        <div>
          <p class="mb-1 text-xs font-medium text-muted-foreground">Blast radius ({blastRadius.length})</p>
          <div class="flex flex-col gap-1">
            {#each blastRadius as item (item.entity.id)}
              <div class="flex items-center justify-between text-xs">
                <span class="font-mono">{item.entity.slug}</span>
                <Badge variant="outline">depth {item.depth}</Badge>
              </div>
            {:else}
              <p class="text-xs text-muted-foreground">No downstream dependents.</p>
            {/each}
          </div>
        </div>
      </div>
    {/if}
  </Sheet.Content>
</Sheet.Root>

<EntitySheet slug={entitySheetSlug} bind:open={entitySheetOpen} />
