/**
 * Cascading failure engine (§5) and the data behind Domino View (§6).
 *
 * A single asset failing is rarely the interesting part. What matters is where its load
 * goes: a transformer that stops carrying 41 MW does not delete that demand, it hands it
 * to whichever units are still standing. This module walks that hand-off across the grid
 * graph, one hop at a time, and records the sequence with timings and probabilities.
 *
 * Every number it produces is derived from live capacity and loading — spare MW on the
 * siblings, the rating of the line the transfer crosses, the customers behind each node.
 * Nothing here is scripted, which is why pointing it at a different asset produces a
 * different, still coherent, chain.
 */

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

/** Loading and capacity of one asset at the moment the cascade starts. */
export interface CascadeAssetState {
  code: string
  loadMw: number
  capacityMw: number
  loadPct: number
  isOutage: boolean
}

export type CascadeMechanism =
  | 'origin_failure'
  | 'load_transfer'
  | 'line_overload'
  | 'bus_stress'
  | 'unserved_load'

export interface CascadeStep {
  ordinal: number
  depth: number
  offsetMin: number
  assetCode: string
  assetName: string
  assetNameAr: string
  assetTypeKey: string
  /**
   * The asset whose failure released the load that reached this one. Empty at the
   * origin. Carried explicitly rather than reconstructed later: inferring parentage
   * from ordering and adjacency gives the wrong answer whenever two branches touch the
   * same bus, which is precisely when the tree matters (§43).
   */
  parentCode: string
  mechanism: CascadeMechanism
  /** `failed` continues the chain; `stressed` is recorded but does not propagate. */
  outcome: 'failed' | 'stressed' | 'unserved'
  loadPctBefore: number
  loadPctAfter: number
  transferredMw: number
  probability: number
  severity: 'low' | 'medium' | 'high' | 'critical'
  customers: number
  capacityMw: number
}

export interface CascadeResult {
  originCode: string
  horizonMin: number
  steps: CascadeStep[]
  affectedAssets: number
  affectedMw: number
  affectedCustomers: number
  unservedMw: number
  maxDepth: number
  /** Regional risk contribution, 0–100, from how much of the region is implicated. */
  regionalRisk: number
  /** Minute at which propagation stops, or null when it runs past the horizon. */
  containedAtMin: number | null
}

/** Loading at which a unit is carrying more than it can hold and hands load on. */
const FAIL_PCT = 100
/** Loading at which a unit is visibly strained but still holding. */
const STRESS_PCT = 92
/** How much of nameplate a healthy unit is willing to absorb in an emergency transfer. */
const ABSORB_CEILING_PCT = 118

/** Minutes for one hop, by the kind of asset receiving the transfer. */
const HOP_BASE_MIN: Record<string, number> = {
  transformer: 7,
  transmission_line: 12,
  substation: 20,
  industrial_load: 5,
  ev_hub: 5,
}

/**
 * How fast a hop matters: a unit pushed far past its limit registers almost at once,
 * one pushed barely past it takes noticeably longer. Both ends are bounded so a timeline
 * never collapses to zero or stretches past the horizon on arithmetic alone.
 */
function hopMinutes(kind: string, loadPctAfter: number): number {
  const base = HOP_BASE_MIN[kind] ?? 10
  const severity = clamp((loadPctAfter - FAIL_PCT) / 20, 0, 1)
  return Math.max(2, Math.round(base * (1.6 - 0.6 * severity)))
}

function severityFor(loadPctAfter: number, probability: number): CascadeStep['severity'] {
  const combined = clamp((loadPctAfter - 80) / 40, 0, 1) * 0.6 + probability * 0.4
  if (combined >= 0.72) return 'critical'
  if (combined >= 0.52) return 'high'
  if (combined >= 0.3) return 'medium'
  return 'low'
}

/**
 * Probability that the chain actually reaches this step. It compounds along the path —
 * a fourth-order effect is genuinely less likely than a first-order one — and is damped
 * where the topology is redundant, because a ring gives the load somewhere else to go.
 */
function hopProbability(parentProbability: number, loadPctAfter: number, redundant: boolean): number {
  const severity = clamp((loadPctAfter - FAIL_PCT) / 18, 0, 1)
  const hop = clamp(0.34 + 0.58 * severity, 0, 0.96) * (redundant ? 0.72 : 1)
  return round(clamp(parentProbability * hop, 0, 1), 3)
}

interface WorkItem {
  code: string
  depth: number
  atMin: number
  probability: number
  releasedMw: number
}

/**
 * Walk the cascade outward from `originCode`.
 *
 * The queue is ordered by time rather than by depth, so the timeline comes out in the
 * order an operator would actually watch it happen.
 */
export function simulateCascade(
  graph: GridGraph,
  states: Map<string, CascadeAssetState>,
  originCode: string,
  options: { horizonMin?: number; maxSteps?: number; minProbability?: number } = {},
): CascadeResult {
  const horizonMin = options.horizonMin ?? 60
  const maxSteps = options.maxSteps ?? 24
  const minProbability = options.minProbability ?? 0.05

  const origin = graph.byCode.get(originCode)
  const originState = states.get(originCode)
  const steps: CascadeStep[] = []

  if (!origin || !originState) {
    return {
      originCode,
      horizonMin,
      steps,
      affectedAssets: 0,
      affectedMw: 0,
      affectedCustomers: 0,
      unservedMw: 0,
      maxDepth: 0,
      regionalRisk: 0,
      containedAtMin: 0,
    }
  }

  // Working copy: loading changes as the cascade redistributes power, and the original
  // snapshot must stay untouched for every other reader of it.
  const load = new Map<string, number>()
  for (const [code, state] of states) load.set(code, state.loadMw)

  const failed = new Set<string>([originCode])
  const touched = new Set<string>([originCode])
  let unservedMw = 0
  let ordinal = 0

  steps.push({
    ordinal: ordinal++,
    depth: 0,
    offsetMin: 0,
    assetCode: origin.code,
    assetName: origin.name,
    assetNameAr: origin.nameAr,
    assetTypeKey: origin.assetTypeKey,
    parentCode: '',
    mechanism: 'origin_failure',
    outcome: 'failed',
    loadPctBefore: round(originState.loadPct, 1),
    loadPctAfter: 0,
    transferredMw: round(Math.max(0, originState.loadMw), 1),
    probability: 1,
    severity: 'critical',
    customers: origin.customersServed || origin.downstreamCustomers,
    capacityMw: round(origin.capacityMw, 1),
  })

  const queue: WorkItem[] = [
    { code: originCode, depth: 0, atMin: 0, probability: 1, releasedMw: Math.max(0, originState.loadMw) },
  ]

  while (queue.length > 0 && steps.length < maxSteps) {
    queue.sort((a, b) => a.atMin - b.atMin)
    const item = queue.shift()!
    if (item.releasedMw <= 0.05) continue

    const node = graph.byCode.get(item.code)
    if (!node) continue

    // Where can this load go? Siblings on the same bus first — that is a switching
    // operation, not a re-dispatch — then the ring, if the bus itself is out of room.
    const peers = siblingsOf(graph, item.code).filter((p) => !failed.has(p.code))
    const absorbers = peers
      .map((peer) => {
        const state = states.get(peer.code)
        const current = load.get(peer.code) ?? 0
        const capacity = Math.max(peer.capacityMw, 0.1)
        const ceiling = (capacity * ABSORB_CEILING_PCT) / 100
        return {
          peer,
          state,
          current,
          currentPct: (current / capacity) * 100,
          spare: Math.max(0, ceiling - current),
        }
      })
      .filter(
        (entry) =>
          entry.state &&
          !entry.state.isOutage &&
          entry.peer.capacityMw > 0 &&
          // A unit already past its own limit is not a place to put more load. Leaving
          // it in the absorber set produced a subtly wrong chain: it would be handed a
          // share, cross 100 % it had already crossed, and be reported as a casualty of
          // an event that had nothing to do with its state.
          entry.currentPct < FAIL_PCT,
      )

    const totalSpare = absorbers.reduce((sum, entry) => sum + entry.spare, 0)
    let remaining = item.releasedMw

    for (const entry of absorbers) {
      if (remaining <= 0.05 || totalSpare <= 0) break
      // Proportional to available headroom: the unit with the most room takes the most,
      // which is what an operator balancing a bus would do by hand.
      const share = Math.min(entry.spare, (entry.spare / totalSpare) * item.releasedMw)
      if (share <= 0.05) continue

      const before = entry.current
      const after = before + share
      load.set(entry.peer.code, after)
      remaining -= share

      const capacity = Math.max(entry.peer.capacityMw, 0.1)
      const loadPctAfter = (after / capacity) * 100
      if (loadPctAfter < STRESS_PCT) continue

      const overloaded = loadPctAfter >= FAIL_PCT
      const redundant = (graph.in.get(entry.peer.code) ?? []).some((edge) => edge.isRedundant)
      const probability = hopProbability(item.probability, loadPctAfter, redundant)
      if (probability < minProbability) continue

      const offsetMin = item.atMin + hopMinutes(entry.peer.assetTypeKey, loadPctAfter)
      if (offsetMin > horizonMin) continue

      touched.add(entry.peer.code)
      steps.push({
        ordinal: ordinal++,
        depth: item.depth + 1,
        offsetMin,
        assetCode: entry.peer.code,
        assetName: entry.peer.name,
        assetNameAr: entry.peer.nameAr,
        assetTypeKey: entry.peer.assetTypeKey,
        parentCode: item.code,
        mechanism: 'load_transfer',
        outcome: overloaded ? 'failed' : 'stressed',
        loadPctBefore: round((before / capacity) * 100, 1),
        loadPctAfter: round(loadPctAfter, 1),
        transferredMw: round(share, 1),
        probability,
        severity: severityFor(loadPctAfter, probability),
        customers: entry.peer.customersServed || entry.peer.downstreamCustomers,
        capacityMw: round(entry.peer.capacityMw, 1),
      })

      if (overloaded && steps.length < maxSteps) {
        failed.add(entry.peer.code)
        queue.push({
          code: entry.peer.code,
          depth: item.depth + 1,
          atMin: offsetMin,
          probability,
          releasedMw: after,
        })
      }
    }

    // ── Anything the bus could not absorb crosses the ring ──────────────────
    if (remaining > 0.05) {
      const parents = parentsOf(graph, item.code).filter((p) => !failed.has(p.code))
      const routed = routeUpstream(graph, states, load, parents, remaining, item, horizonMin, minProbability)
      for (const step of routed.steps) {
        if (steps.length >= maxSteps) break
        step.ordinal = ordinal++
        steps.push(step)
        touched.add(step.assetCode)
        if (step.outcome === 'failed') failed.add(step.assetCode)
      }
      remaining = routed.remainingMw
    }

    // ── Load nobody could take ─────────────────────────────────────────────
    if (remaining > 0.05) {
      unservedMw += remaining
      const dependants = childrenOf(graph, item.code)
      const customers =
        dependants.reduce((sum, child) => sum + child.downstreamCustomers, 0) ||
        node.customersServed ||
        node.downstreamCustomers
      const offsetMin = item.atMin + 2
      if (offsetMin <= horizonMin && steps.length < maxSteps) {
        steps.push({
          ordinal: ordinal++,
          depth: item.depth + 1,
          offsetMin,
          assetCode: node.code,
          assetName: node.name,
          assetNameAr: node.nameAr,
          assetTypeKey: node.assetTypeKey,
          parentCode: item.code,
          mechanism: 'unserved_load',
          outcome: 'unserved',
          loadPctBefore: round(((load.get(node.code) ?? 0) / Math.max(node.capacityMw, 0.1)) * 100, 1),
          loadPctAfter: 0,
          transferredMw: round(remaining, 1),
          probability: item.probability,
          severity: 'critical',
          customers,
          capacityMw: round(node.capacityMw, 1),
        })
      }
    }
  }

  steps.sort((a, b) => a.offsetMin - b.offsetMin || a.ordinal - b.ordinal)
  steps.forEach((step, index) => {
    step.ordinal = index
  })

  const affectedCustomers = [...touched].reduce((sum, code) => {
    const node = graph.byCode.get(code)
    return sum + (node ? node.customersServed || node.downstreamCustomers : 0)
  }, 0)
  const affectedMw = [...touched].reduce((sum, code) => {
    const state = states.get(code)
    return sum + (state ? Math.max(0, state.loadMw) : 0)
  }, 0)

  const maxDepth = steps.reduce((max, step) => Math.max(max, step.depth), 0)
  const lastPropagating = steps.filter((s) => s.outcome === 'failed').at(-1)
  const regionAssets = graph.nodes.filter((n) => n.regionCode === origin.regionCode).length
  const regionalRisk = clamp(
    (touched.size / Math.max(1, regionAssets)) * 260 +
      maxDepth * 6 +
      (unservedMw > 0 ? 18 : 0),
    0,
    100,
  )

  return {
    originCode,
    horizonMin,
    steps,
    affectedAssets: touched.size,
    affectedMw: round(affectedMw, 1),
    affectedCustomers,
    unservedMw: round(unservedMw, 1),
    maxDepth,
    regionalRisk: round(regionalRisk, 1),
    containedAtMin: lastPropagating ? lastPropagating.offsetMin : 0,
  }
}

/**
 * Push load the local bus could not take out across the transmission ring.
 *
 * This is where a transformer's problem becomes a line's problem and then a
 * substation's: the flow has to travel, and the things it travels through have ratings.
 */
function routeUpstream(
  graph: GridGraph,
  states: Map<string, CascadeAssetState>,
  load: Map<string, number>,
  parents: GraphNode[],
  remainingMw: number,
  item: WorkItem,
  horizonMin: number,
  minProbability: number,
): { steps: CascadeStep[]; remainingMw: number } {
  const steps: CascadeStep[] = []
  let remaining = remainingMw

  for (const parent of parents) {
    if (remaining <= 0.05) break
    // Outgoing paths from the feeding bus, cheapest impedance first: re-routed power
    // takes the electrically shortest route, not the alphabetically first one.
    const paths = (graph.out.get(parent.code) ?? [])
      .filter((edge) => edge.to !== item.code)
      .map((edge) => ({ edge, node: graph.byCode.get(edge.to) }))
      .filter((entry): entry is { edge: (typeof entry)['edge']; node: GraphNode } => Boolean(entry.node))
      .filter((entry) => entry.node.assetTypeKey === 'transmission_line')
      .sort((a, b) => a.edge.impedance - b.edge.impedance)

    for (const { edge, node } of paths) {
      if (remaining <= 0.05) break
      const state = states.get(node.code)
      if (!state || state.isOutage) continue

      const before = load.get(node.code) ?? state.loadMw
      // A line is a path, not a sink. What can cross it is bounded twice: by the line's
      // own rating, and by what the transformers at the far end still have room to
      // serve. Without the second bound the model would quietly "solve" any outage by
      // pushing the load onto a wire and leaving it there.
      const far = graph.byCode.get((graph.out.get(node.code) ?? [])[0]?.to ?? '')
      const farHeadroomMw = far
        ? childrenOf(graph, far.code)
            .filter((child) => child.assetTypeKey === 'transformer')
            .reduce((sum, child) => {
              const childState = states.get(child.code)
              if (!childState || childState.isOutage) return sum
              const current = load.get(child.code) ?? childState.loadMw
              return sum + Math.max(0, (child.capacityMw * ABSORB_CEILING_PCT) / 100 - current)
            }, 0)
        : 0

      const carried = Math.min(
        remaining,
        Math.max(0, node.capacityMw * 1.25 - before),
        farHeadroomMw,
      )
      if (carried <= 0.05) continue

      const after = before + carried
      load.set(node.code, after)
      remaining -= carried

      const capacity = Math.max(node.capacityMw, 0.1)
      const loadPctAfter = (after / capacity) * 100
      const lineProbability = hopProbability(item.probability, loadPctAfter, edge.isRedundant)
      const lineOffset = item.atMin + hopMinutes('transmission_line', loadPctAfter)

      // The line is only worth a step of its own once it is genuinely strained. A 380 kV
      // circuit picking up 58 MW is not news.
      if (loadPctAfter >= STRESS_PCT && lineProbability >= minProbability && lineOffset <= horizonMin) {
        steps.push({
          ordinal: 0,
          depth: item.depth + 1,
          offsetMin: lineOffset,
          assetCode: node.code,
          assetName: node.name,
          assetNameAr: node.nameAr,
          assetTypeKey: node.assetTypeKey,
          parentCode: item.code,
          mechanism: 'line_overload',
          outcome: loadPctAfter >= FAIL_PCT ? 'failed' : 'stressed',
          loadPctBefore: round((before / capacity) * 100, 1),
          loadPctAfter: round(loadPctAfter, 1),
          transferredMw: round(carried, 1),
          probability: lineProbability,
          severity: severityFor(loadPctAfter, lineProbability),
          customers: node.downstreamCustomers,
          capacityMw: round(node.capacityMw, 1),
        })
      }

      // The bus at the far end is always worth a step, though.
      //
      // Whether or not the line notices the transfer, a neighbouring substation has just
      // been asked to serve demand it was not serving a minute ago — that is a real
      // reconfiguration with its own margin cost. Recording it only when the line
      // overloads would let the chain disappear precisely when the local bus was too
      // full to help, which is the case an operator most needs to see.
      if (far) {
        const farState = states.get(far.code)
        const farCapacity = Math.max(far.capacityMw, 0.1)
        const farLoadPctAfter = (farState?.loadPct ?? 0) + (carried / farCapacity) * 100
        // Switching onto the ring is an operator action rather than a failure, so it is
        // near-certain once the origin is out — damped only by switching reliability.
        const farProbability = round(clamp(item.probability * 0.9, 0, 1), 3)
        const farOffset = lineOffset + hopMinutes('substation', farLoadPctAfter)

        if (farProbability >= minProbability && farOffset <= horizonMin) {
          steps.push({
            ordinal: 0,
            depth: item.depth + 2,
            offsetMin: farOffset,
            assetCode: far.code,
            assetName: far.name,
            assetNameAr: far.nameAr,
            assetTypeKey: far.assetTypeKey,
            // The bus is reached through the line, not directly from the origin.
            parentCode: node.code,
            mechanism: 'bus_stress',
            outcome: farLoadPctAfter >= FAIL_PCT ? 'failed' : 'stressed',
            loadPctBefore: round(farState?.loadPct ?? 0, 1),
            loadPctAfter: round(farLoadPctAfter, 1),
            transferredMw: round(carried, 1),
            probability: farProbability,
            severity: severityFor(farLoadPctAfter, farProbability),
            customers: far.customersServed || far.downstreamCustomers,
            capacityMw: round(far.capacityMw, 1),
          })
        }
      }
    }
  }

  return { steps, remainingMw: remaining }
}
