<script lang="ts">
  import { onMount, tick, untrack } from 'svelte'
  import uPlot from 'uplot'
  import 'uplot/dist/uPlot.min.css'
  import { marked } from 'marked'
  import DOMPurify from 'dompurify'
  import {
    fetchEntity,
    fetchEntityRelations,
    fetchMetrics,
    fetchEntityEvents,
    fetchEntitySignals,
    fetchEntityExecutions,
    fetchExecutionLogs,
    fetchEntityTasks,
    fetchEntityKnowledge,
    fetchKnowledgeContent,
    fetchChecksForTarget,
    fetchBlastRadius,
    fetchAgentActivity,
    fetchAudit,
    patchCheck,
    ackSignal,
    resolveSignal,
    muteSignal,
    type Entity,
    type Relationship,
    type MetricSeries,
    type Signal,
    type Execution,
    type EntityTask,
    type KnowledgeHit,
    type KnowledgeContent,
    type Check,
    type AgentActivity,
    type AuditEntry,
    type BlastRadiusItem
  } from '$lib/api'
  import { relativeTime, truncateMiddle } from '$lib/utils'
  import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
  import { isHealthEvent, applyHealthEventTo } from '$lib/health'
  import {
    deriveVerdict,
    sectionsForType,
    isObservable,
    checkLabel,
    nomosPrompt,
    type SectionKey
  } from '$lib/entityView'
  import { openNewTaskWindow } from '$lib/stores/windows'
  import DetailSection from '$lib/components/DetailSection.svelte'
  import { Badge } from '$lib/components/ui/badge'
  import { Button } from '$lib/components/ui/button'
  import { Skeleton } from '$lib/components/ui/skeleton'
  import { toast } from 'svelte-sonner'

  const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])

  let { slug, onSelectEntity }: { slug: string; onSelectEntity?: (slug: string) => void } = $props()

  let entity = $state<Entity | null>(null)
  let relations = $state<Relationship[]>([])
  let metrics = $state<MetricSeries[]>([])
  let events = $state<OikosEvent[]>([])
  let signals = $state<Signal[]>([])
  let executions = $state<Execution[]>([])
  let expandedExecution = $state<string | null>(null)
  // Streamed output for the expanded execution, kept separate from
  // result.output: result is only written at the terminal state, so a running
  // command has nothing there and these chunks are the only thing to show.
  let streamedOutput = $state('')
  let streamEl = $state<HTMLPreElement | null>(null)
  // Follow the tail unless the operator has scrolled up to read something.
  let followTail = $state(true)
  let tasks = $state<EntityTask[]>([])
  let knowledge = $state<KnowledgeHit[]>([])
  let ownContent = $state<KnowledgeContent | null>(null)
  let checks = $state<Check[]>([])
  let agentActivity = $state<AgentActivity[]>([])
  let auditEntries = $state<AuditEntry[]>([])
  let blast = $state<BlastRadiusItem[]>([])
  let showAllRelations = $state(false)
  let loading = $state(true)
  let actingSignal = $state<string | null>(null)
  let chartContainers: Record<string, HTMLDivElement> = {}

  // fetchEntityRelations already scopes to edges incident to this entity
  // (source OR target = entity, both directions), so a plain split by which
  // side matches is enough — no risk of an unrelated sibling-to-sibling edge
  // sneaking into either group.
  // The single most valuable line in the window: not just "down", but which
  // probe is responsible. Derived from per-check verdicts (check.last_health),
  // which the API only started exposing for this redesign.
  // Relations are the one section where a single entity can legitimately have
  // hundreds; everything else is naturally bounded.
  const RELATION_PREVIEW = 8
  const hiddenRelations = $derived(
    Math.max(outgoingRelations.length - RELATION_PREVIEW, 0) +
      Math.max(incomingRelations.length - RELATION_PREVIEW, 0)
  )

  const verdict = $derived(deriveVerdict(entity, checks))
  const openSignals = $derived(signals.filter((s) => !['resolved', 'failed'].includes(s.state)))

  function askNomos() {
    if (!entity) return
    openNewTaskWindow(nomosPrompt(entity, verdict))
  }

  const outgoingRelations = $derived(
    entity ? relations.filter((r) => r.source === entity!.slug) : []
  )
  const incomingRelations = $derived(
    entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : []
  )

  async function load(s: string) {
    loading = true
    entity = await fetchEntity(s)
    if (!entity) {
      loading = false
      return
    }
    const [rel, m, ev, sig, ex, tk, kh, oc, ch, aa, au, br] = await Promise.all([
      fetchEntityRelations(entity.id),
      fetchMetrics(entity.id),
      fetchEntityEvents(entity.id),
      fetchEntitySignals(entity.id),
      fetchEntityExecutions(entity.id),
      fetchEntityTasks(entity),
      fetchEntityKnowledge(entity.id),
      KNOWLEDGE_TYPES.has(entity.type) ? fetchKnowledgeContent(entity.id) : Promise.resolve(null),
      fetchChecksForTarget(entity.slug),
      fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
      fetchAudit({ entity_id: entity.id, limit: 50 }),
      // Only for things that can actually break; a document has no blast radius.
      isObservable(entity.type) ? fetchBlastRadius(entity.slug, 2) : Promise.resolve([])
    ])
    relations = rel
    metrics = m
    events = ev
    signals = sig
    executions = ex
    tasks = tk
    knowledge = kh
    ownContent = oc
    checks = ch
    agentActivity = aa
    auditEntries = au
    blast = br
    loading = false

    await tick()
    renderCharts()
  }

  async function ackOpenSignal(id: string) {
    actingSignal = id
    const result = await ackSignal(id)
    actingSignal = null
    if (result) {
      toast.success('Signal acknowledged')
      signals = signals.map((s) => (s.id === id ? result : s))
    } else {
      toast.error('Acknowledge failed')
    }
  }

  async function resolveOpenSignal(id: string) {
    actingSignal = id
    const result = await resolveSignal(id)
    actingSignal = null
    if (result) {
      toast.success('Signal resolved')
      signals = signals.map((s) => (s.id === id ? result : s))
    } else {
      toast.error('Resolve failed')
    }
  }

  async function muteOpenSignal(id: string) {
    actingSignal = id
    const muteUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString()
    const result = await muteSignal(id, muteUntil)
    actingSignal = null
    if (result) {
      toast.success('Signal muted for 1h')
      signals = signals.map((s) => (s.id === id ? result : s))
    } else {
      toast.error('Mute failed')
    }
  }

  const RUNNING_STATUSES = new Set(['running', 'approved', 'executing', 'pending_approval'])

  function isRunning(execution: Execution): boolean {
    return RUNNING_STATUSES.has(execution.status)
  }

  async function loadExecutionLogs(executionId: string) {
    const logs = await fetchExecutionLogs(executionId)
    // Ignore a response that arrives after the operator collapsed the row or
    // opened a different one.
    if (expandedExecution !== executionId) return
    streamedOutput = logs.combined
    if (followTail) {
      await tick()
      if (streamEl) streamEl.scrollTop = streamEl.scrollHeight
    }
  }

  async function toggleExecution(execution: Execution) {
    if (expandedExecution === execution.id) {
      expandedExecution = null
      streamedOutput = ''
      return
    }
    expandedExecution = execution.id
    streamedOutput = ''
    followTail = true
    await loadExecutionLogs(execution.id)
  }

  function onStreamScroll() {
    if (!streamEl) return
    // Re-engage following once the operator scrolls back to the bottom.
    followTail = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 24
  }

  // Live tail. The backend throttles execution.output to one event per second
  // per execution and the NOTIFY payload deliberately omits the data, so the
  // event is only a "there is more" ping — the chunks are re-read here.
  //
  // Lifecycle events (execution.completed/failed) refresh the list instead:
  // without that the row keeps its `running` badge and empty duration forever,
  // which only became visible once running executions were shown at all.
  // untrack: this effect must depend ONLY on liveEvents. It reads `entity`,
  // `executions` and `expandedExecution` and writes all three — refreshExecutions()
  // assigns a fresh array, which re-triggered the effect, which refetched
  // again, until Svelte aborted with effect_update_depth_exceeded.
  $effect(() => {
    const ev = $liveEvents[0]
    if (!ev) return
    untrack(() => handleEvent(ev))
  })

  function handleEvent(ev: OikosEvent) {
    if (ev.type.startsWith('execution.')) {
      if (ev.type === 'execution.output') {
        const target = expandedExecution
        if (target && ev.entity_id === target) loadExecutionLogs(target)
        return
      }
      if (!entity) return
      // Only refetch for an execution this panel is actually showing, so an
      // unrelated command elsewhere in the fleet doesn't cause a request here.
      if (executions.some((e) => e.id === ev.entity_id)) refreshExecutions()
      return
    }

    // Health and signals for THIS entity. The panel loaded these once on
    // open, so a window left on screen kept showing the health it had at
    // mount. Scoped by entity_id so an unrelated entity going down elsewhere
    // costs nothing here.
    if (!entity || ev.entity_id !== entity.id) return

    // Health is patched from the event itself — it carries the new value, so
    // there is nothing to go and ask for.
    if (isHealthEvent(ev)) {
      // The dot patches from the event, but the header's *reason* and the
      // Status section are derived from per-check verdicts — and a health
      // change means some check's verdict just moved. Patching only the entity
      // would leave "ping failing" on screen after ping recovered. One small
      // fetch, scoped to this entity's checks.
      entity = applyHealthEventTo(entity, ev)
      refreshChecks()
      return
    }

    // Signals genuinely need a read: the event says one was raised or
    // resolved, not what the full signal list now looks like. Just the
    // signals, though — not the entity, checks, and everything else.
    if (ev.type.startsWith('signal.') || ev.type.startsWith('coverage.')) {
      refreshSignals()
    }
  }

  async function refreshChecks() {
    if (!entity) return
    const slugAtStart = entity.slug
    const next = await fetchChecksForTarget(slugAtStart)
    if (entity?.slug === slugAtStart) checks = next
  }

  async function refreshSignals() {
    if (!entity) return
    const id = entity.id
    const next = await fetchEntitySignals(id)
    // The panel may have switched entity while this was in flight.
    if (entity?.id === id) signals = next
  }

  async function refreshExecutions() {
    if (!entity) return
    const id = entity.id
    const next = await fetchEntityExecutions(id)
    // Guard against the panel having switched entity mid-flight.
    if (entity?.id === id) executions = next
  }

  onMount(() => {
    load(slug)
    // One shared, reference-counted SSE connection; this just registers
    // interest so the tail receives events while the window is open.
    return subscribeEvents()
  })

  $effect(() => {
    if (slug) load(slug)
  })

  function renderCharts() {
    for (const series of metrics) {
      const el = chartContainers[series.metric]
      if (!el) continue
      el.innerHTML = ''
      const xs = series.samples.map((s) => new Date(s.ts).getTime() / 1000)
      const ys = series.samples.map((s) => s.value ?? s.avg ?? null)
      new uPlot(
        {
          width: el.clientWidth || 400,
          height: 160,
          series: [{}, { label: series.metric, stroke: '#58a6ff', width: 2 }],
          axes: [{ stroke: '#8b949e' }, { stroke: '#8b949e' }],
          scales: { x: { time: true } },
          legend: { show: false }
        },
        [xs, ys],
        el
      )
    }
  }

  // The `run` tool encodes its action as `run:{"command":…,"purpose":…}`, so
  // the raw string is unreadable. Show the command when there is one, the bare
  // verb otherwise. Mirrors splitAction() in internal/httpapi/activity.go.
  function executionSummary(execution: Execution): string {
    const idx = execution.action.indexOf(':')
    if (idx < 0) return execution.action
    const verb = execution.action.slice(0, idx)
    const rest = execution.action.slice(idx + 1)
    try {
      const params = JSON.parse(rest)
      if (typeof params?.command === 'string') return params.command
      if (typeof params?.purpose === 'string') return `${verb} — ${params.purpose}`
    } catch {
      // Not JSON — older actions use `verb:plain-params`.
      return `${verb} ${rest}`
    }
    return verb
  }

  // result is {"output": …} on success and {"output": …, "error": …} on
  // failure. Until now nothing in the UI rendered either.
  function executionOutput(execution: Execution): string {
    const result = execution.result
    if (!result) return ''
    const parts: string[] = []
    if (typeof result.error === 'string' && result.error) parts.push(result.error)
    if (typeof result.output === 'string' && result.output) parts.push(result.output)
    return parts.join('\n\n').trim()
  }

  function executionStatusVariant(
    status: string
  ): 'default' | 'secondary' | 'destructive' | 'outline' {
    if (status === 'failed' || status === 'denied' || status === 'revoked') return 'destructive'
    if (status === 'completed') return 'default'
    if (status === 'running' || status === 'pending_approval') return 'secondary'
    return 'outline'
  }

  function formatDuration(ms: number | null): string {
    if (ms == null) return '—'
    if (ms < 1000) return `${ms}ms`
    const s = Math.round(ms / 1000)
    if (s < 60) return `${s}s`
    return `${Math.floor(s / 60)}m ${s % 60}s`
  }

  function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
    if (sev === 'critical') return 'destructive'
    if (sev === 'warning') return 'secondary'
    return 'default'
  }

  const healthDot: Record<string, string> = {
    healthy: 'bg-success',
    degraded: 'bg-warning',
    down: 'bg-destructive',
    stale: 'bg-warning/50',
    unknown: 'bg-muted-foreground/40'
  }

  async function toggleCheck(check: Check) {
    const updated = await patchCheck(check.id, check.version, { enabled: !check.enabled })
    if (updated) {
      checks = checks.map((c) => (c.id === check.id ? updated : c))
      toast.success(`${check.slug} ${updated.enabled ? 'enabled' : 'disabled'}`)
    } else {
      toast.error('Failed to update check')
    }
  }

  // Attributes are freeform (no attribute_schema on most entity types), so
  // the generic key/value list was truncating anything long — including a
  // document's whole changelog — to an unreadable single line. Recognize a
  // few common shapes and render them properly instead of hiding them.
  interface ChangelogEntry {
    date?: string
    title?: string
    body?: string
  }

  const LONG_TEXT_KEYS = new Set([
    'description',
    'content',
    'summary',
    'notes',
    'note',
    'body',
    'details'
  ])

  function isChangelog(value: unknown): value is ChangelogEntry[] {
    return (
      Array.isArray(value) &&
      value.length > 0 &&
      value.every(
        (v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v)
      )
    )
  }

  function isFlatObject(value: unknown): value is Record<string, unknown> {
    return (
      !!value &&
      typeof value === 'object' &&
      !Array.isArray(value) &&
      Object.values(value as object).every((v) => v === null || typeof v !== 'object')
    )
  }

  type AttributeRow =
    | { key: string; kind: 'long-text'; value: string }
    | { key: string; kind: 'changelog'; value: ChangelogEntry[] }
    | { key: string; kind: 'flat-object'; value: Record<string, unknown> }
    | { key: string; kind: 'simple'; value: unknown }

  function renderMarkdown(text: string): string {
    return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
  }

  function classifyAttributes(attrs: Record<string, unknown>): AttributeRow[] {
    return Object.entries(attrs).map(([key, value]): AttributeRow => {
      if (typeof value === 'string' && (LONG_TEXT_KEYS.has(key) || value.length > 120)) {
        return { key, kind: 'long-text', value }
      }
      if (isChangelog(value)) return { key, kind: 'changelog', value }
      if (isFlatObject(value)) return { key, kind: 'flat-object', value }
      return { key, kind: 'simple', value }
    })
  }
</script>

<div class="flex h-full flex-col gap-1.5 overflow-y-auto p-2">
  {#if loading}
    <Skeleton class="h-6 w-48" />
    <Skeleton class="h-8 w-full" />
    <Skeleton class="h-8 w-full" />
    <Skeleton class="h-8 w-full" />
  {:else if !entity}
    <p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
  {:else}
    <!-- Verdict header. Never collapses, and answers "is this OK, and why?"
         before anything else on screen. Replaces a bare slug heading followed
         by a Details section that had to be expanded to learn anything. -->
    <header class="flex flex-col gap-2 rounded-lg border bg-card px-3 py-2.5">
      <div class="flex items-start justify-between gap-3">
        <div class="flex min-w-0 flex-col gap-0.5">
          <h1 class="truncate font-mono text-sm font-semibold">{entity.slug}</h1>
          <div class="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
            <span>{entity.type}</span>
            {#if entity.state && entity.state !== 'active'}
              <span>·</span><Badge variant="outline">{entity.state}</Badge>
            {/if}
          </div>
        </div>
        <Button size="sm" variant="outline" class="h-7 shrink-0 gap-1.5 text-xs" onclick={askNomos}>
          Ask Nomos
        </Button>
      </div>

      {#if isObservable(entity.type)}
        <div class="flex flex-col gap-1">
          <div class="flex items-center gap-2 text-sm">
            <span
              class="size-2.5 shrink-0 rounded-full {verdict.health === 'unmonitored'
                ? 'bg-muted-foreground/40'
                : (healthDot[verdict.health] ?? healthDot.unknown)}"
            ></span>
            <span class="font-medium">{verdict.health}</span>
            {#if entity.last_check_at}
              <span class="text-xs text-muted-foreground"
                >· checked {relativeTime(entity.last_check_at)}</span
              >
            {/if}
          </div>
          <!-- The reason. Without this the header would just restate the dot. -->
          {#if verdict.reason}
            <p class="text-xs text-muted-foreground">{verdict.reason}</p>
          {/if}
          {#if openSignals.length > 0}
            <p class="text-xs text-warning">
              {openSignals.length}
              open signal{openSignals.length === 1 ? '' : 's'}
            </p>
          {/if}
        </div>
      {/if}
    </header>

    {#snippet detailsContent()}
      <dl class="flex flex-col gap-1 text-xs">
        <div class="flex items-center justify-between gap-3 border-b pb-1">
          <dt class="shrink-0 text-muted-foreground">Type</dt>
          <dd><Badge variant="outline">{entity.type}</Badge></dd>
        </div>
        <div class="flex items-center justify-between gap-3 border-b pb-1">
          <dt class="shrink-0 text-muted-foreground">State</dt>
          <dd>
            {#if entity.state}<Badge>{entity.state}</Badge>{:else}<span
                class="text-muted-foreground">—</span
              >{/if}
          </dd>
        </div>
        <div class="flex items-center justify-between gap-3 border-b pb-1">
          <dt class="shrink-0 text-muted-foreground">Health</dt>
          <dd>
            {#if entity.health}
              <span
                class="flex items-center gap-1.5"
                title="checked {relativeTime(entity.last_check_at)}"
              >
                <span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"
                ></span>
                {entity.health} · checked {relativeTime(entity.last_check_at)}
              </span>
            {:else}
              <span class="text-muted-foreground">not monitored</span>
            {/if}
          </dd>
        </div>
        <div class="flex items-center justify-between gap-3 border-b pb-1">
          <dt class="shrink-0 text-muted-foreground">Version</dt>
          <dd>{entity.version}</dd>
        </div>
        <div class="flex items-center justify-between gap-3 border-b pb-1">
          <dt class="shrink-0 text-muted-foreground">Created</dt>
          <dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
        </div>
        <div
          class="flex items-center justify-between gap-3 {entity.maintenance_until
            ? 'border-b pb-1'
            : ''}"
        >
          <dt class="shrink-0 text-muted-foreground">Updated</dt>
          <dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
        </div>
        {#if entity.maintenance_until}
          <div class="flex items-center justify-between gap-3">
            <dt class="shrink-0 text-muted-foreground">Maintenance until</dt>
            <dd>{new Date(entity.maintenance_until).toLocaleString()}</dd>
          </div>
        {/if}
      </dl>
    {/snippet}

    {#snippet monitoringContent()}
      <div class="flex flex-col gap-1">
        {#each checks as check (check.id)}
          <div class="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs">
            <div class="flex items-center gap-2">
              <Badge variant="outline" class="font-mono">{check.kind}</Badge>
              <span class="text-muted-foreground">every {check.interval_s}s</span>
            </div>
            <button
              type="button"
              class="cursor-pointer"
              onclick={() => toggleCheck(check)}
              title={check.enabled ? 'Click to disable' : 'Click to enable'}
            >
              <Badge variant={check.enabled ? 'default' : 'secondary'}
                >{check.enabled ? 'enabled' : 'disabled'}</Badge
              >
            </button>
          </div>
        {:else}
          <p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
        {/each}
      </div>
    {/snippet}

    {#snippet contentContent()}
      {#if ownContent}
        <div class="markdown-body max-w-none text-xs">
          <!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
          {@html renderMarkdown(ownContent.content)}
        </div>
      {:else}
        <p class="text-xs text-muted-foreground">No content.</p>
      {/if}
    {/snippet}

    {#snippet attributesContent()}
      {#if entity.attributes && Object.keys(entity.attributes).length}
        {@const rows = classifyAttributes(entity.attributes)}
        <div class="flex flex-col gap-2 text-xs">
          {#each rows as row (row.key)}
            {#if row.kind === 'long-text'}
              <div class="flex flex-col gap-0.5">
                <p class="font-mono text-muted-foreground">{row.key}</p>
                <p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">
                  {row.value}
                </p>
              </div>
            {:else if row.kind === 'changelog'}
              <div class="flex flex-col gap-0.5">
                <p class="font-mono text-muted-foreground">{row.key} ({row.value.length})</p>
                <div class="flex flex-col gap-1">
                  {#each row.value as entry}
                    <div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
                      <div class="flex items-baseline gap-1.5">
                        {#if entry.date}<span class="shrink-0 font-mono text-muted-foreground"
                            >{entry.date}</span
                          >{/if}
                        {#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
                      </div>
                      {#if entry.body}<p
                          class="whitespace-pre-wrap break-words text-muted-foreground"
                        >
                          {entry.body}
                        </p>{/if}
                    </div>
                  {/each}
                </div>
              </div>
            {:else if row.kind === 'flat-object'}
              <div class="flex flex-col gap-0.5">
                <p class="font-mono text-muted-foreground">{row.key}</p>
                <dl class="flex flex-col gap-0.5 rounded-md bg-muted/40 p-1.5">
                  {#each Object.entries(row.value) as [subKey, subValue]}
                    <div class="flex items-start justify-between gap-3">
                      <dt class="shrink-0 font-mono text-muted-foreground">{subKey}</dt>
                      <dd class="min-w-0 flex-1 break-words text-right">{String(subValue)}</dd>
                    </div>
                  {/each}
                </dl>
              </div>
            {:else}
              <div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
                <dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
                <dd class="min-w-0 flex-1 break-words text-right">
                  {#if row.value !== null && typeof row.value === 'object'}
                    <pre
                      class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(
                        row.value,
                        null,
                        2
                      )}</pre>
                  {:else}
                    {String(row.value)}
                  {/if}
                </dd>
              </div>
            {/if}
          {/each}
        </div>
      {:else}
        <p class="text-xs text-muted-foreground">No attributes.</p>
      {/if}
    {/snippet}

    {#snippet relationRow(rel: Relationship)}
      <div class="flex min-w-0 items-center gap-1 font-mono text-xs">
        {#if onSelectEntity}
          <button
            type="button"
            class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground"
            title={rel.source}
            onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button
          >
          <span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
          <button
            type="button"
            class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground"
            title={rel.target}
            onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button
          >
        {:else}
          <span class="min-w-0 flex-1 truncate" title={rel.source}
            >{truncateMiddle(rel.source)}</span
          >
          <span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
          <span class="min-w-0 flex-1 truncate" title={rel.target}
            >{truncateMiddle(rel.target)}</span
          >
        {/if}
      </div>
    {/snippet}

    {#snippet relationsContent()}
      {#if outgoingRelations.length === 0 && incomingRelations.length === 0}
        <p class="text-xs text-muted-foreground">No direct relations.</p>
      {:else}
        <div class="flex flex-col gap-3">
          {#if outgoingRelations.length}
            <div>
              <div
                class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"
              >
                Outgoing ({outgoingRelations.length})
              </div>
              <div class="flex flex-col gap-1">
                {#each showAllRelations ? outgoingRelations : outgoingRelations.slice(0, RELATION_PREVIEW) as rel}
                  {@render relationRow(rel)}
                {/each}
              </div>
            </div>
          {/if}
          {#if incomingRelations.length}
            <div>
              <div
                class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"
              >
                Incoming ({incomingRelations.length})
              </div>
              <div class="flex flex-col gap-1">
                {#each showAllRelations ? incomingRelations : incomingRelations.slice(0, RELATION_PREVIEW) as rel}
                  {@render relationRow(rel)}
                {/each}
              </div>
            </div>
          {/if}
          <!-- host:hubris has 223 relations. Rendering them flat put ~540
               interactive elements in one window and buried everything else;
               a preview with an explicit drill-in keeps the section scannable
               without hiding anything. -->
          {#if !showAllRelations && hiddenRelations > 0}
            <button
              type="button"
              class="self-start text-[11px] text-muted-foreground underline-offset-2 hover:underline"
              onclick={() => (showAllRelations = true)}
            >
              Show {hiddenRelations} more
            </button>
          {/if}
        </div>
      {/if}
    {/snippet}

    {#snippet metricsContent()}
      {#if metrics.length}
        <div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
          {#each metrics as series (series.metric)}
            <div>
              <p class="mb-1 text-xs text-muted-foreground">{series.metric} ({series.rollup})</p>
              <div bind:this={chartContainers[series.metric]}></div>
            </div>
          {/each}
        </div>
      {:else}
        <p class="text-xs text-muted-foreground">No metrics tracked.</p>
      {/if}
    {/snippet}

    {#snippet signalsContent()}
      <div class="flex flex-col gap-1.5">
        {#each signals as signal (signal.id)}
          <div class="flex flex-col gap-1 border-b pb-1.5 text-xs last:border-0 last:pb-0">
            <div class="flex items-center justify-between gap-2">
              <span>{signal.kind}</span>
              <div class="flex items-center gap-1">
                <Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
                <Badge variant="outline">{signal.state}</Badge>
              </div>
            </div>
            {#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
              <div class="flex justify-end gap-1.5">
                {#if signal.state === 'raised'}
                  <Button
                    size="sm"
                    variant="outline"
                    class="h-6 px-2 text-xs"
                    disabled={actingSignal === signal.id}
                    onclick={() => ackOpenSignal(signal.id)}>Ack</Button
                  >
                {/if}
                <Button
                  size="sm"
                  variant="outline"
                  class="h-6 px-2 text-xs"
                  disabled={actingSignal === signal.id}
                  onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
                >
                <Button
                  size="sm"
                  class="h-6 px-2 text-xs"
                  disabled={actingSignal === signal.id}
                  onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
                >
              </div>
            {/if}
          </div>
        {:else}
          <p class="text-xs text-muted-foreground">None.</p>
        {/each}
      </div>
    {/snippet}

    {#snippet executionsContent()}
      <div class="flex flex-col gap-1.5">
        {#each executions as execution (execution.id)}
          {@const output = executionOutput(execution)}
          {@const running = isRunning(execution)}
          {@const expanded = expandedExecution === execution.id}
          <div class="flex flex-col gap-1 border-b pb-1.5 text-xs last:border-0 last:pb-0">
            <div class="flex items-center justify-between gap-2">
              <button
                type="button"
                class="flex-1 truncate text-left font-mono hover:underline disabled:cursor-default disabled:no-underline"
                disabled={!output && !running}
                title={output || running ? 'Show output' : undefined}
                onclick={() => toggleExecution(execution)}
              >
                {executionSummary(execution)}
              </button>
              <div class="flex shrink-0 items-center gap-1">
                {#if execution.duration_ms != null}
                  <span class="text-muted-foreground">{formatDuration(execution.duration_ms)}</span>
                {:else if running && execution.started_at}
                  <!-- started_at is now written when the status flips to
                       running, so an in-flight command can show how long it
                       has been going instead of nothing at all. -->
                  <span class="text-muted-foreground">{relativeTime(execution.started_at)}</span>
                {/if}
                <Badge variant={executionStatusVariant(execution.status)}>{execution.status}</Badge>
              </div>
            </div>
            <div class="flex items-center gap-2 text-muted-foreground">
              <span>{relativeTime(execution.started_at ?? execution.created_at)}</span>
              <span>·</span>
              <span>{execution.risk_class}</span>
            </div>
            {#if expanded}
              {@const shown = running ? streamedOutput : streamedOutput || output}
              {#if shown}
                <pre
                  bind:this={streamEl}
                  onscroll={onStreamScroll}
                  class="mt-1 max-h-64 overflow-auto rounded bg-muted p-2 font-mono text-[11px] leading-snug whitespace-pre-wrap">{shown}</pre>
              {:else if running}
                <p class="mt-1 text-xs text-muted-foreground italic">Waiting for output…</p>
              {/if}
              {#if running}
                <div class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
                  <span class="size-1.5 animate-pulse rounded-full bg-warning"></span>
                  <span>{followTail ? 'following output' : 'scrolled up — paused'}</span>
                </div>
              {/if}
            {/if}
          </div>
        {:else}
          <p class="text-xs text-muted-foreground">None.</p>
        {/each}
      </div>
    {/snippet}

    {#snippet tasksContent()}
      <div class="flex flex-col gap-1">
        {#each tasks as { task, executionCount } (task.id)}
          {@const title =
            typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
          {@const outcome =
            typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
          <div
            class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0"
          >
            {#if onSelectEntity}
              <button
                type="button"
                class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground"
                {title}
                onclick={() => onSelectEntity(task.slug)}
              >
                {title}
              </button>
            {:else}
              <span class="min-w-0 flex-1 truncate" {title}>{title}</span>
            {/if}
            <div class="flex shrink-0 items-center gap-1">
              {#if outcome}
                <Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
              {/if}
              <Badge variant="outline"
                >{executionCount} action{executionCount === 1 ? '' : 's'}</Badge
              >
            </div>
          </div>
        {:else}
          <p class="text-xs text-muted-foreground">No tasks have acted on this entity.</p>
        {/each}
      </div>
    {/snippet}

    {#snippet knowledgeContent()}
      <div class="flex flex-col gap-1">
        {#each knowledge as hit (hit.id)}
          <div class="text-xs">
            <Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
          </div>
        {:else}
          <p class="text-xs text-muted-foreground">None linked.</p>
        {/each}
      </div>
    {/snippet}

    {#snippet eventsContent()}
      <div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
        {#each events as ev (ev.id)}
          <div class="flex items-center justify-between gap-2 text-xs">
            <span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
            <span class="truncate">{ev.type}</span>
          </div>
        {:else}
          <p class="text-xs text-muted-foreground">No events yet.</p>
        {/each}
      </div>
    {/snippet}

    {#snippet agentActivityContent()}
      <div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
        {#each agentActivity as activity (activity.id)}
          <div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
            <div class="flex items-center justify-between gap-2">
              <span class="font-mono text-muted-foreground"
                >{new Date(activity.ts).toLocaleString()}</span
              >
              <Badge variant={activity.success === false ? 'destructive' : 'outline'}
                >{activity.activity_type}</Badge
              >
            </div>
            <span class="truncate text-muted-foreground"
              >{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
            >
          </div>
        {:else}
          <p class="text-xs text-muted-foreground">No agent activity.</p>
        {/each}
      </div>
    {/snippet}

    {#snippet auditContent()}
      <div class="flex max-h-72 flex-col gap-1 overflow-y-auto">
        {#each auditEntries as entry (entry.id)}
          <div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
            <div class="flex items-center justify-between gap-2">
              <span class="font-mono text-muted-foreground"
                >{new Date(entry.ts).toLocaleString()}</span
              >
              <Badge variant="outline">{entry.actor_type}</Badge>
            </div>
            <span class="truncate text-muted-foreground"
              >{entry.actor_id ?? '—'} · {entry.action}</span
            >
          </div>
        {:else}
          <p class="text-xs text-muted-foreground">No audit entries.</p>
        {/each}
      </div>
    {/snippet}

    <!-- Status: per-check verdicts. Replaces the old Monitoring section, which
         listed configuration ("ssh-script, every 60s, enabled") and so could
         never explain why an entity was unhealthy. -->
    {#snippet statusContent()}
      <div class="flex flex-col gap-1.5">
        {#each checks.filter((c) => c.enabled) as check (check.id)}
          <div
            class="flex items-center justify-between gap-2 border-b pb-1.5 text-xs last:border-0 last:pb-0"
          >
            <div class="flex min-w-0 items-center gap-2">
              <span
                class="size-2 shrink-0 rounded-full {check.last_health
                  ? (healthDot[check.last_health] ?? healthDot.unknown)
                  : 'bg-muted-foreground/30'}"
              ></span>
              <span class="truncate font-mono">{checkLabel(check)}</span>
            </div>
            <div class="flex shrink-0 items-center gap-2 text-muted-foreground">
              <span>
                {check.last_run_at ? relativeTime(check.last_run_at) : 'not yet run'}
              </span>
              <button
                type="button"
                class="rounded border px-1.5 py-0.5 hover:bg-muted"
                onclick={() => toggleCheck(check)}
                title="Disable this check">disable</button
              >
            </div>
          </div>
        {:else}
          <p class="text-xs text-muted-foreground">
            No checks configured. This entity is unmonitored — its health cannot be known.
          </p>
        {/each}
        {#if checks.some((c) => !c.enabled)}
          <p class="pt-1 text-[11px] text-muted-foreground">
            {checks.filter((c) => !c.enabled).length} disabled
          </p>
        {/if}
      </div>
    {/snippet}

    <!-- Impact: what the ontology exists to answer. -->
    {#snippet impactContent()}
      <div class="flex flex-col gap-2 text-xs">
        {#if blast.length > 1}
          <div class="flex flex-col gap-1">
            {#each blast.filter((b) => b.depth > 0).slice(0, 12) as b (b.entity.id)}
              <button
                type="button"
                class="flex items-center justify-between gap-2 border-b pb-1 text-left last:border-0 last:pb-0 hover:text-foreground"
                onclick={() => onSelectEntity?.(b.entity.slug)}
              >
                <span class="truncate font-mono text-muted-foreground">{b.entity.slug}</span>
                <span class="shrink-0 text-muted-foreground"
                  >{b.depth} hop{b.depth === 1 ? '' : 's'}</span
                >
              </button>
            {/each}
          </div>
          <!-- Honest about the limitation rather than overclaiming: the query
               walks outgoing edges only, so an entity whose importance comes
               from things pointing AT it is under-reported. -->
          <p class="text-[11px] text-muted-foreground">
            Reachable by following this entity's own dependencies. Does not yet include things that
            point at it.
          </p>
        {:else}
          <p class="text-muted-foreground">Nothing downstream depends on this.</p>
        {/if}
      </div>
    {/snippet}

    <!-- Activity: one timeline instead of four separate lists telling the same
         story (executions, signals, events, agent activity). -->
    {#snippet activityContent()}
      <div class="flex flex-col gap-3">
        {#if openSignals.length > 0}
          <div class="flex flex-col gap-1">
            <h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
              Open signals
            </h4>
            {@render signalsContent()}
          </div>
        {/if}
        {#if executions.length > 0}
          <div class="flex flex-col gap-1">
            <h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
              Executions
            </h4>
            {@render executionsContent()}
          </div>
        {/if}
        {#if tasks.length > 0}
          <div class="flex flex-col gap-1">
            <h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
              Tasks
            </h4>
            {@render tasksContent()}
          </div>
        {/if}
        {#if events.length > 0}
          <div class="flex flex-col gap-1">
            <h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
              Events
            </h4>
            {@render eventsContent()}
          </div>
        {/if}
        {#if openSignals.length + executions.length + tasks.length + events.length === 0}
          <p class="text-xs text-muted-foreground">Nothing has happened here yet.</p>
        {/if}
      </div>
    {/snippet}

    <!-- Reference: the material you consult, not the material you react to. -->
    {#snippet referenceContent()}
      <div class="flex flex-col gap-3">
        <div class="flex flex-col gap-1">
          <h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
            Relations
          </h4>
          {@render relationsContent()}
        </div>
        <div class="flex flex-col gap-1">
          <h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
            Attributes
          </h4>
          {@render attributesContent()}
        </div>
        {#if knowledge.length > 0}
          <div class="flex flex-col gap-1">
            <h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
              Knowledge
            </h4>
            {@render knowledgeContent()}
          </div>
        {/if}
        {#if agentActivity.length > 0 || auditEntries.length > 0}
          <div class="flex flex-col gap-1">
            <h4 class="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
              Audit
            </h4>
            {@render auditContent()}
          </div>
        {/if}
      </div>
    {/snippet}

    <!-- Sections are composed from the entity's type rather than being a fixed
         list of thirteen sorted by "has content". A document is not a host: it
         has no checks, metrics or blast radius, and offering it those sections
         was most of why this window read as noise. -->
    {@const registry: Record<SectionKey, { title: string; count: number; content: any } | null> = {
      content: ownContent ? { title: 'Content', count: 1, content: contentContent } : null,
      status: {
        title: 'Status',
        count: checks.filter((c) => c.enabled).length,
        content: statusContent
      },
      impact: {
        title: 'Impact',
        count: Math.max(blast.length - 1, 0),
        content: impactContent
      },
      activity: {
        title: 'Activity',
        count: openSignals.length + executions.length + tasks.length,
        content: activityContent
      },
      metrics: metrics.length
        ? { title: 'Metrics', count: metrics.length, content: metricsContent }
        : null,
      reference: {
        title: 'Reference',
        count: outgoingRelations.length + incomingRelations.length,
        content: referenceContent
      }
    }}

    {#each sectionsForType(entity.type) as key (key)}
      {@const section = registry[key]}
      {#if section}
        <DetailSection
          title={section.title}
          count={section.count}
          defaultOpen={key === 'status' ||
            key === 'content' ||
            (key === 'activity' && openSignals.length > 0)}
        >
          {@render section.content()}
        </DetailSection>
      {/if}
    {/each}
  {/if}
</div>
