<script lang="ts">
  import { onMount, onDestroy } from 'svelte'
  import Sigma from 'sigma'
  import Graph from 'graphology'
  import forceAtlas2 from 'graphology-layout-forceatlas2'
  import { createNodeImageProgram } from '@sigma/node-image'
  import {
    fetchGraph, fetchOntology, fetchAllEntities, fetchBlastRadius,
    type Entity, type EntityType, type GraphView, type BlastRadiusItem
  } from '$lib/api'
  import { subscribeEvents } from '$lib/stores/events'
  import { applyHealthEvent } from '$lib/health'
  import { openEntityWindow } from '$lib/stores/windows'
  import { getTheme } from '$lib/stores/theme.svelte'
  import * as Collapsible from '$lib/components/ui/collapsible'
  import SearchIcon from '@lucide/svelte/icons/search'
  import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
  import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle'
  import LayersIcon from '@lucide/svelte/icons/layers'
  import HeartPulseIcon from '@lucide/svelte/icons/heart-pulse'
  import ZapIcon from '@lucide/svelte/icons/zap'
  import CircleDotIcon from '@lucide/svelte/icons/circle-dot'

  // ── Theme-aware Gruvbox palettes ────────────────────────
  // Light mode = dark/faded gruvbox on warm cream → high contrast
  // Dark mode = bright gruvbox on black → high visibility
  interface Palette {
    health: Record<string, string>
    type: Record<string, string>
    layer: Record<string, string>
    rel: Record<string, string>
    default: string
    fade: string
    edgeFade: string
    label: string
    iconStroke: string
  }

  const LIGHT: Palette = {
    health: { healthy: '#79740e', degraded: '#b57614', down: '#9d0006', unknown: '#7c6f64', stale: '#b57614' },
    type: {
      'proxmox-host': '#af3a03', 'standalone-server': '#af3a03', workstation: '#076678',
      appliance: '#7c6f64', vm: '#b57614', lxc: '#427b58', service: '#79740e',
      'service-group': '#79740e', agent: '#8f3f71', task: '#076678', execution: '#076678',
      document: '#7c6f64', runbook: '#8f3f71', investigation: '#8f3f71', check: '#427b58',
      signal: '#9d0006', 'config-repo': '#b57614', 'deploy-pipeline': '#076678',
      secret: '#9d0006', cluster: '#427b58', 'storage-pool': '#af3a03',
      'network-device': '#427b58', namespace: '#504945', site: '#8f3f71', entity: '#7c6f64'
    },
    layer: { infrastructure: '#af3a03', governance: '#076678', cognition: '#8f3f71', meta: '#7c6f64' },
    rel: {
      hosts: '#076678', 'depends-on': '#af3a03', 'member-of': '#427b58',
      'located-at': '#8f3f71', documents: '#7c6f64', about: '#8f3f71',
      provides: '#79740e', 'backs-up-to': '#427b58', 'part-of': '#504945',
      runs: '#8f3f71', uses: '#b57614'
    },
    default: '#7c6f64',
    fade: '#bcb094',
    edgeFade: '#bcb09433',
    label: '#1d2021',
    iconStroke: '#efe5c0'
  }

  const DARK: Palette = {
    health: { healthy: '#b8bb26', degraded: '#fabd2f', down: '#fb4934', unknown: '#928374', stale: '#fabd2f' },
    type: {
      'proxmox-host': '#fe8019', 'standalone-server': '#fe8019', workstation: '#83a598',
      appliance: '#928374', vm: '#fabd2f', lxc: '#8ec07c', service: '#b8bb26',
      'service-group': '#b8bb26', agent: '#d3869b', task: '#83a598', execution: '#83a598',
      document: '#928374', runbook: '#d3869b', investigation: '#d3869b', check: '#8ec07c',
      signal: '#fb4934', 'config-repo': '#fabd2f', 'deploy-pipeline': '#83a598',
      secret: '#fb4934', cluster: '#8ec07c', 'storage-pool': '#fe8019',
      'network-device': '#8ec07c', namespace: '#a89984', site: '#d3869b', entity: '#928374'
    },
    layer: { infrastructure: '#fe8019', governance: '#83a598', cognition: '#d3869b', meta: '#928374' },
    rel: {
      hosts: '#83a598', 'depends-on': '#fe8019', 'member-of': '#8ec07c',
      'located-at': '#d3869b', documents: '#928374', about: '#d3869b',
      provides: '#b8bb26', 'backs-up-to': '#8ec07c', 'part-of': '#a89984',
      runs: '#d3869b', uses: '#fabd2f'
    },
    default: '#928374',
    fade: '#3c3836',
    edgeFade: '#3c383622',
    label: '#ebdbb2',
    iconStroke: '#1d2021'
  }

  const LAYER_LABEL: Record<string, string> = {
    infrastructure: 'Infrastructure', governance: 'Governance',
    cognition: 'Cognition', meta: 'Meta'
  }

  // Reactive palette — switches when the user toggles the theme
  const pal = $derived<Palette>(getTheme() === 'light' ? LIGHT : DARK)

  type ColorMode = 'health' | 'type'

// ── Lucide icon rendering (synchronous via Path2D) ──────
  type IconNode = [string, Record<string, string>]
  function lucideIcon(node: IconNode[], stroke: string): string {
    const c = document.createElement('canvas')
    c.width = 128; c.height = 128
    const ctx = c.getContext('2d')!
    ctx.scale(128 / 24, 128 / 24)
    ctx.strokeStyle = stroke
    ctx.fillStyle = stroke
    ctx.lineWidth = 2
    ctx.lineCap = 'round'
    ctx.lineJoin = 'round'
    const num = (a: Record<string, string>, k: string) => parseFloat(a[k] ?? '0')
    for (const [tag, a] of node) {
      if (tag === 'path') { ctx.stroke(new Path2D(a.d ?? '')) }
      else if (tag === 'circle') { ctx.beginPath(); ctx.arc(num(a, 'cx'), num(a, 'cy'), num(a, 'r'), 0, Math.PI * 2); ctx.stroke() }
      else if (tag === 'rect') {
        const x = num(a, 'x'), y = num(a, 'y'), w = num(a, 'width'), h = num(a, 'height'), r = num(a, 'rx')
        ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r)
        ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
        ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r)
        ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); ctx.stroke()
      }
      else if (tag === 'line') { ctx.beginPath(); ctx.moveTo(num(a, 'x1'), num(a, 'y1')); ctx.lineTo(num(a, 'x2'), num(a, 'y2')); ctx.stroke() }
      else if (tag === 'polygon') {
        const pts = (a.points ?? '').trim().split(/\s+/).map(p => p.split(',').map(Number))
        ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1])
        for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
        ctx.closePath(); ctx.fill()
      }
      else if (tag === 'polyline') {
        const pts = (a.points ?? '').trim().split(/\s+/).map(p => p.split(',').map(Number))
        ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1])
        for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
        ctx.stroke()
      }
    }
    return c.toDataURL()
  }
  // Icons regenerate when the theme switches stroke color
  const ICONS = $derived.by(() => {
    const mk = (n: IconNode[]) => lucideIcon(n, pal.iconStroke)
    return {
      host: mk([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }]]),
      machine: mk([["rect", { width: "20", height: "8", x: "2", y: "2", rx: "2" }], ["rect", { width: "20", height: "8", x: "2", y: "14", rx: "2" }]]),
      lxc: mk([["path", { d: "M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z" }], ["path", { d: "M10 21.9V14L2.1 9.1" }], ["path", { d: "m10 14 11.9-6.9" }]]),
      vm: mk([["rect", { width: "20", height: "14", x: "2", y: "3", rx: "2" }], ["line", { x1: "8", x2: "16", y1: "21", y2: "21" }], ["line", { x1: "12", x2: "12", y1: "17", y2: "21" }]]),
      service: mk([["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }], ["circle", { cx: "12", cy: "12", r: "3" }]]),
      agent: mk([["path", { d: "M12 8V4H8" }], ["rect", { width: "16", height: "12", x: "4", y: "8", rx: "2" }], ["path", { d: "M2 14h2" }], ["path", { d: "M20 14h2" }], ["path", { d: "M15 13v2" }], ["path", { d: "M9 13v2" }]]),
      task: mk([["path", { d: "M3 3h18v18H3z" }], ["path", { d: "m9 12 2 2 4-4" }]]),
      execution: mk([["polygon", { points: "6 3 20 12 6 21 6 3" }]]),
      document: mk([["path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }], ["polyline", { points: "14 2 14 8 20 8" }], ["line", { x1: "16", x2: "8", y1: "13", y2: "13" }], ["line", { x1: "16", x2: "8", y1: "17", y2: "17" }], ["line", { x1: "10", x2: "8", y1: "9", y2: "9" }]]),
      runbook: mk([["path", { d: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" }], ["path", { d: "M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" }]]),
      investigation: mk([["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]]),
      check: mk([["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }]]),
      signal: mk([["path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" }], ["path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0" }]]),
      'config-repo': mk([["line", { x1: "6", x2: "6", y1: "3", y2: "15" }], ["circle", { cx: "18", cy: "6", r: "3" }], ["circle", { cx: "6", cy: "18", r: "3" }], ["path", { d: "M18 9a9 9 0 0 1-9 9" }]]),
      'deploy-pipeline': mk([["circle", { cx: "18", cy: "18", r: "3" }], ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M13 6h3a2 2 0 0 1 2 2v7" }], ["line", { x1: "6", x2: "9", y1: "9", y2: "6" }]]),
      secret: mk([["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]]),
      cluster: mk([["rect", { width: "6", height: "6", x: "16", y: "16", rx: "1" }], ["rect", { width: "6", height: "6", x: "2", y: "16", rx: "1" }], ["rect", { width: "6", height: "6", x: "9", y: "2", rx: "1" }], ["path", { d: "M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" }], ["path", { d: "M12 12V8" }]]),
      namespace: mk([["path", { d: "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" }]]),
      site: mk([["path", { d: "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" }], ["circle", { cx: "12", cy: "10", r: "3" }]]),
      entity: mk([["circle", { cx: "12", cy: "12", r: "10" }]])
    } as Record<string, string>
  })
  function iconForType(type: string): string {
    return ICONS[type] ?? ICONS[type.split(':')[0]] ?? ICONS.entity
  }

  // ── State ───────────────────────────────────────────────
  let container = $state<HTMLDivElement | null>(null)
  let loading = $state(true)
  let loadError = $state<string | null>(null)
  let search = $state('')
  let colorMode = $state<ColorMode>('health')
  let healthFilter = $state<Set<string>>(new Set())
  let typeFilters = $state<Record<string, boolean>>({})
  let relFilters = $state<Record<string, boolean>>({})
  let selectedNode = $state<string | null>(null)
  let blast = $state<BlastRadiusItem[]>([])
  let hideIsolated = $state(true)

  let sigma: Sigma | null = null
  let graph: Graph | null = null
  let allEntities = $state<Entity[]>([])
  let ontology = $state<{ entityTypes: EntityType[]; relTypes: { name: string }[] }>({ entityTypes: [], relTypes: [] })
  let typeToLayer = $state<Map<string, string>>(new Map())

  onMount(async () => {
    try {
      const [g, ont, entities] = await Promise.all([
        fetchGraph({ includeStatus: true }),
        fetchOntology(),
        fetchAllEntities()
      ])
      allEntities = entities
      ontology = {
        entityTypes: ont.entityTypes,
        relTypes: ont.relationshipTypes.map(r => ({ name: r.name }))
      }
      const t2l = new Map<string, string>()
      for (const t of ont.entityTypes) t2l.set(t.name, t.layer)
      typeToLayer = t2l

      const initialTypes: Record<string, boolean> = {}
      for (const t of ont.entityTypes) if (!t.is_abstract) initialTypes[t.name] = true
      typeFilters = initialTypes

      if (g) {
        const initialRels: Record<string, boolean> = {}
        for (const e of g.edges) initialRels[e.type] = true
        relFilters = initialRels
        buildGraph(g, t2l)
      }
    } catch (e) {
      loadError = e instanceof Error ? e.message : 'Failed to load graph'
    } finally {
      loading = false
    }
    return subscribeEvents()
  })

  // ── Graph construction ──────────────────────────────────
  function nodeColor(type: string, health: string): string {
    if (colorMode === 'health') return pal.health[health] ?? pal.health.unknown
    return pal.type[type] ?? pal.layer[typeToLayer.get(type) ?? 'meta'] ?? pal.default
  }

  function buildGraph(g: GraphView, t2l: Map<string, string>) {
    if (sigma) { sigma.kill(); sigma = null }
    if (!container) return

    const gr = new Graph({ multi: true, type: 'directed', allowSelfLoops: false })
    const idBySlug = new Map<string, string>()
    const degree = new Map<string, number>()

    for (const node of g.nodes) { idBySlug.set(node.slug, node.slug); degree.set(node.slug, 0) }
    for (const edge of g.edges) {
      if (idBySlug.has(edge.source) && idBySlug.has(edge.target)) {
        degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1)
        degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1)
      }
    }
    const maxDeg = Math.max(...degree.values(), 1)
    const minDeg = Math.min(...degree.values(), 0)

    for (const node of g.nodes) {
      const id = node.slug
      const deg = degree.get(id) ?? 0
      const size = 4 + ((deg - minDeg) / Math.max(maxDeg - minDeg, 1)) * 20
      const layer = t2l.get(node.type) ?? 'meta'
      const hp = node.health ?? 'unknown'
      gr.addNode(id, {
        label: node.name || id.split(':').pop() || id,
        size, x: Math.random() * 600 - 300, y: Math.random() * 600 - 300,
        color: nodeColor(node.type, hp),
        slug: id, entityType: node.type, layer, health: hp, degree: deg,
        image: iconForType(node.type)
      })
    }
    for (const edge of g.edges) {
      const s = idBySlug.get(edge.source)
      const t = idBySlug.get(edge.target)
      if (s && t && s !== t) {
        try {
          const relColor = pal.rel[edge.type] ?? pal.default
        gr.addEdgeWithKey(`${s}→${t}→${edge.type}`, s, t, {
          size: 1, relType: edge.type, color: relColor + '99'
        })
        } catch { /* duplicate edge key */ }
      }
    }

    forceAtlas2.assign(gr, {
      iterations: 140,
      settings: { slowDown: 12, scalingRatio: 12, gravity: 1, edgeWeightInfluence: 0 }
    })
    // Normalize layout
    let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
    gr.forEachNode((n) => {
      const x = gr.getNodeAttribute(n, 'x'), y = gr.getNodeAttribute(n, 'y')
      minX = Math.min(minX, x); maxX = Math.max(maxX, x)
      minY = Math.min(minY, y); maxY = Math.max(maxY, y)
    })
    const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2
    gr.forEachNode((n) => {
      gr.setNodeAttribute(n, 'x', (gr.getNodeAttribute(n, 'x') - cx) * 2)
      gr.setNodeAttribute(n, 'y', (gr.getNodeAttribute(n, 'y') - cy) * 2)
    })

    const sig = new Sigma(gr, container, {
      allowInvalidContainer: true,
      nodeProgramClasses: { image: createNodeImageProgram({ size: { mode: 'force', value: 128 }, keepWithinCircle: true }) },
      defaultNodeType: 'image',
      labelDensity: 0.07, labelGridCellSize: 60, labelRenderedSizeThreshold: 10,
      labelFont: 'JetBrains Mono, monospace', labelSize: 11,
      labelColor: { color: pal.label },
      zIndex: true, defaultEdgeType: 'arrow',
      minCameraRatio: 0.02, maxCameraRatio: 10,
      renderEdgeLabels: false
    })

    sig.on('enterNode', ({ node }) => {
      container!.style.cursor = 'pointer'
      applyHover(sig, gr, node)
    })
    sig.on('leaveNode', () => {
      container!.style.cursor = ''
      if (!selectedNode) clearHover(sig, gr)
      else applyHover(sig, gr, selectedNode)
    })
    sig.on('clickNode', ({ node }) => { selectNode(node) })
    sig.on('clickStage', () => {
      selectedNode = null
      blast = []
      clearHover(sig, gr)
      sig.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 400 })
    })
    sig.on('doubleClickNode', ({ node }) => {
      openEntityWindow(gr.getNodeAttribute(node, 'slug'))
    })

    sigma = sig
    graph = gr
    applyAllFilters()
  }

  async function selectNode(node: string) {
    selectedNode = node
    const slug = graph?.getNodeAttribute(node, 'slug') as string
    if (slug) {
      blast = []
      try { blast = await fetchBlastRadius(slug, 3) } catch { /* ignore */ }
    }
    if (sigma && graph) applyHover(sigma, graph, node)
  }

  // ── Hover / selection highlight ─────────────────────────
  function applyHover(sig: Sigma, gr: Graph, node: string) {
    const hc = sig.getNodeDisplayData(node)?.color ?? pal.default
    sig.setSetting('nodeReducer', (n, data) => {
      if (gr.hasEdge(n, node) || gr.hasEdge(node, n) || n === node)
        return { ...data, zIndex: 1, highlighted: n === node }
      return { ...data, zIndex: 0, label: '', color: pal.fade, image: null, highlighted: false }
    })
    sig.setSetting('edgeReducer', (edge, data) => {
      if (gr.hasExtremity(edge, node)) return { ...data, color: hc, size: 2.5 }
      return { ...data, color: pal.edgeFade, hidden: true }
    })
    sig.refresh()
  }
  function clearHover(sig: Sigma, gr: Graph) {
    sig.setSetting('nodeReducer', null)
    sig.setSetting('edgeReducer', null)
    sig.refresh()
  }

  // ── Filters ─────────────────────────────────────────────
  function applyAllFilters() {
    if (!sigma || !graph) return
    const hiddenTypes = new Set(Object.entries(typeFilters).filter(([, v]) => !v).map(([k]) => k))
    const hiddenRels = new Set(Object.entries(relFilters).filter(([, v]) => !v).map(([k]) => k))
    const hiddenHealth = healthFilter.size > 0
      ? new Set(Object.keys(pal.health).filter(h => {
          if (h === 'stale') return !healthFilter.has('degraded') && !healthFilter.has('stale')
          return !healthFilter.has(h)
        }))
      : null

    graph.forEachNode((n) => {
      const type = graph!.getNodeAttribute(n, 'entityType') as string
      const health = graph!.getNodeAttribute(n, 'health') as string
      const deg = graph!.getNodeAttribute(n, 'degree') as number
      const hide = hiddenTypes.has(type) || (hiddenHealth?.has(health) ?? false) || (hideIsolated && deg === 0)
      graph!.setNodeAttribute(n, 'hidden', hide)
      graph!.setNodeAttribute(n, 'color', nodeColor(type, health))
    })
    graph.forEachEdge((e) => {
      const rt = graph!.getEdgeAttribute(e, 'relType') as string
      const src = graph!.source(e), tgt = graph!.target(e)
      const endpointHidden = graph!.getNodeAttribute(src, 'hidden') || graph!.getNodeAttribute(tgt, 'hidden')
      graph!.setEdgeAttribute(e, 'hidden', hiddenRels.has(rt) || endpointHidden)
    })
    sigma.refresh()
  }

  // Palette changes (theme toggle): regenerate icons + label color
  $effect(() => {
    void pal
    if (!graph || !sigma) return
    graph.forEachNode((n) => {
      graph!.setNodeAttribute(n, 'image', iconForType(graph!.getNodeAttribute(n, 'entityType') as string))
    })
    sigma.setSetting('labelColor', { color: pal.label })
  })

  // Filter changes: apply visibility + color filters
  $effect(() => {
    void [typeFilters, relFilters, healthFilter, colorMode, pal, hideIsolated]
    if (graph && sigma) applyAllFilters()
  })

  // ── Quick presets ───────────────────────────────────────
  function setPreset(p: 'all' | 'problems' | 'infra') {
    healthFilter = new Set()
    const allTrue: Record<string, boolean> = {}
    for (const t of ontology.entityTypes) if (!t.is_abstract) allTrue[t.name] = true
    const allRels: Record<string, boolean> = {}
    for (const k of Object.keys(relFilters)) allRels[k] = true

    if (p === 'all') {
      typeFilters = { ...allTrue }
      relFilters = { ...allRels }
      colorMode = 'health'
    } else if (p === 'problems') {
      healthFilter = new Set(['degraded', 'down', 'stale'])
      typeFilters = { ...allTrue }
      relFilters = { ...allRels }
      colorMode = 'health'
    } else if (p === 'infra') {
      typeFilters = { ...allTrue }
      const infra = ontology.entityTypes.filter(t => t.layer === 'infrastructure' && !t.is_abstract)
      for (const k of Object.keys(typeFilters)) {
        typeFilters[k] = infra.some(t => t.name === k)
      }
      relFilters = { ...allRels, 'hosts': true, 'depends-on': true }
      colorMode = 'type'
    }
  }

  // ── Search ──────────────────────────────────────────────
  function focusSearch() {
    if (!sigma || !graph || !search) return
    const q = search.toLowerCase()
    let best: string | null = null
    let bestDeg = -1
    graph.forEachNode((n) => {
      const label = (graph!.getNodeAttribute(n, 'label') as string) ?? ''
      if (label.toLowerCase().includes(q)) {
        const d = graph!.getNodeAttribute(n, 'degree') as number
        if (d > bestDeg) { best = n; bestDeg = d }
      }
    })
    if (best) {
      const dd = sigma.getNodeDisplayData(best)
      if (dd) sigma.getCamera().animate({ x: dd.x, y: dd.y, ratio: 0.15 }, { duration: 600 })
      selectNode(best)
    }
  }

  function resetView() { sigma?.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 400 }) }
  function zoom(f: number) { const c = sigma?.getCamera(); if (c) c.animate({ ratio: c.ratio * f }, { duration: 200 }) }

  onDestroy(() => { sigma?.kill() })

  // ── Derived stats ───────────────────────────────────────
  const healthCounts = $derived.by(() => {
    const c: Record<string, number> = { healthy: 0, degraded: 0, down: 0, unknown: 0, stale: 0 }
    for (const e of allEntities) {
      const h = e.health ?? 'unknown'
      c[h] = (c[h] ?? 0) + 1
    }
    return c
  })

  const totalHealth = $derived(Object.values(healthCounts).reduce((a, b) => a + b, 0) || 1)

  const typesByLayer = $derived.by(() => {
    const m: Record<string, EntityType[]> = {}
    for (const t of ontology.entityTypes) {
      if (t.is_abstract) continue
      const layer = t.layer ?? 'meta'
      if (!m[layer]) m[layer] = []
      m[layer].push(t)
    }
    return Object.entries(m).sort(([a], [b]) => a.localeCompare(b))
  })

  const relTypeList = $derived(
    [...new Set(Object.keys(relFilters))].sort()
  )

  const selectedEntity = $derived.by(() => {
    if (!selectedNode || !graph) return null
    const slug = graph.getNodeAttribute(selectedNode, 'slug') as string
    return allEntities.find(e => e.slug === slug) ?? null
  })

  const stats = $derived.by(() => {
    if (!graph) return { nodes: 0, edges: 0, visible: 0 }
    let visible = 0
    graph.forEachNode((n) => { if (!graph!.getNodeAttribute(n, 'hidden')) visible++ })
    return { nodes: graph.order, edges: graph.size, visible }
  })
</script>

<div class="flex h-full w-full overflow-hidden bg-card">
  <!-- Graph canvas -->
  <div class="relative flex-1">
    <div class="absolute inset-0 bg-card" style="background-image: radial-gradient(circle, color-mix(in oklab, var(--foreground) 8%, transparent) 1px, transparent 1px); background-size: 24px 24px;" bind:this={container}></div>

    {#if loading}
      <div class="absolute inset-0 flex items-center justify-center bg-card">
        <div class="flex flex-col items-center gap-3">
          <div class="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
          <span class="text-xs text-muted-foreground">Loading entity graph…</span>
        </div>
      </div>
    {:else if loadError}
      <div class="absolute inset-0 flex items-center justify-center bg-card">
        <div class="flex flex-col items-center gap-2 px-6 text-center">
          <AlertTriangleIcon class="size-6 text-destructive" />
          <span class="text-sm text-destructive">{loadError}</span>
        </div>
      </div>
    {:else}
      <!-- Zoom controls -->
      <div class="absolute bottom-3 left-3 flex flex-col gap-1">
        <button class="flex size-7 items-center justify-center rounded-md border border-border/60 bg-card/90 backdrop-blur-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onclick={() => zoom(0.7)} title="Zoom in">
          <svg viewBox="0 0 24 24" class="size-3.5" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
        </button>
        <button class="flex size-7 items-center justify-center rounded-md border border-border/60 bg-card/90 backdrop-blur-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onclick={() => zoom(1 / 0.7)} title="Zoom out">
          <svg viewBox="0 0 24 24" class="size-3.5" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
        </button>
        <button class="flex size-7 items-center justify-center rounded-md border border-border/60 bg-card/90 backdrop-blur-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onclick={resetView} title="Reset view">
          <Maximize2Icon class="size-3.5" />
        </button>
      </div>

      <!-- Color mode toggle -->
      <div class="absolute right-3 top-3 flex overflow-hidden rounded-md border border-border/60 bg-card/90 backdrop-blur-sm">
        <button class="flex items-center gap-1 px-2.5 py-1.5 text-[10px] font-medium transition-colors {colorMode === 'health' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}" onclick={() => colorMode = 'health'}>
          <HeartPulseIcon class="size-3" /> Health
        </button>
        <button class="flex items-center gap-1 px-2.5 py-1.5 text-[10px] font-medium transition-colors {colorMode === 'type' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}" onclick={() => colorMode = 'type'}>
          <LayersIcon class="size-3" /> Type
        </button>
      </div>
    {/if}
  </div>

  <!-- Sidebar -->
  <aside class="flex w-80 shrink-0 flex-col border-l bg-card">
    <!-- Search -->
    <div class="border-b p-2.5">
      <div class="relative">
        <SearchIcon class="absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
        <input type="search" placeholder="Search entities…" bind:value={search}
          oninput={focusSearch}
          onkeydown={(e) => e.key === 'Enter' && focusSearch()}
          class="h-8 w-full rounded-md border bg-background pl-7 pr-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring" />
      </div>
      <div class="mt-2 flex gap-1">
        <button class="flex-1 rounded border border-border/60 px-1.5 py-1 text-[10px] font-medium transition-colors hover:bg-muted" onclick={() => setPreset('all')}>All</button>
        <button class="flex-1 rounded border border-warning/40 px-1.5 py-1 text-[10px] font-medium text-warning transition-colors hover:bg-warning/10" onclick={() => setPreset('problems')}>
          <AlertTriangleIcon class="mr-0.5 inline size-2.5" />Problems
        </button>
        <button class="flex-1 rounded border border-border/60 px-1.5 py-1 text-[10px] font-medium transition-colors hover:bg-muted" onclick={() => setPreset('infra')}>Infra</button>
      </div>
    </div>

    <div class="flex-1 overflow-y-auto">
      <!-- Health distribution -->
      <div class="border-b p-2.5">
        <div class="mb-1.5 flex items-center justify-between">
          <span class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Health</span>
          {#if healthFilter.size > 0}
            <button class="text-[10px] text-primary hover:underline" onclick={() => healthFilter = new Set()}>clear</button>
          {/if}
        </div>
        <div class="flex h-4 overflow-hidden rounded-sm">
          {#each ['healthy', 'degraded', 'down', 'unknown'] as h}
            {@const cnt = healthCounts[h] ?? 0}
            {#if cnt > 0}
              {@const active = healthFilter.size === 0 || healthFilter.has(h)}
              <button
                style="background:{pal.health[h]}; width:{(cnt / totalHealth) * 100}%; opacity:{active ? 1 : 0.25}"
                title="{h}: {cnt}"
                onclick={() => {
                  const s = new Set(healthFilter)
                  if (s.has(h)) s.delete(h); else s.add(h)
                  healthFilter = s.size === Object.keys(pal.health).length ? new Set() : s
                }}
                class="h-full min-w-[2px] transition-opacity hover:opacity-80 cursor-pointer"></button>
            {/if}
          {/each}
        </div>
        <div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5">
          {#each [['healthy', healthCounts.healthy ?? 0], ['degraded', healthCounts.degraded ?? 0], ['down', healthCounts.down ?? 0], ['unknown', healthCounts.unknown ?? 0]] as [h, cnt]}
            <button class="flex items-center gap-1 text-[10px] transition-opacity {(healthFilter.size === 0 || healthFilter.has(h)) ? 'text-foreground' : 'text-muted-foreground/40'}"
              onclick={() => {
                const s = new Set(healthFilter)
                if (s.has(h)) s.delete(h); else s.add(h)
                healthFilter = s.size === Object.keys(pal.health).length ? new Set() : s
              }}>
              <span class="size-1.5 rounded-full" style="background:{pal.health[h]}"></span>
              {h} <span class="font-mono">{cnt}</span>
            </button>
          {/each}
        </div>
      </div>

      <!-- Selected node detail -->
      {#if selectedEntity}
        <div class="border-b bg-muted/30 p-2.5">
          <div class="flex items-start justify-between gap-2">
            <div class="min-w-0 flex-1">
              <div class="flex items-center gap-1.5">
                <span class="size-2 shrink-0 rounded-full" style="background:{pal.health[selectedEntity.health ?? 'unknown']}"></span>
                <span class="truncate text-xs font-semibold">{selectedEntity.name}</span>
              </div>
              <span class="mt-0.5 block font-mono text-[10px] text-muted-foreground">{selectedEntity.slug}</span>
            </div>
            <button class="shrink-0 text-muted-foreground hover:text-foreground" onclick={() => { selectedNode = null; blast = []; clearHover(sigma!, graph!) }} title="Close">×</button>
          </div>

          <div class="mt-2 flex gap-1">
            <button class="flex flex-1 items-center justify-center gap-1 rounded border px-1 py-1 text-[10px] hover:bg-muted" onclick={() => openEntityWindow(selectedEntity.slug)}>
              <CircleDotIcon class="size-3" /> Details
            </button>
          </div>

          {#if blast.length > 1}
            <div class="mt-2">
              <div class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
                <ZapIcon class="size-2.5" /> Blast Radius ({blast.length - 1})
              </div>
              <div class="flex flex-wrap gap-1">
                {#each blast.filter(b => b.depth > 0).slice(0, 15) as b (b.entity.id)}
                  <button onclick={() => selectNode(b.entity.slug)}
                    class="rounded border px-1 py-0.5 font-mono text-[9px] text-muted-foreground hover:bg-muted"
                    title="{b.entity.slug} — {b.depth} hop{b.depth === 1 ? '' : 's'}">
                    {b.entity.slug.split(':').pop()}
                  </button>
                {/each}
              </div>
            </div>
          {/if}
        </div>
      {/if}

      <!-- Entity types by layer -->
      <div class="border-b p-2.5">
        <div class="mb-1.5 flex items-center justify-between">
          <span class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Entity Types</span>
          <span class="font-mono text-[10px] text-muted-foreground">{stats.visible}/{stats.nodes}</span>
        </div>
        {#each typesByLayer as [layer, types]}
          {@const allOn = types.every(t => typeFilters[t.name] ?? true)}
          <Collapsible.Root>
            <div class="flex items-center">
              <Collapsible.Trigger class="flex flex-1 items-center gap-1.5 py-1 text-[10px] font-medium hover:text-foreground {allOn ? 'text-muted-foreground' : 'text-foreground'}">
                <svg viewBox="0 0 24 24" class="size-3" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
                <span class="size-2 rounded-full" style="background:{pal.layer[layer]}"></span>
                {LAYER_LABEL[layer] ?? layer}
              </Collapsible.Trigger>
              <button class="text-[9px] text-muted-foreground hover:text-foreground" onclick={() => {
                typeFilters = { ...typeFilters, ...Object.fromEntries(types.map(t => [t.name, !allOn])) }
              }}>{allOn ? 'all' : 'none'}</button>
            </div>
            <Collapsible.Content>
              <div class="ml-4 space-y-0.5 pb-1">
                {#each types as t}
                  {@const cnt = allEntities.filter(e => e.type === t.name).length}
                  {@const color = pal.type[t.name] ?? pal.default}
                  <label class="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50">
                    <input type="checkbox" checked={typeFilters[t.name] ?? true}
                      onchange={() => { typeFilters = { ...typeFilters, [t.name]: !typeFilters[t.name] } }}
                      class="size-2.5" />
                    <span class="size-1.5 shrink-0 rounded-full" style="background:{color}"></span>
                    <span class="truncate flex-1">{t.name}</span>
                    <span class="font-mono text-muted-foreground">{cnt}</span>
                  </label>
                {/each}
              </div>
            </Collapsible.Content>
          </Collapsible.Root>
        {/each}
      </div>

      <!-- Relationship types -->
      {#if relTypeList.length > 0}
        <div class="p-2.5">
          <div class="mb-1.5 flex items-center justify-between">
            <span class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Relationships</span>
            <span class="font-mono text-[10px] text-muted-foreground">{stats.edges} edges</span>
          </div>
          <div class="space-y-0.5">
            {#each relTypeList as rt}
              {@const color = pal.rel[rt] ?? pal.default}
              <label class="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-[11px] hover:bg-muted/50">
                <input type="checkbox" checked={relFilters[rt] ?? true}
                  onchange={() => { relFilters = { ...relFilters, [rt]: !relFilters[rt] } }}
                  class="size-2.5" />
                <span class="h-0.5 w-4 shrink-0 rounded" style="background:{color}"></span>
                <span class="truncate">{rt}</span>
              </label>
            {/each}
          </div>
        </div>
      {/if}
    </div>

    <!-- Footer hint -->
    <div class="flex items-center justify-between border-t px-2.5 py-1.5">
      <label class="flex cursor-pointer items-center gap-1.5 text-[10px] text-muted-foreground">
        <input type="checkbox" bind:checked={hideIsolated} class="size-2.5" />
        Hide isolated
      </label>
      <span class="text-[9px] leading-relaxed text-muted-foreground/60">Hover to trace · Click for blast radius · Dbl-click to open</span>
    </div>
  </aside>
</div>