import 'server-only'

import { prisma } from '../db'
import { REGIONS, getRegion } from '../data/regions'
import { buildGridSnapshot, environmentFor, type AssetSnapshot, type GridSnapshot } from '../engine/grid'
import { NO_PERTURBATION, sampleAsset, type Perturbation } from '../engine/telemetry'
import { predictFailure } from '../engine/prediction'
import { round, clamp } from '../engine/rng'
import {
  assessFidelity,
  buildTwinScene,
  calibrate,
  detectDeviation,
  type CalibrationSample,
  type TwinFidelity,
  type TwinLevel,
  type TwinScene,
  type TwinDeviationResult,
} from '../engine/twin'
import { childrenOf, parentsOf, type GridGraph } from '../engine/graph'
import { getGridSnapshot, loadAssetProfiles, loadDnaPatterns, loadRiskWeights } from './grid-service'
import { loadGridGraph } from './graph-service'

/**
 * The digital twin's server side (§13–§14, §30–§35, §75–§77).
 *
 * Two rules shape everything here.
 *
 * The first is isolation (§79): a simulation reads the twin and writes to
 * `twin_simulations` and `twin_snapshots`. It never writes a telemetry row, never
 * updates an asset, and never touches the live state. That is enforced structurally —
 * the simulation path simply has no write access to those tables — rather than by
 * remembering not to.
 *
 * The second is that the twin's state is *derived*, not stored. Asking for the state at
 * a moment recomputes it from the same engine the console uses, so the twin and the
 * console can never show different numbers for the same instant.
 */

const MS_PER_MIN = 60_000
/** A reading older than this is not "live" for fidelity purposes. */
const FRESHNESS_WINDOW_MS = 15 * MS_PER_MIN

export interface TwinRecord {
  id: string
  code: string
  level: TwinLevel
  name: string
  nameAr: string
  regionCode: string | null
  siteCode: string | null
  assetCode: string | null
  mode: string
  autonomyLevel: number
}

export interface TwinAssetState {
  code: string
  name: string
  nameAr: string
  assetTypeKey: string
  loadPct: number
  loadMw: number
  capacityMw: number
  tempC: number
  ambientC: number
  voltageKv: number
  voltageDeviationPct: number
  currentA: number
  powerFactor: number
  thdPct: number
  healthScore: number
  riskScore: number
  state: string
  customersServed: number
  isOutage: boolean
  /** Thermal stress, 0–1, for the heat overlay (§20). */
  thermalStress: number
}

export interface TwinStateResult {
  twin: TwinRecord
  ts: number
  scene: TwinScene
  assets: TwinAssetState[]
  fidelity: TwinFidelity
  snapshot: {
    totalLoadMw: number
    stabilityIndex: number
    frequencyHz: number
    peakRisk: number
    ambientC: number
    renewableMw: number
    assetsAtRisk: number
  }
  /** Which mode the numbers came from, so the UI can label them (§82). */
  source: 'live' | 'simulated'
}

function toRecord(row: {
  id: string
  code: string
  level: string
  name: string
  nameAr: string
  regionCode: string | null
  siteCode: string | null
  mode: string
  autonomyLevel: number
  asset?: { code: string } | null
}): TwinRecord {
  return {
    id: row.id,
    code: row.code,
    level: row.level as TwinLevel,
    name: row.name,
    nameAr: row.nameAr,
    regionCode: row.regionCode,
    siteCode: row.siteCode,
    assetCode: row.asset?.code ?? null,
    mode: row.mode,
    autonomyLevel: row.autonomyLevel,
  }
}

export async function listTwins(): Promise<TwinRecord[]> {
  const rows = await prisma.digitalTwin.findMany({
    where: { isActive: true },
    orderBy: [{ level: 'asc' }, { code: 'asc' }],
    include: { asset: { select: { code: true } } },
  })
  return rows.map(toRecord)
}

export async function getTwinRecord(code: string): Promise<TwinRecord | null> {
  const row = await prisma.digitalTwin.findUnique({
    where: { code },
    include: { asset: { select: { code: true } } },
  })
  return row ? toRecord(row) : null
}

/** The members a twin covers, given its level and scope. */
function membersFor(twin: TwinRecord, graph: GridGraph, siteMembers: string[]): string[] {
  if (twin.level === 'national') return graph.nodes.map((node) => node.code)
  if (twin.level === 'region')
    return graph.nodes.filter((node) => node.regionCode === twin.regionCode).map((node) => node.code)
  if (twin.level === 'site') return siteMembers
  const focus = twin.assetCode ? graph.byCode.get(twin.assetCode) : null
  if (!focus) return []
  return [
    focus.code,
    ...parentsOf(graph, focus.code).map((node) => node.code),
    ...childrenOf(graph, focus.code).map((node) => node.code),
  ]
}

/** Assets sharing a site with the twin's anchor, read from the register. */
async function siteMemberCodes(siteCode: string | null): Promise<string[]> {
  if (!siteCode) return []
  const rows = await prisma.asset.findMany({
    where: { site: { code: siteCode } },
    select: { code: true },
  })
  return rows.map((row) => row.code)
}

function toAssetState(entry: AssetSnapshot, name: string, nameAr: string): TwinAssetState {
  const ratedTempC = entry.profile.ratedTempC
  return {
    code: entry.profile.code,
    name,
    nameAr,
    assetTypeKey: entry.profile.assetTypeKey,
    loadPct: round(entry.sample.loadPct, 1),
    loadMw: round(entry.sample.loadMw, 1),
    capacityMw: entry.profile.capacityMw,
    tempC: round(entry.sample.tempC, 1),
    ambientC: round(entry.sample.ambientC, 1),
    voltageKv: round(entry.sample.voltageKv, 2),
    voltageDeviationPct: round(entry.sample.voltageDeviationPct, 2),
    currentA: round(entry.sample.currentA, 1),
    powerFactor: round(entry.sample.powerFactor, 3),
    thdPct: round(entry.sample.thdPct, 2),
    healthScore: round(entry.sample.effectiveHealth, 1),
    riskScore: round(entry.risk.score, 1),
    state: entry.state,
    customersServed: entry.profile.customersServed,
    isOutage: entry.sample.isOutage,
    // Thermal headroom consumed, which is what the heat overlay actually shades.
    thermalStress: round(clamp((entry.sample.tempC - (ratedTempC - 35)) / 35, 0, 1), 3),
  }
}

/**
 * The twin's state at an instant.
 *
 * `perturbation` is what separates a shadow reading from a sandbox one: with it absent
 * this is the twin observing, with it present the twin is being asked a question. The
 * returned `source` says which, and every caller is expected to surface that.
 */
export async function getTwinState(
  code: string,
  options: { ts?: number; perturbation?: Perturbation } = {},
): Promise<TwinStateResult | null> {
  const twin = await getTwinRecord(code)
  if (!twin) return null

  const ts = options.ts ?? Date.now()
  const perturbation = options.perturbation
  const [snapshot, graph, siteMembers] = await Promise.all([
    getGridSnapshot({ ts, perturbation }),
    loadGridGraph(),
    siteMemberCodes(twin.siteCode),
  ])

  const memberCodes = new Set(membersFor(twin, graph, siteMembers))
  const scene = buildTwinScene(graph, twin.level, twin.assetCode ?? null, {
    regionCode: twin.regionCode,
    siteMembers,
  })

  const assets = snapshot.assets
    .filter((entry) => memberCodes.has(entry.profile.code))
    .map((entry) => {
      const node = graph.byCode.get(entry.profile.code)
      return toAssetState(entry, node?.name ?? entry.profile.code, node?.nameAr ?? entry.profile.code)
    })

  const fidelity = await computeFidelity(twin, [...memberCodes], ts)

  const scoped = assets.length > 0 ? assets : snapshot.assets.map((entry) => toAssetState(entry, entry.profile.code, entry.profile.code))
  const region = twin.regionCode ? snapshot.regions.find((entry) => entry.code === twin.regionCode) : null

  return {
    twin,
    ts,
    scene,
    assets,
    fidelity,
    snapshot: {
      totalLoadMw: round(region?.loadMw ?? snapshot.totalLoadMw, 0),
      stabilityIndex: round(snapshot.stabilityIndex, 1),
      frequencyHz: round(snapshot.frequencyHz, 3),
      peakRisk: round(Math.max(...scoped.map((entry) => entry.riskScore), 0), 1),
      ambientC: round(region?.env.tempC ?? snapshot.regions[0]?.env.tempC ?? 0, 1),
      renewableMw: round(region?.renewableMw ?? snapshot.renewableMw, 0),
      assetsAtRisk: scoped.filter((entry) => entry.riskScore >= 55).length,
    },
    source: perturbation ? 'simulated' : 'live',
  }
}

/**
 * Twin fidelity (§31, §32).
 *
 * Read from the instrumentation record rather than asserted: how many of the twin's
 * members actually have sensors, how recently those sensors reported, and how much they
 * are trusted. A twin covering assets with no instruments scores low, and should.
 */
export async function computeFidelity(
  twin: TwinRecord,
  memberCodes: string[],
  now: number,
): Promise<TwinFidelity> {
  const sensors = await prisma.sensor.findMany({
    where: { asset: { code: { in: memberCodes } } },
    select: {
      lastSeenAt: true,
      asset: { select: { code: true } },
      trust: { select: { score: true } },
    },
  })

  const sensorsExpected = Math.max(sensors.length, memberCodes.length)
  const online = sensors.filter(
    (sensor) => sensor.lastSeenAt !== null && now - sensor.lastSeenAt.getTime() <= FRESHNESS_WINDOW_MS,
  )
  const lastSeen = sensors.reduce(
    (latest, sensor) => Math.max(latest, sensor.lastSeenAt?.getTime() ?? 0),
    0,
  )
  const trustScores = sensors.map((sensor) => sensor.trust?.score).filter((score): score is number => typeof score === 'number')
  const instrumented = new Set(sensors.map((sensor) => sensor.asset.code))

  const earliest = await prisma.sensorReading.findFirst({
    where: { asset: { code: { in: memberCodes } } },
    orderBy: { ts: 'asc' },
    select: { ts: true },
  })

  return assessFidelity({
    sensorsOnline: online.length,
    sensorsExpected,
    msSinceLastSync: lastSeen > 0 ? Math.max(0, now - lastSeen) : FRESHNESS_WINDOW_MS * 4,
    // An uninstrumented member contributes no trust rather than a default one.
    meanSensorTrust:
      trustScores.length > 0 ? trustScores.reduce((sum, score) => sum + score, 0) / trustScores.length : 0,
    modelCoverage: memberCodes.length > 0 ? instrumented.size / memberCodes.length : 0,
    historyHours: earliest ? Math.min(168, (now - earliest.ts.getTime()) / 3_600_000) : 0,
  })
}

export async function persistFidelity(twinId: string, fidelity: TwinFidelity): Promise<void> {
  await prisma.twinFidelityScore.create({
    data: {
      twinId,
      score: fidelity.score,
      band: fidelity.band,
      sensorCoverage: fidelity.sensorCoverage,
      dataFreshness: fidelity.dataFreshness,
      sensorTrust: fidelity.sensorTrust,
      modelCoverage: fidelity.modelCoverage,
      historicalDepth: fidelity.historicalDepth,
      lastSyncAt: new Date(Date.now() - fidelity.msSinceLastSync),
      sensorsOnline: fidelity.sensorsOnline,
      sensorsExpected: fidelity.sensorsExpected,
    },
  })
}

// ─────────────────────────────────────────────────────────────────────────────
// Deviation (§33)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Compare the twin's expectation with what the instruments recorded.
 *
 * The expectation is the model with the scenario removed — the asset behaving as it
 * should for this hour under baseline conditions — and the observation is the most
 * recent stored reading. Where they disagree, the cause attribution asks whether one
 * instrument is out of step or the whole asset is.
 */
export async function getDeviations(
  code: string,
  options: { ts?: number; limit?: number } = {},
): Promise<TwinDeviationResult[]> {
  const twin = await getTwinRecord(code)
  if (!twin) return []

  const ts = options.ts ?? Date.now()
  const [graph, siteMembers, profiles] = await Promise.all([
    loadGridGraph(),
    siteMemberCodes(twin.siteCode),
    loadAssetProfiles(),
  ])
  const memberCodes = membersFor(twin, graph, siteMembers).slice(0, options.limit ?? 12)
  const profileByCode = new Map(profiles.map((profile) => [profile.code, profile]))

  const readings = await prisma.sensorReading.findMany({
    where: {
      asset: { code: { in: memberCodes } },
      kind: { in: ['temperature', 'load', 'voltage'] },
      ts: { gte: new Date(ts - 60 * MS_PER_MIN) },
    },
    orderBy: { ts: 'desc' },
    take: 400,
    select: {
      kind: true,
      value: true,
      ts: true,
      asset: { select: { code: true } },
      sensor: { select: { trust: { select: { score: true } } } },
    },
  })

  // Latest reading per (asset, channel).
  const latest = new Map<string, (typeof readings)[number]>()
  for (const reading of readings) {
    const key = `${reading.asset.code}:${reading.kind}`
    if (!latest.has(key)) latest.set(key, reading)
  }

  const maintenance = await prisma.asset.findMany({
    where: { code: { in: memberCodes } },
    select: { code: true, lastMaintenanceAt: true },
  })
  const maintenanceByCode = new Map(
    maintenance.map((row) => [row.code, row.lastMaintenanceAt?.getTime() ?? null]),
  )

  const results: TwinDeviationResult[] = []
  const byAsset = new Map<string, number>()

  for (const [key, reading] of latest) {
    const [assetCode, channel] = key.split(':')
    const profile = profileByCode.get(assetCode)
    if (!profile) continue

    const region = getRegion(profile.regionCode)
    const env = environmentFor(region, NO_PERTURBATION)(reading.ts.getTime())
    const expectedSample = sampleAsset(profile, reading.ts.getTime(), env, NO_PERTURBATION)
    const expected =
      channel === 'temperature'
        ? expectedSample.tempC
        : channel === 'load'
          ? expectedSample.loadPct
          : expectedSample.voltageKv

    const base = Math.max(Math.abs(expected), 0.5)
    const magnitude = Math.abs(((reading.value - expected) / base) * 100)
    if (magnitude >= 3) byAsset.set(assetCode, (byAsset.get(assetCode) ?? 0) + 1)
  }

  for (const [key, reading] of latest) {
    const [assetCode, channel] = key.split(':')
    const profile = profileByCode.get(assetCode)
    if (!profile) continue

    const region = getRegion(profile.regionCode)
    const env = environmentFor(region, NO_PERTURBATION)(reading.ts.getTime())
    const expectedSample = sampleAsset(profile, reading.ts.getTime(), env, NO_PERTURBATION)
    const expected =
      channel === 'temperature'
        ? expectedSample.tempC
        : channel === 'load'
          ? expectedSample.loadPct
          : expectedSample.voltageKv

    const lastMaintenance = maintenanceByCode.get(assetCode) ?? null
    const result = detectDeviation({
      assetCode,
      channel,
      expected,
      observed: reading.value,
      sensorTrust: reading.sensor?.trust?.score ?? 70,
      correlatedChannels: byAsset.get(assetCode) ?? 0,
      maintenanceAgeDays: lastMaintenance ? (ts - lastMaintenance) / 86_400_000 : 999,
    })
    if (result.isSignificant) results.push(result)
  }

  return results.sort((a, b) => Math.abs(b.deviationPct) - Math.abs(a.deviationPct))
}

// ─────────────────────────────────────────────────────────────────────────────
// Snapshots (§75) and simulation isolation (§79)
// ─────────────────────────────────────────────────────────────────────────────

export interface SnapshotSummary {
  code: string
  ts: number
  reason: string
  assetCount: number
  totalLoadMw: number
  stabilityIndex: number
  peakRisk: number
}

/**
 * Freeze the twin before a run.
 *
 * Taken automatically ahead of every simulation, because "what was the grid doing when
 * we decided this" is the first question asked afterwards and the worst one to have to
 * answer with a shrug.
 */
export async function createSnapshot(
  twinCode: string,
  options: { ts?: number; reason?: string; perturbation?: Perturbation; userId?: string | null } = {},
): Promise<SnapshotSummary | null> {
  const twin = await getTwinRecord(twinCode)
  if (!twin) return null

  const ts = options.ts ?? Date.now()
  const state = await getTwinState(twinCode, { ts, perturbation: options.perturbation })
  if (!state) return null

  const code = `SNP-${ts.toString(36).toUpperCase()}-${twin.code}`
  const perturbationDigest = options.perturbation
    ? Object.entries(options.perturbation)
        .filter(([, value]) => value !== undefined && value !== null && !Array.isArray(value))
        .map(([key, value]) => `${key}=${String(value)}`)
        .join(',')
    : ''

  await prisma.twinSnapshot.create({
    data: {
      code,
      twinId: twin.id,
      ts: new Date(ts),
      reason: options.reason ?? 'pre_simulation',
      assetCount: state.assets.length,
      totalLoadMw: state.snapshot.totalLoadMw,
      stabilityIndex: state.snapshot.stabilityIndex,
      peakRisk: state.snapshot.peakRisk,
      ambientC: state.snapshot.ambientC,
      renewableMw: state.snapshot.renewableMw,
      perturbation: perturbationDigest,
      stateJson: JSON.stringify(state.assets),
      createdById: options.userId ?? null,
    },
  })

  return {
    code,
    ts,
    reason: options.reason ?? 'pre_simulation',
    assetCount: state.assets.length,
    totalLoadMw: state.snapshot.totalLoadMw,
    stabilityIndex: state.snapshot.stabilityIndex,
    peakRisk: state.snapshot.peakRisk,
  }
}

export async function listSnapshots(twinCode: string, limit = 15) {
  return prisma.twinSnapshot.findMany({
    where: { twin: { code: twinCode } },
    orderBy: { ts: 'desc' },
    take: limit,
  })
}

export async function getSnapshot(code: string) {
  return prisma.twinSnapshot.findUnique({ where: { code } })
}

// ─────────────────────────────────────────────────────────────────────────────
// Twin time machine and ghost future (§21, §22)
// ─────────────────────────────────────────────────────────────────────────────

export const TWIN_OFFSETS_MIN = [0, 5, 15, 30, 60, 180, 360, 720, 1440] as const

export interface TwinFutureFrame {
  offsetMin: number
  ts: number
  assets: Array<{
    code: string
    loadPct: number
    tempC: number
    riskScore: number
    state: string
    thermalStress: number
  }>
  peakRisk: number
  stabilityIndex: number
  /** True once any member breaches a limit at this offset. */
  breached: boolean
}

/**
 * Walk the twin forward.
 *
 * One snapshot per offset rather than a continuous integration: the offsets are what the
 * interface actually shows, and computing nine of them is cheap enough to do on request
 * while a continuous walk of a national fleet would not be.
 */
export async function getTwinFuture(
  twinCode: string,
  options: { ts?: number; perturbation?: Perturbation; offsets?: readonly number[] } = {},
): Promise<{ twin: TwinRecord; frames: TwinFutureFrame[] } | null> {
  const twin = await getTwinRecord(twinCode)
  if (!twin) return null

  const ts = options.ts ?? Date.now()
  const offsets = options.offsets ?? TWIN_OFFSETS_MIN
  const [profiles, patterns, weights, graph, siteMembers] = await Promise.all([
    loadAssetProfiles(),
    loadDnaPatterns(),
    loadRiskWeights(),
    loadGridGraph(),
    siteMemberCodes(twin.siteCode),
  ])
  const memberCodes = new Set(membersFor(twin, graph, siteMembers))

  const frames: TwinFutureFrame[] = offsets.map((offsetMin) => {
    const at = ts + offsetMin * MS_PER_MIN
    const snapshot: GridSnapshot = buildGridSnapshot(profiles, at, {
      perturbation: options.perturbation,
      patterns,
      weights,
    })
    const members = snapshot.assets.filter((entry) => memberCodes.has(entry.profile.code))
    return {
      offsetMin,
      ts: at,
      assets: members.map((entry) => ({
        code: entry.profile.code,
        loadPct: round(entry.sample.loadPct, 1),
        tempC: round(entry.sample.tempC, 1),
        riskScore: round(entry.risk.score, 1),
        state: entry.state,
        thermalStress: round(
          clamp((entry.sample.tempC - (entry.profile.ratedTempC - 35)) / 35, 0, 1),
          3,
        ),
      })),
      peakRisk: round(Math.max(...members.map((entry) => entry.risk.score), 0), 1),
      stabilityIndex: round(snapshot.stabilityIndex, 1),
      breached: members.some(
        (entry) =>
          Math.abs(entry.sample.loadPct) >= 108 ||
          entry.sample.tempC >= entry.profile.ratedTempC + 20,
      ),
    }
  })

  return { twin, frames }
}

/**
 * The ghost: the same asset, now and at a future offset, side by side (§22).
 *
 * Returned as a pair rather than a diff because the interface draws both — the solid
 * present and the translucent future — and a diff would throw away the half that makes
 * the picture legible.
 */
export async function getGhostFuture(
  assetCode: string,
  options: { ts?: number; perturbation?: Perturbation; offsetMin?: number } = {},
): Promise<{
  present: TwinAssetState
  future: TwinAssetState
  offsetMin: number
  etaMinutes: number | null
  breachReason: string | null
} | null> {
  const ts = options.ts ?? Date.now()
  const offsetMin = options.offsetMin ?? 30
  const [profiles, patterns, weights, graph] = await Promise.all([
    loadAssetProfiles(),
    loadDnaPatterns(),
    loadRiskWeights(),
    loadGridGraph(),
  ])

  const profile = profiles.find((entry) => entry.code === assetCode)
  if (!profile) return null
  const node = graph.byCode.get(assetCode)

  const at = (offset: number) =>
    buildGridSnapshot(profiles, ts + offset * MS_PER_MIN, {
      perturbation: options.perturbation,
      patterns,
      weights,
    }).assets.find((entry) => entry.profile.code === assetCode)

  const present = at(0)
  const future = at(offsetMin)
  if (!present || !future) return null

  const region = getRegion(profile.regionCode)
  const prediction = predictFailure(
    profile,
    ts,
    environmentFor(region, options.perturbation ?? NO_PERTURBATION),
    {
      perturbation: options.perturbation,
      dnaSimilarity: present.dnaSimilarity,
      neighbourStress: present.neighbourStress,
      weights,
      horizonMin: Math.max(180, offsetMin),
      stepMin: 5,
    },
  )

  const name = node?.name ?? assetCode
  const nameAr = node?.nameAr ?? assetCode
  return {
    present: toAssetState(present, name, nameAr),
    future: toAssetState(future, name, nameAr),
    offsetMin,
    etaMinutes: prediction.etaMinutes,
    breachReason: prediction.breachReason,
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Dependencies (§29)
// ─────────────────────────────────────────────────────────────────────────────

export interface DependencyView {
  upstream: Array<{ code: string; name: string; nameAr: string; assetTypeKey: string }>
  focus: { code: string; name: string; nameAr: string; assetTypeKey: string } | null
  downstream: Array<{ code: string; name: string; nameAr: string; assetTypeKey: string; customersServed: number }>
  downstreamCustomers: number
}

export async function getDependencies(assetCode: string): Promise<DependencyView> {
  const graph = await loadGridGraph()
  const node = graph.byCode.get(assetCode)
  if (!node) return { upstream: [], focus: null, downstream: [], downstreamCustomers: 0 }

  const brief = (entry: { code: string; name: string; nameAr: string; assetTypeKey: string }) => ({
    code: entry.code,
    name: entry.name,
    nameAr: entry.nameAr,
    assetTypeKey: entry.assetTypeKey,
  })

  return {
    upstream: parentsOf(graph, assetCode).map(brief),
    focus: brief(node),
    downstream: childrenOf(graph, assetCode).map((child) => ({
      ...brief(child),
      customersServed: child.customersServed,
    })),
    downstreamCustomers: node.downstreamCustomers,
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Calibration (§84, §85)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Compare what the twin predicted with what was subsequently recorded.
 *
 * Both sides come from the same tables the console reads. In this build they agree
 * closely — the seed wrote the history from the same model — and that is itself the
 * point: the calibration harness exists so that when they stop agreeing, somebody finds
 * out from a number rather than from a surprise.
 */
export async function getCalibration(twinCode: string, options: { ts?: number } = {}) {
  const twin = await getTwinRecord(twinCode)
  if (!twin) return null

  const ts = options.ts ?? Date.now()
  const [graph, siteMembers, profiles] = await Promise.all([
    loadGridGraph(),
    siteMemberCodes(twin.siteCode),
    loadAssetProfiles(),
  ])
  const memberCodes = membersFor(twin, graph, siteMembers).slice(0, 10)
  const profileByCode = new Map(profiles.map((profile) => [profile.code, profile]))

  const readings = await prisma.sensorReading.findMany({
    where: {
      asset: { code: { in: memberCodes } },
      kind: { in: ['temperature', 'load', 'voltage'] },
      ts: { gte: new Date(ts - 6 * 60 * MS_PER_MIN), lte: new Date(ts) },
    },
    orderBy: { ts: 'desc' },
    take: 240,
    select: { kind: true, value: true, ts: true, asset: { select: { code: true } } },
  })

  const samples: CalibrationSample[] = []
  for (const reading of readings) {
    const profile = profileByCode.get(reading.asset.code)
    if (!profile) continue
    const region = getRegion(profile.regionCode)
    const env = environmentFor(region, NO_PERTURBATION)(reading.ts.getTime())
    const predictedSample = sampleAsset(profile, reading.ts.getTime(), env, NO_PERTURBATION)
    const predicted =
      reading.kind === 'temperature'
        ? predictedSample.tempC
        : reading.kind === 'load'
          ? predictedSample.loadPct
          : predictedSample.voltageKv
    samples.push({ channel: reading.kind, predicted, observed: reading.value })
  }

  return { twin, ...calibrate(samples) }
}

// ─────────────────────────────────────────────────────────────────────────────
// Scenarios (§36) and isolated simulation (§79)
// ─────────────────────────────────────────────────────────────────────────────

export interface ScenarioEventInput {
  kind: string
  label?: string
  labelAr?: string
  offsetMin?: number
  durationMin?: number
  magnitude: number
  unit?: string
  regionCode?: string | null
  targetCode?: string | null
}

/**
 * Compose a list of timeline events into one perturbation.
 *
 * Events of the same kind add up — an EV surge on top of an industrial block is heavier
 * than either alone — and outages accumulate into a list rather than replacing one
 * another. The composition is deliberately total: an event kind the vocabulary does not
 * recognise is ignored rather than silently reinterpreted as something else.
 */
export function composeScenario(events: ScenarioEventInput[]): Perturbation {
  const perturbation: Perturbation = {}
  const outages: string[] = []

  for (const event of events) {
    switch (event.kind) {
      case 'heat_wave':
        perturbation.tempDeltaC = (perturbation.tempDeltaC ?? 0) + event.magnitude
        break
      case 'load_spike':
      case 'ev_surge':
      case 'industrial_surge':
        perturbation.loadDeltaPct = (perturbation.loadDeltaPct ?? 0) + event.magnitude
        break
      case 'solar_drop':
        perturbation.solarDeltaPct = (perturbation.solarDeltaPct ?? 0) - Math.abs(event.magnitude)
        break
      case 'wind_surge':
        perturbation.windDeltaPct = (perturbation.windDeltaPct ?? 0) - Math.abs(event.magnitude)
        break
      case 'transformer_degradation':
        // Degradation shows up as extra thermal load rather than as a separate channel.
        perturbation.tempDeltaC = (perturbation.tempDeltaC ?? 0) + event.magnitude * 0.4
        break
      case 'line_failure':
      case 'battery_failure':
      case 'asset_outage':
      case 'maintenance_outage':
        if (event.targetCode) outages.push(event.targetCode)
        break
      default:
        break
    }
    if (event.regionCode) perturbation.regionCode = event.regionCode
  }

  if (outages.length > 0) perturbation.outageAssetCodes = outages
  return perturbation
}

export interface TwinSimulationResult {
  code: string
  twinCode: string
  snapshotCode: string | null
  horizonMin: number
  riskBefore: number
  riskAfter: number
  stabilityBefore: number
  stabilityAfter: number
  assetsAffected: number
  downtimeMin: number
  summary: string
  summaryAr: string
  /** Per-offset trajectory, for the twin's own timeline. */
  frames: Array<{ offsetMin: number; peakRisk: number; stabilityIndex: number; breached: boolean }>
  durationMs: number
  isSimulated: true
}

/**
 * Run a scenario against a twin.
 *
 * The order matters and is the whole of the isolation guarantee: snapshot first, then
 * compute, then persist to the twin's own tables. Nothing in this path writes a telemetry
 * row or an asset field, so a simulation cannot leave a trace a later reader would
 * mistake for something that happened (§79).
 */
export async function runTwinSimulation(
  twinCode: string,
  input: {
    events: ScenarioEventInput[]
    horizonMin?: number
    ts?: number
    userId?: string | null
    scenarioCode?: string | null
  },
): Promise<TwinSimulationResult | null> {
  const started = Date.now()
  const twin = await getTwinRecord(twinCode)
  if (!twin) return null

  const ts = input.ts ?? Date.now()
  const horizonMin = input.horizonMin ?? 180
  const perturbation = composeScenario(input.events)

  const snapshot = await createSnapshot(twinCode, {
    ts,
    reason: 'pre_simulation',
    userId: input.userId,
  })

  const [before, after] = await Promise.all([
    getTwinState(twinCode, { ts }),
    getTwinState(twinCode, { ts, perturbation }),
  ])
  if (!before || !after) return null

  const future = await getTwinFuture(twinCode, {
    ts,
    perturbation,
    offsets: [0, 15, 30, 60, Math.min(horizonMin, 180)],
  })

  const worsened = after.assets.filter((asset) => {
    const previous = before.assets.find((entry) => entry.code === asset.code)
    return previous ? asset.riskScore > previous.riskScore + 5 : false
  })
  const breachedFrame = future?.frames.find((frame) => frame.breached) ?? null
  const downtimeMin = breachedFrame ? Math.max(0, horizonMin - breachedFrame.offsetMin) : 0

  const code = `SIM-${ts.toString(36).toUpperCase()}-${twin.code}`
  const summary = `Risk on ${twin.code} moves ${before.snapshot.peakRisk} % → ${after.snapshot.peakRisk} % under ${input.events.length} composed event(s); ${worsened.length} member(s) worsen materially.`
  const summaryAr = `ينتقل الخطر على ${twin.code} من ${before.snapshot.peakRisk}٪ إلى ${after.snapshot.peakRisk}٪ تحت ${input.events.length} حدثًا مركّبًا، مع تدهور ملموس في ${worsened.length} عضوًا.`

  const durationMs = Date.now() - started
  const result: TwinSimulationResult = {
    code,
    twinCode: twin.code,
    snapshotCode: snapshot?.code ?? null,
    horizonMin,
    riskBefore: before.snapshot.peakRisk,
    riskAfter: after.snapshot.peakRisk,
    stabilityBefore: before.snapshot.stabilityIndex,
    stabilityAfter: after.snapshot.stabilityIndex,
    assetsAffected: worsened.length,
    downtimeMin,
    summary,
    summaryAr,
    frames:
      future?.frames.map((frame) => ({
        offsetMin: frame.offsetMin,
        peakRisk: frame.peakRisk,
        stabilityIndex: frame.stabilityIndex,
        breached: frame.breached,
      })) ?? [],
    durationMs,
    isSimulated: true,
  }

  const snapshotRow = snapshot
    ? await prisma.twinSnapshot.findUnique({ where: { code: snapshot.code }, select: { id: true } })
    : null
  const scenarioRow = input.scenarioCode
    ? await prisma.twinScenario.findUnique({
        where: { code: input.scenarioCode },
        select: { id: true },
      })
    : null

  await prisma.twinSimulation.create({
    data: {
      code,
      twinId: twin.id,
      snapshotId: snapshotRow?.id ?? null,
      scenarioId: scenarioRow?.id ?? null,
      kind: 'scenario',
      status: 'completed',
      startedAt: new Date(started),
      finishedAt: new Date(),
      durationMs,
      horizonMin,
      riskBefore: result.riskBefore,
      riskAfter: result.riskAfter,
      stabilityBefore: result.stabilityBefore,
      stabilityAfter: result.stabilityAfter,
      assetsAffected: result.assetsAffected,
      downtimeMin,
      summary,
      summaryAr,
      resultJson: JSON.stringify(result.frames),
      createdById: input.userId ?? null,
    },
  })

  return result
}

export async function listTwinSimulations(twinCode: string, limit = 15) {
  return prisma.twinSimulation.findMany({
    where: { twin: { code: twinCode } },
    orderBy: { createdAt: 'desc' },
    take: limit,
  })
}

export async function getTwinSimulation(code: string) {
  return prisma.twinSimulation.findUnique({
    where: { code },
    include: { snapshot: true, twin: { select: { code: true, name: true, nameAr: true } } },
  })
}

export async function listScenarios(twinCode?: string) {
  return prisma.twinScenario.findMany({
    where: twinCode ? { twin: { code: twinCode } } : undefined,
    orderBy: [{ isPreset: 'desc' }, { createdAt: 'desc' }],
    take: 40,
    include: { events: { orderBy: { ordinal: 'asc' } } },
  })
}

export async function getScenario(code: string) {
  return prisma.twinScenario.findUnique({
    where: { code },
    include: { events: { orderBy: { ordinal: 'asc' } } },
  })
}

/**
 * Persist a composed scenario so it can be re-run and shared (§87).
 *
 * `origin` records where it came from — hand-built, generated, or found by the
 * adversarial search — because "who thought of this scenario" turns out to matter when
 * the result is surprising.
 */
export async function saveScenario(input: {
  twinCode: string
  name: string
  nameAr: string
  description?: string
  descriptionAr?: string
  origin?: string
  regionCode?: string | null
  durationMin?: number
  events: ScenarioEventInput[]
  userId?: string | null
}): Promise<string | null> {
  const twin = await getTwinRecord(input.twinCode)
  if (!twin) return null

  const code = `SCN-${Date.now().toString(36).toUpperCase()}`
  const scenario = await prisma.twinScenario.create({
    data: {
      code,
      twinId: twin.id,
      name: input.name,
      nameAr: input.nameAr,
      description: input.description ?? '',
      descriptionAr: input.descriptionAr ?? '',
      origin: input.origin ?? 'manual',
      regionCode: input.regionCode ?? null,
      durationMin: input.durationMin ?? 120,
      isPreset: false,
      createdById: input.userId ?? null,
    },
  })

  await prisma.twinEvent.createMany({
    data: input.events.map((event, index) => ({
      twinId: twin.id,
      scenarioId: scenario.id,
      ordinal: index,
      kind: event.kind,
      label: event.label ?? event.kind,
      labelAr: event.labelAr ?? event.kind,
      offsetMin: event.offsetMin ?? 0,
      durationMin: event.durationMin ?? 60,
      magnitude: event.magnitude,
      unit: event.unit ?? '',
      targetCode: event.targetCode ?? null,
      regionCode: event.regionCode ?? null,
    })),
  })

  return code
}

// ─────────────────────────────────────────────────────────────────────────────
// Scenario generation (§37)
// ─────────────────────────────────────────────────────────────────────────────

export type GeneratedKind = 'worst_case' | 'realistic' | 'hidden_risk'

/**
 * Generate a scenario rather than asking the operator to invent one.
 *
 * The three kinds answer three different questions, and they are constructed from the
 * grid's *current* state rather than from fixed templates: a worst case built in mild
 * weather is not a worst case. "Hidden risk" is the interesting one — it looks for the
 * combination that moves the fleet most while staying individually unremarkable, which
 * is the shape of event that gets missed.
 */
export async function generateScenario(
  kind: GeneratedKind,
  options: { ts?: number; regionCode?: string | null } = {},
): Promise<{ kind: GeneratedKind; events: ScenarioEventInput[]; rationale: string; rationaleAr: string }> {
  const ts = options.ts ?? Date.now()
  const snapshot = await getGridSnapshot({ ts })
  const region =
    (options.regionCode
      ? snapshot.regions.find((entry) => entry.code === options.regionCode)
      : null) ??
    [...snapshot.regions].sort((a, b) => b.peakRisk - a.peakRisk)[0]

  const regionCode = region?.code ?? null
  const ambient = region?.env.tempC ?? 40
  const headroom = Math.max(2, region?.reserveMarginPct ?? 12)

  if (kind === 'worst_case') {
    // Sized against the headroom that actually exists: enough to consume it, plus a
    // margin, rather than an arbitrary large number.
    return {
      kind,
      events: [
        { kind: 'heat_wave', magnitude: round(clamp(48 - ambient, 4, 12), 1), unit: '°C', regionCode, label: 'Extreme heat', labelAr: 'حرارة قصوى' },
        { kind: 'load_spike', magnitude: round(headroom * 1.8, 0), unit: '%', regionCode, label: 'Demand beyond reserve', labelAr: 'طلب يتجاوز الاحتياطي' },
        { kind: 'solar_drop', magnitude: 55, unit: '%', label: 'Solar collapse', labelAr: 'انهيار الإنتاج الشمسي' },
      ],
      rationale: `Built against ${region?.name ?? 'the grid'}: heat taken to 48 °C, demand raised past the ${round(headroom, 0)} % reserve margin, and solar removed.`,
      rationaleAr: `مبني على ${region?.nameAr ?? 'الشبكة'}: رفع الحرارة إلى ٤٨ مئوية، وزيادة الطلب فوق هامش الاحتياطي البالغ ${round(headroom, 0)}٪، وإزالة الإنتاج الشمسي.`,
    }
  }

  if (kind === 'realistic') {
    return {
      kind,
      events: [
        { kind: 'heat_wave', magnitude: 3, unit: '°C', regionCode, label: 'Warm afternoon', labelAr: 'بعد ظهر حار' },
        { kind: 'load_spike', magnitude: round(clamp(headroom * 0.6, 6, 18), 0), unit: '%', regionCode, label: 'Ordinary demand rise', labelAr: 'ارتفاع طلب اعتيادي' },
      ],
      rationale: 'An ordinary summer afternoon: a few degrees of extra heat and a demand rise well inside the reserve margin.',
      rationaleAr: 'بعد ظهر صيفي اعتيادي: بضع درجات حرارة إضافية وارتفاع طلب ضمن هامش الاحتياطي.',
    }
  }

  // Hidden risk: individually unremarkable, jointly consequential.
  return {
    kind,
    events: [
      { kind: 'heat_wave', magnitude: 2.5, unit: '°C', regionCode, label: 'Slightly above normal', labelAr: 'أعلى قليلًا من المعتاد' },
      { kind: 'load_spike', magnitude: 9, unit: '%', regionCode, label: 'Demand a little ahead of profile', labelAr: 'طلب يسبق المنحنى قليلًا' },
      { kind: 'solar_drop', magnitude: 18, unit: '%', label: 'Thin cloud', labelAr: 'غيوم خفيفة' },
    ],
    rationale: 'Three movements no operator would raise an eyebrow at individually. The question is what they do together.',
    rationaleAr: 'ثلاث حركات لا يرفع أي مشغل حاجبه لأي منها منفردة. والسؤال ماذا تفعل مجتمعة.',
  }
}

export { REGIONS }
