'use client'

import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Canvas, useFrame, useThree, type ThreeEvent } from '@react-three/fiber'
import { Html, OrbitControls, type OrbitControlsProps } from '@react-three/drei'
import * as THREE from 'three'

import { STATE_STYLES } from '@/lib/ui/state-styles'
import type { GridState } from '@/lib/domain/enums'
import type { GridLayer, SceneAsset, SceneEdge, SceneNode } from './types'

/**
 * The NABDH Live 3D Grid (4.2).
 *
 * A substation and its network drawn from the twin's own geometry, coloured by whichever
 * electrical layer is selected, with power moving along every path at a speed the flow
 * solution decided. Three commitments shape the whole file:
 *
 * **Every object is an asset.** A mesh's identity is its `assetCode`, and there is no
 * visual state that does not come from the payload the console already holds. Nothing here
 * invents a value to make a picture look alive.
 *
 * **Colour is never the only channel** (§24, §91). Colour carries the layer, but the label
 * beside each asset carries the code, the reading and the status word, and the flow
 * carries direction as motion. A viewer who cannot distinguish the hues still gets the
 * whole answer.
 *
 * **Nothing is rebuilt when a number changes** (§85). Geometry is created once and shared
 * by archetype; materials are cached by colour; the flow particles are a single instanced
 * mesh whose matrices are rewritten in place. A telemetry tick writes to existing objects.
 */

// ─────────────────────────────────────────────────────────────────────────────
// Shared geometry and materials
// ─────────────────────────────────────────────────────────────────────────────

/**
 * One geometry per archetype for the whole scene.
 *
 * Module scope rather than component state on purpose: a scene of three hundred members
 * allocates twelve geometries, not three hundred, and switching twins reuses them.
 */
const GEOMETRY = {
  box: new THREE.BoxGeometry(1, 1, 1),
  cylinder: new THREE.CylinderGeometry(0.5, 0.5, 1, 12),
  bushing: new THREE.CylinderGeometry(0.18, 0.26, 1, 8),
  sphere: new THREE.SphereGeometry(0.5, 12, 10),
  panel: new THREE.BoxGeometry(1, 0.08, 1),
  blade: new THREE.BoxGeometry(0.12, 1, 0.04),
  ring: new THREE.RingGeometry(0.82, 1, 40),
  particle: new THREE.SphereGeometry(1, 6, 5),
}

const MATERIALS = new Map<string, THREE.MeshStandardMaterial>()

function material(colour: string, opacity = 1, emissive = 0): THREE.MeshStandardMaterial {
  const key = `${colour}|${opacity}|${emissive}`
  const cached = MATERIALS.get(key)
  if (cached) return cached
  const created = new THREE.MeshStandardMaterial({
    color: colour,
    roughness: 0.52,
    metalness: 0.22,
    transparent: opacity < 1,
    opacity,
    emissive: new THREE.Color(colour),
    emissiveIntensity: emissive,
  })
  MATERIALS.set(key, created)
  return created
}

// ─────────────────────────────────────────────────────────────────────────────
// Layer colouring
// ─────────────────────────────────────────────────────────────────────────────

/** A cool-to-hot ramp used by every scalar layer, so one reading transfers to the next. */
const RAMP = ['#2c4a6e', '#38bdf8', '#24d07f', '#fbbf24', '#f97316', '#ef4444']

function ramp(fraction: number): string {
  const clamped = Math.min(0.999, Math.max(0, fraction))
  return RAMP[Math.floor(clamped * RAMP.length)]
}

function stateHex(state: string): string {
  return STATE_STYLES[(state as GridState) in STATE_STYLES ? (state as GridState) : 'normal'].hex
}

/**
 * The colour for one asset under one layer.
 *
 * Each branch names the quantity it is showing. A layer that fell through to a default
 * would be a layer that silently showed something else.
 */
export function layerColour(layer: GridLayer, asset: SceneAsset | undefined): string {
  if (!asset) return '#33445c'
  if (asset.isOutage) return '#4b5563'

  switch (layer) {
    case 'voltage': {
      // Centred on nominal: too low and too high are both wrong, in opposite directions.
      const deviation = Math.abs(asset.voltageDeviationPct)
      return deviation >= 10 ? '#ef4444' : deviation >= 5 ? '#f97316' : deviation >= 2 ? '#fbbf24' : '#24d07f'
    }
    case 'current':
      return ramp(Math.abs(asset.currentA) / Math.max(1, asset.ratedCurrentA))
    case 'load':
      return ramp(Math.abs(asset.loadPct) / 120)
    case 'frequency': {
      const off = Math.abs(asset.frequencyHz - 60)
      return off >= 0.4 ? '#ef4444' : off >= 0.2 ? '#f97316' : off >= 0.08 ? '#fbbf24' : '#24d07f'
    }
    case 'temperature':
      return ramp(asset.thermalStress)
    case 'health':
      return ramp(1 - asset.healthScore / 100)
    case 'risk':
      return ramp(asset.riskScore / 100)
    case 'failure_dna':
      return asset.dnaMatchPct >= 70 ? '#a78bfa' : asset.dnaMatchPct >= 45 ? '#6d5bd0' : '#33445c'
    case 'cascade':
      return asset.cascadeDepth === null ? '#2a3446' : ramp(1 - asset.cascadeDepth / 5)
    case 'predicted':
      return ramp((asset.predictedRisk ?? asset.riskScore) / 100)
    case 'maintenance':
      return asset.hasOpenWorkOrder ? '#fbbf24' : '#2f7d5e'
    case 'data_quality':
      return ramp(1 - asset.dataQualityPct / 100)
    case 'xray':
      return '#38bdf8'
    case 'power_flow':
    default:
      return stateHex(asset.state)
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Asset archetypes
// ─────────────────────────────────────────────────────────────────────────────

/** Footprint in metres per archetype: width, height, depth. */
const FOOTPRINT: Record<string, [number, number, number]> = {
  transformer: [7, 8, 7],
  substation: [14, 6, 11],
  line: [3, 20, 3],
  plant: [15, 13, 12],
  solar: [16, 1.5, 10],
  wind: [2, 22, 2],
  battery: [11, 4.5, 7],
  load: [10, 7, 9],
  ev: [7, 4.5, 6],
  building: [8, 6.5, 7],
  busbar: [22, 1.2, 1.2],
  box: [7, 6, 7],
}

/**
 * One asset, drawn as the kind of thing it is.
 *
 * The shapes are schematic rather than photographic: an operator needs to see which unit
 * is hot and what it feeds, not what its paint looks like. But they are *distinguishable* —
 * a transformer reads as a transformer at a glance because of its radiator fins and
 * bushings, and that is what stops a scene of identical boxes from being useless.
 */
function AssetMesh({
  node,
  asset,
  layer,
  selected,
  dimmed,
  onSelect,
}: {
  node: SceneNode
  asset: SceneAsset | undefined
  layer: GridLayer
  selected: boolean
  dimmed: boolean
  onSelect: (code: string) => void
}) {
  const [w, h, d] = FOOTPRINT[node.shape] ?? FOOTPRINT.box
  const scale = node.scale
  const xray = layer === 'xray'
  const height = h * scale * (xray ? 0.4 : 1)
  const colour = layerColour(layer, asset)
  const opacity = xray ? 0.28 : asset?.isOutage ? 0.35 : dimmed ? 0.22 : 1
  const glow = selected ? 0.5 : asset && asset.riskScore >= 78 ? 0.35 : 0

  const body = useMemo(() => material(colour, opacity, glow), [colour, opacity, glow])
  const trim = useMemo(() => material('#8fa3bd', Math.min(opacity, 0.85)), [opacity])

  const click = (event: ThreeEvent<MouseEvent>) => {
    event.stopPropagation()
    onSelect(node.code)
  }

  const hover = (over: boolean) => (event: ThreeEvent<PointerEvent>) => {
    event.stopPropagation()
    document.body.style.cursor = over ? 'pointer' : ''
  }

  return (
    <group position={[node.x, 0, node.z]} rotation={[0, node.rotationY, 0]}>
      {/* The tank / body. Its mesh is the click target for the whole asset. */}
      <mesh
        geometry={GEOMETRY.box}
        material={body}
        position={[0, height / 2, 0]}
        scale={[w * scale, height, d * scale]}
        onClick={click}
        onPointerOver={hover(true)}
        onPointerOut={hover(false)}
      />

      {/* Archetype detail. Skipped in x-ray, where the point is to see past the plant. */}
      {!xray ? <AssetDetail shape={node.shape} width={w * scale} height={height} depth={d * scale} trim={trim} body={body} /> : null}

      {selected ? (
        <mesh position={[0, height / 2, 0]} scale={[w * scale * 1.14, height * 1.1, d * scale * 1.14]}>
          <boxGeometry args={[1, 1, 1]} />
          <meshBasicMaterial color="#ffffff" wireframe transparent opacity={0.45} />
        </mesh>
      ) : null}

      {/*
        The loading column. A second, non-colour channel for the same reading: an asset at
        110 % is a tall bar as well as a red one.
      */}
      {asset && !asset.isOutage && !xray ? (
        <mesh
          geometry={GEOMETRY.box}
          material={body}
          position={[w * scale * 0.62, Math.min(Math.abs(asset.loadPct), 130) * 0.05, 0]}
          scale={[0.7, Math.max(0.2, Math.min(Math.abs(asset.loadPct), 130) * 0.1), 0.7]}
        />
      ) : null}
    </group>
  )
}

/** The details that make one archetype recognisable from another at a distance. */
function AssetDetail({
  shape,
  width,
  height,
  depth,
  trim,
  body,
}: {
  shape: string
  width: number
  height: number
  depth: number
  trim: THREE.MeshStandardMaterial
  body: THREE.MeshStandardMaterial
}) {
  switch (shape) {
    case 'transformer':
      return (
        <>
          {/* Three HV bushings on the lid — the silhouette that says "transformer". */}
          {[-0.3, 0, 0.3].map((offset) => (
            <mesh
              key={offset}
              geometry={GEOMETRY.bushing}
              material={trim}
              position={[width * offset, height + 1.1, 0]}
              scale={[1, 2.2, 1]}
            />
          ))}
          {/* Radiator bank down one flank. */}
          <mesh
            geometry={GEOMETRY.box}
            material={trim}
            position={[-width * 0.58, height * 0.5, 0]}
            scale={[width * 0.16, height * 0.72, depth * 0.86]}
          />
        </>
      )
    case 'wind':
      return (
        <group position={[0, height * 0.94, 0]}>
          {[0, 120, 240].map((degrees) => (
            <mesh
              key={degrees}
              geometry={GEOMETRY.blade}
              material={trim}
              rotation={[0, 0, (degrees * Math.PI) / 180]}
              position={[
                Math.sin((degrees * Math.PI) / 180) * 3.2,
                Math.cos((degrees * Math.PI) / 180) * 3.2,
                0.6,
              ]}
              scale={[1, 6.4, 1]}
            />
          ))}
        </group>
      )
    case 'solar':
      return (
        <mesh
          geometry={GEOMETRY.panel}
          material={body}
          position={[0, height + 0.9, 0]}
          rotation={[-0.42, 0, 0]}
          scale={[width * 0.92, 1, depth * 0.86]}
        />
      )
    case 'battery':
      return (
        <mesh
          geometry={GEOMETRY.box}
          material={trim}
          position={[0, height + 0.35, 0]}
          scale={[width * 0.8, 0.7, depth * 0.6]}
        />
      )
    case 'substation':
      return (
        <>
          {/* Busbar gantry across the yard. */}
          <mesh
            geometry={GEOMETRY.box}
            material={trim}
            position={[0, height + 2.6, 0]}
            scale={[width * 1.05, 0.5, 0.5]}
          />
          {[-0.4, 0.4].map((offset) => (
            <mesh
              key={offset}
              geometry={GEOMETRY.cylinder}
              material={trim}
              position={[width * offset, height + 1.3, 0]}
              scale={[0.5, 2.6, 0.5]}
            />
          ))}
        </>
      )
    case 'plant':
      return (
        <mesh
          geometry={GEOMETRY.cylinder}
          material={trim}
          position={[width * 0.3, height * 1.25, 0]}
          scale={[3.2, height * 0.5, 3.2]}
        />
      )
    default:
      return null
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Paths and flow
// ─────────────────────────────────────────────────────────────────────────────

const EDGE_STATE_COLOUR: Record<string, string> = {
  normal: '#2f7d5e',
  watch: STATE_STYLES.watch.hex,
  warning: STATE_STYLES.warning.hex,
  critical: STATE_STYLES.critical.hex,
}

/**
 * The conductors, batched by state.
 *
 * Four line sets rather than one object per path: a national scene has hundreds of edges,
 * and four draw calls is the difference between a scene that holds sixty frames and one
 * that does not.
 */
function Conductors({
  nodes,
  edges,
  dimmedCodes,
}: {
  nodes: Map<string, SceneNode>
  edges: SceneEdge[]
  dimmedCodes: Set<string> | null
}) {
  const bands = useMemo(() => {
    const groups: Record<string, number[]> = { normal: [], watch: [], warning: [], critical: [] }
    for (const edge of edges) {
      if (dimmedCodes && !dimmedCodes.has(edge.from) && !dimmedCodes.has(edge.to)) continue
      const from = nodes.get(edge.from)
      const to = nodes.get(edge.to)
      if (!from || !to) continue
      const bucket = groups[edge.state] ?? groups.normal
      bucket.push(from.x, 2.4, from.z, to.x, 2.4, to.z)
    }
    return groups
  }, [edges, nodes, dimmedCodes])

  return (
    <>
      {Object.entries(bands).map(([state, points]) => {
        if (points.length === 0) return null
        return (
          <lineSegments key={state}>
            <bufferGeometry>
              <bufferAttribute attach="attributes-position" args={[new Float32Array(points), 3]} />
            </bufferGeometry>
            <lineBasicMaterial
              color={EDGE_STATE_COLOUR[state] ?? EDGE_STATE_COLOUR.normal}
              transparent
              opacity={state === 'normal' ? 0.5 : 0.9}
            />
          </lineSegments>
        )
      })}
    </>
  )
}

/** Particles per path, so a busy line reads as busy before any number is read. */
function particleCount(intensity: number): number {
  if (intensity <= 0) return 0
  return Math.max(2, Math.round(2 + intensity * 6))
}

/**
 * Animated power flow (§9–§11).
 *
 * One instanced mesh for every particle in the scene — a single draw call regardless of
 * how many paths are carrying. Each particle walks its edge from `sourceCode` to
 * `sinkCode`, which is the *solved* direction: when a battery starts discharging, the
 * particles on its feeder turn around, because the solver said so.
 *
 * Speed comes from the edge's normalised intensity, which the flow engine derives from
 * active power. A constant-speed animation would be decoration; this is a reading.
 */
function PowerFlow({
  nodes,
  edges,
  reducedMotion,
  dimmedCodes,
}: {
  nodes: Map<string, SceneNode>
  edges: SceneEdge[]
  reducedMotion: boolean
  dimmedCodes: Set<string> | null
}) {
  const meshRef = useRef<THREE.InstancedMesh>(null)
  const dummy = useMemo(() => new THREE.Object3D(), [])

  // The particle table. Rebuilt only when the topology or the flow pattern changes, not
  // on every frame and not on every telemetry tick that leaves directions alone.
  const particles = useMemo(() => {
    const list: Array<{
      ax: number
      az: number
      bx: number
      bz: number
      offset: number
      speed: number
      size: number
      colour: THREE.Color
    }> = []

    for (const edge of edges) {
      if (edge.direction === 'idle') continue
      if (dimmedCodes && !dimmedCodes.has(edge.from) && !dimmedCodes.has(edge.to)) continue

      const source = nodes.get(edge.sourceCode)
      const sink = nodes.get(edge.sinkCode)
      if (!source || !sink) continue

      const count = particleCount(edge.intensity)
      const colour = new THREE.Color(EDGE_STATE_COLOUR[edge.state] ?? EDGE_STATE_COLOUR.normal)

      for (let index = 0; index < count; index += 1) {
        list.push({
          ax: source.x,
          az: source.z,
          bx: sink.x,
          bz: sink.z,
          offset: index / count,
          // A floor so a trickle still reads as movement rather than as a stalled dot.
          speed: 0.06 + edge.intensity * 0.34,
          size: 0.55 + edge.intensity * 0.85,
          colour,
        })
      }
    }
    return list
  }, [edges, nodes, dimmedCodes])

  useFrame(({ clock }) => {
    const mesh = meshRef.current
    if (!mesh || particles.length === 0) return

    // Frozen at a readable position under reduced motion: the particles still show
    // *where* power is, they simply stop moving (§89).
    const time = reducedMotion ? 0.5 : clock.elapsedTime

    for (let index = 0; index < particles.length; index += 1) {
      const particle = particles[index]
      const t = (particle.offset + time * particle.speed) % 1
      dummy.position.set(
        particle.ax + (particle.bx - particle.ax) * t,
        2.4,
        particle.az + (particle.bz - particle.az) * t,
      )
      dummy.scale.setScalar(particle.size)
      dummy.updateMatrix()
      mesh.setMatrixAt(index, dummy.matrix)
      mesh.setColorAt(index, particle.colour)
    }

    mesh.instanceMatrix.needsUpdate = true
    if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true
  })

  if (particles.length === 0) return null

  return (
    <instancedMesh
      ref={meshRef}
      args={[GEOMETRY.particle, undefined, particles.length]}
      frustumCulled={false}
    >
      <meshBasicMaterial toneMapped={false} />
    </instancedMesh>
  )
}

// ─────────────────────────────────────────────────────────────────────────────
// Labels (§18, §24)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The label beside an asset.
 *
 * Rendered as DOM through drei's `Html` rather than as 3D text: it inherits the console's
 * type and colour tokens, it is selectable, and a screen reader can reach it — which is
 * what §91 asks for and what an in-scene glyph atlas could not give.
 */
function AssetLabel({
  node,
  asset,
  layer,
}: {
  node: SceneNode
  asset: SceneAsset
  layer: GridLayer
}) {
  const [, h] = FOOTPRINT[node.shape] ?? FOOTPRINT.box
  const reading = readingFor(layer, asset)

  return (
    <Html
      position={[node.x, h * node.scale + 5, node.z]}
      center
      distanceFactor={140}
      zIndexRange={[20, 0]}
      style={{ pointerEvents: 'none' }}
    >
      <div className="nabdh-scene-label" data-asset={node.code} data-state={asset.state}>
        <span className="code">{node.code}</span>
        <span className="reading">{reading.value}</span>
        <span className="status">{reading.status}</span>
      </div>
    </Html>
  )
}

/** What the label shows depends on the layer — the reading follows the question asked. */
function readingFor(layer: GridLayer, asset: SceneAsset): { value: string; status: string } {
  const status = asset.isOutage ? 'OUT OF SERVICE' : asset.state.toUpperCase()
  switch (layer) {
    case 'voltage':
      return { value: `${asset.voltageKv.toFixed(1)} kV`, status }
    case 'current':
      return { value: `${Math.round(asset.currentA)} A`, status }
    case 'temperature':
      return { value: `${asset.tempC.toFixed(1)} °C`, status }
    case 'frequency':
      return { value: `${asset.frequencyHz.toFixed(2)} Hz`, status }
    case 'health':
      return { value: `HEALTH ${Math.round(asset.healthScore)}%`, status }
    case 'risk':
    case 'predicted':
      return { value: `RISK ${Math.round(asset.predictedRisk ?? asset.riskScore)}%`, status }
    case 'data_quality':
      return { value: `DATA ${Math.round(asset.dataQualityPct)}%`, status }
    case 'power_flow':
      return { value: `${asset.activePowerMw.toFixed(1)} MW`, status }
    default:
      return { value: `LOAD ${Math.round(asset.loadPct)}%`, status }
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Camera
// ─────────────────────────────────────────────────────────────────────────────

export type CameraPreset =
  | 'overview'
  | 'transformers'
  | 'switchyard'
  | 'power_flow'
  | 'critical'
  | 'cascade'
  | 'future'

/**
 * The camera state two scenes share so one orbit moves both (§11).
 *
 * A single mutable object rather than React state: the camera changes every frame while a
 * pointer is down, and routing that through a re-render would make the follower lag the
 * leader by exactly the thing the comparison is trying to hold constant.
 */
export interface CameraSyncState {
  position: [number, number, number]
  target: [number, number, number]
  /** Which pane last moved the camera. The others follow; the mover does not follow itself. */
  owner: string
  version: number
}

export interface CameraSync {
  id: string
  bus: { current: CameraSyncState }
}

/**
 * Move the camera to a preset or to a focused asset.
 *
 * Eased over roughly a second rather than cut, because a hard cut in a 3D scene loses the
 * viewer's place — except under reduced motion, where a cut is exactly what is wanted.
 *
 * When a sync bus is supplied, this also publishes every camera move to it and adopts
 * moves published by another pane. Comparing two scenes from two viewpoints compares the
 * viewpoints, so the viewpoint is the one thing the panes are not allowed to differ on.
 */
function CameraDirector({
  target,
  radius,
  reducedMotion,
  sync,
}: {
  target: { x: number; z: number; distance: number } | null
  radius: number
  reducedMotion: boolean
  sync?: CameraSync
}) {
  const { camera } = useThree()
  const controls = useRef<OrbitControlsProps & { target?: THREE.Vector3; update?: () => void }>(null)
  const goal = useRef<{ position: THREE.Vector3; look: THREE.Vector3 } | null>(null)
  /** The last bus version this pane has taken on, so it adopts each move exactly once. */
  const adopted = useRef(0)
  /** Set while adopting, so the resulting `onChange` is not published straight back. */
  const adopting = useRef(false)

  const publish = useCallback(() => {
    if (!sync || adopting.current) return
    const look = controls.current?.target
    const next: CameraSyncState = {
      position: [camera.position.x, camera.position.y, camera.position.z],
      target: look ? [look.x, look.y, look.z] : [0, 0, 0],
      owner: sync.id,
      version: sync.bus.current.version + 1,
    }
    sync.bus.current = next
    adopted.current = next.version
  }, [sync, camera])

  useEffect(() => {
    if (!target) return
    const distance = target.distance
    goal.current = {
      position: new THREE.Vector3(target.x + distance * 0.7, distance * 0.62, target.z + distance * 0.7),
      look: new THREE.Vector3(target.x, 0, target.z),
    }
    if (reducedMotion) {
      camera.position.copy(goal.current.position)
      controls.current?.target?.copy(goal.current.look)
      controls.current?.update?.()
      goal.current = null
    }
  }, [target, camera, reducedMotion])

  useFrame(() => {
    if (!goal.current) return
    camera.position.lerp(goal.current.position, 0.075)
    const look = controls.current?.target
    if (look) {
      look.lerp(goal.current.look, 0.075)
      controls.current?.update?.()
    }
    if (camera.position.distanceTo(goal.current.position) < 0.6) goal.current = null
  })

  // Adopt whatever another pane published. Runs after the easing above so a focus move in
  // one pane is followed by the other rather than fought over.
  useFrame(() => {
    if (!sync) return
    const bus = sync.bus.current
    if (bus.owner === sync.id || bus.version === adopted.current) return
    adopting.current = true
    camera.position.set(bus.position[0], bus.position[1], bus.position[2])
    const look = controls.current?.target
    if (look) {
      look.set(bus.target[0], bus.target[1], bus.target[2])
      controls.current?.update?.()
    }
    adopted.current = bus.version
    adopting.current = false
  })

  const distance = Math.max(70, radius * 1.6)

  return (
    <OrbitControls
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      ref={controls as any}
      makeDefault
      onChange={publish}
      enablePan
      enableDamping
      dampingFactor={0.08}
      minDistance={18}
      maxDistance={distance * 3.2}
      // Stops the camera dropping below the ground plane, which is disorienting and shows
      // the underside of a scene that has no underside.
      maxPolarAngle={Math.PI / 2.12}
    />
  )
}

// ─────────────────────────────────────────────────────────────────────────────
// The scene
// ─────────────────────────────────────────────────────────────────────────────

export interface LiveGridSceneProps {
  nodes: SceneNode[]
  edges: SceneEdge[]
  assets: SceneAsset[]
  layer: GridLayer
  radius: number
  selectedCode: string | null
  /** Codes to keep bright; everything else dims. Used by risk mode and by tracing. */
  highlight: string[] | null
  showLabels: boolean
  reducedMotion: boolean
  quality: 'high' | 'balanced' | 'performance'
  cameraTarget: { x: number; z: number; distance: number } | null
  /** Supplied by the compare surface so both panes share one camera (§11). */
  sync?: CameraSync
  onSelect: (code: string) => void
}

export default function LiveGridScene({
  nodes,
  edges,
  assets,
  layer,
  radius,
  selectedCode,
  highlight,
  showLabels,
  reducedMotion,
  quality,
  cameraTarget,
  sync,
  onSelect,
}: LiveGridSceneProps) {
  const nodeByCode = useMemo(() => new Map(nodes.map((node) => [node.code, node])), [nodes])
  const assetByCode = useMemo(() => new Map(assets.map((asset) => [asset.code, asset])), [assets])
  const highlighted = useMemo(() => (highlight ? new Set(highlight) : null), [highlight])

  const distance = Math.max(70, radius * 1.6)
  const xray = layer === 'xray'

  // Labels are the expensive part of the scene — each one is a DOM node positioned every
  // frame. Capped, and prioritised by what an operator needs to read: the selection, then
  // anything highlighted, then the worst off.
  const labelled = useMemo(() => {
    if (!showLabels) return []
    const cap = quality === 'performance' ? 6 : quality === 'balanced' ? 12 : 20
    const ranked = [...assets].sort((a, b) => {
      const priority = (asset: SceneAsset) =>
        (asset.code === selectedCode ? 1_000 : 0) +
        (highlighted?.has(asset.code) ? 500 : 0) +
        asset.riskScore
      return priority(b) - priority(a)
    })
    return ranked.slice(0, cap)
  }, [assets, showLabels, quality, selectedCode, highlighted])

  return (
    <Canvas
      // 'always' because the flow animation needs frames; reduced motion drops to demand,
      // which on a wall display left open for hours is the difference between an idle GPU
      // and a hot one.
      frameloop={reducedMotion ? 'demand' : 'always'}
      dpr={quality === 'performance' ? [1, 1] : quality === 'balanced' ? [1, 1.5] : [1, 2]}
      camera={{ position: [distance * 0.75, distance * 0.7, distance * 0.75], fov: 44, far: 9_000 }}
      gl={{ antialias: quality !== 'performance', powerPreference: 'high-performance' }}
      style={{ width: '100%', height: '100%' }}
    >
      <color attach="background" args={['#060a11']} />
      <fog attach="fog" args={['#060a11', distance * 1.5, distance * 4.5]} />

      <ambientLight intensity={xray ? 0.85 : 0.5} />
      <directionalLight position={[70, 110, 50]} intensity={1.15} color="#e2ecff" />
      <directionalLight position={[-80, 50, -60]} intensity={0.32} color="#38bdf8" />

      {/* The substation apron. Suppressed in x-ray, where civil works are the noise. */}
      {!xray ? (
        <gridHelper args={[radius * 4, 30, '#1b2b45', '#101a2b']} position={[0, -0.05, 0]} />
      ) : null}

      <Conductors nodes={nodeByCode} edges={edges} dimmedCodes={highlighted} />
      <PowerFlow
        nodes={nodeByCode}
        edges={edges}
        reducedMotion={reducedMotion}
        dimmedCodes={highlighted}
      />

      {nodes.map((node) => (
        <AssetMesh
          key={node.code}
          node={node}
          asset={assetByCode.get(node.code)}
          layer={layer}
          selected={node.code === selectedCode}
          dimmed={Boolean(highlighted) && !highlighted!.has(node.code)}
          onSelect={onSelect}
        />
      ))}

      {labelled.map((asset) => {
        const node = nodeByCode.get(asset.code)
        if (!node) return null
        return <AssetLabel key={asset.code} node={node} asset={asset} layer={layer} />
      })}

      <CameraDirector
        target={cameraTarget}
        radius={radius}
        reducedMotion={reducedMotion}
        sync={sync}
      />
    </Canvas>
  )
}
