<script lang="ts">
  import { onDestroy, untrack } from 'svelte'
  import {
    forceSimulation,
    forceLink,
    forceManyBody,
    forceCenter,
    forceCollide,
    forceX,
    forceY,
    type Simulation
  } from 'd3-force'
  import { fetchGraph, type Entity } from '$lib/api'
  import type { ChatMessage } from '$lib/stores/chat'
  import type { TouchedEntity, HealthDiff } from '$lib/stores/workspace'
  import { openEntityWindow, wmState } from '$lib/stores/windows'

  // Prop-driven (not store-imported) so this can render either the main
  // page's global "current session" data or a floating task window's own
  // per-session data — see TaskContextPanel.svelte, which supplies both.
  let {
    messages,
    touched,
    healthDiffs
  }: { messages: ChatMessage[]; touched: TouchedEntity[]; healthDiffs: HealthDiff[] } = $props()

  // SVG ids are document-global, not scoped to this <svg> — several task
  // windows can each have their own Scope graph open at once, and without a
  // per-instance suffix every one of them would define (and reference)
  // <pattern id="dot-grid">, so only the first in the document would ever
  // actually paint (the rest resolve to nothing, background reads blank).
  const dotGridId = `dot-grid-${crypto.randomUUID().slice(0, 8)}`

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

  // Probe/bookkeeping entity types are excluded — a health conversation
  // mentions dozens of check:… slugs that would swamp the fleet topology.
  const EXCLUDED = new Set(['check', 'execution'])

  // Slug shape: lowercase type prefix, then one or more colon-separated
  // segments (host:hubris, check:ping:8cf, lxc:caddy, investigation:foo/bar).
  const SLUG_RE = /\b[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*/g

  let nodes = $state<Node[]>([])
  let links = $state<Edge[]>([])
  let selected = $state<Node | null>(null)

  let sim: Simulation<Node, Edge> | null = null

  // Non-reactive caches (persist across message deltas). resolvedVersion is a
  // reactive counter bumped when async resolution finishes, so the reconcile
  // effect re-runs once entities come back.
  const resolvedCache = new Map<string, Node | null>()
  const edgeCache: { source: string; target: string; type: string }[] = []
  const edgeKeys = new Set<string>()
  const resolving = new Set<string>()
  let resolvedVersion = $state(0)

  // container size drives the simulation coordinate space (1:1 with pixels so
  // node dragging maps cleanly regardless of the resizable panel width).
  let container = $state<HTMLDivElement | null>(null)
  let cw = $state(300)
  let ch = $state(300)

  // View transform (zoom-to-fit + drag-pan). The force simulation runs in its
  // own graph coordinate space; this maps graph→screen so every entity stays
  // visible regardless of how far the layout spreads or how narrow the panel
  // is. tx/ty are screen px; scale is unitless. `userPanned` pauses auto-fit
  // once the operator drags the background, until the entity set changes or
  // they double-click to reset.
  let tx = $state(0)
  let ty = $state(0)
  let scale = $state(1)
  let userPanned = $state(false)
  const viewTransform = $derived(`translate(${tx},${ty}) scale(${scale})`)

  function collectSlugs(value: unknown, out: Set<string>) {
    if (typeof value === 'string') {
      const m = value.match(SLUG_RE)
      if (m) for (const s of m) out.add(s.replace(/[.,;)\]]+$/, ''))
    } else if (Array.isArray(value)) {
      for (const v of value) collectSlugs(v, out)
    } else if (value && typeof value === 'object') {
      for (const v of Object.values(value)) collectSlugs(v, out)
    }
  }

  // Only pull from what the conversation is *about*: message text and the
  // arguments the agent passed to tools — never bulk result rows (a single
  // get_health_summary would otherwise dump all 168 entities into the graph).
  const candidateSlugs = $derived.by(() => {
    const out = new Set<string>()
    for (const m of messages) {
      collectSlugs(m.text, out)
      for (const t of m.tools) collectSlugs(t.args, out)
    }
    return out
  })

  async function resolveSlugs(slugs: string[]) {
    const todo = slugs.filter((s) => !resolvedCache.has(s) && !resolving.has(s))
    if (!todo.length) return
    for (const s of todo) resolving.add(s)
    await Promise.all(
      todo.map(async (s) => {
        try {
          const g = await fetchGraph({ root: s, depth: 1 })
          const root = g?.nodes.find((n) => n.slug === s) ?? null
          resolvedCache.set(s, root ? { ...root, degree: 0 } : null)
          if (g && root) {
            for (const e of g.edges) {
              const k = `${e.source}|${e.target}|${e.type}`
              if (!edgeKeys.has(k)) {
                edgeKeys.add(k)
                edgeCache.push({ source: e.source, target: e.target, type: e.type })
              }
            }
          }
        } catch {
          resolvedCache.set(s, null)
        } finally {
          resolving.delete(s)
        }
      })
    )
    resolvedVersion++
  }

  function reconcile(cands: Set<string>) {
    const desired: Node[] = []
    const seen = new Set<string>()
    for (const s of cands) {
      const e = resolvedCache.get(s)
      if (e && !EXCLUDED.has(e.type) && !seen.has(e.slug)) {
        seen.add(e.slug)
        desired.push(e)
      }
    }
    const desiredSlugs = new Set(desired.map((e) => e.slug))
    const current = nodes
    const curSlugs = new Set(current.map((n) => n.slug))

    let changed = desiredSlugs.size !== curSlugs.size
    if (!changed)
      for (const s of desiredSlugs)
        if (!curSlugs.has(s)) {
          changed = true
          break
        }
    if (!changed) return

    const bySlug = new Map(current.map((n) => [n.slug, n]))
    const ls = edgeCache
      .filter((e) => desiredSlugs.has(e.source) && desiredSlugs.has(e.target))
      .map((e) => ({ ...e }))

    const deg = new Map<string, number>()
    for (const l of ls) {
      deg.set(l.source as string, (deg.get(l.source as string) ?? 0) + 1)
      deg.set(l.target as string, (deg.get(l.target as string) ?? 0) + 1)
    }

    const next = desired.map((e) => {
      const p = bySlug.get(e.slug)
      return { ...e, x: p?.x, y: p?.y, vx: p?.vx, vy: p?.vy, degree: deg.get(e.slug) ?? 0 }
    })

    nodes = next
    links = ls
    if (selected && !desiredSlugs.has(selected.slug)) selected = null
    buildSim()
  }

  $effect(() => {
    const cands = candidateSlugs
    void resolvedVersion
    const missing = [...cands].filter((s) => !resolvedCache.has(s))
    if (missing.length) resolveSlugs(missing)
    untrack(() => reconcile(cands))
  })

  function buildSim() {
    sim?.stop()
    if (!nodes.length) {
      sim = null
      return
    }
    sim = forceSimulation(nodes)
      .force(
        'link',
        forceLink<Node, Edge>(links)
          .id((n) => n.slug)
          .distance(48)
          .strength(0.5)
      )
      .force('charge', forceManyBody().strength(-150).distanceMax(240))
      .force('center', forceCenter(cw / 2, ch / 2))
      .force(
        'collide',
        forceCollide<Node>((n) => nodeRadius(n) + 6)
      )
      .force('x', forceX(cw / 2).strength(0.06))
      .force('y', forceY(ch / 2).strength(0.06))
      .velocityDecay(0.34)
      .alphaDecay(0.045)
      .on('tick', () => {
        nodes = [...nodes]
        if (!userPanned) fitView()
      })
  }

  // keep the layout centred as the panel resizes
  $effect(() => {
    const w = cw
    const h = ch
    if (sim) {
      sim.force('center', forceCenter(w / 2, h / 2))
      sim.force('x', forceX(w / 2).strength(0.06))
      sim.force('y', forceY(h / 2).strength(0.06))
      sim.alpha(0.3).restart()
    }
  })

  $effect(() => {
    if (!container) return
    const ro = new ResizeObserver((entries) => {
      const r = entries[0].contentRect
      cw = Math.max(r.width, 1)
      ch = Math.max(r.height, 1)
    })
    ro.observe(container)
    return () => ro.disconnect()
  })

  // The highlight ring/dim styling below is tied to the node whose window
  // was last opened — once that window is closed (from WindowLayer, not
  // necessarily from here), the ring should go with it rather than pointing
  // at a window that no longer exists.
  $effect(() => {
    if (selected && !$wmState.windows[selected.slug]) selected = null
  })

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

  const healthColor: Record<string, string> = {
    healthy: 'var(--success)',
    degraded: 'var(--warning)',
    down: 'var(--destructive)',
    stale: 'var(--warning)',
    unknown: 'var(--muted-foreground)'
  }
  function nodeColor(n: Node): string {
    return n.health
      ? (healthColor[n.health] ?? 'var(--muted-foreground)')
      : 'var(--muted-foreground)'
  }
  function nodeRadius(n: Node): number {
    return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
  }
  function shortName(slug: string): string {
    return slug.split(':').pop() ?? slug
  }

  // Compute the view transform that fits every node (with label clearance)
  // inside the panel, clamped so a single node doesn't fill it and a huge
  // graph stays legible. No-op until the layout has positions / a size.
  function fitView() {
    if (!nodes.length || cw <= 1 || ch <= 1) return
    let minX = Infinity
    let minY = Infinity
    let maxX = -Infinity
    let maxY = -Infinity
    for (const n of nodes) {
      if (n.x == null || n.y == null) continue
      const r = nodeRadius(n) + 12 // node + label clearance
      minX = Math.min(minX, n.x - r)
      minY = Math.min(minY, n.y - r)
      maxX = Math.max(maxX, n.x + r)
      maxY = Math.max(maxY, n.y + r)
    }
    if (!Number.isFinite(minX)) return
    const pad = 16
    const w = Math.max(maxX - minX, 1)
    const h = Math.max(maxY - minY, 1)
    const s = Math.min((cw - pad * 2) / w, (ch - pad * 2) / h)
    const clamped = Math.max(0.2, Math.min(2.5, Number.isFinite(s) ? s : 1))
    scale = clamped
    tx = (cw - w * clamped) / 2 - minX * clamped
    ty = (ch - h * clamped) / 2 - minY * clamped
  }

  // When the entity SET changes (a new node added/removed), re-engage auto-fit
  // so the new entity is brought into view. Same-slug re-renders (every sim
  // tick) leave the signature unchanged and don't reset.
  let lastMembership = ''
  $effect(() => {
    const sig = nodes
      .map((n) => n.slug)
      .sort()
      .join('|')
    if (sig !== lastMembership) {
      lastMembership = sig
      userPanned = false
    }
  })

  // Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
  // during render. Kept as plain objects (not Maps) since Svelte 5 runes track
  // object identity fine and this is small (≤12 touched, ≤8 diffs).
  const touchedBySlug = $derived.by(() => {
    const m: Record<string, true> = {}
    for (const t of touched) m[t.slug] = true
    return m
  })
  const diffBySlug = $derived.by(() => {
    const m: Record<string, { from: string; to: string }> = {}
    for (const d of healthDiffs) if (!(d.slug in m)) m[d.slug] = d
    return m
  })
  const nowTouching = $derived(touched[0] ?? null)

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

  // ─── drag / select / pan ─────────────────────────────────────────────
  // A click (pointerdown+up with no movement in between) opens the entity
  // straight in its own floating window (WindowLayer); `selected` only drives
  // the highlight/dim styling. Node drag pins the node in GRAPH coords
  // (screen→graph via the inverse view transform). Background drag pans the
  // view and sets userPanned so auto-fit pauses. Double-click background
  // re-fits all entities.
  let dragState: { node: Node; moved: boolean } | null = null
  let panState: { x: number; y: number } | null = null

  function toGraph(clientX: number, clientY: number) {
    const rect = container!.getBoundingClientRect()
    return {
      x: (clientX - rect.left - tx) / scale,
      y: (clientY - rect.top - ty) / scale
    }
  }

  function onNodeDown(e: PointerEvent, node: Node) {
    e.stopPropagation()
    ;(e.currentTarget as Element).setPointerCapture(e.pointerId)
    dragState = { node, moved: false }
    sim?.alphaTarget(0.2).restart()
  }
  function onBgDown(e: PointerEvent) {
    panState = { x: e.clientX - tx, y: e.clientY - ty }
    ;(e.currentTarget as Element).setPointerCapture(e.pointerId)
  }
  function onMove(e: PointerEvent) {
    if (dragState) {
      const p = toGraph(e.clientX, e.clientY)
      dragState.node.fx = p.x
      dragState.node.fy = p.y
      dragState.moved = true
      nodes = [...nodes]
      return
    }
    if (panState) {
      tx = e.clientX - panState.x
      ty = e.clientY - panState.y
      userPanned = true
    }
  }
  function selectAndOpen(node: Node) {
    selected = node
    openEntityWindow(node.slug)
  }
  function onUp() {
    if (dragState) {
      const { node, moved } = dragState
      node.fx = null
      node.fy = null
      sim?.alphaTarget(0)
      dragState = null
      if (!moved) selectAndOpen(node)
      return
    }
    panState = null
  }
  function refit() {
    userPanned = false
    fitView()
  }

  const selectedRelations = $derived(
    selected
      ? links
          .filter(
            (l) =>
              endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug
          )
          .map((l) => {
            const outgoing = endpointSlug(l.source) === selected!.slug
            return {
              dir: outgoing ? '→' : '←',
              type: l.type,
              other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source)
            }
          })
      : []
  )
</script>

<aside class="flex h-full min-h-0 flex-col bg-card">
  {#if nowTouching}
    <div
      class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
    >
      <span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
      Now touching <code class="font-mono">{nowTouching.slug}</code>
    </div>
  {/if}

  <div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
    {#if nodes.length === 0}
      <div
        class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center"
      >
        <svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
          <circle cx="60" cy="60" r="6" fill="currentColor">
            <animate
              attributeName="opacity"
              values="0.4;1;0.4"
              dur="2.4s"
              repeatCount="indefinite"
            />
          </circle>
          <g stroke="currentColor" stroke-width="1" opacity="0.5">
            <line x1="60" y1="60" x2="26" y2="34"
              ><animate
                attributeName="opacity"
                values="0.1;0.5;0.1"
                dur="3s"
                repeatCount="indefinite"
              /></line
            >
            <line x1="60" y1="60" x2="96" y2="40"
              ><animate
                attributeName="opacity"
                values="0.1;0.5;0.1"
                dur="3.4s"
                repeatCount="indefinite"
              /></line
            >
            <line x1="60" y1="60" x2="34" y2="92"
              ><animate
                attributeName="opacity"
                values="0.1;0.5;0.1"
                dur="2.8s"
                repeatCount="indefinite"
              /></line
            >
            <line x1="60" y1="60" x2="92" y2="90"
              ><animate
                attributeName="opacity"
                values="0.1;0.5;0.1"
                dur="3.1s"
                repeatCount="indefinite"
              /></line
            >
          </g>
          <g fill="currentColor">
            <circle cx="26" cy="34" r="3.5"
              ><animate
                attributeName="opacity"
                values="0.2;0.7;0.2"
                dur="3s"
                repeatCount="indefinite"
              /></circle
            >
            <circle cx="96" cy="40" r="3.5"
              ><animate
                attributeName="opacity"
                values="0.2;0.7;0.2"
                dur="3.4s"
                repeatCount="indefinite"
              /></circle
            >
            <circle cx="34" cy="92" r="3.5"
              ><animate
                attributeName="opacity"
                values="0.2;0.7;0.2"
                dur="2.8s"
                repeatCount="indefinite"
              /></circle
            >
            <circle cx="92" cy="90" r="3.5"
              ><animate
                attributeName="opacity"
                values="0.2;0.7;0.2"
                dur="3.1s"
                repeatCount="indefinite"
              /></circle
            >
          </g>
        </svg>
        <p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
          Entities Nomos explores in this conversation appear here, wired up by their relationships.
        </p>
      </div>
    {:else}
      <svg
        width={cw}
        height={ch}
        viewBox="0 0 {cw} {ch}"
        class="h-full w-full touch-none select-none"
        role="application"
        aria-label="Session entity graph"
        onpointerdown={onBgDown}
        onpointermove={onMove}
        onpointerup={onUp}
        onpointercancel={onUp}
        ondblclick={refit}
      >
        <defs>
          <pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
            <circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
          </pattern>
        </defs>
        <rect width={cw} height={ch} fill="url(#{dotGridId})" />
        <g transform={viewTransform}>
          <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}
              {@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
              {@const dx = t.x - s.x}
              {@const dy = t.y - s.y}
              {@const len = Math.max(Math.hypot(dx, dy), 1)}
              {@const curve = Math.min(len * 0.15, 40)}
              {@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
              {@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
              <path
                d="M {s.x},{s.y} Q {cx},{cy} {t.x},{t.y}"
                fill="none"
                stroke="var(--muted-foreground)"
                stroke-width={focus ? 1.6 : 1}
                opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
              >
                <title>{link.type}</title>
              </path>
            {/if}
          {/each}
        </g>
        <g>
          {#each nodes as node (node.slug)}
            {#if node.x != null && node.y != null}
              {@const r = nodeRadius(node)}
              {@const isSel = selected?.slug === node.slug}
              {@const dim =
                selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
              {@const isTouched = node.slug in touchedBySlug}
              {@const diff = diffBySlug[node.slug]}
              <g
                transform="translate({node.x},{node.y})"
                class="cursor-pointer"
                opacity={dim ? 0.35 : 1}
                role="button"
                tabindex="0"
                onpointerdown={(e) => onNodeDown(e, node)}
                onkeydown={(e) => e.key === 'Enter' && selectAndOpen(node)}
              >
                {#if isSel}
                  <circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
                {/if}
                {#if isTouched}
                  <circle
                    r={r + 4}
                    fill="none"
                    stroke="var(--primary)"
                    stroke-width="1.5"
                    opacity="0.8"
                  >
                    <animate
                      attributeName="r"
                      values="{r + 3};{r + 8};{r + 3}"
                      dur="1.6s"
                      repeatCount="indefinite"
                    />
                    <animate
                      attributeName="opacity"
                      values="0.8;0.1;0.8"
                      dur="1.6s"
                      repeatCount="indefinite"
                    />
                  </circle>
                {/if}
                <circle
                  {r}
                  fill={nodeColor(node)}
                  stroke={isSel ? 'var(--foreground)' : 'var(--background)'}
                  stroke-width={isSel ? 2 : 1.5}
                />
                <text
                  y={r + 10}
                  text-anchor="middle"
                  font-size="9"
                  fill={isSel ? 'var(--foreground)' : 'var(--muted-foreground)'}
                  paint-order="stroke"
                  stroke="var(--background)"
                  stroke-width="2.5"
                  class="pointer-events-none"
                >
                  {shortName(node.slug)}
                </text>
                {#if diff}
                  <text
                    y={-r - 6}
                    text-anchor="middle"
                    font-size="8"
                    fill="var(--warning)"
                    paint-order="stroke"
                    stroke="var(--background)"
                    stroke-width="2.5"
                    class="pointer-events-none"
                  >
                    {diff.from} → {diff.to}
                  </text>
                {/if}
              </g>
            {/if}
          {/each}
        </g>
        </g>
      </svg>
    {/if}
  </div>
</aside>
