/**
 * Digital twin core (§13–§14, §30–§34).
 *
 * A twin is not a 3-D model with a data feed bolted on. It is a claim: that this
 * arrangement of shapes and numbers is a faithful stand-in for a piece of physical
 * plant, faithful enough that a decision taken against it would have been the same
 * decision taken against the real thing.
 *
 * That claim is checkable, and this module is mostly about checking it. Scene layout is
 * the easy part; the parts that matter are the fidelity score — how much of the twin is
 * measurement and how much is inference — and the deviation detector, which watches for
 * the twin and the grid drifting apart and, crucially, tries to say which of the two is
 * wrong.
 */

import { clamp, round } from './rng'
import type { GraphNode, GridGraph } from './graph'
import { childrenOf, parentsOf } from './graph'

export const TWIN_LEVELS = ['national', 'region', 'site', 'asset'] as const
export type TwinLevel = (typeof TWIN_LEVELS)[number]

/** The visual archetype the renderer draws for a member of the scene. */
export const TWIN_SHAPES = [
  'transformer',
  'substation',
  'line',
  'plant',
  'solar',
  'wind',
  'battery',
  'load',
  'ev',
  'building',
  'busbar',
  'box',
] as const
export type TwinShape = (typeof TWIN_SHAPES)[number]

const SHAPE_BY_ASSET_TYPE: Record<string, TwinShape> = {
  transformer: 'transformer',
  substation: 'substation',
  transmission_line: 'line',
  power_plant: 'plant',
  solar_farm: 'solar',
  wind_farm: 'wind',
  battery_storage: 'battery',
  industrial_load: 'load',
  ev_hub: 'ev',
}

export interface TwinSceneNode {
  code: string
  name: string
  nameAr: string
  assetTypeKey: string
  shape: TwinShape
  /** Scene position in metres, twin-local. */
  x: number
  y: number
  z: number
  scale: number
  rotationY: number
  capacityMw: number
  customersServed: number
  /** True for the node the twin is focused on. */
  isFocus: boolean
}

export interface TwinSceneEdge {
  from: string
  to: string
  kind: string
  capacityMw: number
  /** Normalised flow, 0–1, used for line thickness and animation speed. */
  intensity: number
}

export interface TwinScene {
  level: TwinLevel
  focusCode: string | null
  nodes: TwinSceneNode[]
  edges: TwinSceneEdge[]
  /** Half-extent of the scene in metres, so a camera can frame it without guessing. */
  radius: number
}

/**
 * Equirectangular projection into twin-local metres.
 *
 * Good enough at the scale of one country and exact enough that two assets a kilometre
 * apart are a kilometre apart in the scene, which is what makes distance in the twin
 * mean something.
 */
function projectMetres(lat: number, lon: number, originLat: number, originLon: number) {
  const toRad = Math.PI / 180
  const meanLat = ((lat + originLat) / 2) * toRad
  return {
    x: (lon - originLon) * toRad * Math.cos(meanLat) * 6_371_000,
    z: -(lat - originLat) * toRad * 6_371_000,
  }
}

/** Vertical placement by voltage level: the higher the voltage, the higher the bus. */
function heightFor(assetTypeKey: string, capacityMw: number): number {
  switch (assetTypeKey) {
    case 'substation':
      return 6
    case 'transmission_line':
      return 14
    case 'power_plant':
      return 8
    case 'wind_farm':
      return 12
    case 'transformer':
      return 3
    default:
      return 2 + clamp(capacityMw / 800, 0, 2)
  }
}

/**
 * Build the scene for a level.
 *
 * National and regional levels place assets geographically. Site level abandons geography
 * — a substation's transformers are metres apart and would collapse into one point — and
 * lays the yard out as an engineer would draw it: a busbar with units ranked along it.
 */
export function buildTwinScene(
  graph: GridGraph,
  level: TwinLevel,
  focusCode: string | null,
  options: { regionCode?: string | null; siteMembers?: string[] } = {},
): TwinScene {
  const focus = focusCode ? graph.byCode.get(focusCode) ?? null : null

  let members: GraphNode[]
  if (level === 'national') {
    // Every asset at national scale is unreadable; show the backbone.
    members = graph.nodes.filter(
      (node) =>
        node.assetTypeKey === 'substation' ||
        node.assetTypeKey === 'power_plant' ||
        node.assetTypeKey === 'transmission_line' ||
        node.assetTypeKey === 'solar_farm' ||
        node.assetTypeKey === 'wind_farm' ||
        node.assetTypeKey === 'battery_storage',
    )
  } else if (level === 'region') {
    const region = options.regionCode ?? focus?.regionCode ?? null
    members = graph.nodes.filter((node) => node.regionCode === region)
  } else if (level === 'site') {
    const codes = new Set(options.siteMembers ?? [])
    if (focus) {
      codes.add(focus.code)
      for (const parent of parentsOf(graph, focus.code)) {
        codes.add(parent.code)
        for (const child of childrenOf(graph, parent.code)) codes.add(child.code)
      }
      for (const child of childrenOf(graph, focus.code)) codes.add(child.code)
    }
    members = graph.nodes.filter((node) => codes.has(node.code))
  } else {
    const codes = new Set<string>(focus ? [focus.code] : [])
    if (focus) {
      for (const parent of parentsOf(graph, focus.code)) codes.add(parent.code)
      for (const child of childrenOf(graph, focus.code)) codes.add(child.code)
    }
    members = graph.nodes.filter((node) => codes.has(node.code))
  }

  if (members.length === 0) {
    return { level, focusCode, nodes: [], edges: [], radius: 100 }
  }

  const originLat = members.reduce((sum, node) => sum + node.lat, 0) / members.length
  const originLon = members.reduce((sum, node) => sum + node.lon, 0) / members.length

  const geographic = level === 'national' || level === 'region'
  const nodes: TwinSceneNode[] = []

  if (geographic) {
    // Scaled down so a country fits a scene a camera can orbit, while keeping relative
    // distances exact.
    const divisor = level === 'national' ? 4_200 : 900
    for (const node of members) {
      const projected = projectMetres(node.lat, node.lon, originLat, originLon)
      nodes.push({
        code: node.code,
        name: node.name,
        nameAr: node.nameAr,
        assetTypeKey: node.assetTypeKey,
        shape: SHAPE_BY_ASSET_TYPE[node.assetTypeKey] ?? 'box',
        x: round(projected.x / divisor, 2),
        y: round(heightFor(node.assetTypeKey, node.capacityMw) * 0.35, 2),
        z: round(projected.z / divisor, 2),
        scale: round(0.6 + clamp(node.capacityMw / 1_600, 0, 1.4), 2),
        rotationY: 0,
        capacityMw: node.capacityMw,
        customersServed: node.customersServed,
        isFocus: node.code === focusCode,
      })
    }
  } else {
    // Yard layout: the bus runs along x, transformers rank along it, supply sits behind
    // and load in front. Deterministic ordering by code so the scene never reshuffles.
    const ordered = [...members].sort((a, b) => a.code.localeCompare(b.code))
    const rows: Record<string, GraphNode[]> = { supply: [], bus: [], transformer: [], load: [] }
    for (const node of ordered) {
      if (node.assetTypeKey === 'substation' || node.assetTypeKey === 'transmission_line')
        rows.bus.push(node)
      else if (node.assetTypeKey === 'transformer') rows.transformer.push(node)
      else if (node.isLoad) rows.load.push(node)
      else rows.supply.push(node)
    }

    const place = (group: GraphNode[], z: number, spacing: number) => {
      const width = (group.length - 1) * spacing
      group.forEach((node, index) => {
        nodes.push({
          code: node.code,
          name: node.name,
          nameAr: node.nameAr,
          assetTypeKey: node.assetTypeKey,
          shape: SHAPE_BY_ASSET_TYPE[node.assetTypeKey] ?? 'box',
          x: round(index * spacing - width / 2, 2),
          y: round(heightFor(node.assetTypeKey, node.capacityMw) * 0.5, 2),
          z,
          scale: round(0.8 + clamp(node.capacityMw / 900, 0, 1.1), 2),
          rotationY: 0,
          capacityMw: node.capacityMw,
          customersServed: node.customersServed,
          isFocus: node.code === focusCode,
        })
      })
    }

    place(rows.supply, -26, 16)
    place(rows.bus, -10, 20)
    place(rows.transformer, 6, 12)
    place(rows.load, 24, 16)
  }

  const present = new Set(nodes.map((node) => node.code))
  const edges: TwinSceneEdge[] = graph.edges
    .filter((edge) => present.has(edge.from) && present.has(edge.to))
    .map((edge) => ({
      from: edge.from,
      to: edge.to,
      kind: edge.kind,
      capacityMw: edge.capacityMw,
      intensity: round(clamp(edge.shareOfFlow, 0.05, 1), 3),
    }))

  const radius = Math.max(
    30,
    ...nodes.map((node) => Math.hypot(node.x, node.z)),
  )

  return { level, focusCode, nodes, edges, radius: round(radius * 1.25, 1) }
}

// ─────────────────────────────────────────────────────────────────────────────
// Fidelity (§31) and freshness (§32)
// ─────────────────────────────────────────────────────────────────────────────

export type FidelityBand = 'high' | 'good' | 'limited' | 'low'

export interface FidelityInput {
  /** Sensors reporting within the freshness window, over sensors expected. */
  sensorsOnline: number
  sensorsExpected: number
  /** Milliseconds since the most recent reading anywhere in the twin. */
  msSinceLastSync: number
  /** Mean sensor trust across the twin, 0–100. */
  meanSensorTrust: number
  /** Share of twin members the engine has a behavioural model for, 0–1. */
  modelCoverage: number
  /** Hours of history available, capped at a week for scoring. */
  historyHours: number
}

export interface TwinFidelity {
  score: number
  band: FidelityBand
  sensorCoverage: number
  dataFreshness: number
  sensorTrust: number
  modelCoverage: number
  historicalDepth: number
  msSinceLastSync: number
  sensorsOnline: number
  sensorsExpected: number
  /** Stated plainly when the twin should not be leaned on. */
  warning: string | null
  warningAr: string | null
}

export function fidelityBand(score: number): FidelityBand {
  if (score >= 90) return 'high'
  if (score >= 75) return 'good'
  if (score >= 55) return 'limited'
  return 'low'
}

/** Freshness decays over ten minutes: a twin fed an hour ago is not a live twin. */
function freshnessScore(msSinceLastSync: number): number {
  const minutes = msSinceLastSync / 60_000
  return clamp(100 - minutes * 10, 0, 100)
}

export function assessFidelity(input: FidelityInput): TwinFidelity {
  const sensorCoverage = clamp(
    input.sensorsExpected > 0 ? (input.sensorsOnline / input.sensorsExpected) * 100 : 0,
    0,
    100,
  )
  const dataFreshness = freshnessScore(input.msSinceLastSync)
  const sensorTrust = clamp(input.meanSensorTrust, 0, 100)
  const modelCoverage = clamp(input.modelCoverage * 100, 0, 100)
  const historicalDepth = clamp((input.historyHours / 168) * 100, 0, 100)

  // Coverage and trust dominate: a twin can survive being a few minutes stale, but not
  // being fed by instruments nobody believes.
  const score = round(
    clamp(
      sensorCoverage * 0.3 +
        sensorTrust * 0.26 +
        dataFreshness * 0.2 +
        modelCoverage * 0.16 +
        historicalDepth * 0.08,
      0,
      100,
    ),
    1,
  )

  const band = fidelityBand(score)
  let warning: string | null = null
  let warningAr: string | null = null
  if (band === 'low') {
    warning = 'Low twin confidence — this model is not currently a reliable stand-in for the asset.'
    warningAr = 'ثقة منخفضة في التوأم — النموذج حاليًا ليس بديلًا موثوقًا عن الأصل.'
  } else if (band === 'limited') {
    warning = 'Limited twin confidence — some channels are stale or unverified.'
    warningAr = 'ثقة محدودة في التوأم — بعض القنوات قديمة أو غير مُتحقق منها.'
  } else if (dataFreshness < 60) {
    warning = 'Telemetry is falling behind; readings may not reflect the present state.'
    warningAr = 'القياسات تتأخر؛ قد لا تعكس القراءات الحالة الراهنة.'
  }

  return {
    score,
    band,
    sensorCoverage: round(sensorCoverage, 1),
    dataFreshness: round(dataFreshness, 1),
    sensorTrust: round(sensorTrust, 1),
    modelCoverage: round(modelCoverage, 1),
    historicalDepth: round(historicalDepth, 1),
    msSinceLastSync: Math.round(input.msSinceLastSync),
    sensorsOnline: input.sensorsOnline,
    sensorsExpected: input.sensorsExpected,
    warning,
    warningAr,
  }
}

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

export const DEVIATION_CAUSES = [
  'sensor_fault',
  'model_drift',
  'unexpected_behaviour',
  'asset_degradation',
] as const
export type DeviationCause = (typeof DEVIATION_CAUSES)[number]

export interface DeviationInput {
  assetCode: string
  channel: string
  /** What the twin's model says this channel should read. */
  expected: number
  /** What the instrument actually reported. */
  observed: number
  /** Trust in the instrument supplying `observed`, 0–100. */
  sensorTrust: number
  /** True when other channels on the same asset also disagree. */
  correlatedChannels: number
  /** Days since the asset was last inspected. */
  maintenanceAgeDays: number
}

export interface TwinDeviationResult {
  assetCode: string
  channel: string
  expected: number
  observed: number
  deviationPct: number
  isSignificant: boolean
  cause: DeviationCause
  confidence: number
  detail: string
  detailAr: string
}

/** Below this, the disagreement is inside the model's own resolution. */
export const DEVIATION_THRESHOLD_PCT = 6

/**
 * Attribute a disagreement between twin and instrument.
 *
 * The interesting question is never "do they differ" — they always differ a little —
 * but *which one to believe*. One channel disagreeing on a distrusted sensor is an
 * instrument problem. Every channel disagreeing in the same direction is the asset
 * genuinely behaving differently, and that is the case worth waking somebody for.
 */
export function detectDeviation(input: DeviationInput): TwinDeviationResult {
  const base = Math.max(Math.abs(input.expected), 0.5)
  const deviationPct = round(((input.observed - input.expected) / base) * 100, 2)
  const magnitude = Math.abs(deviationPct)

  let cause: DeviationCause
  let confidence: number
  let detail: string
  let detailAr: string

  if (input.sensorTrust < 60 && input.correlatedChannels <= 1) {
    cause = 'sensor_fault'
    confidence = round(clamp(88 - input.sensorTrust * 0.4, 40, 92), 1)
    detail = `Only this channel disagrees, and its instrument scores ${round(input.sensorTrust, 0)} % trust.`
    detailAr = `هذه القناة وحدها تختلف، وثقة حساسها ${round(input.sensorTrust, 0)}٪.`
  } else if (input.correlatedChannels >= 3 && deviationPct > 0) {
    cause = 'unexpected_behaviour'
    confidence = round(clamp(58 + input.correlatedChannels * 7, 40, 93), 1)
    detail = `${input.correlatedChannels} channels disagree in the same direction; the asset is doing something the model does not expect.`
    detailAr = `${input.correlatedChannels} قنوات تختلف في الاتجاه نفسه؛ الأصل يفعل شيئًا لا يتوقعه النموذج.`
  } else if (input.maintenanceAgeDays > 400 && deviationPct > 0) {
    cause = 'asset_degradation'
    confidence = round(clamp(46 + input.maintenanceAgeDays / 20, 40, 88), 1)
    detail = `Worsening deviation on an asset last inspected ${Math.round(input.maintenanceAgeDays)} days ago.`
    detailAr = `انحراف يتفاقم على أصل آخر فحص له قبل ${Math.round(input.maintenanceAgeDays)} يومًا.`
  } else {
    cause = 'model_drift'
    confidence = round(clamp(44 + magnitude, 35, 82), 1)
    detail = `The model reads ${round(input.expected, 2)} where the instrument reads ${round(input.observed, 2)}; no single instrument is implicated.`
    detailAr = `النموذج يقرأ ${round(input.expected, 2)} بينما الجهاز يقرأ ${round(input.observed, 2)}؛ ولا يوجد جهاز بعينه متهم.`
  }

  return {
    assetCode: input.assetCode,
    channel: input.channel,
    expected: round(input.expected, 2),
    observed: round(input.observed, 2),
    deviationPct,
    isSignificant: magnitude >= DEVIATION_THRESHOLD_PCT,
    cause,
    confidence,
    detail,
    detailAr,
  }
}

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

export interface CalibrationSample {
  channel: string
  predicted: number
  observed: number
}

export interface CalibrationResult {
  samples: number
  /** Mean absolute error, in the channels' own units, per channel. */
  perChannel: Array<{ channel: string; meanAbsError: number; meanBiasPct: number; samples: number }>
  /** Mean absolute percentage error across everything measured. */
  meanAbsPct: number
  /** Signed bias: positive means the model reads high. */
  biasPct: number
  drift: 'stable' | 'drifting' | 'diverged'
  recommendation: string
  recommendationAr: string
}

/**
 * Compare what the twin predicted with what was later observed.
 *
 * The output is a report, never an automatic adjustment: a model that quietly retunes
 * itself against its own errors is a model nobody can audit, and the governance step is
 * the point of the exercise (§85).
 */
export function calibrate(samples: CalibrationSample[]): CalibrationResult {
  if (samples.length === 0) {
    return {
      samples: 0,
      perChannel: [],
      meanAbsPct: 0,
      biasPct: 0,
      drift: 'stable',
      recommendation: 'No paired predictions and observations are available yet.',
      recommendationAr: 'لا تتوفر بعد أزواج من التنبؤات والقياسات.',
    }
  }

  const byChannel = new Map<string, CalibrationSample[]>()
  for (const sample of samples) {
    const bucket = byChannel.get(sample.channel)
    if (bucket) bucket.push(sample)
    else byChannel.set(sample.channel, [sample])
  }

  const perChannel = [...byChannel.entries()].map(([channel, entries]) => {
    const absError = entries.reduce((sum, e) => sum + Math.abs(e.predicted - e.observed), 0) / entries.length
    const biasPct =
      (entries.reduce(
        (sum, e) => sum + (e.predicted - e.observed) / Math.max(Math.abs(e.observed), 0.5),
        0,
      ) /
        entries.length) *
      100
    return {
      channel,
      meanAbsError: round(absError, 3),
      meanBiasPct: round(biasPct, 2),
      samples: entries.length,
    }
  })

  const meanAbsPct = round(
    (samples.reduce(
      (sum, e) => sum + Math.abs(e.predicted - e.observed) / Math.max(Math.abs(e.observed), 0.5),
      0,
    ) /
      samples.length) *
      100,
    2,
  )
  const biasPct = round(
    (samples.reduce(
      (sum, e) => sum + (e.predicted - e.observed) / Math.max(Math.abs(e.observed), 0.5),
      0,
    ) /
      samples.length) *
      100,
    2,
  )

  const drift = meanAbsPct > 18 ? 'diverged' : meanAbsPct > 8 ? 'drifting' : 'stable'
  const recommendation =
    drift === 'stable'
      ? 'The twin tracks observation within tolerance; no calibration is indicated.'
      : drift === 'drifting'
        ? 'Calibration recommended: error is outside the comfortable band but the model is still usable.'
        : 'Calibration required before the twin is relied on for decisions.'
  const recommendationAr =
    drift === 'stable'
      ? 'يتتبع التوأم القياسات ضمن الحدود المقبولة؛ لا حاجة للمعايرة.'
      : drift === 'drifting'
        ? 'يُوصى بالمعايرة: الخطأ خارج النطاق المريح لكن النموذج ما زال صالحًا للاستخدام.'
        : 'المعايرة مطلوبة قبل الاعتماد على التوأم في اتخاذ القرار.'

  return { samples: samples.length, perChannel, meanAbsPct, biasPct, drift, recommendation, recommendationAr }
}
