'use client'

import dynamic from 'next/dynamic'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

import Link from 'next/link'

import { Button, Notice, Segmented } from '@/components/ui/controls'
import {
  Badge,
  EmptyState,
  KeyValue,
  Meter,
  Panel,
  PanelBody,
  PanelHeader,
  Skeleton,
  StateBadge,
} from '@/components/ui/display'
import { useI18n } from '@/components/providers/i18n-provider'
import { api } from '@/lib/api/client'
import { cn } from '@/lib/cn'
import type { GridState } from '@/lib/domain/enums'
import { LiveCharts } from './live-charts'
import { FallbackNotice, LiveGrid2D } from './live-grid-2d'
import { GRID_LAYERS, type GridLayer, type SceneAlert, type SceneAsset, type SceneEdge, type SceneNode, type StreamState } from './types'

/**
 * The NABDH Live 3D Grid console (4.2).
 *
 * This component owns the one thing that must not be duplicated: *what the viewer is
 * currently looking at*. The mode, the scenario, the time offset, the selected asset and
 * the active layer live here, and the scene, the charts, the inspector and the cascade
 * player all read them as props. A panel that kept its own copy of the selection is how a
 * control room ends up showing two different transformers at once.
 *
 * Data arrives on one Server-Sent Events connection. Geometry is fetched once; the stream
 * carries only what moves, and a tick writes into existing meshes rather than rebuilding
 * the scene (§85).
 *
 * Modes are never blurred (§104, §105). LIVE reads the model as it stands. DEMO drives it
 * with a named scenario. SIMULATION applies a strategy inside the sandbox. The bar at the
 * top says which, always, and a simulated result carries that word wherever it appears.
 */

// Code-split: three.js is the largest thing this application can load, and a viewer who
// falls back to the 2D plan must never pay for it.
const LiveGridScene = dynamic(() => import('./live-grid-scene'), {
  ssr: false,
  loading: () => <Skeleton className="h-full w-full" />,
})

// ─────────────────────────────────────────────────────────────────────────────
// Types crossing the boundary from the server
// ─────────────────────────────────────────────────────────────────────────────

export interface LiveGridInitial {
  mode: string
  kind: string
  ts: number
  twin: { code: string; level: string; name: string; nameAr: string; regionCode: string | null }
  breadcrumb: Array<{ code: string; label: string; labelAr: string; level: string }>
  nodes: SceneNode[]
  assets: SceneAsset[]
  edges: SceneEdge[]
  flowMethod: string
  kpis: import('./types').GridKpis
  connection: import('./types').ConnectionBanner
  fidelity: {
    score: number
    band: string
    sensorCoverage: number
    dataFreshness: number
    sensorTrust: number
    modelCoverage: number
    sensorsOnline: number
    sensorsExpected: number
    warning: string | null
    warningAr: string | null
  }
  radius: number
  focusCode: string | null
  alerts: SceneAlert[]
  scenes: Array<{ code: string; level: string; name: string; nameAr: string }>
  scenarios: Array<{ key: string; label: string; labelAr: string; watchFor: string; watchForAr: string }>
  canSimulate: boolean
  /** True on the demonstration surfaces, which run on the pinned peak-afternoon clock. */
  demoClock?: boolean
}

interface FutureFrame {
  offsetMin: number
  ts: number
  assets: Array<{
    code: string
    loadPct: number
    tempC: number
    riskScore: number
    state: string
    deltaLoadPct: number
    deltaRisk: number
  }>
  peakRisk: number
  stabilityIndex: number
  breached: boolean
}

interface CascadePayload {
  originCode: string
  kind: string
  steps: Array<{
    ordinal: number
    offsetMin: number
    assetCode: string
    assetName: string
    assetNameAr: string
    parentCode: string
    outcome: string
    loadPctBefore: number
    loadPctAfter: number
    probability: number
    severity: string
  }>
  next: { assetCode: string; etaMin: number; confidence: number } | null
  breakpoints: Array<{
    assetCode: string
    action: string
    actionAr: string
    riskReduction: number
    interventionMw: number
    isBest: boolean
    interventionPotential: string
  }>
  regionalRisk: number
  affectedAssets: number
  affectedCustomers: number
}

interface PreventionPayload {
  assetCode: string | null
  kind: string
  strategy: {
    label: string
    labelAr: string
    actions: Array<{ type: string; assetCode: string; magnitudeMw: number }>
    costSar: number
    executionMin: number
    confidence: number
  }
  before: { peakRisk: number; assetsAtRisk: number; bottlenecks: string[]; assets: Array<{ code: string; loadPct: number; riskScore: number }> }
  after: { peakRisk: number; assetsAtRisk: number; bottlenecks: string[]; assets: Array<{ code: string; loadPct: number; riskScore: number }> }
  redistributed: Array<{ from: string; to: string; beforePct: number; afterPct: number }>
}

// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────

const TIME_OFFSETS = [0, 5, 15, 30, 60, 180, 360, 1440] as const

const CAMERA_PRESETS: Array<{ key: string; types: string[] | null }> = [
  { key: 'overview', types: null },
  { key: 'transformers', types: ['transformer'] },
  { key: 'switchyard', types: ['substation'] },
  { key: 'power_flow', types: null },
  { key: 'critical', types: null },
]

const ROLE_LAYERS: Record<string, GridLayer[]> = {
  // Three curated subsets (§76–§78). Every layer stays reachable from the full list; these
  // decide what is offered *first*, which is what makes the toolbar usable rather than a
  // wall of fourteen equal buttons.
  executive: ['risk', 'load', 'predicted', 'power_flow'],
  operator: ['power_flow', 'load', 'risk', 'cascade', 'temperature', 'predicted'],
  engineer: [...GRID_LAYERS],
}

function detectWebGL(): boolean {
  try {
    const canvas = document.createElement('canvas')
    const context =
      canvas.getContext('webgl2') ??
      canvas.getContext('webgl') ??
      canvas.getContext('experimental-webgl')
    if (!context) return false
    ;(context as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext()
    return true
  } catch {
    return false
  }
}

function gridState(value: string): GridState {
  return (['normal', 'watch', 'warning', 'critical', 'emergency'] as const).includes(value as GridState)
    ? (value as GridState)
    : 'normal'
}

// ─────────────────────────────────────────────────────────────────────────────
// The console
// ─────────────────────────────────────────────────────────────────────────────

export function LiveGridConsole({
  initial,
  wallMode = false,
}: {
  initial: LiveGridInitial
  wallMode?: boolean
}) {
  const { t, locale, n, mw, pct, relative } = useI18n()
  const ar = locale === 'ar'

  // ── What the viewer is looking at ─────────────────────────────────────────
  const [assets, setAssets] = useState(initial.assets)
  const [edges, setEdges] = useState(initial.edges)
  const [kpis, setKpis] = useState(initial.kpis)
  const [connection, setConnection] = useState(initial.connection)
  const [fidelity, setFidelity] = useState(initial.fidelity)
  const [alerts, setAlerts] = useState(initial.alerts)
  const [ts, setTs] = useState(initial.ts)
  const [kind, setKind] = useState(initial.kind)

  const [layer, setLayer] = useState<GridLayer>('power_flow')
  const [selected, setSelected] = useState<string | null>(initial.focusCode)
  const [selectedEdge, setSelectedEdge] = useState<SceneEdge | null>(null)
  const [scenario, setScenario] = useState<string>('normal')
  const [offsetMin, setOffsetMin] = useState(0)
  const [ghostMode, setGhostMode] = useState<'current' | 'ghost' | 'future'>('current')
  const [role, setRole] = useState<'operator' | 'engineer' | 'executive'>('engineer')
  const [quality, setQuality] = useState<'high' | 'balanced' | 'performance'>('balanced')
  const [showLabels, setShowLabels] = useState(true)
  const [renderer, setRenderer] = useState<'3d' | '2d'>('3d')

  const [future, setFuture] = useState<FutureFrame | null>(null)
  const [cascade, setCascade] = useState<CascadePayload | null>(null)
  const [cascadeStep, setCascadeStep] = useState(0)
  const [cascadePlaying, setCascadePlaying] = useState(false)
  const [cascadeSpeed, setCascadeSpeed] = useState<'1' | '2' | '4'>('1')
  const [prevention, setPrevention] = useState<PreventionPayload | null>(null)
  const [trace, setTrace] = useState<{ direction: string; path: string[] } | null>(null)

  const [stream, setStream] = useState<StreamState>('connecting')
  const [webgl, setWebgl] = useState<boolean | null>(null)
  const [busy, setBusy] = useState<string | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [cameraTarget, setCameraTarget] = useState<{ x: number; z: number; distance: number } | null>(null)
  const [tick, setTick] = useState(0)
  const clockQuery = initial.demoClock ? '&clock=demo' : ''

  const reducedMotion = usePrefersReducedMotion()
  const nodeByCode = useMemo(() => new Map(initial.nodes.map((node) => [node.code, node])), [initial.nodes])
  const assetByCode = useMemo(() => new Map(assets.map((asset) => [asset.code, asset])), [assets])

  // ── WebGL probe ───────────────────────────────────────────────────────────
  useEffect(() => {
    const available = detectWebGL()
    setWebgl(available)
    if (!available) setRenderer('2d')
    // A narrow viewport gets the plan by default; the 3D scene stays one tap away (§90).
    if (window.innerWidth < 900) setRenderer('2d')
  }, [])

  // ── The stream ────────────────────────────────────────────────────────────
  useEffect(() => {
    // While the ghost is showing a projected state, the live stream would keep overwriting
    // it with the present. The subscription pauses instead of fighting the viewer.
    if (ghostMode === 'future' || prevention) {
      setStream('disconnected')
      return
    }

    const query = new URLSearchParams({ twin: initial.twin.code })
    if (scenario !== 'normal') query.set('scenario', scenario)
    if (initial.demoClock) query.set('clock', 'demo')

    const source = new EventSource(`/api/stream/live-grid?${query.toString()}`)
    let seenTick = false

    source.addEventListener('open', () => setStream(seenTick ? 'connected' : 'connecting'))

    source.addEventListener('grid', (event) => {
      seenTick = true
      setStream('connected')
      try {
        const payload = JSON.parse((event as MessageEvent).data)
        // Merged rather than replaced: the stream sends the moving fields only, and the
        // static ones (name, capacity, customers) came with the topology.
        setAssets((current) =>
          current.map((asset) => {
            const update = payload.assets.find((entry: { code: string }) => entry.code === asset.code)
            return update ? { ...asset, ...update } : asset
          }),
        )
        setEdges((current) =>
          current.map((edge) => {
            const update = payload.edges.find(
              (entry: { from: string; to: string }) => entry.from === edge.from && entry.to === edge.to,
            )
            return update ? { ...edge, ...update } : edge
          }),
        )
        setKpis(payload.kpis)
        setConnection(payload.connection)
        setFidelity(payload.fidelity)
        setTs(payload.ts)
        setKind(payload.kind)
        if (payload.alerts) setAlerts(payload.alerts)
        setTick((value) => value + 1)
      } catch {
        setStream('stale')
      }
    })

    source.addEventListener('error', () => setStream(seenTick ? 'reconnecting' : 'disconnected'))

    return () => source.close()
  }, [initial.twin.code, initial.demoClock, scenario, ghostMode, prevention])

  // ── Cascade playback ──────────────────────────────────────────────────────
  useEffect(() => {
    if (!cascadePlaying || !cascade) return
    if (cascadeStep >= cascade.steps.length - 1) {
      setCascadePlaying(false)
      return
    }
    const timer = setTimeout(() => setCascadeStep((step) => step + 1), 1_600 / Number(cascadeSpeed))
    return () => clearTimeout(timer)
  }, [cascadePlaying, cascade, cascadeStep, cascadeSpeed])

  // ── Actions ───────────────────────────────────────────────────────────────
  const run = useCallback(async (label: string, task: () => Promise<void>) => {
    setBusy(label)
    setError(null)
    try {
      await task()
    } catch (caught) {
      setError(caught instanceof Error ? caught.message : 'Request failed')
    } finally {
      setBusy(null)
    }
  }, [])

  const focusOn = useCallback(
    (code: string, distance = 70) => {
      const node = nodeByCode.get(code)
      if (node) setCameraTarget({ x: node.x, z: node.z, distance })
    },
    [nodeByCode],
  )

  const select = useCallback(
    (code: string) => {
      setSelected(code)
      setSelectedEdge(null)
      focusOn(code)
    },
    [focusOn],
  )

  const applyScenario = (key: string) =>
    run('scenario', async () => {
      setScenario(key)
      setPrevention(null)
      setGhostMode('current')
      const payload = await api.get<{ assets: SceneAsset[]; flow: { edges: SceneEdge[] }; kpis: typeof kpis; kind: string; ts: number }>(
        `/api/live-grid/state?twin=${initial.twin.code}${key !== 'normal' ? `&scenario=${key}` : ''}${clockQuery}`,
      )
      setAssets((current) =>
        current.map((asset) => {
          const update = payload.assets.find((entry) => entry.code === asset.code)
          return update ? { ...asset, ...update } : asset
        }),
      )
      setEdges((current) =>
        current.map((edge) => {
          const update = payload.flow.edges.find((entry) => entry.from === edge.from && entry.to === edge.to)
          return update ? { ...edge, ...update } : edge
        }),
      )
      setKpis(payload.kpis)
      setKind(payload.kind)
      setTs(payload.ts)
      setTick((value) => value + 1)
    })

  const runTimeMachine = (offset: number) =>
    run('time', async () => {
      setOffsetMin(offset)
      if (offset === 0) {
        setFuture(null)
        setGhostMode('current')
        return
      }
      const result = await api.post<{ frame: FutureFrame | null }>('/api/live-grid/time-machine', {
        twinCode: initial.twin.code,
        offsetMin: offset,
        scenario: scenario === 'normal' ? undefined : scenario,
      })
      setFuture(result.frame)
      setGhostMode('ghost')
    })

  const runCascade = (assetCode: string) =>
    run('cascade', async () => {
      const result = await api.get<CascadePayload>(
        `/api/live-grid/cascade?asset=${assetCode}&twin=${initial.twin.code}${scenario !== 'normal' ? `&scenario=${scenario}` : ''}${clockQuery}`,
      )
      setCascade(result)
      setCascadeStep(0)
      setLayer('cascade')
      focusOn(assetCode, 90)
    })

  const runPrevention = () =>
    run('prevent', async () => {
      const result = await api.post<PreventionPayload>('/api/live-grid/simulate', {
        twinCode: initial.twin.code,
        scenario: scenario === 'normal' ? undefined : scenario,
        preventForAsset: selected ?? undefined,
        demoClock: initial.demoClock ?? undefined,
      })
      setPrevention(result)
      // Show the after-state on the scene so redistribution is visible, not described.
      setAssets((current) =>
        current.map((asset) => {
          const update = result.after.assets.find((entry) => entry.code === asset.code)
          return update ? { ...asset, ...update } : asset
        }),
      )
      setKind('simulated')
    })

  const runTrace = (direction: 'source' | 'dependents') =>
    run('trace', async () => {
      if (!selected) return
      const result = await api.post<{ direction: string; path: string[] }>('/api/live-grid/trace', {
        assetCode: selected,
        direction,
        twinCode: initial.twin.code,
        scenario: scenario === 'normal' ? undefined : scenario,
      })
      setTrace(result)
    })

  // ── Derived scene state ───────────────────────────────────────────────────
  const cascadeCodes = useMemo(
    () => (cascade ? cascade.steps.slice(0, cascadeStep + 1).map((step) => step.assetCode) : null),
    [cascade, cascadeStep],
  )

  /**
   * The assets the scene actually draws.
   *
   * When the ghost is on, the *projected* values are laid over the present ones and the
   * result is labelled `predicted`. The present is never edited — swapping in the frame's
   * numbers here rather than mutating `assets` is what keeps §36 true.
   */
  const sceneAssets = useMemo(() => {
    const withCascade = assets.map((asset) => ({
      ...asset,
      cascadeDepth: cascade
        ? (cascade.steps.find((step) => step.assetCode === asset.code)?.ordinal ?? null)
        : null,
    }))

    if (ghostMode === 'current' || !future) return withCascade

    return withCascade.map((asset) => {
      const projected = future.assets.find((entry) => entry.code === asset.code)
      if (!projected) return asset
      return {
        ...asset,
        loadPct: projected.loadPct,
        tempC: projected.tempC,
        riskScore: projected.riskScore,
        state: projected.state,
        predictedRisk: projected.riskScore,
        kind: 'predicted',
      }
    })
  }, [assets, future, ghostMode, cascade])

  const highlight = useMemo(() => {
    if (trace) return trace.path
    if (layer === 'cascade' && cascadeCodes) return cascadeCodes
    if (layer === 'risk') {
      // Risk mode drops the visual noise (§23): only what is actually at risk stays lit.
      const atRisk = assets.filter((asset) => asset.riskScore >= 55).map((asset) => asset.code)
      return atRisk.length > 0 ? atRisk : null
    }
    return null
  }, [trace, layer, cascadeCodes, assets])

  const selectedAsset = selected ? assetByCode.get(selected) ?? null : null
  const selectedNode = selected ? nodeByCode.get(selected) ?? null : null
  const offeredLayers = ROLE_LAYERS[role]

  const modeLabel =
    prevention || kind === 'simulated'
      ? t('liveGrid.mode.simulation')
      : scenario !== 'normal'
        ? t('liveGrid.mode.demo')
        : t('liveGrid.mode.live')

  // ─────────────────────────────────────────────────────────────────────────
  return (
    <div className={cn('space-y-3', wallMode && 'px-2')} data-testid="live-grid-console" data-mode={modeLabel}>
      {/* ── Status bar (§8, §105) ───────────────────────────────────────── */}
      <div
        className="flex flex-wrap items-center gap-x-5 gap-y-1.5 rounded-[--radius-panel] border border-border bg-surface px-3.5 py-2 text-[11px]"
        data-testid="live-grid-status"
      >
        <span className="flex items-center gap-2">
          <span className="text-text-faint">{t('liveGrid.status.mode')}</span>
          <Badge tone={modeLabel === t('liveGrid.mode.live') ? 'brand' : 'accent'} dot>
            {modeLabel}
          </Badge>
        </span>

        <span className="flex items-center gap-2">
          <span className="text-text-faint">{t('liveGrid.status.dataSource')}</span>
          <Badge tone={connection.dataSource === 'live' ? 'brand' : 'accent'}>
            {connection.dataSource === 'live'
              ? t('liveGrid.status.sourceLive')
              : connection.dataSource === 'demo'
                ? t('liveGrid.status.sourceDemo')
                : t('liveGrid.status.sourceNone')}
          </Badge>
        </span>

        <span>
          <span className="text-text-faint">{t('liveGrid.status.connection')} </span>
          <span
            className={cn(
              'font-medium',
              stream === 'connected'
                ? 'text-normal'
                : stream === 'reconnecting' || stream === 'stale'
                  ? 'text-watch'
                  : 'text-warning',
            )}
            data-stream={stream}
          >
            {t(`liveGrid.stream.${stream}`)}
          </span>
        </span>

        <span>
          <span className="text-text-faint">{t('liveGrid.status.lastUpdate')} </span>
          <span className="font-mono tabular-nums">{relative(ts)}</span>
        </span>

        <span>
          <span className="text-text-faint">{t('liveGrid.status.latency')} </span>
          <span className="font-mono tabular-nums">{n(connection.latencyMs, { maximumFractionDigits: 0 })} ms</span>
        </span>

        <span>
          <span className="text-text-faint">{t('liveGrid.status.dataQuality')} </span>
          <span className="font-mono tabular-nums">{pct(connection.dataQualityPct, 1)}</span>
        </span>

        <span>
          <span className="text-text-faint">{t('liveGrid.status.twinFidelity')} </span>
          <span className="font-mono tabular-nums">{pct(fidelity.score, 1)}</span>
        </span>

        <span className="ms-auto flex items-center gap-2">
          {initial.breadcrumb.map((crumb, index) => (
            <span key={crumb.code} className="text-text-faint">
              {index > 0 ? <span className="mx-1">/</span> : null}
              <Link
                href={`/live-grid?twin=${crumb.code}`}
                className={cn(index === initial.breadcrumb.length - 1 && 'text-text')}
              >
                {ar ? crumb.labelAr : crumb.label}
              </Link>
            </span>
          ))}
        </span>
      </div>

      {kind === 'simulated' || prevention ? (
        <Notice tone="warning">{t('liveGrid.simulatedNotice')}</Notice>
      ) : null}
      {kind === 'predicted' || ghostMode !== 'current' ? (
        <Notice tone="info">{t('liveGrid.predictedNotice')}</Notice>
      ) : null}
      {connection.dataSource === 'demo' ? (
        <Notice tone="info">{t('liveGrid.demoSourceNotice')}</Notice>
      ) : null}

      {/* ── KPI bar (§30) ───────────────────────────────────────────────── */}
      <div className="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-10">
        <Kpi label={t('liveGrid.kpi.stability')} value={pct(kpis.stabilityIndex, 1)} state={kpis.stabilityIndex >= 90 ? 'normal' : kpis.stabilityIndex >= 75 ? 'watch' : 'warning'} />
        <Kpi label={t('liveGrid.kpi.demand')} value={mw(kpis.totalLoadMw)} />
        <Kpi label={t('liveGrid.kpi.capacity')} value={mw(kpis.availableCapacityMw)} />
        <Kpi label={t('liveGrid.kpi.renewables')} value={pct(kpis.renewableSharePct, 1)} />
        <Kpi label={t('liveGrid.kpi.frequency')} value={`${n(kpis.frequencyHz, { maximumFractionDigits: 3 })} Hz`} state={Math.abs(kpis.frequencyHz - 60) >= 0.2 ? 'warning' : 'normal'} />
        <Kpi label={t('liveGrid.kpi.activePower')} value={mw(kpis.totalActivePowerMw)} />
        <Kpi label={t('liveGrid.kpi.reactivePower')} value={`${n(kpis.totalReactivePowerMvar, { maximumFractionDigits: 0 })} MVAr`} />
        <Kpi label={t('liveGrid.kpi.assetsAtRisk')} value={n(kpis.assetsAtRisk)} state={kpis.assetsAtRisk > 0 ? 'warning' : 'normal'} />
        <Kpi label={t('liveGrid.kpi.criticalAlerts')} value={n(kpis.criticalAlerts)} state={kpis.criticalAlerts > 0 ? 'critical' : 'normal'} />
        <Kpi label={t('liveGrid.kpi.dataConfidence')} value={pct(kpis.dataConfidencePct, 1)} state={kpis.dataConfidencePct >= 85 ? 'normal' : 'watch'} />
      </div>

      {error ? <Notice tone="danger">{error}</Notice> : null}

      {/* ── Layer toolbar (§13) ─────────────────────────────────────────── */}
      <div className="flex flex-wrap items-center gap-2">
        <div className="flex flex-wrap gap-1" role="group" aria-label={t('liveGrid.layers')}>
          {offeredLayers.map((key) => (
            <button
              key={key}
              type="button"
              onClick={() => setLayer(key)}
              data-layer={key}
              aria-pressed={layer === key}
              className={cn(
                'rounded-md border px-2 py-1 text-[10px] font-medium uppercase tracking-wide transition-colors',
                layer === key
                  ? 'border-brand/50 bg-brand/12 text-brand'
                  : 'border-border bg-surface-2 text-text-muted hover:border-brand/30',
              )}
            >
              {t(`liveGrid.layer.${key}`)}
            </button>
          ))}
        </div>

        <div className="ms-auto flex flex-wrap items-center gap-2">
          <Segmented
            value={role}
            onChange={setRole}
            ariaLabel={t('liveGrid.view')}
            size="sm"
            options={[
              { value: 'operator' as const, label: t('liveGrid.roleOperator') },
              { value: 'engineer' as const, label: t('liveGrid.roleEngineer') },
              { value: 'executive' as const, label: t('liveGrid.roleExecutive') },
            ]}
          />
          <Segmented
            value={quality}
            onChange={setQuality}
            ariaLabel={t('liveGrid.quality')}
            size="sm"
            options={[
              { value: 'performance' as const, label: t('liveGrid.qualityPerformance') },
              { value: 'balanced' as const, label: t('liveGrid.qualityBalanced') },
              { value: 'high' as const, label: t('liveGrid.qualityHigh') },
            ]}
          />
          {webgl ? (
            <Segmented
              value={renderer}
              onChange={setRenderer}
              ariaLabel={t('liveGrid.renderer')}
              size="sm"
              options={[
                { value: '3d' as const, label: '3D' },
                { value: '2d' as const, label: '2D' },
              ]}
            />
          ) : null}
          <Button size="sm" variant="ghost" onClick={() => setShowLabels((value) => !value)}>
            {showLabels ? t('liveGrid.labelsOn') : t('liveGrid.labelsOff')}
          </Button>
        </div>
      </div>

      {/* ── Scene + side panel ──────────────────────────────────────────── */}
      <div className={cn('grid gap-3', wallMode ? 'xl:grid-cols-[1fr_20rem]' : 'xl:grid-cols-[1fr_23rem]')}>
        <div className="space-y-3">
          {webgl === false ? (
            <FallbackNotice reason={t('liveGrid.webglUnsupported')} />
          ) : null}

          <div
            className={cn(
              'relative overflow-hidden rounded-[--radius-panel] border border-border bg-[--color-scene]',
              wallMode ? 'h-[68vh]' : 'h-[58vh] min-h-[420px]',
            )}
            data-testid="live-grid-canvas"
            data-renderer={renderer}
          >
            {webgl === null ? (
              <Skeleton className="h-full w-full" />
            ) : renderer === '3d' && webgl ? (
              <LiveGridScene
                nodes={initial.nodes}
                edges={edges}
                assets={sceneAssets}
                layer={layer}
                radius={initial.radius}
                selectedCode={selected}
                highlight={highlight}
                showLabels={showLabels}
                reducedMotion={reducedMotion}
                quality={quality}
                cameraTarget={cameraTarget}
                onSelect={select}
              />
            ) : (
              <LiveGrid2D
                nodes={initial.nodes}
                edges={edges}
                assets={sceneAssets}
                layer={layer}
                selectedCode={selected}
                highlight={highlight}
                showLabels={showLabels}
                reducedMotion={reducedMotion}
                onSelect={select}
              />
            )}

            {/* Camera presets, laid over the scene rather than beside it. */}
            {renderer === '3d' && webgl ? (
              <div className="absolute bottom-3 start-3 flex flex-wrap gap-1">
                {CAMERA_PRESETS.map((preset) => (
                  <button
                    key={preset.key}
                    type="button"
                    data-camera={preset.key}
                    onClick={() => {
                      if (preset.key === 'overview') {
                        setCameraTarget({ x: 0, z: 0, distance: Math.max(90, initial.radius * 1.7) })
                        return
                      }
                      if (preset.key === 'critical') {
                        const worst = [...assets].sort((a, b) => b.riskScore - a.riskScore)[0]
                        if (worst) select(worst.code)
                        return
                      }
                      if (preset.key === 'power_flow') {
                        setLayer('power_flow')
                        setCameraTarget({ x: 0, z: 0, distance: Math.max(70, initial.radius * 1.2) })
                        return
                      }
                      const match = initial.nodes.find((node) =>
                        preset.types ? preset.types.includes(node.assetTypeKey) : false,
                      )
                      if (match) select(match.code)
                    }}
                    className="rounded-md border border-white/15 bg-black/45 px-2 py-1 text-[10px] uppercase tracking-wide text-white/80 backdrop-blur transition-colors hover:border-brand/60 hover:text-white"
                  >
                    {t(`liveGrid.camera.${preset.key}`)}
                  </button>
                ))}
              </div>
            ) : null}

            {/* Mini-map (§79): the whole twin, with the viewport's focus marked. */}
            <MiniMap nodes={initial.nodes} assets={assets} selected={selected} radius={initial.radius} />
          </div>

          {/* ── Time machine (§35–§37) ────────────────────────────────── */}
          <Panel>
            <PanelHeader
              title={t('liveGrid.timeMachine')}
              subtitle={t('liveGrid.timeMachineNote')}
              action={
                <Segmented
                  value={ghostMode}
                  onChange={setGhostMode}
                  ariaLabel={t('liveGrid.ghostMode')}
                  size="sm"
                  options={[
                    { value: 'current' as const, label: t('liveGrid.current') },
                    { value: 'ghost' as const, label: t('liveGrid.ghost') },
                    { value: 'future' as const, label: t('liveGrid.futureOnly') },
                  ]}
                />
              }
            />
            <PanelBody className="space-y-2">
              <div className="flex flex-wrap gap-1.5">
                {TIME_OFFSETS.map((offset) => (
                  <button
                    key={offset}
                    type="button"
                    data-offset={offset}
                    disabled={busy !== null}
                    onClick={() => runTimeMachine(offset)}
                    className={cn(
                      'rounded-md border px-2.5 py-1 text-[11px] font-mono tabular-nums transition-colors',
                      offsetMin === offset
                        ? 'border-brand/50 bg-brand/12 text-brand'
                        : 'border-border bg-surface-2 text-text-muted hover:border-brand/30',
                    )}
                  >
                    {offset === 0 ? t('liveGrid.now') : `+${offset < 60 ? `${offset}m` : `${offset / 60}h`}`}
                  </button>
                ))}
              </div>

              {future ? (
                <div className="rounded-lg border border-info/25 bg-info/8 p-2.5" data-testid="ghost-future">
                  <p className="text-[11px] font-medium text-info">
                    {t('liveGrid.predictedAt').replace('{offset}', String(future.offsetMin))} ·{' '}
                    {t('liveGrid.kind.predicted')}
                  </p>
                  {selectedAsset ? (
                    <GhostComparison
                      code={selectedAsset.code}
                      nowLoad={assetByCode.get(selectedAsset.code)?.loadPct ?? 0}
                      nowRisk={assetByCode.get(selectedAsset.code)?.riskScore ?? 0}
                      nowTemp={assetByCode.get(selectedAsset.code)?.tempC ?? 0}
                      frame={future.assets.find((entry) => entry.code === selectedAsset.code) ?? null}
                    />
                  ) : null}
                  <p className="mt-1.5 text-[11px] text-text-muted">
                    {t('liveGrid.peakRisk')}: <span className="font-mono tabular-nums">{n(future.peakRisk, { maximumFractionDigits: 1 })}</span>
                    {future.breached ? <span className="ms-2 text-critical">{t('liveGrid.limitBreached')}</span> : null}
                  </p>
                </div>
              ) : null}
            </PanelBody>
          </Panel>

          {/* ── Charts (§26–§28) ──────────────────────────────────────── */}
          <Panel>
            <PanelHeader title={t('liveGrid.charts')} subtitle={t('liveGrid.chartsNote')} />
            <PanelBody>
              <LiveCharts
                assetCode={selected}
                assetName={selectedNode ? (ar ? selectedNode.nameAr : selectedNode.name) : null}
                refreshToken={Math.floor(tick / 10)}
              />
            </PanelBody>
          </Panel>
        </div>

        {/* ── Right rail ─────────────────────────────────────────────── */}
        <div className="space-y-3">
          {/* Scenario controls (§95) */}
          <Panel>
            <PanelHeader title={t('liveGrid.scenarios')} subtitle={t('liveGrid.scenariosNote')} />
            <PanelBody className="space-y-2">
              <div className="flex flex-wrap gap-1.5">
                {initial.scenarios.map((entry) => (
                  <button
                    key={entry.key}
                    type="button"
                    data-scenario={entry.key}
                    disabled={busy !== null}
                    onClick={() => applyScenario(entry.key)}
                    title={ar ? entry.watchForAr : entry.watchFor}
                    className={cn(
                      'rounded-md border px-2 py-1 text-[10px] font-medium transition-colors',
                      scenario === entry.key
                        ? 'border-brand/50 bg-brand/12 text-brand'
                        : 'border-border bg-surface-2 text-text-muted hover:border-brand/30',
                    )}
                  >
                    {ar ? entry.labelAr : entry.label}
                  </button>
                ))}
              </div>
              <Button
                size="sm"
                variant="secondary"
                disabled={busy !== null}
                data-testid="surprise-me"
                onClick={() => {
                  // Deterministic: cycles the coherent set-pieces rather than drawing noise.
                  const pool = initial.scenarios.filter((entry) => entry.key !== 'normal')
                  const next = pool[tick % pool.length]
                  if (next) applyScenario(next.key)
                }}
              >
                {t('liveGrid.surpriseMe')}
              </Button>
              {scenario !== 'normal' ? (
                <p className="text-[11px] leading-relaxed text-text-muted">
                  {ar
                    ? initial.scenarios.find((entry) => entry.key === scenario)?.watchForAr
                    : initial.scenarios.find((entry) => entry.key === scenario)?.watchFor}
                </p>
              ) : null}
            </PanelBody>
          </Panel>

          {/* Inspector (§5, §12) */}
          <Panel>
            <PanelHeader
              title={selectedEdge ? t('liveGrid.lineInspector') : t('liveGrid.assetInspector')}
              action={
                selectedAsset ? (
                  <StateBadge
                    state={gridState(selectedAsset.state)}
                    label={t(`states.${selectedAsset.state}`)}
                    size="sm"
                  />
                ) : null
              }
            />
            <PanelBody>
              {selectedEdge ? (
                <LineInspector edge={selectedEdge} nodeByCode={nodeByCode} />
              ) : selectedAsset && selectedNode ? (
                <AssetInspector
                  asset={selectedAsset}
                  node={selectedNode}
                  edges={edges.filter((edge) => edge.from === selected || edge.to === selected)}
                  onSelectEdge={setSelectedEdge}
                  onCascade={() => runCascade(selectedAsset.code)}
                  onTrace={runTrace}
                  onPrevent={initial.canSimulate ? runPrevention : null}
                  busy={busy}
                />
              ) : (
                <EmptyState title={t('liveGrid.noSelection')} body={t('liveGrid.noSelectionBody')} />
              )}
            </PanelBody>
          </Panel>

          {/* Trace result */}
          {trace ? (
            <Panel>
              <PanelHeader
                title={trace.direction === 'source' ? t('liveGrid.tracedSource') : t('liveGrid.tracedDependents')}
                action={
                  <Button size="sm" variant="ghost" onClick={() => setTrace(null)}>
                    {t('common.clear')}
                  </Button>
                }
              />
              <PanelBody>
                <p className="font-mono text-[11px] leading-relaxed text-text-muted">
                  {trace.path.join(' → ')}
                </p>
              </PanelBody>
            </Panel>
          ) : null}

          {/* Cascade (§40–§45) */}
          {cascade ? (
            <Panel>
              <PanelHeader
                title={t('liveGrid.cascade')}
                action={<Badge tone="accent">{t('liveGrid.kind.simulated')}</Badge>}
              />
              <PanelBody className="space-y-2">
                {cascade.next ? (
                  <p className="rounded-lg border border-warning/25 bg-warning/8 px-2.5 py-1.5 text-[11px] text-warning">
                    {t('liveGrid.nextPropagation')}: <span className="font-mono">{cascade.next.assetCode}</span> ·{' '}
                    {t('liveGrid.eta')} {cascade.next.etaMin} min · {t('liveGrid.confidence')} {cascade.next.confidence}%
                  </p>
                ) : null}

                <div className="flex flex-wrap items-center gap-1.5">
                  <Button size="sm" variant="secondary" onClick={() => setCascadeStep((s) => Math.max(0, s - 1))}>
                    {t('liveGrid.step')} −
                  </Button>
                  <Button size="sm" variant="primary" onClick={() => setCascadePlaying((v) => !v)}>
                    {cascadePlaying ? t('replay.pause') : t('replay.play')}
                  </Button>
                  <Button size="sm" variant="secondary" onClick={() => setCascadeStep((s) => Math.min(cascade.steps.length - 1, s + 1))}>
                    {t('liveGrid.step')} +
                  </Button>
                  <Button size="sm" variant="ghost" onClick={() => { setCascadePlaying(false); setCascadeStep(0) }}>
                    {t('replay.restart')}
                  </Button>
                  <Segmented
                    value={cascadeSpeed}
                    onChange={setCascadeSpeed}
                    ariaLabel={t('replay.speed')}
                    size="sm"
                    options={[
                      { value: '1' as const, label: '1×' },
                      { value: '2' as const, label: '2×' },
                      { value: '4' as const, label: '4×' },
                    ]}
                  />
                </div>

                <ol className="space-y-1">
                  {cascade.steps.map((step, index) => (
                    <li
                      key={`${step.assetCode}-${step.ordinal}`}
                      data-cascade-step={step.assetCode}
                      className={cn(
                        'flex items-baseline justify-between gap-2 rounded-md border px-2 py-1 text-[11px]',
                        index <= cascadeStep
                          ? 'border-critical/35 bg-critical/8'
                          : 'border-border bg-surface-2/40 opacity-55',
                      )}
                    >
                      <span className="font-mono">
                        T+{step.offsetMin} · {step.assetCode}
                      </span>
                      <span className="text-text-muted">
                        {n(step.loadPctBefore, { maximumFractionDigits: 0 })}% → {n(step.loadPctAfter, { maximumFractionDigits: 0 })}%
                      </span>
                    </li>
                  ))}
                </ol>

                {cascade.breakpoints.length > 0 ? (
                  <div>
                    <p className="mb-1 text-[10px] uppercase tracking-wide text-text-faint">
                      {t('liveGrid.breakPoint')}
                    </p>
                    <ul className="space-y-1">
                      {cascade.breakpoints.slice(0, 3).map((breakpoint) => (
                        <li
                          key={breakpoint.assetCode}
                          className="rounded-md border border-border bg-surface-2/40 px-2 py-1.5 text-[11px]"
                          data-breakpoint={breakpoint.assetCode}
                        >
                          <span className="font-mono text-brand">{breakpoint.assetCode}</span>{' '}
                          <span className="text-text-muted">
                            {t(`liveGrid.potential.${breakpoint.interventionPotential}`)} · −
                            {n(breakpoint.riskReduction, { maximumFractionDigits: 1 })}
                          </span>
                        </li>
                      ))}
                    </ul>
                  </div>
                ) : null}
              </PanelBody>
            </Panel>
          ) : null}

          {/* Prevention (§46–§48) */}
          {prevention ? (
            <Panel>
              <PanelHeader
                title={t('liveGrid.preventionTitle')}
                action={<Badge tone="accent">{t('liveGrid.simulatedResult')}</Badge>}
              />
              <PanelBody className="space-y-2" data-testid="prevention-result">
                <p className="text-[12px]">{ar ? prevention.strategy.labelAr : prevention.strategy.label}</p>
                <div className="grid grid-cols-2 gap-2 text-[11px]">
                  <div className="rounded-md border border-border bg-surface-2/40 p-2">
                    <p className="text-text-faint">{t('liveGrid.before')}</p>
                    <p className="font-mono text-lg tabular-nums text-warning">
                      {n(prevention.before.peakRisk, { maximumFractionDigits: 0 })}
                    </p>
                    <p className="text-text-faint">{prevention.before.bottlenecks.length} {t('liveGrid.bottlenecks')}</p>
                  </div>
                  <div className="rounded-md border border-normal/30 bg-normal/8 p-2">
                    <p className="text-text-faint">{t('liveGrid.after')}</p>
                    <p className="font-mono text-lg tabular-nums text-normal" data-testid="prevention-after">
                      {n(prevention.after.peakRisk, { maximumFractionDigits: 0 })}
                    </p>
                    <p className="text-text-faint">{prevention.after.bottlenecks.length} {t('liveGrid.bottlenecks')}</p>
                  </div>
                </div>

                {prevention.redistributed.length > 0 ? (
                  <div>
                    <p className="mb-1 text-[10px] uppercase tracking-wide text-text-faint">
                      {t('liveGrid.redistributed')}
                    </p>
                    <ul className="space-y-0.5">
                      {prevention.redistributed.slice(0, 5).map((entry) => (
                        <li key={`${entry.from}-${entry.to}`} className="flex justify-between gap-2 text-[11px]">
                          <span className="font-mono text-text-faint">{entry.from} → {entry.to}</span>
                          <span className="font-mono tabular-nums">
                            {n(entry.beforePct, { maximumFractionDigits: 0 })}% →{' '}
                            <span className={entry.afterPct < entry.beforePct ? 'text-normal' : 'text-warning'}>
                              {n(entry.afterPct, { maximumFractionDigits: 0 })}%
                            </span>
                          </span>
                        </li>
                      ))}
                    </ul>
                  </div>
                ) : null}

                <p className="text-[10px] leading-relaxed text-text-faint">{t('liveGrid.preventionNote')}</p>

                <div className="flex flex-wrap gap-1.5">
                  <Link
                    href="/proofs"
                    className="rounded-md border border-border px-2.5 py-1 text-[11px] text-text-muted hover:border-brand/40"
                  >
                    {t('proof.proveIt')}
                  </Link>
                  <Button size="sm" variant="ghost" onClick={() => { setPrevention(null); applyScenario(scenario) }}>
                    {t('common.clear')}
                  </Button>
                </div>
              </PanelBody>
            </Panel>
          ) : null}

          {/* Alerts (§56) */}
          {alerts.length > 0 ? (
            <Panel>
              <PanelHeader title={t('liveGrid.alerts')} />
              <PanelBody>
                <ul className="space-y-1">
                  {alerts.slice(0, 6).map((alert) => (
                    <li key={alert.code}>
                      <button
                        type="button"
                        onClick={() => select(alert.assetCode)}
                        data-alert={alert.code}
                        className="flex w-full flex-wrap items-baseline justify-between gap-2 rounded-md border border-border bg-surface-2/40 px-2 py-1.5 text-start text-[11px] hover:border-brand/30"
                      >
                        <span>
                          <span className="me-1.5 font-mono text-text-faint">{alert.assetCode}</span>
                          {ar ? alert.titleAr : alert.title}
                        </span>
                        <span className="font-mono tabular-nums text-warning">
                          {n(alert.riskScore, { maximumFractionDigits: 0 })}%
                        </span>
                      </button>
                    </li>
                  ))}
                </ul>
              </PanelBody>
            </Panel>
          ) : null}

          {/* Twin fidelity (§65) */}
          <Panel>
            <PanelHeader title={t('liveGrid.twinFidelity')} />
            <PanelBody className="space-y-1.5">
              <Meter label={t('liveGrid.fidelity.overall')} value={fidelity.score} max={100} />
              <Meter label={t('liveGrid.fidelity.sensorCoverage')} value={fidelity.sensorCoverage} max={100} />
              <Meter label={t('liveGrid.fidelity.freshness')} value={fidelity.dataFreshness} max={100} />
              <Meter label={t('liveGrid.fidelity.modelCoverage')} value={fidelity.modelCoverage} max={100} />
              <Meter label={t('liveGrid.fidelity.topology')} value={fidelity.sensorTrust} max={100} />
              {fidelity.warning ? (
                <p className="text-[11px] leading-relaxed text-watch">
                  {ar ? fidelity.warningAr : fidelity.warning}
                </p>
              ) : null}
              {connection.weakestSource ? (
                <p className="text-[11px] text-text-faint">
                  {t('liveGrid.weakestSource')}: <span className="font-mono">{connection.weakestSource.code}</span> ·{' '}
                  {pct(connection.weakestSource.quality, 1)}
                </p>
              ) : null}
            </PanelBody>
          </Panel>
        </div>
      </div>
    </div>
  )

  // ── Local components ──────────────────────────────────────────────────────

  function Kpi({ label, value, state }: { label: string; value: string; state?: GridState }) {
    return (
      <div className="rounded-lg border border-border bg-surface-2/40 px-2.5 py-1.5">
        <p className="truncate text-[10px] uppercase tracking-wide text-text-faint">{label}</p>
        <p
          className={cn(
            'font-mono text-[15px] tabular-nums',
            state === 'critical' ? 'text-critical' : state === 'warning' ? 'text-warning' : state === 'watch' ? 'text-watch' : 'text-text',
          )}
        >
          {value}
        </p>
      </div>
    )
  }

  function GhostComparison({
    code,
    nowLoad,
    nowRisk,
    nowTemp,
    frame,
  }: {
    code: string
    nowLoad: number
    nowRisk: number
    nowTemp: number
    frame: { loadPct: number; riskScore: number; tempC: number } | null
  }) {
    if (!frame) return null
    return (
      <dl className="mt-1.5 grid grid-cols-3 gap-2 text-[11px]" data-ghost-asset={code}>
        <Compare label={t('liveGrid.chart.load')} now={`${n(nowLoad, { maximumFractionDigits: 0 })}%`} next={`${n(frame.loadPct, { maximumFractionDigits: 0 })}%`} worse={frame.loadPct > nowLoad} />
        <Compare label={t('liveGrid.chart.risk')} now={n(nowRisk, { maximumFractionDigits: 0 })} next={n(frame.riskScore, { maximumFractionDigits: 0 })} worse={frame.riskScore > nowRisk} />
        <Compare label={t('liveGrid.chart.temperature')} now={`${n(nowTemp, { maximumFractionDigits: 0 })}°`} next={`${n(frame.tempC, { maximumFractionDigits: 0 })}°`} worse={frame.tempC > nowTemp} />
      </dl>
    )
  }

  function Compare({ label, now, next, worse }: { label: string; now: string; next: string; worse: boolean }) {
    return (
      <div>
        <dt className="text-text-faint">{label}</dt>
        <dd className="font-mono tabular-nums">
          <span className="text-text-muted">{now}</span>
          <span className="mx-1 text-text-faint">→</span>
          <span className={worse ? 'text-warning' : 'text-normal'}>{next}</span>
        </dd>
      </div>
    )
  }

  function AssetInspector({
    asset,
    node,
    edges: connected,
    onSelectEdge,
    onCascade,
    onTrace,
    onPrevent,
    busy: working,
  }: {
    asset: SceneAsset
    node: SceneNode
    edges: SceneEdge[]
    onSelectEdge: (edge: SceneEdge) => void
    onCascade: () => void
    onTrace: (direction: 'source' | 'dependents') => void
    onPrevent: (() => void) | null
    busy: string | null
  }) {
    return (
      <div className="space-y-2.5" data-testid="asset-inspector" data-inspector-asset={asset.code}>
        <div>
          <p className="text-sm font-medium">{ar ? node.nameAr : node.name}</p>
          <p className="font-mono text-[11px] text-text-faint">
            {asset.code} · {t(`assetType.${asset.assetTypeKey}`)} ·{' '}
            <span className="uppercase">{t(`liveGrid.kind.${asset.kind}`)}</span>
          </p>
        </div>

        <dl className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
          <KeyValue label={t('liveGrid.chart.voltage')} value={`${n(asset.voltageKv, { maximumFractionDigits: 2 })} kV`} mono />
          <KeyValue label={t('liveGrid.deviation')} value={`${n(asset.voltageDeviationPct, { maximumFractionDigits: 2 })}%`} mono />
          <KeyValue label={t('liveGrid.chart.current')} value={`${n(asset.currentA, { maximumFractionDigits: 0 })} A`} mono />
          <KeyValue label={t('liveGrid.chart.activePower')} value={`${n(asset.activePowerMw, { maximumFractionDigits: 2 })} MW`} mono />
          <KeyValue label={t('liveGrid.chart.reactivePower')} value={`${n(asset.reactivePowerMvar, { maximumFractionDigits: 2 })} MVAr`} mono />
          <KeyValue label={t('liveGrid.chart.powerFactor')} value={n(asset.powerFactor, { maximumFractionDigits: 3 })} mono />
          <KeyValue label={t('liveGrid.chart.load')} value={`${n(asset.loadPct, { maximumFractionDigits: 1 })}%`} mono />
          <KeyValue label={t('liveGrid.chart.frequency')} value={`${n(asset.frequencyHz, { maximumFractionDigits: 3 })} Hz`} mono />
          <KeyValue label={t('liveGrid.chart.temperature')} value={`${n(asset.tempC, { maximumFractionDigits: 1 })} °C`} mono />
          <KeyValue label={t('liveGrid.health')} value={`${n(asset.healthScore, { maximumFractionDigits: 0 })}%`} mono />
          <KeyValue label={t('liveGrid.chart.risk')} value={`${n(asset.riskScore, { maximumFractionDigits: 0 })}%`} mono />
          <KeyValue label={t('liveGrid.rul')} value={`${n(asset.rulDays)} ${t('common.units.days')}`} mono />
          <KeyValue label={t('liveGrid.dataQuality')} value={`${n(asset.dataQualityPct, { maximumFractionDigits: 0 })}%`} mono />
          {asset.socPct !== null ? (
            <KeyValue label={t('liveGrid.soc')} value={`${n(asset.socPct, { maximumFractionDigits: 1 })}%`} mono />
          ) : null}
        </dl>

        {connected.length > 0 ? (
          <div>
            <p className="mb-1 text-[10px] uppercase tracking-wide text-text-faint">{t('liveGrid.connections')}</p>
            <ul className="space-y-0.5">
              {connected.map((edge) => (
                <li key={`${edge.from}-${edge.to}`}>
                  <button
                    type="button"
                    onClick={() => onSelectEdge(edge)}
                    data-edge={`${edge.from}-${edge.to}`}
                    className="flex w-full items-baseline justify-between gap-2 rounded-md border border-border bg-surface-2/40 px-2 py-1 text-start text-[11px] hover:border-brand/30"
                  >
                    <span className="font-mono">
                      {edge.sourceCode} → {edge.sinkCode}
                    </span>
                    <span className="font-mono tabular-nums text-text-muted">
                      {n(edge.activePowerMw, { maximumFractionDigits: 1 })} MW · {n(edge.utilisationPct, { maximumFractionDigits: 0 })}%
                    </span>
                  </button>
                </li>
              ))}
            </ul>
          </div>
        ) : null}

        <div className="flex flex-wrap gap-1.5">
          <Button size="sm" variant="secondary" disabled={working !== null} onClick={onCascade}>
            {t('liveGrid.showCascade')}
          </Button>
          <Button size="sm" variant="ghost" disabled={working !== null} onClick={() => onTrace('source')}>
            {t('liveGrid.tracePower')}
          </Button>
          <Button size="sm" variant="ghost" disabled={working !== null} onClick={() => onTrace('dependents')}>
            {t('liveGrid.showDependents')}
          </Button>
          {onPrevent ? (
            <Button size="sm" variant="primary" disabled={working !== null} onClick={onPrevent} data-testid="visualize-prevention">
              {working === 'prevent' ? t('common.running') : t('liveGrid.visualizePrevention')}
            </Button>
          ) : null}
        </div>
      </div>
    )
  }

  function LineInspector({ edge, nodeByCode: byCode }: { edge: SceneEdge; nodeByCode: Map<string, SceneNode> }) {
    const from = byCode.get(edge.from)
    const to = byCode.get(edge.to)
    return (
      <div className="space-y-2" data-testid="line-inspector">
        <p className="font-mono text-[12px]">
          {edge.sourceCode} → {edge.sinkCode}
        </p>
        <p className="text-[11px] text-text-muted">
          {ar ? from?.nameAr : from?.name} → {ar ? to?.nameAr : to?.name}
        </p>
        <dl className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
          <KeyValue label={t('liveGrid.direction')} value={t(`liveGrid.flow.${edge.direction}`)} />
          <KeyValue label={t('liveGrid.chart.activePower')} value={`${n(edge.activePowerMw, { maximumFractionDigits: 2 })} MW`} mono />
          <KeyValue label={t('liveGrid.chart.current')} value={`${n(edge.currentA, { maximumFractionDigits: 0 })} A`} mono />
          <KeyValue label={t('liveGrid.utilisation')} value={`${n(edge.utilisationPct, { maximumFractionDigits: 1 })}%`} mono />
          <KeyValue label={t('liveGrid.kind_')} value={edge.kind} />
          <KeyValue label={t('common.status')} value={t(`states.${edge.state}`)} />
        </dl>
        <Button size="sm" variant="ghost" onClick={() => setSelectedEdge(null)}>
          {t('common.clear')}
        </Button>
      </div>
    )
  }

  function MiniMap({
    nodes,
    assets: states,
    selected: focus,
    radius,
  }: {
    nodes: SceneNode[]
    assets: SceneAsset[]
    selected: string | null
    radius: number
  }) {
    const byCode = new Map(states.map((asset) => [asset.code, asset]))
    const extent = Math.max(1, radius * 1.1)
    return (
      <svg
        viewBox={`${-extent} ${-extent} ${extent * 2} ${extent * 2}`}
        className="absolute bottom-3 end-3 h-24 w-24 rounded-md border border-white/15 bg-black/50 backdrop-blur"
        role="img"
        aria-label={t('liveGrid.miniMap')}
        data-testid="live-grid-minimap"
      >
        {nodes.map((node) => {
          const asset = byCode.get(node.code)
          const risk = asset?.riskScore ?? 0
          return (
            <circle
              key={node.code}
              cx={node.x}
              cy={node.z}
              r={extent * (node.code === focus ? 0.05 : 0.028)}
              fill={risk >= 78 ? '#ef4444' : risk >= 55 ? '#f97316' : node.code === focus ? '#ffffff' : '#24d07f'}
              opacity={node.code === focus ? 1 : 0.75}
            />
          )
        })}
      </svg>
    )
  }
}

/** Respects the operating system's motion preference (§89). */
function usePrefersReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false)
  const query = useRef<MediaQueryList | null>(null)

  useEffect(() => {
    query.current = window.matchMedia('(prefers-reduced-motion: reduce)')
    setReduced(query.current.matches)
    const listener = (event: MediaQueryListEvent) => setReduced(event.matches)
    query.current.addEventListener('change', listener)
    return () => query.current?.removeEventListener('change', listener)
  }, [])

  return reduced
}
