<script lang="ts" module>
  export type Health = 'healthy' | 'degraded' | 'down' | 'unknown'

  export interface FleetMapProps {
    // Shared with the entity table — highlights matching nodes, dims the rest.
    search?: string
    // The entity currently open in a floating window, mirrored as a ring.
    selectedSlug?: string | null
    onSelect: (slug: string | null) => void
  }
</script>

<script lang="ts">
  import { onMount, onDestroy, untrack } from 'svelte'
  import { fetchGraph, type GraphView, type Entity } from '$lib/api'
  import { liveEvents, subscribeEvents } from '$lib/stores/events'
  import { isHealthEvent, applyHealthEvent, healthFromEvent } from '$lib/health'
  import { Skeleton } from '$lib/components/ui/skeleton'
  import { Button } from '$lib/components/ui/button'
  import GlobeIcon from '@lucide/svelte/icons/globe'
  import LockIcon from '@lucide/svelte/icons/lock'
  import LockOpenIcon from '@lucide/svelte/icons/lock-open'
  import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert'
  import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'

  let { search = '', selectedSlug = null, onSelect }: FleetMapProps = $props()

  // Health palette matches the rest of the app's graph/status surfaces.
  const HEALTH_COLOR: Record<Health, string> = {
    healthy: '#3fb950',
    degraded: '#d29922',
    down: '#f85149',
    unknown: '#8b949e'
  }
  const HEALTH_LABEL: Record<Health, string> = {
    healthy: 'Healthy',
    degraded: 'Degraded',
    down: 'Down',
    unknown: 'Unknown'
  }
  const HEALTH_ORDER: Health[] = ['healthy', 'degraded', 'down', 'unknown']

  // ─── data ──────────────────────────────────────────────────────────────
  let graph = $state<GraphView | null>(null)
  let loading = $state(true)

  async function load() {
    loading = true
    // No root = the whole fleet (server caps at the 500 most-connected
    // entities, well above our ~170). include=status attaches live health.
    graph = await fetchGraph({ includeStatus: true })
    loading = false
  }

  onMount(() => {
    load()
    return subscribeEvents()
  })

  // Structural changes need a refetch — they change which nodes and edges
  // exist. Health does not: the event carries the new value, so the node is
  // patched in place instead of pulling the entire fleet graph (and its
  // layout) down again for one colour change.
  // untrack: this effect must depend ONLY on liveEvents. It both reads and
  // writes `graph`, and the health patch builds a new object every time — so
  // tracking that read made each write re-trigger the effect, which wrote
  // again, until Svelte aborted with effect_update_depth_exceeded.
  $effect(() => {
    const ev = $liveEvents[0]
    if (!ev) return
    untrack(() => {
      if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.')) {
        load()
        return
      }
      if (isHealthEvent(ev) && graph) {
        const health = healthFromEvent(ev)
        if (!health || !ev.entity_id) return
        // healthOf() reads graph.health[id] in preference to the node's own
        // field (include=status attaches it as a side map), so patching only
        // the nodes would leave the rendered colour unchanged. Patch both.
        const nodes = applyHealthEvent(graph.nodes, ev)
        graph = {
          ...graph,
          nodes,
          health: { ...(graph.health ?? {}), [ev.entity_id]: health as Health }
        }
      }
    })
  })

  // ─── interaction state ─────────────────────────────────────────────────
  // Highlight + detail follow the *hover* — a click is reserved for opening
  // the entity window, so pointing is enough to trace a chain. A short grace
  // timer keeps the focus alive while the cursor crosses into the detail
  // panel, so its links stay clickable instead of vanishing on mouse-out.
  let focus = $state<string | null>(null)
  let problemMode = $state(false)
  let healthFilter = $state<Health | null>(null)
  let clearTimer: ReturnType<typeof setTimeout> | null = null

  function setFocus(slug: string) {
    if (clearTimer) {
      clearTimeout(clearTimer)
      clearTimer = null
    }
    focus = slug
  }
  function keepFocus() {
    if (clearTimer) {
      clearTimeout(clearTimer)
      clearTimer = null
    }
  }
  function scheduleClear() {
    if (clearTimer) clearTimeout(clearTimer)
    clearTimer = setTimeout(() => {
      focus = null
      clearTimer = null
    }, 90)
  }
  function clearFocusNow() {
    if (clearTimer) {
      clearTimeout(clearTimer)
      clearTimer = null
    }
    focus = null
  }
  onDestroy(() => {
    if (clearTimer) clearTimeout(clearTimer)
  })

  // ─── helpers ───────────────────────────────────────────────────────────
  function attr(n: Entity | undefined, key: string): string | undefined {
    const v = n?.attributes?.[key]
    return typeof v === 'string' ? v : typeof v === 'number' ? String(v) : undefined
  }
  function boolAttr(n: Entity | undefined, key: string): boolean | undefined {
    const v = n?.attributes?.[key]
    return typeof v === 'boolean' ? v : undefined
  }
  function healthOf(n: Entity): Health {
    const raw = graph?.health?.[n.id] ?? n.health ?? 'unknown'
    if (raw === 'stale') return 'degraded'
    return (HEALTH_ORDER as string[]).includes(raw) ? (raw as Health) : 'unknown'
  }
  function active(n: Entity): boolean {
    return !n.state || n.state === 'active'
  }

  // ─── layout constants ──────────────────────────────────────────────────
  const LANE_X = [16, 214, 470]
  const LANE_W = [150, 176, 214]
  const ROW_H = 52
  const NODE_H = 42
  const TOP0 = 40
  const BOT_PAD = 40

  type Kind = 'host' | 'container' | 'service'
  interface LaidNode {
    slug: string
    id: string
    name: string
    type: string
    kind: Kind
    health: Health
    lane: number
    x: number
    y: number
    w: number
    h: number
    meta: string
    publicHost?: string
    fauth?: boolean
    role?: string
    ip?: string
    url?: string
    provider?: string
    deps: string[]
    mounts: string[]
    repo?: string
  }
  interface LaidEdge {
    d: string
    kind: 'prov' | 'dep'
    s: string
    t: string
  }
  interface Model {
    nodes: LaidNode[]
    bySlug: Map<string, LaidNode>
    edges: LaidEdge[]
    width: number
    height: number
    counts: Record<Health, number>
    providesMap: Map<string, string[]>
    providedBy: Map<string, string>
    dependents: Map<string, string[]>
    depsOf: Map<string, string[]>
  }

  const model = $derived.by<Model | null>(() => {
    const g = graph
    if (!g) return null

    const bySlugEntity = new Map<string, Entity>()
    for (const n of g.nodes) if (active(n)) bySlugEntity.set(n.slug, n)
    const typeOf = (slug: string) => bySlugEntity.get(slug)?.type
    const isContainer = (slug: string) => {
      const t = typeOf(slug)
      return t === 'lxc' || t === 'vm'
    }
    const isService = (slug: string) => typeOf(slug) === 'service'

    // relationship-derived maps
    const containerHost = new Map<string, string>() // container -> host
    const serviceProvider = new Map<string, string>() // service -> provider (host or container)
    const depsOf = new Map<string, string[]>() // service -> [service]
    const dependents = new Map<string, string[]>() // service -> [dependent services]
    const routesTo = new Map<string, string>() // service -> ingress slug
    const repoOf = new Map<string, string>() // service -> repo slug
    const mountsOf = new Map<string, string[]>() // container -> [volume slug]
    const hostCandidates = new Set<string>()

    const pushMap = (m: Map<string, string[]>, k: string, v: string) => {
      const a = m.get(k)
      if (a) a.push(v)
      else m.set(k, [v])
    }

    for (const e of g.edges) {
      switch (e.type) {
        case 'hosts':
          if (bySlugEntity.has(e.source) && bySlugEntity.has(e.target)) {
            containerHost.set(e.target, e.source)
            hostCandidates.add(e.source)
          }
          break
        case 'provides':
          if (bySlugEntity.has(e.source) && bySlugEntity.has(e.target)) {
            serviceProvider.set(e.target, e.source)
            if (!isContainer(e.source) && !isService(e.source)) hostCandidates.add(e.source)
          }
          break
        case 'depends-on':
          if (bySlugEntity.has(e.source) && bySlugEntity.has(e.target)) {
            pushMap(depsOf, e.source, e.target)
            pushMap(dependents, e.target, e.source)
          }
          break
        case 'routes-to':
          if (bySlugEntity.has(e.target)) routesTo.set(e.target, e.source)
          break
        case 'configured-by':
          if (bySlugEntity.has(e.source)) repoOf.set(e.source, e.target)
          break
        case 'mounts':
          if (bySlugEntity.has(e.source)) pushMap(mountsOf, e.source, e.target)
          break
      }
    }

    // lane membership
    const services = g.nodes.filter((n) => active(n) && n.type === 'service')
    const containers = g.nodes.filter((n) => active(n) && (n.type === 'lxc' || n.type === 'vm'))
    // A host is any provider/hoster that isn't itself a container or service,
    // and that actually anchors at least one child in the map.
    const hosts = g.nodes.filter(
      (n) => active(n) && hostCandidates.has(n.slug) && !isContainer(n.slug) && !isService(n.slug)
    )

    // ── ordering ──
    // Hosts: biggest subtree first (most infrastructure hangs off it).
    const childCount = (slug: string) =>
      containers.filter((c) => containerHost.get(c.slug) === slug).length +
      services.filter((s) => serviceProvider.get(s.slug) === slug).length
    hosts.sort((a, b) => childCount(b.slug) - childCount(a.slug) || a.name.localeCompare(b.name))
    const hostRank = new Map(hosts.map((h, i) => [h.slug, i]))

    // Containers: grouped by host order, alphabetical within a host.
    containers.sort((a, b) => {
      const ra = hostRank.get(containerHost.get(a.slug) ?? '') ?? 999
      const rb = hostRank.get(containerHost.get(b.slug) ?? '') ?? 999
      return ra - rb || a.name.localeCompare(b.name)
    })

    // ── vertical positions ──
    const yById = new Map<string, number>()
    containers.forEach((c, i) => yById.set(c.slug, TOP0 + i * ROW_H))

    // Host y = mean of its containers (empty hosts handled after services).
    for (const h of hosts) {
      const kids = containers.filter((c) => containerHost.get(c.slug) === h.slug)
      if (kids.length) {
        yById.set(h.slug, kids.reduce((s, c) => s + (yById.get(c.slug) ?? 0), 0) / kids.length)
      }
    }

    // Services: seed y from provider, sort, then de-overlap preserving order.
    const svcRaw = new Map<string, number>()
    for (const s of services) {
      const p = serviceProvider.get(s.slug)
      svcRaw.set(s.slug, p && yById.has(p) ? yById.get(p)! : TOP0)
    }
    services.sort(
      (a, b) => svcRaw.get(a.slug)! - svcRaw.get(b.slug)! || a.name.localeCompare(b.name)
    )
    let prevY = TOP0 - ROW_H
    for (const s of services) {
      const y = Math.max(svcRaw.get(s.slug)!, prevY + ROW_H)
      yById.set(s.slug, y)
      prevY = y
    }

    // Hosts with no containers: center on the services they provide directly.
    for (const h of hosts) {
      if (yById.has(h.slug)) continue
      const provided = services.filter((s) => serviceProvider.get(s.slug) === h.slug)
      yById.set(
        h.slug,
        provided.length
          ? provided.reduce((s, x) => s + (yById.get(x.slug) ?? TOP0), 0) / provided.length
          : TOP0
      )
    }

    // ── build laid nodes ──
    const laid: LaidNode[] = []
    const bySlug = new Map<string, LaidNode>()
    const counts: Record<Health, number> = { healthy: 0, degraded: 0, down: 0, unknown: 0 }

    const makeNode = (n: Entity, kind: Kind, lane: number): LaidNode => {
      const h = healthOf(n)
      counts[h]++
      const ingress = routesTo.get(n.slug)
      const ingressEntity = ingress ? bySlugEntity.get(ingress) : undefined
      const publicHost = ingress
        ? (ingressEntity?.name ?? ingress.replace(/^ingress:/, ''))
        : undefined
      const fauth = ingressEntity ? boolAttr(ingressEntity, 'forward_auth') : undefined
      const provider = kind === 'service' ? serviceProvider.get(n.slug) : containerHost.get(n.slug)
      const mnt = (kind === 'container' ? mountsOf.get(n.slug) : mountsOf.get(provider ?? '')) ?? []
      const role = attr(n, 'role')
      const ip = attr(n, 'lan_ip') ?? attr(n, 'public_ipv4')
      const url = attr(n, 'url')
      let meta: string
      if (kind === 'container') {
        const pve = attr(n, 'pve_id')
        meta = `${n.type.toUpperCase()}${pve ? ' ' + pve : ''}${role ? ' · ' + role : ''}`
      } else if (kind === 'service') {
        meta = publicHost ?? role ?? n.slug
      } else {
        meta = `${n.type}${ip ? ' · ' + ip : ''}`
      }
      const node: LaidNode = {
        slug: n.slug,
        id: n.id,
        name: n.name,
        type: n.type,
        kind,
        health: h,
        lane,
        x: LANE_X[lane],
        y: yById.get(n.slug) ?? TOP0,
        w: LANE_W[lane],
        h: NODE_H,
        meta,
        publicHost,
        fauth,
        role,
        ip,
        url,
        provider,
        deps: depsOf.get(n.slug) ?? [],
        mounts: mnt,
        repo: repoOf.get(n.slug)
      }
      laid.push(node)
      bySlug.set(node.slug, node)
      return node
    }

    hosts.forEach((n) => makeNode(n, 'host', 0))
    containers.forEach((n) => makeNode(n, 'container', 1))
    services.forEach((n) => makeNode(n, 'service', 2))

    // ── edges ──
    const cx = (slug: string, side: 'l' | 'r') => {
      const n = bySlug.get(slug)!
      return LANE_X[n.lane] + (side === 'r' ? LANE_W[n.lane] : 0)
    }
    const cy = (slug: string) => bySlug.get(slug)!.y + NODE_H / 2
    const edges: LaidEdge[] = []
    const provChildren = new Map<string, string[]>() // provider -> [child]
    // provision edges: host->container, provider->service
    for (const c of containers) {
      const host = containerHost.get(c.slug)
      if (host && bySlug.has(host)) {
        const x1 = cx(host, 'r'),
          y1 = cy(host),
          x2 = cx(c.slug, 'l'),
          y2 = cy(c.slug)
        const mx = (x1 + x2) / 2
        edges.push({
          d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`,
          kind: 'prov',
          s: host,
          t: c.slug
        })
        pushMap(provChildren, host, c.slug)
      }
    }
    for (const s of services) {
      const p = serviceProvider.get(s.slug)
      if (p && bySlug.has(p)) {
        const x1 = cx(p, 'r'),
          y1 = cy(p),
          x2 = cx(s.slug, 'l'),
          y2 = cy(s.slug)
        const mx = (x1 + x2) / 2
        edges.push({
          d: `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`,
          kind: 'prov',
          s: p,
          t: s.slug
        })
        pushMap(provChildren, p, s.slug)
      }
    }
    // dependency arcs bulge to the right of the services lane
    for (const [srcSlug, targets] of depsOf) {
      if (!bySlug.has(srcSlug)) continue
      for (const t of targets) {
        if (!bySlug.has(t)) continue
        const x1 = cx(srcSlug, 'r'),
          y1 = cy(srcSlug),
          x2 = cx(t, 'r'),
          y2 = cy(t)
        const bulge = Math.max(x1, x2) + 34 + Math.min(70, Math.abs(y1 - y2) * 0.32)
        edges.push({
          d: `M${x1},${y1} C${bulge},${y1} ${bulge},${y2} ${x2},${y2}`,
          kind: 'dep',
          s: srcSlug,
          t
        })
      }
    }

    const height =
      Math.max(
        containers.length ? (yById.get(containers[containers.length - 1].slug) ?? 0) + ROW_H : 0,
        services.length ? (yById.get(services[services.length - 1].slug) ?? 0) + ROW_H : 0,
        TOP0 + ROW_H
      ) + BOT_PAD
    const width = LANE_X[2] + LANE_W[2] + 84

    return {
      nodes: laid,
      bySlug,
      edges,
      width,
      height,
      counts,
      providesMap: provChildren,
      providedBy: new Map(
        [...containerHost.entries(), ...serviceProvider.entries()].filter(([, p]) => bySlug.has(p))
      ),
      dependents,
      depsOf
    }
  })

  // ─── graph traversals ──────────────────────────────────────────────────
  function servicesUnder(m: Model, id: string): Set<string> {
    const out = new Set<string>()
    const stack = [id]
    while (stack.length) {
      const cur = stack.pop()!
      for (const t of m.providesMap.get(cur) ?? []) {
        if (m.bySlug.get(t)?.kind === 'service') out.add(t)
        stack.push(t)
      }
    }
    return out
  }
  function transitiveDependents(m: Model, seed: Iterable<string>): Set<string> {
    const out = new Set<string>()
    const stack = [...seed]
    while (stack.length) {
      const cur = stack.pop()!
      for (const d of m.dependents.get(cur) ?? []) {
        if (!out.has(d)) {
          out.add(d)
          stack.push(d)
        }
      }
    }
    return out
  }
  function blastRadius(m: Model, id: string): Set<string> {
    const node = m.bySlug.get(id)
    const direct = node?.kind === 'service' ? new Set([id]) : servicesUnder(m, id)
    const set = new Set<string>([...direct, ...transitiveDependents(m, direct)])
    if (node?.kind === 'service') set.delete(id)
    return set
  }
  function upstream(m: Model, id: string): string[] {
    const out: string[] = []
    let up = m.providedBy.get(id)
    let guard = 0
    while (up && guard++ < 8) {
      out.push(up)
      up = m.providedBy.get(up)
    }
    return out
  }
  function chainOf(m: Model, id: string): Set<string> {
    const set = new Set<string>([id])
    for (const u of upstream(m, id)) set.add(u)
    const n = m.bySlug.get(id)
    if (n) for (const d of n.deps) set.add(d)
    for (const s of blastRadius(m, id)) {
      set.add(s)
      const p = m.providedBy.get(s)
      if (p) set.add(p)
    }
    return set
  }
  function problemSet(m: Model): Set<string> {
    const set = new Set<string>()
    for (const n of m.nodes) {
      if (n.health === 'healthy') continue
      set.add(n.slug)
      for (const u of upstream(m, n.slug)) set.add(u)
      for (const s of blastRadius(m, n.slug)) {
        set.add(s)
        const p = m.providedBy.get(s)
        if (p) set.add(p)
      }
    }
    return set
  }
  function healthSet(m: Model, h: Health): Set<string> {
    const set = new Set<string>()
    for (const n of m.nodes) {
      if (n.health !== h) continue
      set.add(n.slug)
      for (const u of upstream(m, n.slug)) set.add(u)
    }
    return set
  }

  // ─── derived highlight + detail ────────────────────────────────────────
  const activeSet = $derived.by<Set<string> | null>(() => {
    const m = model
    if (!m) return null
    if (focus && m.bySlug.has(focus)) return chainOf(m, focus)
    if (problemMode) return problemSet(m)
    if (healthFilter) return healthSet(m, healthFilter)
    const q = search.trim().toLowerCase()
    if (q) {
      const s = new Set<string>()
      for (const n of m.nodes)
        if (n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)) s.add(n.slug)
      return s
    }
    return null
  })

  function nodeState(slug: string): '' | 'dim' | 'hl' | 'sel' {
    if (focus === slug) return 'sel'
    const s = activeSet
    if (!s) return ''
    return s.has(slug) ? 'hl' : 'dim'
  }
  function edgeState(e: LaidEdge): '' | 'dim' | 'hl' {
    const s = activeSet
    if (!s) return ''
    return s.has(e.s) && s.has(e.t) ? 'hl' : 'dim'
  }

  // ─── actions ───────────────────────────────────────────────────────────
  // A click opens the entity in a floating window; hover already handled the
  // highlight, so clicking never has to double as "select".
  function openNode(slug: string) {
    onSelect(slug)
  }
  function reset() {
    clearFocusNow()
    problemMode = false
    healthFilter = null
    onSelect(null)
  }
  function toggleProblems() {
    clearFocusNow()
    healthFilter = null
    problemMode = !problemMode
  }
  function toggleHealth(h: Health) {
    clearFocusNow()
    problemMode = false
    healthFilter = healthFilter === h ? null : h
  }

  // Detail-panel target derived from mode.
  const detailNode = $derived(focus && model?.bySlug.get(focus) ? model!.bySlug.get(focus)! : null)
  const detailBlast = $derived.by(() => {
    const m = model
    if (!m || !detailNode) return [] as LaidNode[]
    return [...blastRadius(m, detailNode.slug)]
      .map((s) => m.bySlug.get(s))
      .filter((n): n is LaidNode => !!n)
  })
  const detailRunsOn = $derived.by(() => {
    const m = model
    if (!m || !detailNode) return [] as LaidNode[]
    return upstream(m, detailNode.slug)
      .map((s) => m.bySlug.get(s))
      .filter((n): n is LaidNode => !!n)
  })
  const detailProvides = $derived.by(() => {
    const m = model
    if (!m || !detailNode) return [] as LaidNode[]
    return (m.providesMap.get(detailNode.slug) ?? [])
      .map((s) => m.bySlug.get(s))
      .filter((n): n is LaidNode => !!n)
  })
  const detailDeps = $derived.by(() => {
    const m = model
    if (!m || !detailNode) return [] as LaidNode[]
    return detailNode.deps.map((s) => m.bySlug.get(s)).filter((n): n is LaidNode => !!n)
  })
  const problemGroups = $derived.by(() => {
    const m = model
    if (!m) return [] as { h: Health; items: LaidNode[] }[]
    return (['down', 'degraded', 'unknown'] as Health[])
      .map((h) => ({ h, items: m.nodes.filter((n) => n.health === h) }))
      .filter((g) => g.items.length)
  })
  const healthItems = $derived.by(() => {
    const m = model
    if (!m || !healthFilter) return [] as LaidNode[]
    return m.nodes.filter((n) => n.health === healthFilter)
  })
  const problemCount = $derived(
    model ? model.nodes.filter((n) => n.health !== 'healthy').length : 0
  )
</script>

{#if loading && !model}
  <Skeleton class="h-full min-h-0" />
{:else if model}
  <div class="fleet flex h-full min-h-0 flex-col overflow-hidden rounded-lg border">
    <!-- toolbar: health chips + problem/reset -->
    <div class="flex shrink-0 flex-wrap items-center gap-2 border-b bg-card/60 px-3 py-2">
      {#each HEALTH_ORDER as h}
        {#if model.counts[h]}
          <button
            class="chip"
            class:on={healthFilter === h}
            onclick={() => toggleHealth(h)}
            type="button"
          >
            <span class="dot" style="background:{HEALTH_COLOR[h]}"></span>
            <b>{model.counts[h]}</b>
            {HEALTH_LABEL[h].toLowerCase()}
          </button>
        {/if}
      {/each}
      <div class="ml-auto flex items-center gap-2">
        <Button
          variant={problemMode ? 'default' : 'outline'}
          size="sm"
          class="h-7 text-xs"
          onclick={toggleProblems}
        >
          <AlertTriangleIcon class="mr-1 size-3.5" />
          Show problems
        </Button>
        {#if problemMode || healthFilter}
          <Button variant="ghost" size="sm" class="h-7 text-xs" onclick={reset}>
            <RotateCcwIcon class="mr-1 size-3.5" />
            Reset
          </Button>
        {/if}
      </div>
    </div>

    <div class="flex min-h-0 flex-1">
      <!-- stage -->
      <div class="stage relative min-w-0 flex-1 overflow-auto">
        <div class="relative" style="width:{model.width}px;height:{model.height}px">
          <!-- background click target: clears selection -->
          <button type="button" class="reset-layer" aria-label="Clear selection" onclick={reset}
          ></button>
          <!-- lane headers -->
          <div class="pointer-events-none absolute inset-x-0 top-0 z-10">
            {#each [['Hosts', model.nodes.filter((n) => n.kind === 'host').length], ['Containers', model.nodes.filter((n) => n.kind === 'container').length], ['Services', model.nodes.filter((n) => n.kind === 'service').length]] as [label, count], i}
              <div class="lane-head" style="left:{LANE_X[i]}px;width:{LANE_W[i]}px">
                {label} · <b>{count}</b>
              </div>
            {/each}
          </div>

          <!-- edges -->
          <svg
            class="pointer-events-none absolute inset-0"
            width={model.width}
            height={model.height}
          >
            {#each model.edges as e}
              <path d={e.d} class="edge {e.kind} {edgeState(e)}" />
            {/each}
          </svg>

          <!-- nodes -->
          {#each model.nodes as n (n.slug)}
            <button
              type="button"
              class="node {nodeState(n.slug)}"
              class:svc={n.kind === 'service'}
              class:is-open={selectedSlug === n.slug}
              class:pulse={n.health === 'down'}
              style="left:{n.x}px;top:{n.y}px;width:{n.w}px;height:{n.h}px;--nc:{HEALTH_COLOR[
                n.health
              ]}"
              onmouseenter={() => setFocus(n.slug)}
              onmouseleave={scheduleClear}
              onfocus={() => setFocus(n.slug)}
              onblur={scheduleClear}
              onclick={(ev) => {
                ev.stopPropagation()
                openNode(n.slug)
              }}
              title={n.slug}
            >
              {#if n.publicHost}
                <span
                  class="badge"
                  title={n.publicHost +
                    (n.fauth ? ' · forward-auth' : n.fauth === false ? ' · no auth gate' : '')}
                >
                  {#if n.fauth}
                    <LockIcon class="size-3" />
                  {:else if n.fauth === false}
                    <LockOpenIcon class="size-3" />
                  {:else}
                    <GlobeIcon class="size-3" />
                  {/if}
                </span>
              {/if}
              <span class="nm">{n.name}</span>
              <span class="meta">{n.meta}</span>
            </button>
          {/each}
        </div>
      </div>

      <!-- detail panel -->
      <!-- svelte-ignore a11y_no_static_element_interactions -->
      <div
        class="panel shrink-0 overflow-auto border-l bg-card/40"
        onmouseenter={keepFocus}
        onmouseleave={scheduleClear}
      >
        {#if !detailNode && problemMode}
          <div class="p-4">
            <div class="det-kind">status</div>
            <div class="det-name">Problems</div>
            <div class="blast">
              <div class="n warn">{problemCount}</div>
              <div class="lbl">{problemCount === 1 ? 'entity' : 'entities'} not healthy</div>
            </div>
            {#each problemGroups as g}
              <div class="det-sec">
                <h4>{HEALTH_LABEL[g.h]} · {g.items.length}</h4>
                {#each g.items as it}
                  <button type="button" class="link" onclick={() => openNode(it.slug)}>
                    <span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
                    <span class="t">{it.name}</span>
                    <span class="sub">{it.kind}</span>
                  </button>
                {/each}
              </div>
            {/each}
          </div>
        {:else if !detailNode && healthFilter}
          <div class="p-4">
            <div class="det-kind">status</div>
            <div class="det-name">{HEALTH_LABEL[healthFilter]}</div>
            <div class="blast">
              <div class="n">{healthItems.length}</div>
              <div class="lbl">{healthItems.length === 1 ? 'entity' : 'entities'}</div>
            </div>
            <div class="det-sec">
              <h4>{HEALTH_LABEL[healthFilter]}</h4>
              {#each healthItems as it}
                <button type="button" class="link" onclick={() => openNode(it.slug)}>
                  <span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
                  <span class="t">{it.name}</span>
                  <span class="sub">{it.kind}</span>
                </button>
              {/each}
            </div>
          </div>
        {:else if detailNode}
          <div class="p-4">
            <div class="det-kind">{detailNode.slug}</div>
            <div class="det-name">{detailNode.name}</div>
            <span class="det-health" style="--hc:{HEALTH_COLOR[detailNode.health]}">
              <span class="d"></span>{HEALTH_LABEL[detailNode.health]}
            </span>

            <div class="blast">
              <div class="n" class:warn={detailBlast.length > 0}>{detailBlast.length}</div>
              <div class="lbl">
                {detailNode.kind === 'service'
                  ? 'downstream service'
                  : 'service'}{detailBlast.length === 1 ? '' : 's'}
                affected if this goes down
              </div>
            </div>

            <div class="det-sec">
              <h4>Attributes</h4>
              {#if detailNode.kind === 'container'}
                <div class="row">
                  <span class="k">type</span><span class="v">{detailNode.type}</span>
                </div>
              {/if}
              {#if detailNode.role}<div class="row">
                  <span class="k">role</span><span class="v">{detailNode.role}</span>
                </div>{/if}
              {#if detailNode.ip}<div class="row">
                  <span class="k">ip</span><span class="v">{detailNode.ip}</span>
                </div>{/if}
              {#if detailNode.publicHost}
                <div class="row">
                  <span class="k">url</span><span class="v">{detailNode.publicHost}</span>
                </div>
                <div class="row">
                  <span class="k">exposure</span><span class="v"
                    >{detailNode.fauth
                      ? 'forward-auth gated'
                      : detailNode.fauth === false
                        ? 'no auth gate'
                        : 'public'}</span
                  >
                </div>
              {:else if detailNode.url}
                <div class="row">
                  <span class="k">url</span><span class="v">{detailNode.url}</span>
                </div>
              {/if}
              {#if detailNode.mounts.length}<div class="row">
                  <span class="k">mounts</span><span class="v">{detailNode.mounts.join(', ')}</span>
                </div>{/if}
              {#if detailNode.repo}<div class="row">
                  <span class="k">config</span><span class="v">{detailNode.repo}</span>
                </div>{/if}
            </div>

            {#if detailRunsOn.length}
              <div class="det-sec">
                <h4>Runs on</h4>
                {#each detailRunsOn as it}
                  <button type="button" class="link" onclick={() => openNode(it.slug)}>
                    <span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
                    <span class="t">{it.name}</span>
                    <span class="sub">{it.kind}</span>
                  </button>
                {/each}
              </div>
            {/if}
            {#if detailDeps.length}
              <div class="det-sec">
                <h4>Depends on</h4>
                {#each detailDeps as it}
                  <button type="button" class="link" onclick={() => openNode(it.slug)}>
                    <span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
                    <span class="t">{it.name}</span>
                    <span class="sub">{it.kind}</span>
                  </button>
                {/each}
              </div>
            {/if}
            {#if detailProvides.length}
              <div class="det-sec">
                <h4>{detailNode.kind === 'host' ? 'Hosts' : 'Provides'}</h4>
                {#each detailProvides as it}
                  <button type="button" class="link" onclick={() => openNode(it.slug)}>
                    <span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
                    <span class="t">{it.name}</span>
                    <span class="sub">{it.kind}</span>
                  </button>
                {/each}
              </div>
            {/if}
            {#if detailNode.kind === 'service' && detailBlast.length}
              <div class="det-sec">
                <h4>Depended on by</h4>
                {#each detailBlast as it}
                  <button type="button" class="link" onclick={() => openNode(it.slug)}>
                    <span class="d" style="background:{HEALTH_COLOR[it.health]}"></span>
                    <span class="t">{it.name}</span>
                    <span class="sub">{it.kind}</span>
                  </button>
                {/each}
              </div>
            {/if}
          </div>
        {:else}
          <!-- legend -->
          <div class="p-4">
            <h3 class="legend-h">Health</h3>
            {#each HEALTH_ORDER as h}
              <div class="legend-row">
                <span class="sw" style="background:{HEALTH_COLOR[h]}"></span>{HEALTH_LABEL[h]}
              </div>
            {/each}
            <div class="sep"></div>
            <h3 class="legend-h">Lanes</h3>
            <div class="legend-row"><span class="lane-swatch"></span>Hosts — physical machines</div>
            <div class="legend-row">
              <span class="lane-swatch"></span>Containers — LXCs &amp; VMs
            </div>
            <div class="legend-row">
              <span class="lane-swatch"></span>Services — what you actually use
            </div>
            <div class="sep"></div>
            <p class="hint">
              Every service flows right from the machine that runs it. Hover any node to trace its
              chain and blast radius — what breaks if it goes down. Click to open it in a window.
              Dashed arcs are service-to-service dependencies.
            </p>
          </div>
        {/if}
      </div>
    </div>
  </div>
{:else}
  <div class="flex h-full items-center justify-center text-xs text-muted-foreground">
    Failed to load graph
  </div>
{/if}

<style>
  .stage {
    background-image: radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0);
    background-size: 26px 26px;
  }
  .reset-layer {
    position: absolute;
    inset: 0;
    z-index: 0;
    width: 100%;
    height: 100%;
    padding: 0;
    background: transparent;
    border: 0;
    cursor: default;
  }
  .lane-head {
    position: absolute;
    top: 0;
    padding: 8px 0 6px 4px;
    font-size: 10.5px;
    font-weight: 500;
    letter-spacing: 0.9px;
    text-transform: uppercase;
    color: var(--muted-foreground);
  }
  .lane-head b {
    font-weight: 500;
    color: var(--foreground);
    font-variant-numeric: tabular-nums;
  }

  .edge {
    fill: none;
    stroke: var(--border);
    stroke-width: 1.4;
    opacity: 0.6;
    transition:
      opacity 0.16s,
      stroke 0.16s;
  }
  .edge.dep {
    stroke-dasharray: 4 3;
  }
  .edge.dim {
    opacity: 0.08;
  }
  .edge.hl {
    stroke: var(--primary);
    stroke-width: 2;
    opacity: 0.95;
    stroke-dasharray: 5 4;
    animation: dash 1s linear infinite;
  }

  @keyframes dash {
    to {
      stroke-dashoffset: -18;
    }
  }

  .node {
    position: absolute;
    z-index: 2;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 2px;
    padding: 6px 10px 6px 13px;
    text-align: left;
    background: var(--card);
    border: 1px solid var(--border);
    border-radius: 9px;
    overflow: hidden;
    cursor: pointer;
    transition:
      opacity 0.16s,
      border-color 0.14s,
      transform 0.12s,
      box-shadow 0.14s;
  }
  .node::before {
    content: '';
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: 4px;
    background: var(--nc);
  }
  .node .nm {
    font-size: 12.5px;
    font-weight: 500;
    line-height: 1.2;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    color: var(--foreground);
  }
  .node .meta {
    font-family: var(--font-mono);
    font-size: 10px;
    line-height: 1.2;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    color: var(--muted-foreground);
  }
  .node.svc .nm {
    font-size: 13px;
  }
  .node .badge {
    position: absolute;
    top: 5px;
    right: 6px;
    color: var(--muted-foreground);
    display: flex;
  }
  .node:hover {
    border-color: var(--primary);
    transform: translateY(-1px);
  }
  .node.dim {
    opacity: 0.24;
  }
  .node.hl {
    border-color: var(--primary);
    box-shadow: 0 0 0 1px var(--primary);
  }
  .node.sel {
    border-color: var(--primary);
    box-shadow: 0 0 0 2px var(--primary);
  }
  .node.is-open {
    border-color: var(--primary);
  }
  .node.pulse::before {
    animation: pulse 1.8s ease-in-out infinite;
  }
  @keyframes pulse {
    0%,
    100% {
      opacity: 1;
    }
    50% {
      opacity: 0.4;
    }
  }

  .chip {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 9px;
    font-size: 12px;
    font-weight: 500;
    border: 1px solid var(--border);
    border-radius: 0;
    background: transparent;
    color: var(--muted-foreground);
    cursor: pointer;
    transition:
      color 0.12s,
      border-color 0.12s,
      background 0.12s;
  }
  .chip:hover {
    border-color: var(--primary);
  }
  .chip.on {
    color: var(--background);
    background: var(--foreground);
    border-color: var(--foreground);
  }
  .chip.on b {
    color: var(--background);
  }
  .chip .dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
  }
  .chip b {
    font-weight: 500;
    color: var(--foreground);
    font-variant-numeric: tabular-nums;
  }

  .panel {
    width: 300px;
    font-size: 13px;
  }
  .legend-h {
    margin: 0 0 8px;
    font-family: var(--font-sans);
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0.8px;
    text-transform: uppercase;
    color: var(--muted-foreground);
  }
  .legend-row {
    display: flex;
    align-items: center;
    gap: 9px;
    padding: 4px 0;
    color: var(--muted-foreground);
    font-size: 12.5px;
  }
  .legend-row .sw {
    width: 12px;
    height: 12px;
    border-radius: 3px;
  }
  .lane-swatch {
    position: relative;
    width: 20px;
    height: 14px;
    border-radius: 4px;
    border: 1px solid var(--border);
    background: var(--card);
  }
  .lane-swatch::before {
    content: '';
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: 3px;
    border-radius: 4px 0 0 4px;
    background: var(--muted-foreground);
  }
  .sep {
    height: 1px;
    background: var(--border);
    margin: 14px 0;
  }
  .hint {
    color: var(--muted-foreground);
    font-size: 12px;
    line-height: 1.6;
  }

  .det-kind {
    font-family: var(--font-mono);
    font-size: 11px;
    color: var(--muted-foreground);
    word-break: break-all;
  }
  .det-name {
    font-family: var(--font-sans);
    font-size: 17px;
    font-weight: 500;
    margin: 2px 0 8px;
  }
  .det-health {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    padding: 3px 10px;
    font-size: 12.5px;
    font-weight: 500;
    border-radius: 999px;
    color: var(--hc);
    background: color-mix(in oklab, var(--hc) 14%, transparent);
  }
  .det-health .d {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--hc);
  }
  .blast {
    margin: 14px 0;
    padding: 12px 14px;
    border: 1px solid var(--border);
    border-radius: 10px;
    background: var(--secondary);
  }
  .blast .n {
    font-size: 25px;
    font-weight: 500;
    line-height: 1;
    font-variant-numeric: tabular-nums;
  }
  .blast .n.warn {
    color: #f85149;
  }
  .blast .lbl {
    margin-top: 4px;
    font-size: 12px;
    color: var(--muted-foreground);
  }
  .det-sec {
    margin-top: 14px;
  }
  .det-sec h4 {
    margin: 0 0 6px;
    font-family: var(--font-sans);
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0.7px;
    text-transform: uppercase;
    color: var(--muted-foreground);
  }
  .row {
    display: flex;
    gap: 8px;
    padding: 3px 0;
    font-size: 12.5px;
  }
  .row .k {
    min-width: 66px;
    color: var(--muted-foreground);
  }
  .row .v {
    font-family: var(--font-mono);
    font-size: 11.5px;
    color: var(--foreground);
    word-break: break-word;
  }
  .link {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    padding: 5px 8px;
    border-radius: 7px;
    background: transparent;
    cursor: pointer;
    text-align: left;
  }
  .link:hover {
    background: var(--secondary);
  }
  .link .d {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    flex: none;
  }
  .link .t {
    font-size: 12.5px;
    color: var(--foreground);
  }
  .link .sub {
    margin-left: auto;
    font-family: var(--font-mono);
    font-size: 10.5px;
    color: var(--muted-foreground);
  }

  @media (prefers-reduced-motion: reduce) {
    .edge.hl {
      animation: none;
    }
    .node.pulse::before {
      animation: none;
    }
    .node,
    .edge {
      transition: none;
    }
  }
</style>
