/**
 * The twin verification loop (§6, §7, §8, §54, §63).
 *
 * This is the module that makes the rest of the platform falsifiable. Everything else
 * predicts; this compares the prediction to what was actually observed and reports the
 * difference — including when the difference is embarrassing.
 *
 * Two rules shape it:
 *
 *   1. **A prediction that was never checked is not a success.** The metrics below count
 *      pending and expired verifications explicitly, because a coverage figure computed
 *      only over the predictions that happened to be verified is a survivorship figure.
 *
 *   2. **Error is reported per channel.** "The model was 1.6 % out" is a comfortable
 *      average that can hide being 0.2 °C out on temperature and 14 % out on load. The
 *      channels are kept apart and the worst one is named.
 *
 * Pure functions. The service layer supplies rows; nothing here reads a clock or a
 * database.
 */

import { clamp, round } from './rng'

// ─────────────────────────────────────────────────────────────────────────────
// One comparison
// ─────────────────────────────────────────────────────────────────────────────

export interface ChannelPrediction {
  channel: string
  unit: string
  predicted: number
  /** The stated band, when the prediction carried one. */
  intervalLow?: number | null
  intervalHigh?: number | null
}

export interface ChannelObservation {
  channel: string
  observed: number
}

export interface ChannelError {
  channel: string
  unit: string
  predicted: number
  observed: number
  absError: number
  pctError: number
  intervalLow: number | null
  intervalHigh: number | null
  withinInterval: boolean
}

export interface VerificationComparison {
  channels: ChannelError[]
  /** Mean absolute error across channels, in each channel's own unit — see note below. */
  meanAbsError: number
  /** Mean absolute *percentage* error, which is the only cross-channel comparable. */
  meanAbsPctError: number
  worstChannel: string | null
  worstError: number
  /** Signed mean percentage error. Positive = the model ran hot. */
  bias: number
  /** True when every channel that stated a band landed inside it. */
  withinInterval: boolean
  /** Share of channels that landed inside their stated band, 0–100. */
  coveragePct: number
}

/**
 * Compare one prediction against one observation.
 *
 * `meanAbsError` mixes units by construction — it averages °C with % — and is kept only
 * because a single headline number is genuinely useful when every channel happens to be
 * a percentage. `meanAbsPctError` is the figure to compare across assets.
 */
export function compareToObservation(
  predictions: ChannelPrediction[],
  observations: ChannelObservation[],
): VerificationComparison {
  const observedBy = new Map(observations.map((o) => [o.channel, o.observed]))
  const channels: ChannelError[] = []

  for (const p of predictions) {
    const observed = observedBy.get(p.channel)
    if (observed === undefined) continue

    const absError = Math.abs(p.predicted - observed)
    // Percentage error needs a non-zero base. A channel legitimately sitting at zero
    // (a battery at rest) would otherwise produce an infinite error, so the scale falls
    // back to the prediction's own magnitude, and then to 1.
    const base = Math.abs(observed) > 0.5 ? Math.abs(observed) : Math.max(Math.abs(p.predicted), 1)
    const low = p.intervalLow ?? null
    const high = p.intervalHigh ?? null
    const stated = low !== null && high !== null

    channels.push({
      channel: p.channel,
      unit: p.unit,
      predicted: round(p.predicted, 2),
      observed: round(observed, 2),
      absError: round(absError, 3),
      pctError: round((absError / base) * 100, 2),
      intervalLow: low,
      intervalHigh: high,
      withinInterval: stated ? observed >= low && observed <= high : false,
    })
  }

  if (!channels.length) {
    return {
      channels: [],
      meanAbsError: 0,
      meanAbsPctError: 0,
      worstChannel: null,
      worstError: 0,
      bias: 0,
      withinInterval: false,
      coveragePct: 0,
    }
  }

  const meanAbsError = channels.reduce((s, c) => s + c.absError, 0) / channels.length
  const meanAbsPctError = channels.reduce((s, c) => s + c.pctError, 0) / channels.length
  const worst = channels.reduce((a, b) => (b.pctError > a.pctError ? b : a))

  const bias =
    channels.reduce((s, c) => {
      const base = Math.abs(c.observed) > 0.5 ? Math.abs(c.observed) : 1
      return s + ((c.predicted - c.observed) / base) * 100
    }, 0) / channels.length

  const stated = channels.filter((c) => c.intervalLow !== null && c.intervalHigh !== null)
  const inside = stated.filter((c) => c.withinInterval)

  return {
    channels,
    meanAbsError: round(meanAbsError, 3),
    meanAbsPctError: round(meanAbsPctError, 2),
    worstChannel: worst.channel,
    worstError: round(worst.pctError, 2),
    bias: round(bias, 2),
    withinInterval: stated.length > 0 && inside.length === stated.length,
    coveragePct: stated.length ? round((inside.length / stated.length) * 100, 1) : 0,
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Rolled-up quality (§63)
// ─────────────────────────────────────────────────────────────────────────────

export interface VerificationSample {
  status: 'pending' | 'verified' | 'expired' | 'superseded'
  meanAbsPctError: number | null
  bias: number | null
  withinInterval: boolean | null
  predictedConfidence: number
  /** Did the predicted event actually occur? Null while unresolved. */
  eventPredicted: boolean
  eventObserved: boolean | null
  leadMinutes: number | null
  fidelity: number
}

export interface EarlyWarningQuality {
  sampleCount: number
  verifiedCount: number
  pendingCount: number
  expiredCount: number
  meanAbsPctError: number
  bias: number
  coveragePct: number
  calibrationGap: number
  leadMinutesMean: number
  /** Predicted an event, none occurred. */
  falsePositiveRate: number
  /** An event occurred that was not predicted. */
  missRate: number
  precision: number
  fidelityMean: number
  /** True when too little has been verified for the figures above to mean anything. */
  underpowered: boolean
}

/**
 * The metric block behind /verification.
 *
 * `underpowered` exists because a false-positive rate computed over four samples is
 * noise wearing a percentage sign, and a demo that presents it as a performance claim
 * is doing exactly what §63 forbids.
 */
export function assessEarlyWarning(samples: VerificationSample[]): EarlyWarningQuality {
  const verified = samples.filter((s) => s.status === 'verified')
  const resolved = verified.filter((s) => s.eventObserved !== null)

  const mean = (values: number[]) =>
    values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0

  const errors = verified.map((s) => s.meanAbsPctError ?? 0)
  const biases = verified.map((s) => s.bias ?? 0)
  const stated = verified.filter((s) => s.withinInterval !== null)
  const covered = stated.filter((s) => s.withinInterval === true)

  const truePositive = resolved.filter((s) => s.eventPredicted && s.eventObserved).length
  const falsePositive = resolved.filter((s) => s.eventPredicted && !s.eventObserved).length
  const falseNegative = resolved.filter((s) => !s.eventPredicted && s.eventObserved).length

  const coveragePct = stated.length ? (covered.length / stated.length) * 100 : 0
  const claimedMean = mean(verified.map((s) => s.predictedConfidence))

  const leads = verified.map((s) => s.leadMinutes).filter((v): v is number => v !== null)

  return {
    sampleCount: samples.length,
    verifiedCount: verified.length,
    pendingCount: samples.filter((s) => s.status === 'pending').length,
    expiredCount: samples.filter((s) => s.status === 'expired').length,
    meanAbsPctError: round(mean(errors), 2),
    bias: round(mean(biases), 2),
    coveragePct: round(coveragePct, 1),
    // Positive means the system claimed more confidence than its intervals earned.
    calibrationGap: round(claimedMean - coveragePct, 1),
    leadMinutesMean: round(mean(leads), 1),
    falsePositiveRate: round(
      truePositive + falsePositive ? (falsePositive / (truePositive + falsePositive)) * 100 : 0,
      1,
    ),
    missRate: round(
      truePositive + falseNegative ? (falseNegative / (truePositive + falseNegative)) * 100 : 0,
      1,
    ),
    precision: round(
      truePositive + falsePositive ? (truePositive / (truePositive + falsePositive)) * 100 : 0,
      1,
    ),
    fidelityMean: round(mean(samples.map((s) => s.fidelity)), 1),
    underpowered: resolved.length < 12,
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Fidelity feedback (§6)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Move the twin's fidelity in response to a verification result.
 *
 * Deliberately asymmetric and slow: a single good comparison should nudge fidelity up a
 * fraction of a point, while a badly wrong one should cost more. A twin that jumps to
 * 99 % after one lucky match is a twin whose fidelity number means nothing.
 */
export function updateFidelity(current: number, meanAbsPctError: number): number {
  // Under 2 % error the twin is tracking; over 10 % it is not.
  const quality = clamp(1 - (meanAbsPctError - 2) / 8, 0, 1)
  const delta = quality > 0.5 ? (quality - 0.5) * 1.6 : (quality - 0.5) * 4.2
  return round(clamp(current + delta, 0, 100), 1)
}
