import 'server-only'

import { prisma } from '../db'
import { environmentFor } from '../engine/grid'
import {
  solvePowerFlow,
  tracePowerSource,
  traceDependentLoads,
  type FlowEdgeInput,
  type FlowNodeInput,
  type PowerFlowResult,
} from '../engine/power-flow'
import {
  NO_PERTURBATION,
  remainingUsefulLifeDays,
  sampleAsset,
  type Perturbation,
} from '../engine/telemetry'
import { round } from '../engine/rng'
import {
  GRID_SCENARIOS,
  SCENARIO_KEYS,
  scenarioFor,
  type GridScenario,
} from '../engine/grid-scenarios'
import { demoBaseTimestamp } from '../demo/config'
import { REGIONS } from '../data/regions'
import { getGridSnapshot, loadAssetProfiles } from './grid-service'
import { loadGridGraph } from './graph-service'
import { getHubOverview } from './integration-service'
import { getTwinState, getTwinFuture, listTwins, TWIN_OFFSETS_MIN } from './twin-service'
import type { TwinAssetState, TwinStateResult } from './twin-service'

/**
 * The Live 3D Grid's server side (4.2).
 *
 * This file composes; it does not compute. Topology and per-asset electrical state come
 * from the twin, the numbers behind them from the telemetry engine, the future from the
 * twin's own forward walk, connection health from the Integration Hub, and directed flow
 * from the power-flow engine. Nothing here holds a second copy of anything — a 3D view
 * with its own data source would be a 3D view that could disagree with the console beside
 * it, which is the one failure a digital twin cannot survive.
 *
 * The four modes are kept apart deliberately (§104). `live` reads the model with no
 * perturbation. `simulation` applies one and is labelled. `predicted` walks forward.
 * `demo` runs a named scenario. A mode is carried on every payload so no surface can
 * render one as another.
 */

const MIN = 60_000

export type GridMode = 'live' | 'demo' | 'simulation' | 'replay'
export type StateKind = 'observed' | 'synthetic' | 'simulated' | 'predicted'

// ─────────────────────────────────────────────────────────────────────────────
// Connection banner (§8)
// ─────────────────────────────────────────────────────────────────────────────

export interface ConnectionStatus {
  /** live | demo — what the sources actually are, never what we would like them to be. */
  dataSource: 'live' | 'demo' | 'none'
  status: 'connected' | 'degraded' | 'disconnected'
  /** Rendered by the client as "N sec ago"; the server sends the instant. */
  lastUpdateTs: number | null
  latencyMs: number
  dataQualityPct: number
  sources: number
  sourcesDelivering: number
  /** The connector dragging the mean down, so the banner points at a remedy. */
  weakestSource: { code: string; label: string; quality: number } | null
}

async function connectionStatus(): Promise<ConnectionStatus> {
  const hub = await getHubOverview()
  const delivering = hub.connected
  const weakest = [...hub.connectors].sort((a, b) => a.dataQuality - b.dataQuality)[0] ?? null

  return {
    // Every connector in this build is a demonstration source, and the banner says so
    // rather than saying "live" because data is arriving.
    dataSource: hub.total === 0 ? 'none' : hub.live > 0 ? 'live' : 'demo',
    status: delivering === 0 ? 'disconnected' : delivering < hub.total ? 'degraded' : 'connected',
    lastUpdateTs: hub.lastSyncAt,
    latencyMs: round(
      hub.connectors.reduce((sum, c) => sum + c.latencyMs, 0) / Math.max(1, hub.connectors.length),
      1,
    ),
    dataQualityPct: hub.meanQuality,
    sources: hub.total,
    sourcesDelivering: delivering,
    weakestSource: weakest
      ? { code: weakest.code, label: weakest.label, quality: weakest.dataQuality }
      : null,
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Scene payload
// ─────────────────────────────────────────────────────────────────────────────

export interface LiveGridNode {
  code: string
  name: string
  nameAr: string
  assetTypeKey: string
  shape: string
  x: number
  y: number
  z: number
  scale: number
  rotationY: number
  capacityMw: number
  customersServed: number
  isFocus: boolean
}

/** Everything the inspector and the layers read, per asset, in one object. */
export interface LiveGridAsset extends TwinAssetState {
  activePowerMw: number
  reactivePowerMvar: number
  /** Role in the flow solution — source, sink, transit or storage. */
  role: string
  netInjectionMw: number
  downstreamDemandMw: number
  downstreamNodes: number
  /** Battery state of charge; null on anything that is not storage. */
  socPct: number | null
  /** Grid frequency is a property of the synchronous area, repeated here for the label. */
  frequencyHz: number
  rulDays: number
  /** Nameplate current, so the current layer has a denominator rather than a guess. */
  ratedCurrentA: number
  /** Closest Failure DNA match, 0–100. Zero when nothing in the library is close. */
  dnaMatchPct: number
  /** Filled by the ghost, never by the live payload — a present has no predicted risk. */
  predictedRisk: number | null
  hasOpenWorkOrder: boolean
  /** The stored per-asset data-quality score; falls back to the scene's confidence. */
  dataQualityPct: number
  /** Set by the cascade view; null until a cascade has been run. */
  cascadeDepth: number | null
  /** Where this asset's numbers came from, so a label can never be guessed. */
  kind: StateKind
}

export interface LiveGridKpis {
  stabilityIndex: number
  totalLoadMw: number
  availableCapacityMw: number
  renewableMw: number
  renewableSharePct: number
  frequencyHz: number
  totalActivePowerMw: number
  totalReactivePowerMvar: number
  assetsAtRisk: number
  criticalAlerts: number
  dataConfidencePct: number
  ambientC: number
  peakRisk: number
}

export interface LiveGridPayload {
  mode: GridMode
  kind: StateKind
  ts: number
  offsetMin: number
  twin: { code: string; level: string; name: string; nameAr: string; regionCode: string | null }
  breadcrumb: Array<{ code: string; label: string; labelAr: string; level: string }>
  nodes: LiveGridNode[]
  assets: LiveGridAsset[]
  flow: PowerFlowResult
  kpis: LiveGridKpis
  fidelity: TwinStateResult['fidelity']
  connection: ConnectionStatus
  radius: number
  focusCode: string | null
  /** Present only when a perturbation was applied — the scenario the numbers are under. */
  scenario: string | null
}

// ─────────────────────────────────────────────────────────────────────────────
// Composition
// ─────────────────────────────────────────────────────────────────────────────

function toFlowNode(asset: TwinAssetState): FlowNodeInput {
  return {
    code: asset.code,
    assetTypeKey: asset.assetTypeKey,
    capacityMw: asset.capacityMw,
    loadPct: asset.loadPct,
    loadMw: asset.loadMw,
    voltageKv: asset.voltageKv,
    powerFactor: asset.powerFactor,
    isOutage: asset.isOutage,
  }
}

/**
 * Battery state of charge.
 *
 * Derived from the signed loading rather than stored: a battery that has been discharging
 * has been draining, and the twin has no separate SOC meter to read. Reported as an
 * estimate everywhere it surfaces, and null on anything that is not storage.
 */
function socFor(asset: TwinAssetState): number | null {
  if (asset.assetTypeKey !== 'battery_storage') return null
  // Discharging (positive loading) walks down from a nominal 82 %; charging walks up.
  const drift = -asset.loadPct * 0.28
  return round(Math.min(100, Math.max(4, 82 + drift)), 1)
}

export interface LiveGridOptions {
  twinCode?: string
  ts?: number
  mode?: GridMode
  offsetMin?: number
  perturbation?: Perturbation
  scenario?: string | null
}

/**
 * Turn a named scenario into a perturbation, resolving its storage target against the
 * twin actually in view.
 *
 * Done here rather than in the scenario table because a scenario should not need to know
 * which battery exists in which twin — it says "dispatch 45 MW of storage", and the scene
 * decides which unit that is. A twin with no storage gets the demand change and no
 * dispatch, which is the honest outcome rather than an invented battery.
 */
export function perturbationFor(
  scenario: GridScenario,
  assets: Array<{ code: string; assetTypeKey: string; capacityMw: number }>,
): Perturbation {
  const base: Perturbation = { ...scenario.perturbation }
  if (!scenario.dispatchStorageMw) return base

  const storage = assets
    .filter((asset) => asset.assetTypeKey === 'battery_storage')
    .sort((a, b) => b.capacityMw - a.capacityMw)[0]
  if (!storage) return base

  return {
    ...base,
    actions: [
      ...(base.actions ?? []),
      {
        type: 'battery_discharge',
        assetCode: storage.code,
        magnitudeMw: Math.min(scenario.dispatchStorageMw, storage.capacityMw),
      },
    ],
  }
}

/**
 * The scene under a named scenario.
 *
 * Two passes: the first resolves the topology so the scenario can be pointed at real
 * storage, the second runs it. The extra pass costs one snapshot and buys a scenario
 * table that contains no asset codes it had to guess.
 */
export async function getScenarioGrid(
  scenarioKey: string,
  options: LiveGridOptions = {},
): Promise<LiveGridPayload | null> {
  const scenario = scenarioFor(scenarioKey)
  if (scenario.key === 'normal') {
    return getLiveGrid({ ...options, mode: options.mode ?? 'demo', scenario: scenario.key })
  }

  const base = await getLiveGrid({ ...options, perturbation: undefined })
  if (!base) return null

  return getLiveGrid({
    ...options,
    mode: options.mode ?? 'demo',
    scenario: scenario.key,
    perturbation: perturbationFor(scenario, base.assets),
  })
}

export async function getLiveGrid(options: LiveGridOptions = {}): Promise<LiveGridPayload | null> {
  const now = options.ts ?? Date.now()
  const offsetMin = options.offsetMin ?? 0
  const ts = now + offsetMin * MIN
  const mode: GridMode = options.mode ?? 'live'

  const twins = await listTwins()
  const twin =
    twins.find((entry) => entry.code === options.twinCode) ??
    twins.find((entry) => entry.level === 'national') ??
    twins[0]
  if (!twin) return null

  const [state, connection] = await Promise.all([
    getTwinState(twin.code, { ts, perturbation: options.perturbation }),
    connectionStatus(),
  ])
  if (!state) return null

  // The kind of every number on this payload, decided once. A predicted state is never
  // labelled simulated and a simulated one is never labelled observed.
  const kind: StateKind =
    offsetMin > 0 ? 'predicted' : options.perturbation ? 'simulated' : 'synthetic'

  // ── The graph the flow is solved over ────────────────────────────────────
  //
  // The *whole* topology among this twin's members, not the subset the scene draws. At
  // national scale the scene shows the backbone only — substations, plants, storage — and
  // solving over that subset alone would find no load anywhere and report every path as
  // idle, which is exactly the bug this replaced. A 380 kV line carries the demand of the
  // city behind it whether or not the city is drawn, and the solver has to see the city.
  const graph = await loadGridGraph()
  const memberCodes = new Set(state.assets.map((asset) => asset.code))

  const flow = solvePowerFlow({
    nodes: state.assets.map(toFlowNode),
    edges: graph.edges
      .filter((edge) => memberCodes.has(edge.from) && memberCodes.has(edge.to))
      .map<FlowEdgeInput>((edge) => ({
        from: edge.from,
        to: edge.to,
        kind: edge.kind,
        capacityMw: edge.capacityMw,
      })),
  })

  const flowByCode = new Map(flow.nodes.map((node) => [node.code, node]))
  const codes = state.assets.map((asset) => asset.code)

  // Three bulk queries for the whole scene rather than three per asset. A national twin
  // has hundreds of members, and a per-asset lookup here is the difference between a
  // payload that arrives in tens of milliseconds and one that does not.
  const [profiles, snapshot, qualityRows, workOrderRows] = await Promise.all([
    loadAssetProfiles(),
    getGridSnapshot({ ts, perturbation: options.perturbation }),
    prisma.dataQualityScore.findMany({
      where: { scopeKind: 'asset', scopeCode: { in: codes } },
      select: { scopeCode: true, score: true },
    }),
    prisma.maintenanceRecord.findMany({
      where: { asset: { code: { in: codes } }, status: { in: ['scheduled', 'in_progress'] } },
      select: { asset: { select: { code: true } } },
    }),
  ])

  const profileByCode = new Map(profiles.map((profile) => [profile.code, profile]))
  const qualityByCode = new Map(qualityRows.map((row) => [row.scopeCode, row.score]))
  const openWorkOrders = new Set(
    workOrderRows.map((row) => row.asset?.code).filter((code): code is string => Boolean(code)),
  )
  // The DNA similarity the snapshot already computed for the prediction. Reused rather
  // than re-matched: running the library again here would be a second answer to a
  // question already asked this request.
  const dnaByCode = new Map(
    snapshot.assets.map((entry) => [entry.profile.code, entry.dnaSimilarity]),
  )

  // Remaining life reads a whole sample, so the sample is taken at the same instant the
  // scene was built rather than reconstructed from the rounded figures on the view.
  const envByRegion = new Map(
    REGIONS.map((region) => [
      region.code,
      environmentFor(region, options.perturbation ?? NO_PERTURBATION),
    ]),
  )

  const assets: LiveGridAsset[] = state.assets.map((asset) => {
    const node = flowByCode.get(asset.code)
    const profile = profileByCode.get(asset.code)
    const reactive = Math.abs(asset.loadMw) * Math.tan(Math.acos(Math.min(0.9999, asset.powerFactor)))

    return {
      ...asset,
      activePowerMw: round(asset.loadMw, 3),
      reactivePowerMvar: round(reactive, 3),
      role: node?.role ?? 'transit',
      netInjectionMw: node?.netInjectionMw ?? 0,
      downstreamDemandMw: node?.downstreamDemandMw ?? 0,
      downstreamNodes: node?.downstreamNodes ?? 0,
      socPct: socFor(asset),
      frequencyHz: state.snapshot.frequencyHz,
      // I = S / (√3 · V) at nameplate, with the same 0.95 reference power factor the
      // rating plates use. Zero-voltage assets get zero rather than an infinity.
      ratedCurrentA: round(
        asset.voltageKv > 0.01
          ? (asset.capacityMw * 1_000_000) / (Math.sqrt(3) * asset.voltageKv * 1_000 * 0.95)
          : 0,
        0,
      ),
      // Already a percentage — `dnaSimilarity` is in [0, 100] and the prediction engine
      // divides it by a hundred to get a prior. Multiplying here reported 5,950 %.
      dnaMatchPct: round(dnaByCode.get(asset.code) ?? 0, 1),
      predictedRisk: null,
      hasOpenWorkOrder: openWorkOrders.has(asset.code),
      dataQualityPct: round(qualityByCode.get(asset.code) ?? connection.dataQualityPct, 1),
      cascadeDepth: null,
      rulDays: profile
        ? round(
            remainingUsefulLifeDays(
              profile,
              sampleAsset(
                profile,
                ts,
                (envByRegion.get(profile.regionCode) ?? envByRegion.get(REGIONS[0].code)!)(ts),
                options.perturbation,
              ),
            ),
            0,
          )
        : 0,
      kind,
    }
  })

  return {
    mode,
    kind,
    ts,
    offsetMin,
    twin: {
      code: twin.code,
      level: twin.level,
      name: twin.name,
      nameAr: twin.nameAr,
      regionCode: twin.regionCode,
    },
    breadcrumb: buildBreadcrumb(twins, twin),
    nodes: state.scene.nodes.map((node) => ({
      code: node.code,
      name: node.name,
      nameAr: node.nameAr,
      assetTypeKey: node.assetTypeKey,
      shape: node.shape,
      x: node.x,
      y: node.y,
      z: node.z,
      scale: node.scale,
      rotationY: node.rotationY,
      capacityMw: node.capacityMw,
      customersServed: node.customersServed,
      isFocus: node.isFocus,
    })),
    assets,
    flow,
    kpis: {
      stabilityIndex: state.snapshot.stabilityIndex,
      totalLoadMw: state.snapshot.totalLoadMw,
      availableCapacityMw: round(snapshot.availableCapacityMw, 0),
      renewableMw: state.snapshot.renewableMw,
      renewableSharePct: round(snapshot.renewableSharePct, 1),
      frequencyHz: state.snapshot.frequencyHz,
      // From the flow solution's endpoints, not from summing every asset. The same
      // megawatt passes a plant, a substation, a transformer and a load, and adding all
      // four together produced a fleet generating more than its own nameplate.
      totalActivePowerMw: flow.totalGenerationMw,
      totalReactivePowerMvar: round(
        assets
          .filter((asset) => asset.role !== 'transit')
          .reduce((sum, asset) => sum + asset.reactivePowerMvar, 0),
        1,
      ),
      assetsAtRisk: state.snapshot.assetsAtRisk,
      criticalAlerts: assets.filter((a) => a.riskScore >= 78).length,
      // Capped by the weakest of the three, the platform's standing rule: a confident
      // model reading a failing sensor is not a confident answer.
      dataConfidencePct: round(
        Math.min(connection.dataQualityPct, state.fidelity.score, 100),
        1,
      ),
      ambientC: state.snapshot.ambientC,
      peakRisk: state.snapshot.peakRisk,
    },
    fidelity: state.fidelity,
    connection,
    radius: state.scene.radius,
    focusCode: state.scene.focusCode,
    scenario: options.scenario ?? null,
  }
}

/** National → region → site → asset, for the breadcrumb (§33). */
function buildBreadcrumb(
  twins: Awaited<ReturnType<typeof listTwins>>,
  target: Awaited<ReturnType<typeof listTwins>>[number],
): LiveGridPayload['breadcrumb'] {
  const order = ['national', 'region', 'site', 'asset']
  const depth = order.indexOf(target.level)
  const trail: LiveGridPayload['breadcrumb'] = []

  for (let level = 0; level <= depth; level += 1) {
    const key = order[level]
    if (key === target.level) {
      trail.push({ code: target.code, label: target.name, labelAr: target.nameAr, level: key })
      continue
    }
    // The ancestor at this level is the twin that contains the target. Matching on region
    // and site rather than on a stored parent id, because the twin table has no parent
    // column and inventing one would be a migration for a label.
    const ancestor = twins.find(
      (entry) =>
        entry.level === key &&
        (key === 'national' ||
          (key === 'region' && entry.regionCode === target.regionCode) ||
          (key === 'site' && entry.siteCode === target.siteCode)),
    )
    if (ancestor) {
      trail.push({ code: ancestor.code, label: ancestor.name, labelAr: ancestor.nameAr, level: key })
    }
  }

  return trail
}

// ─────────────────────────────────────────────────────────────────────────────
// History for the charts (§26–§28)
// ─────────────────────────────────────────────────────────────────────────────

export const CHART_WINDOWS_MIN = [1, 5, 15, 60, 360, 1_440] as const

export interface TelemetryPoint {
  ts: number
  loadPct: number
  loadMw: number
  voltageKv: number
  currentA: number
  activePowerMw: number
  reactivePowerMvar: number
  powerFactor: number
  tempC: number
  frequencyHz: number
  riskScore: number
}

export interface TelemetryHistory {
  assetCode: string
  windowMin: number
  kind: StateKind
  points: TelemetryPoint[]
  /** The nominal the deviation band is measured against. */
  nominalVoltageKv: number
  ratedTempC: number
  capacityMw: number
}

/**
 * The last N minutes for one asset.
 *
 * Sampled from the same engine the live view reads rather than from a stored series, so
 * the last point of the history and the current reading are the same number by
 * construction. A chart that came from a different source than the scene would drift from
 * it within a minute of watching.
 */
export async function getAssetHistory(
  assetCode: string,
  options: { windowMin?: number; ts?: number; points?: number; perturbation?: Perturbation } = {},
): Promise<TelemetryHistory | null> {
  const windowMin = options.windowMin ?? 15
  const now = options.ts ?? Date.now()
  const count = Math.min(240, Math.max(12, options.points ?? 60))

  const profiles = await loadAssetProfiles()
  const profile = profiles.find((entry) => entry.code === assetCode)
  if (!profile) return null

  const region = REGIONS.find((entry) => entry.code === profile.regionCode) ?? REGIONS[0]
  const envAt = environmentFor(region, options.perturbation ?? NO_PERTURBATION)

  const step = (windowMin * MIN) / (count - 1)
  const points: TelemetryPoint[] = []

  for (let index = 0; index < count; index += 1) {
    const ts = now - windowMin * MIN + index * step
    const sample = sampleAsset(profile, ts, envAt(ts), options.perturbation)

    // Risk here is the thermal-and-loading stress the sample itself carries, not a second
    // call into the risk engine: sixty risk assessments for one chart would cost more than
    // the whole scene.
    const stress =
      Math.max(0, Math.abs(sample.loadPct) - 70) * 1.4 +
      Math.max(0, sample.tempC - profile.ratedTempC + 10) * 2.2

    points.push({
      ts: Math.round(ts),
      loadPct: sample.loadPct,
      loadMw: sample.loadMw,
      voltageKv: sample.voltageKv,
      currentA: sample.currentA,
      activePowerMw: sample.activePowerMw,
      reactivePowerMvar: sample.reactivePowerMvar,
      powerFactor: sample.powerFactor,
      tempC: sample.tempC,
      frequencyHz: 0,
      riskScore: round(Math.min(100, Math.max(0, stress)), 1),
    })
  }

  // Frequency is a property of the synchronous area, so it is stamped from the grid
  // snapshot rather than sampled per asset — and it is not a constant across scenarios.
  const snapshot = await getGridSnapshot({ ts: now, perturbation: options.perturbation })
  for (const point of points) point.frequencyHz = snapshot.frequencyHz

  return {
    assetCode,
    windowMin,
    kind: options.perturbation ? 'simulated' : 'synthetic',
    points,
    nominalVoltageKv: profile.voltageKv,
    ratedTempC: profile.ratedTempC,
    capacityMw: profile.capacityMw,
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Line inspector (§12)
// ─────────────────────────────────────────────────────────────────────────────

export interface LineDetail {
  id: string
  from: string
  to: string
  fromName: string
  toName: string
  kind: string
  direction: string
  voltageKv: number
  currentA: number
  activePowerMw: number
  reactivePowerMvar: number
  apparentPowerMva: number
  powerFactor: number
  capacityMw: number
  utilisationPct: number
  lossMw: number
  state: string
  isBottleneck: boolean
  frequencyHz: number
  /** Present only when both ends carry a temperature; a line has no meter of its own. */
  tempC: number | null
  riskScore: number
  kind_: StateKind
}

export async function getLineDetail(
  from: string,
  to: string,
  options: LiveGridOptions = {},
): Promise<LineDetail | null> {
  const payload = await getLiveGrid(options)
  if (!payload) return null

  const edge = payload.flow.edges.find((entry) => entry.from === from && entry.to === to)
  if (!edge) return null

  const byCode = new Map(payload.assets.map((asset) => [asset.code, asset]))
  const nodeByCode = new Map(payload.nodes.map((node) => [node.code, node]))
  const a = byCode.get(from)
  const b = byCode.get(to)

  return {
    id: `${from}→${to}`,
    from,
    to,
    fromName: nodeByCode.get(from)?.name ?? from,
    toName: nodeByCode.get(to)?.name ?? to,
    kind: edge.kind,
    direction: edge.direction,
    voltageKv: edge.voltageKv,
    currentA: edge.currentA,
    activePowerMw: edge.activePowerMw,
    reactivePowerMvar: edge.reactivePowerMvar,
    apparentPowerMva: edge.apparentPowerMva,
    powerFactor: edge.powerFactor,
    capacityMw: edge.capacityMw,
    utilisationPct: edge.utilisationPct,
    lossMw: edge.lossMw,
    state: edge.state,
    isBottleneck: edge.isBottleneck,
    frequencyHz: payload.kpis.frequencyHz,
    tempC: a && b ? round(Math.max(a.tempC, b.tempC), 1) : null,
    riskScore: a && b ? round(Math.max(a.riskScore, b.riskScore), 1) : 0,
    kind_: payload.kind,
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tracing (§54, §55)
// ─────────────────────────────────────────────────────────────────────────────

export async function traceAsset(
  assetCode: string,
  direction: 'source' | 'dependents',
  options: LiveGridOptions = {},
): Promise<{
  direction: string
  assetCode: string
  path: string[]
  edges: Array<{ from: string; to: string; activePowerMw: number }>
  sources: string[]
  demandMw: number
} | null> {
  const payload = await getLiveGrid(options)
  if (!payload) return null

  if (direction === 'source') {
    const trace = tracePowerSource(payload.flow, assetCode)
    return {
      direction,
      assetCode,
      path: trace.path,
      edges: trace.edges.map((edge) => ({
        from: edge.sourceCode,
        to: edge.sinkCode,
        activePowerMw: edge.activePowerMw,
      })),
      sources: trace.sources,
      demandMw: 0,
    }
  }

  const trace = traceDependentLoads(payload.flow, assetCode)
  return {
    direction,
    assetCode,
    path: trace.codes,
    edges: trace.edges.map((edge) => ({
      from: edge.sourceCode,
      to: edge.sinkCode,
      activePowerMw: edge.activePowerMw,
    })),
    sources: [],
    demandMw: trace.demandMw,
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Ghost future (§34–§37)
// ─────────────────────────────────────────────────────────────────────────────

export { TWIN_OFFSETS_MIN }

export interface FutureGrid {
  offsetMin: number
  ts: number
  kind: 'predicted'
  assets: Array<{
    code: string
    loadPct: number
    tempC: number
    riskScore: number
    state: string
    thermalStress: number
    /** Change from now, so the ghost can be read without a second lookup. */
    deltaLoadPct: number
    deltaRisk: number
  }>
  peakRisk: number
  stabilityIndex: number
  breached: boolean
}

/**
 * The scene as it is projected to be, alongside the scene as it is.
 *
 * The present is never modified: §36 is explicit, and it is the right rule. A ghost is an
 * overlay, and the moment it edits the thing it is overlaying it stops being one.
 */
export async function getFutureGrid(
  options: LiveGridOptions & { offsets?: readonly number[] } = {},
): Promise<{ present: LiveGridPayload; frames: FutureGrid[] } | null> {
  const present = await getLiveGrid({ ...options, offsetMin: 0 })
  if (!present) return null

  const future = await getTwinFuture(present.twin.code, {
    ts: options.ts ?? Date.now(),
    perturbation: options.perturbation,
    offsets: options.offsets ?? TWIN_OFFSETS_MIN,
  })
  if (!future) return { present, frames: [] }

  const nowByCode = new Map(present.assets.map((asset) => [asset.code, asset]))

  return {
    present,
    frames: future.frames.map((frame) => ({
      offsetMin: frame.offsetMin,
      ts: frame.ts,
      kind: 'predicted' as const,
      assets: frame.assets.map((asset) => {
        const now = nowByCode.get(asset.code)
        return {
          ...asset,
          deltaLoadPct: round(asset.loadPct - (now?.loadPct ?? asset.loadPct), 2),
          deltaRisk: round(asset.riskScore - (now?.riskScore ?? asset.riskScore), 1),
        }
      }),
      peakRisk: frame.peakRisk,
      stabilityIndex: frame.stabilityIndex,
      breached: frame.breached,
    })),
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Alerts beside the asset (§56)
// ─────────────────────────────────────────────────────────────────────────────

export async function getSceneAlerts(codes: string[], limit = 12) {
  if (codes.length === 0) return []
  const rows = await prisma.alert.findMany({
    where: { asset: { code: { in: codes } }, status: { in: ['new', 'acknowledged'] } },
    orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
    take: limit,
    select: {
      code: true,
      level: true,
      title: true,
      titleAr: true,
      status: true,
      createdAt: true,
      expectedAt: true,
      riskScore: true,
      asset: { select: { code: true } },
    },
  })

  return rows.map((row) => ({
    code: row.code,
    assetCode: row.asset?.code ?? '',
    level: row.level,
    title: row.title,
    titleAr: row.titleAr,
    status: row.status,
    riskScore: row.riskScore,
    raisedAt: row.createdAt.getTime(),
    expectedAt: row.expectedAt?.getTime() ?? null,
  }))
}

// ─────────────────────────────────────────────────────────────────────────────
// Cascade in the scene (§40–§45)
// ─────────────────────────────────────────────────────────────────────────────

export interface GridCascadeStep {
  ordinal: number
  depth: number
  offsetMin: number
  assetCode: string
  assetName: string
  assetNameAr: string
  parentCode: string
  mechanism: string
  outcome: string
  loadPctBefore: number
  loadPctAfter: number
  transferredMw: number
  probability: number
  severity: string
}

export interface GridCascade {
  originCode: string
  /** Always simulated. A cascade that had happened would be an incident. */
  kind: 'simulated'
  horizonMin: number
  steps: GridCascadeStep[]
  affectedAssets: number
  affectedMw: number
  affectedCustomers: number
  unservedMw: number
  regionalRisk: number
  containedAtMin: number | null
  /** The next hop and when it is expected — the banner in §44. */
  next: { assetCode: string; etaMin: number; confidence: number } | null
  /** Where the chain can be cut, by how much cutting there removes (§45). */
  breakpoints: Array<{
    assetCode: string
    action: string
    actionAr: string
    riskReduction: number
    interventionMw: number
    isBest: boolean
    interventionPotential: 'high' | 'medium' | 'low'
  }>
}

/**
 * The cascade path, shaped for playback in the scene.
 *
 * Reuses the cascade engine rather than deriving a second propagation model for the 3D
 * view — a scene animating a different chain from the one the cascade page shows would be
 * two answers to one question.
 */
export async function getGridCascade(
  assetCode: string,
  options: LiveGridOptions = {},
): Promise<GridCascade | null> {
  const { analyseAsset } = await import('./intelligence-service')
  const scenario = scenarioFor(options.scenario)

  let perturbation = options.perturbation
  if (!perturbation && scenario.key !== 'normal') {
    const base = await getLiveGrid({ twinCode: options.twinCode })
    if (base) perturbation = perturbationFor(scenario, base.assets)
  }

  const intel = await analyseAsset(assetCode, { ts: options.ts, perturbation })
  if (!intel) return null

  const cascade = intel.cascade
  const first = cascade.steps.find((step) => step.assetCode !== cascade.originCode) ?? null

  return {
    originCode: cascade.originCode,
    kind: 'simulated',
    horizonMin: cascade.horizonMin,
    steps: cascade.steps.map((step) => ({
      ordinal: step.ordinal,
      depth: step.depth,
      offsetMin: step.offsetMin,
      assetCode: step.assetCode,
      assetName: step.assetName,
      assetNameAr: step.assetNameAr,
      parentCode: step.parentCode,
      mechanism: step.mechanism,
      outcome: step.outcome,
      loadPctBefore: step.loadPctBefore,
      loadPctAfter: step.loadPctAfter,
      transferredMw: step.transferredMw,
      probability: step.probability,
      severity: step.severity,
    })),
    affectedAssets: cascade.affectedAssets,
    affectedMw: cascade.affectedMw,
    affectedCustomers: cascade.affectedCustomers,
    unservedMw: cascade.unservedMw,
    regionalRisk: cascade.regionalRisk,
    containedAtMin: cascade.containedAtMin,
    next: first
      ? {
          assetCode: first.assetCode,
          etaMin: first.offsetMin,
          confidence: round(first.probability * 100, 0),
        }
      : null,
    // Only the feasible ones. Offering the origin's own failure as a place to cut the
    // chain would be offering a break point that cannot be taken.
    breakpoints: intel.breakpoints
      .filter((breakpoint) => breakpoint.feasible)
      .map((breakpoint) => ({
        assetCode: breakpoint.assetCode,
        action: breakpoint.rationale,
        actionAr: breakpoint.rationaleAr,
        riskReduction: breakpoint.riskReduction,
        interventionMw: breakpoint.interventionMw,
        isBest: breakpoint.isBest,
        interventionPotential: (breakpoint.riskReduction >= 25
          ? 'high'
          : breakpoint.riskReduction >= 10
            ? 'medium'
            : 'low') as 'high' | 'medium' | 'low',
      })),
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Visualise the fix (§46–§48)
// ─────────────────────────────────────────────────────────────────────────────

export interface PreventionSide {
  kind: StateKind
  peakRisk: number
  stabilityIndex: number
  totalLoadMw: number
  assetsAtRisk: number
  bottlenecks: string[]
  totalLossMw: number
  assets: Array<{
    code: string
    loadPct: number
    tempC: number
    riskScore: number
    state: string
    socPct: number | null
  }>
  edges: Array<{
    from: string
    to: string
    direction: string
    activePowerMw: number
    utilisationPct: number
    intensity: number
    state: string
  }>
}

export interface PreventionResult {
  twinCode: string
  assetCode: string | null
  scenario: string | null
  /** Always simulated. Never presented as a failure that was actually prevented. */
  kind: 'simulated'
  strategy: {
    key: string
    label: string
    labelAr: string
    actions: Array<{ type: string; assetCode: string; magnitudeMw: number }>
    costSar: number
    executionMin: number
    confidence: number
  }
  before: PreventionSide
  after: PreventionSide
  /** Paths whose loading fell, so redistribution can be pointed at rather than described. */
  redistributed: Array<{ from: string; to: string; beforePct: number; afterPct: number }>
}

function toSide(payload: LiveGridPayload): PreventionSide {
  return {
    kind: payload.kind,
    peakRisk: payload.kpis.peakRisk,
    stabilityIndex: payload.kpis.stabilityIndex,
    totalLoadMw: payload.kpis.totalLoadMw,
    assetsAtRisk: payload.kpis.assetsAtRisk,
    bottlenecks: payload.flow.bottlenecks,
    totalLossMw: payload.flow.totalLossMw,
    assets: payload.assets.map((asset) => ({
      code: asset.code,
      loadPct: asset.loadPct,
      tempC: asset.tempC,
      riskScore: asset.riskScore,
      state: asset.state,
      socPct: asset.socPct,
    })),
    edges: payload.flow.edges.map((edge) => ({
      from: edge.from,
      to: edge.to,
      direction: edge.direction,
      activePowerMw: edge.activePowerMw,
      utilisationPct: edge.utilisationPct,
      intensity: edge.intensity,
      state: edge.state,
    })),
  }
}

/**
 * Apply the recommended strategy inside the sandbox and re-solve the scene.
 *
 * The two sides are computed by the *same* engines at the *same* instant, differing only
 * by the actions applied. That is what makes the comparison worth showing: a before/after
 * whose halves came from different code paths would be measuring the code paths.
 *
 * Nothing here writes. Both sides and the result itself are labelled `simulated`, so no
 * surface can present this as a failure that was actually prevented.
 */
export async function simulatePrevention(input: {
  twinCode?: string
  scenario?: string | null
  overlay?: Perturbation
  assetCode?: string | null
  offsetMin?: number
  ts?: number
}): Promise<PreventionResult | null> {
  const { analyseAsset } = await import('./intelligence-service')
  const scenario = scenarioFor(input.scenario)
  const offsetMin = input.offsetMin ?? 0

  const base = await getLiveGrid({ twinCode: input.twinCode, ts: input.ts, offsetMin })
  if (!base) return null

  // The conditions the prevention is measured against: the scenario, plus any overlay the
  // caller supplied on top of it.
  const stressed: Perturbation = {
    ...(scenario.key === 'normal' ? {} : perturbationFor(scenario, base.assets)),
    ...(input.overlay ?? {}),
  }
  const hasStress = Object.keys(stressed).length > 0

  const before = await getLiveGrid({
    twinCode: input.twinCode,
    ts: input.ts,
    offsetMin,
    mode: 'simulation',
    scenario: scenario.key,
    perturbation: hasStress ? stressed : undefined,
  })
  if (!before) return null

  // The asset to prevent for: the one asked about, or whichever is worst off.
  const target =
    input.assetCode ?? [...before.assets].sort((a, b) => b.riskScore - a.riskScore)[0]?.code ?? null
  if (!target) return null

  const intel = await analyseAsset(target, {
    ts: (input.ts ?? Date.now()) + offsetMin * MIN,
    perturbation: hasStress ? stressed : undefined,
  })
  if (!intel) return null

  const strategy =
    intel.optimisation.strategies.find((entry) => entry.isRecommended) ??
    intel.optimisation.strategies[0]
  if (!strategy) return null

  const after = await getLiveGrid({
    twinCode: input.twinCode,
    ts: input.ts,
    offsetMin,
    mode: 'simulation',
    scenario: scenario.key,
    perturbation: {
      ...stressed,
      actions: [...(stressed.actions ?? []), ...strategy.actions],
    },
  })
  if (!after) return null

  const afterByEdge = new Map(
    after.flow.edges.map((edge) => [`${edge.from}->${edge.to}`, edge.utilisationPct]),
  )

  return {
    twinCode: before.twin.code,
    assetCode: target,
    scenario: scenario.key === 'normal' ? null : scenario.key,
    kind: 'simulated',
    strategy: {
      key: strategy.key,
      label: strategy.label,
      labelAr: strategy.labelAr,
      actions: strategy.actions.map((action) => ({
        type: action.type,
        assetCode: action.assetCode,
        magnitudeMw: round(action.magnitudeMw, 2),
      })),
      costSar: round(strategy.costSar, 0),
      executionMin: strategy.executionMin,
      confidence: round(strategy.confidence, 1),
    },
    before: toSide(before),
    after: toSide(after),
    redistributed: before.flow.edges
      .map((edge) => ({
        from: edge.from,
        to: edge.to,
        beforePct: edge.utilisationPct,
        afterPct: afterByEdge.get(`${edge.from}->${edge.to}`) ?? edge.utilisationPct,
      }))
      // Any measurable move counts. A half-point threshold was hiding exactly the case
      // this panel exists to show: a small, cheap intervention that eases a path without
      // dramatically changing it.
      .filter((entry) => Math.abs(entry.afterPct - entry.beforePct) >= 0.01)
      .sort((a, b) => a.afterPct - a.beforePct - (b.afterPct - b.beforePct)),
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// First render
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Everything the console needs to paint before any JavaScript runs.
 *
 * Assembled here rather than in the page so the shape is defined once and both the console
 * route and the immersive demo get the same object. The client narrows it to what the
 * renderer reads; nothing on the wire is a field no surface uses.
 */
export async function buildInitialGrid(options: {
  twinCode?: string
  focusAsset?: string
  scenario?: string | null
  /** Pin the scene to the demonstration instant. Never set by the live console. */
  demoClock?: boolean
  canSimulate: boolean
}) {
  const ts = options.demoClock ? demoBaseTimestamp() : undefined
  const payload =
    options.scenario && options.scenario !== 'normal'
      ? await getScenarioGrid(options.scenario, { twinCode: options.twinCode, ts })
      : await getLiveGrid({ twinCode: options.twinCode, ts })
  if (!payload) return null

  const [alerts, twins] = await Promise.all([
    getSceneAlerts(payload.assets.map((asset) => asset.code)),
    listTwins(),
  ])

  // Open on the asset that was asked for, then on the twin's own focus, then on whichever
  // member is worst off — a command centre that opens on nothing has wasted its first
  // second.
  const focusCode =
    (options.focusAsset && payload.assets.some((asset) => asset.code === options.focusAsset)
      ? options.focusAsset
      : null) ??
    payload.focusCode ??
    [...payload.assets].sort((a, b) => b.riskScore - a.riskScore)[0]?.code ??
    null

  return {
    mode: payload.mode,
    kind: payload.kind,
    ts: payload.ts,
    twin: payload.twin,
    breadcrumb: payload.breadcrumb,
    nodes: payload.nodes,
    assets: payload.assets,
    edges: payload.flow.edges.map((edge) => ({
      from: edge.from,
      to: edge.to,
      kind: edge.kind,
      sourceCode: edge.sourceCode,
      sinkCode: edge.sinkCode,
      direction: edge.direction,
      activePowerMw: edge.activePowerMw,
      currentA: edge.currentA,
      utilisationPct: edge.utilisationPct,
      intensity: edge.intensity,
      state: edge.state,
    })),
    // Carried so a surface can say what kind of answer the flow figures are. This is a
    // demand allocation over the topology, not an AC load-flow solution.
    flowMethod: payload.flow.method,
    kpis: payload.kpis,
    connection: payload.connection,
    // Narrowed to the five figures the panel shows, under the names the twin engine
    // already uses — renaming them here would give the same measurement two names.
    fidelity: {
      score: payload.fidelity.score,
      band: payload.fidelity.band,
      sensorCoverage: payload.fidelity.sensorCoverage,
      dataFreshness: payload.fidelity.dataFreshness,
      sensorTrust: payload.fidelity.sensorTrust,
      modelCoverage: payload.fidelity.modelCoverage,
      sensorsOnline: payload.fidelity.sensorsOnline,
      sensorsExpected: payload.fidelity.sensorsExpected,
      warning: payload.fidelity.warning,
      warningAr: payload.fidelity.warningAr,
    },
    radius: payload.radius,
    focusCode,
    alerts,
    scenes: twins.map((twin) => ({
      code: twin.code,
      level: twin.level,
      name: twin.name,
      nameAr: twin.nameAr,
    })),
    scenarios: SCENARIO_KEYS.map((key) => ({
      key,
      label: GRID_SCENARIOS[key].label,
      labelAr: GRID_SCENARIOS[key].labelAr,
      watchFor: GRID_SCENARIOS[key].watchFor,
      watchForAr: GRID_SCENARIOS[key].watchForAr,
    })),
    canSimulate: options.canSimulate,
    demoClock: Boolean(options.demoClock),
  }
}
