import type { Branch, Bus, NetworkModel } from './ac-power-flow'
import { round } from './rng'

/**
 * Turning the platform's grid into an electrical network the AC solver can take (5.0).
 *
 * The graph the rest of NABDH runs on carries a scalar `impedance`, a length and a
 * capacity. A load flow needs resistance and reactance separately, line charging, a
 * transformer's tap, and a declared type for every bus. This file derives those, and it is
 * explicit about which numbers are measured and which are typical:
 *
 * - **Length, capacity, voltage class and topology are the platform's own data.**
 * - **Per-kilometre impedance and transformer percentage impedance are typical values for
 *   the voltage class**, not measurements of these assets. They are stated here as a table
 *   an engineer can read and correct in one place, and every result built from them is
 *   marked `impedanceSource: 'typical'` so nobody mistakes a plausible reactance for a
 *   nameplate one. When real impedance data arrives, this table is what it replaces.
 *
 * The alternative — inventing a single impedance and calling it measured — would make
 * every voltage angle the solver produces a fiction with a confident face on it.
 */

// ─────────────────────────────────────────────────────────────────────────────
// Typical electrical parameters
// ─────────────────────────────────────────────────────────────────────────────

interface LineParameters {
  /** Series resistance, ohms per kilometre. */
  rOhmPerKm: number
  /** Series reactance, ohms per kilometre. */
  xOhmPerKm: number
  /** Shunt susceptance, microsiemens per kilometre. */
  bMicroSPerKm: number
}

/**
 * Overhead line parameters by voltage class.
 *
 * Typical values for ACSR conductor at each class: resistance falls and reactance rises
 * with conductor size and spacing, which is why a 380 kV circuit is nearly all reactance
 * while a 13.8 kV feeder is nearly all resistance. That ratio is what decides whether a
 * network's problem is voltage drop or losses, so it matters more than the absolute
 * numbers.
 */
const LINE_BY_CLASS: Array<{ minKv: number; parameters: LineParameters }> = [
  { minKv: 300, parameters: { rOhmPerKm: 0.03, xOhmPerKm: 0.30, bMicroSPerKm: 3.8 } },
  { minKv: 200, parameters: { rOhmPerKm: 0.06, xOhmPerKm: 0.33, bMicroSPerKm: 3.4 } },
  { minKv: 100, parameters: { rOhmPerKm: 0.12, xOhmPerKm: 0.40, bMicroSPerKm: 2.8 } },
  { minKv: 60, parameters: { rOhmPerKm: 0.20, xOhmPerKm: 0.40, bMicroSPerKm: 2.6 } },
  { minKv: 20, parameters: { rOhmPerKm: 0.35, xOhmPerKm: 0.38, bMicroSPerKm: 2.2 } },
  { minKv: 0, parameters: { rOhmPerKm: 0.60, xOhmPerKm: 0.40, bMicroSPerKm: 1.8 } },
]

export function lineParametersFor(voltageKv: number): LineParameters {
  return (LINE_BY_CLASS.find((entry) => voltageKv >= entry.minKv) ?? LINE_BY_CLASS[LINE_BY_CLASS.length - 1])
    .parameters
}

/**
 * Transformer percentage impedance on its own rating, and its X/R ratio.
 *
 * A large transmission transformer is stiffer and far more inductive than a distribution
 * unit; both facts are why a fault behind one looks different from a fault behind the
 * other.
 */
export function transformerImpedance(ratingMva: number): { percentZ: number; xOverR: number } {
  if (ratingMva >= 300) return { percentZ: 14, xOverR: 30 }
  if (ratingMva >= 100) return { percentZ: 12, xOverR: 22 }
  if (ratingMva >= 30) return { percentZ: 10, xOverR: 14 }
  return { percentZ: 6, xOverR: 8 }
}

/** Assumed displacement factor where a source reports no power factor of its own. */
const ASSUMED_POWER_FACTOR = 0.95

/**
 * Smallest series reactance any branch is given, per unit.
 *
 * A branch of near-zero impedance is a short circuit between two buses, and its admittance
 * — the reciprocal — is enormous. A handful of those make the admittance matrix badly
 * conditioned and the Newton step meaningless, which is why every production load flow
 * either merges such buses into one or floors the impedance. Flooring keeps the topology
 * intact, and at this value the electrical error is negligible: 5e-4 pu on a 100 MVA base
 * at 380 kV is under a tenth of an ohm.
 */
const MIN_REACTANCE_PU = 5e-4

// ─────────────────────────────────────────────────────────────────────────────
// Inputs
// ─────────────────────────────────────────────────────────────────────────────

export interface ModelAsset {
  code: string
  assetTypeKey: string
  voltageKv: number
  capacityMw: number
  /** Signed loading. Negative on storage means charging. */
  loadPct: number
  loadMw: number
  powerFactor: number
  isOutage: boolean
}

export interface ModelEdge {
  from: string
  to: string
  kind: string
  capacityMw: number
  lengthKm: number
  outOfService?: boolean
}

export interface BuildOptions {
  baseMva?: number
  /** Force a particular bus to be the reference. Otherwise the largest source is chosen. */
  slackCode?: string
  /** Codes to take out of service on top of any already flagged — the N-1 lever. */
  outages?: string[]
  /** Transformer tap positions by branch key `from→to`. Absent means nominal. */
  taps?: Record<string, number>
}

export interface BuiltNetwork {
  model: NetworkModel
  /** Always 'typical' in this build. Stated on the payload so nothing has to assume. */
  impedanceSource: 'typical' | 'measured'
  /** Buses that no path connects to the reference — reported, never silently dropped. */
  islanded: string[]
  /** Why each bus got the type it has, so the choice is inspectable. */
  slackReason: string
  /** Assets present in the scene but excluded from the solve, with the reason. */
  excluded: Array<{ code: string; reason: string }>
}

const GENERATION = new Set(['power_plant'])
const INVERTER_SOURCES = new Set(['solar_farm', 'wind_farm'])
const STORAGE = new Set(['battery_storage'])

/** Reactive power implied by a real injection and a displacement factor. */
function reactiveFor(pMw: number, powerFactor: number): number {
  const pf = Math.min(0.9999, Math.max(0.5, powerFactor || ASSUMED_POWER_FACTOR))
  return Math.abs(pMw) * Math.tan(Math.acos(pf)) * Math.sign(pMw || 1)
}

/**
 * Build the network.
 *
 * Bus typing follows what the plant actually is:
 *
 * - The largest in-service conventional generator becomes the **slack**. It is the machine
 *   that would in fact take up the imbalance, and picking it rather than an arbitrary
 *   first bus keeps the reported slack injection physically meaningful.
 * - Other conventional generators become **PV**: they hold voltage.
 * - Solar, wind and storage become **PQ**. Inverter-connected plant on this grid runs at a
 *   scheduled power factor rather than regulating bus voltage, and typing it PV would make
 *   the solver hold a voltage nothing is holding.
 * - Everything else — substations, transformers, lines, loads — is **PQ**.
 */
export function buildNetwork(
  assets: ModelAsset[],
  edges: ModelEdge[],
  options: BuildOptions = {},
): BuiltNetwork {
  const baseMva = options.baseMva ?? 100
  const outages = new Set(options.outages ?? [])
  const excluded: Array<{ code: string; reason: string }> = []

  // ── Buses ─────────────────────────────────────────────────────────────────
  const usable = assets.filter((asset) => {
    if (asset.voltageKv <= 0.01) {
      excluded.push({ code: asset.code, reason: 'No voltage level, so it has no per-unit base' })
      return false
    }
    return true
  })

  const byCode = new Map(usable.map((asset) => [asset.code, asset]))

  const isOut = (asset: ModelAsset) => asset.isOutage || outages.has(asset.code)

  // The reference: the largest conventional generator still in service.
  const generators = usable
    .filter((asset) => GENERATION.has(asset.assetTypeKey) && !isOut(asset))
    .sort((a, b) => b.capacityMw - a.capacityMw)

  const forced = options.slackCode ? byCode.get(options.slackCode) : undefined
  const slack = forced && !isOut(forced) ? forced : generators[0]

  const slackReason = !slack
    ? 'No conventional generator is in service, so there is no reference bus.'
    : forced && slack.code === forced.code
      ? `Reference fixed to ${slack.code} by the caller.`
      : `Largest in-service generator (${slack.code}, ${round(slack.capacityMw, 0)} MW).`

  const buses: Bus[] = usable.map((asset) => {
    const out = isOut(asset)
    // An asset out of service injects and draws nothing. Its bus stays in the model so
    // the topology is unchanged and the islanding check can still see it.
    const pMw = out ? 0 : signedInjection(asset)
    const qMvar = out ? 0 : reactiveFor(pMw, asset.powerFactor)

    let type: Bus['type'] = 'pq'
    if (slack && asset.code === slack.code) type = 'slack'
    else if (GENERATION.has(asset.assetTypeKey) && !out) type = 'pv'

    return {
      code: asset.code,
      type,
      baseKv: asset.voltageKv,
      pMw: round(pMw, 4),
      qMvar: round(qMvar, 4),
      vSetPu: type === 'slack' ? 1.02 : type === 'pv' ? 1.01 : undefined,
      // A generator's reactive capability is conventionally about half its MW rating.
      qMinMvar: type === 'pv' ? -0.5 * asset.capacityMw : undefined,
      qMaxMvar: type === 'pv' ? 0.5 * asset.capacityMw : undefined,
    }
  })

  // ── Branches ──────────────────────────────────────────────────────────────
  const branches: Branch[] = []
  for (const edge of edges) {
    const from = byCode.get(edge.from)
    const to = byCode.get(edge.to)
    if (!from || !to || from.code === to.code) continue

    const key = `${edge.from}→${edge.to}`
    const isTransformer =
      from.assetTypeKey === 'transformer' ||
      to.assetTypeKey === 'transformer' ||
      Math.abs(from.voltageKv - to.voltageKv) > 0.5

    const branch = isTransformer
      ? transformerBranch(edge, from, to, baseKvFor(from, to), baseMva, options.taps?.[key])
      : lineBranch(edge, from, to, baseMva)

    branch.outOfService =
      edge.outOfService === true ||
      outages.has(edge.from) ||
      outages.has(edge.to) ||
      outages.has(key)

    branches.push(branch)
  }

  // ── Islanding ─────────────────────────────────────────────────────────────
  const islanded = slack ? findIslanded(buses, branches, slack.code) : buses.map((bus) => bus.code)

  return {
    model: { buses, branches, baseMva },
    impedanceSource: 'typical',
    islanded,
    slackReason,
    excluded,
  }
}

/** Generation is positive, consumption negative, storage decided by the sign of loading. */
function signedInjection(asset: ModelAsset): number {
  const magnitude = Math.abs(asset.loadMw)
  if (GENERATION.has(asset.assetTypeKey) || INVERTER_SOURCES.has(asset.assetTypeKey)) return magnitude
  if (STORAGE.has(asset.assetTypeKey)) return asset.loadPct < 0 ? -magnitude : magnitude
  if (asset.assetTypeKey === 'industrial_load' || asset.assetTypeKey === 'ev_hub') return -magnitude
  // Transformers, substations and lines neither make nor use the power they pass. Their
  // losses belong to the branches, and giving them an injection would double-count.
  return 0
}

/** The higher of the two ends: a transformer's impedance is referred to its HV side. */
function baseKvFor(from: ModelAsset, to: ModelAsset): number {
  return Math.max(from.voltageKv, to.voltageKv)
}

function lineBranch(edge: ModelEdge, from: ModelAsset, to: ModelAsset, baseMva: number): Branch {
  const kv = Math.max(from.voltageKv, to.voltageKv)
  const parameters = lineParametersFor(kv)
  // Z_base = kV² / MVA_base. Everything per-unit hangs off this one line.
  const zBase = (kv * kv) / baseMva
  const length = Math.max(0.1, edge.lengthKm)

  const rawX = (parameters.xOhmPerKm * length) / zBase
  const rawR = (parameters.rOhmPerKm * length) / zBase
  // Floor the reactance and lift the resistance with it, so the X/R ratio — which decides
  // whether a circuit's problem is voltage drop or losses — survives the floor.
  const lift = rawX < MIN_REACTANCE_PU ? MIN_REACTANCE_PU / Math.max(rawX, 1e-12) : 1

  return {
    from: edge.from,
    to: edge.to,
    rPu: round(rawR * lift, 8),
    xPu: round(rawX * lift, 8),
    // Susceptance is an admittance, so it is multiplied by the base impedance rather than
    // divided by it — the single easiest sign-of-exponent mistake in this whole file.
    bPu: round(parameters.bMicroSPerKm * 1e-6 * length * zBase, 8),
    ratingMva: round(edge.capacityMw / ASSUMED_POWER_FACTOR, 3),
    kind: edge.kind,
    tap: 1,
  }
}

function transformerBranch(
  edge: ModelEdge,
  from: ModelAsset,
  to: ModelAsset,
  kv: number,
  baseMva: number,
  tap?: number,
): Branch {
  // The unit's own rating drives its impedance; the smaller end is the transformer.
  const ratingMva = Math.max(1, Math.min(from.capacityMw, to.capacityMw)) / ASSUMED_POWER_FACTOR
  const { percentZ, xOverR } = transformerImpedance(ratingMva)

  // Referred from the transformer's own base to the system base.
  const zPu = (percentZ / 100) * (baseMva / ratingMva)
  const rawX = zPu / Math.sqrt(1 + 1 / (xOverR * xOverR))
  const x = Math.max(rawX, MIN_REACTANCE_PU)
  const r = x / xOverR

  return {
    from: edge.from,
    to: edge.to,
    rPu: round(r, 8),
    xPu: round(x, 8),
    // A transformer has no line charging worth modelling at this fidelity.
    bPu: 0,
    ratingMva: round(ratingMva, 3),
    kind: 'transformer',
    tap: tap && tap > 0.5 && tap < 1.5 ? tap : 1,
    // Kept for readers: the base the impedance was referred from.
    shiftDeg: 0,
  }
}

/**
 * Buses no in-service path connects to the reference.
 *
 * Reported rather than removed. An island is a finding — it is what an N-1 contingency is
 * looking for — and silently dropping those buses would turn the most important result
 * into a smaller network.
 */
export function findIslanded(buses: Bus[], branches: Branch[], slackCode: string): string[] {
  const neighbours = new Map<string, string[]>()
  for (const bus of buses) neighbours.set(bus.code, [])
  for (const branch of branches) {
    if (branch.outOfService) continue
    neighbours.get(branch.from)?.push(branch.to)
    neighbours.get(branch.to)?.push(branch.from)
  }

  const reached = new Set<string>([slackCode])
  const queue = [slackCode]
  while (queue.length > 0) {
    const code = queue.shift()!
    for (const next of neighbours.get(code) ?? []) {
      if (reached.has(next)) continue
      reached.add(next)
      queue.push(next)
    }
  }

  return buses.map((bus) => bus.code).filter((code) => !reached.has(code))
}

/**
 * Drop the islanded part and keep the connected network.
 *
 * Used by the solver path once the islanding has already been *reported*: a load flow over
 * a disconnected network has no solution, so the connected component is solved and the
 * island is carried alongside as a finding.
 */
export function connectedSubnetwork(built: BuiltNetwork): NetworkModel {
  if (built.islanded.length === 0) return built.model
  const dropped = new Set(built.islanded)
  return {
    baseMva: built.model.baseMva,
    buses: built.model.buses.filter((bus) => !dropped.has(bus.code)),
    branches: built.model.branches.filter(
      (branch) => !dropped.has(branch.from) && !dropped.has(branch.to),
    ),
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Islands
// ─────────────────────────────────────────────────────────────────────────────

export interface NetworkIsland {
  /** Codes in this island, in the order they appear in the model. */
  codes: string[]
  model: NetworkModel
  /** The bus chosen as this island's reference, or null when it has no generation. */
  slackCode: string | null
  energised: boolean
  /** Total in-service generation inside the island after dispatch, MW. */
  generationMw: number
  /** Total demand inside the island, MW. */
  demandMw: number
  /**
   * Factor applied to the island's scheduled generation to match its own demand.
   *
   * 1 means the sampled dispatch was left alone. Below 1 means the island's plants were
   * sampled producing far more than anything in it draws — which is what happens when the
   * loads they actually serve sit in a different electrical area — and they have been
   * scaled back in merit order. Reported so nobody reads a modelled dispatch as a metered
   * one.
   */
  dispatchScale: number
}

/**
 * Split a model into its connected components and give each one its own reference.
 *
 * A load flow needs exactly one slack per synchronous area. A model containing several
 * electrically separate areas has several, and forcing them into one solve produces a
 * matrix with no solution — which is what a single global reference was doing to the
 * seeded grid.
 *
 * An island with no generation is returned with `energised: false` and no slack. That is
 * a finding: everything in it is unsupplied.
 */
export function splitIntoIslands(model: NetworkModel): NetworkIsland[] {
  const neighbours = new Map<string, string[]>()
  for (const bus of model.buses) neighbours.set(bus.code, [])
  for (const branch of model.branches) {
    if (branch.outOfService) continue
    neighbours.get(branch.from)?.push(branch.to)
    neighbours.get(branch.to)?.push(branch.from)
  }

  const byCode = new Map(model.buses.map((bus) => [bus.code, bus]))
  const seen = new Set<string>()
  const islands: NetworkIsland[] = []

  for (const bus of model.buses) {
    if (seen.has(bus.code)) continue

    const codes: string[] = []
    const queue = [bus.code]
    seen.add(bus.code)
    while (queue.length > 0) {
      const code = queue.shift()!
      codes.push(code)
      for (const next of neighbours.get(code) ?? []) {
        if (seen.has(next)) continue
        seen.add(next)
        queue.push(next)
      }
    }

    const members = new Set(codes)
    const buses = model.buses.filter((entry) => members.has(entry.code))

    // The reference for this island: whichever bus is already typed slack, else the
    // largest generator in it. Every other generator stays PV.
    const declaredSlack = buses.find((entry) => entry.type === 'slack')
    const generators = buses
      .filter((entry) => entry.type === 'pv' || entry.type === 'slack')
      .sort((a, b) => Math.abs(b.pMw) - Math.abs(a.pMw))
    const slack = declaredSlack ?? generators[0] ?? null

    // ── Dispatch ──────────────────────────────────────────────────────────
    //
    // A load flow needs generation that matches demand. The telemetry samples each plant's
    // output on its own, so an area holding three large stations and one small load is
    // scheduled to produce five times what it consumes — and the reference bus would have
    // to absorb the difference, which is not an operating point any network has.
    //
    // Scheduled generation is therefore scaled to the island's own demand plus a loss
    // allowance, in proportion to what each machine was sampled producing, so the merit
    // order survives. The reference bus is left free to take up the remainder, which is
    // what a reference bus is for. `dispatchScale` reports what was done.
    const demandMw = buses.reduce((sum, entry) => sum + Math.max(0, -entry.pMw), 0)
    const scheduledMw = buses
      .filter((entry) => entry.code !== slack?.code)
      .reduce((sum, entry) => sum + Math.max(0, entry.pMw), 0)

    // Three per cent covers losses on a well-built network and is generous on a poor one;
    // the reference bus absorbs whatever the estimate misses by.
    const target = demandMw * 1.03
    const dispatchScale =
      scheduledMw > target && scheduledMw > 0 ? round(target / scheduledMw, 6) : 1

    const islandBuses = buses.map((entry) => {
      if (!slack) return { ...entry, type: 'pq' as const }
      if (entry.code === slack.code) {
        return { ...entry, type: 'slack' as const, vSetPu: entry.vSetPu ?? 1.02 }
      }

      const scaled =
        dispatchScale < 1 && entry.pMw > 0
          ? {
              ...entry,
              pMw: round(entry.pMw * dispatchScale, 4),
              qMvar: round(entry.qMvar * dispatchScale, 4),
            }
          : entry

      // A bus that was the global slack but is not this island's reference becomes a
      // generator again rather than staying a second reference.
      return scaled.type === 'slack' ? { ...scaled, type: 'pv' as const } : scaled
    })

    islands.push({
      codes,
      model: {
        baseMva: model.baseMva,
        buses: islandBuses,
        branches: model.branches.filter(
          (branch) => members.has(branch.from) && members.has(branch.to),
        ),
      },
      slackCode: slack?.code ?? null,
      energised: Boolean(slack),
      generationMw: round(
        islandBuses.reduce((sum, entry) => sum + Math.max(0, entry.pMw), 0),
        3,
      ),
      demandMw: round(demandMw, 3),
      dispatchScale,
    })

    void byCode
  }

  // Largest first: the main system is the one worth looking at.
  return islands.sort((a, b) => b.codes.length - a.codes.length)
}
