/**
 * The uncertainty engine (§14, §15, §16, §17).
 *
 * A prediction with one number attached to it — "failure in 43 minutes" — is a claim the
 * system cannot support and an operator cannot act on proportionately. What they need is
 * the shape of the belief: how likely, how soon, how wide the window, and *why* the
 * confidence is what it is.
 *
 * The design rule here is that confidence is **capped by its weakest input, not averaged
 * with it**. A model in perfect health fed by three dead sensors is not 80 % confident;
 * it is as confident as its data, which is to say not very. Averaging lets a strong
 * component paper over a fatal one, which is exactly the failure §17 is written to
 * prevent.
 *
 * Pure functions only.
 */

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

// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────

export interface UncertaintyInput {
  /** Risk score now, 0–100. */
  currentRisk: number
  /** Peak risk over the prediction horizon, 0–100. */
  peakRisk: number
  /** Minutes to the modelled limit crossing, or null when no crossing is predicted. */
  etaMinutes: number | null
  /** The prediction engine's own confidence, 0–100. */
  modelConfidence: number
  /** Data quality for this asset, 0–100. */
  dataConfidence: number
  /** Twin fidelity for the twin covering this asset, 0–100. */
  twinFidelity: number
  /** Physical plausibility of the predicted trajectory, 0–100. */
  physicsConfidence: number
  /** Best Failure-DNA similarity, 0–100. Zero when nothing matched. */
  dnaSimilarity: number
  /** Sensors that should be reporting for this asset but are not. */
  missingSensors: number
  /** Sensors reporting but not trusted. */
  untrustedSensors: number
  /** How much recorded history the asset has, 0–100. */
  historicalCoverage: number
  /** Agreement between the specialist agents, 0–100. */
  agentConsensus?: number
}

export interface ConfidenceComponent {
  key:
    | 'data_quality'
    | 'model_performance'
    | 'dna_match'
    | 'physics'
    | 'historical_coverage'
    | 'twin_fidelity'
    | 'agent_consensus'
  value: number
  weight: number
  label: string
  labelAr: string
}

export interface UncertaintyResult {
  /** Probability the event occurs at all within the horizon, 0–100. */
  probability: number
  /** Central estimate, minutes. Null when nothing is predicted to happen. */
  expectedMinutes: number | null
  /** The likely window. Equal to the estimate when no window can be formed. */
  windowLowMinutes: number | null
  windowHighMinutes: number | null
  /** The four confidences §15 asks to be shown separately. */
  predictionConfidence: number
  dataConfidence: number
  modelConfidence: number
  physicsConfidence: number
  twinFidelity: number
  /** What the confidence would have been before degradation, and the gap (§17). */
  rawConfidence: number
  degradedBy: number
  degradeReason: string
  degradeReasonAr: string
  /** The breakdown behind "why this confidence?" (§16). */
  components: ConfidenceComponent[]
}

// ─────────────────────────────────────────────────────────────────────────────
// Weighting
// ─────────────────────────────────────────────────────────────────────────────

const WEIGHTS: Record<ConfidenceComponent['key'], number> = {
  data_quality: 0.24,
  model_performance: 0.2,
  dna_match: 0.14,
  physics: 0.16,
  historical_coverage: 0.08,
  twin_fidelity: 0.13,
  agent_consensus: 0.05,
}

const LABELS: Record<ConfidenceComponent['key'], [string, string]> = {
  data_quality: ['Data quality', 'جودة البيانات'],
  model_performance: ['Model performance', 'أداء النموذج'],
  dna_match: ['Failure DNA match', 'تطابق بصمة العطل'],
  physics: ['Physics consistency', 'الاتساق الفيزيائي'],
  historical_coverage: ['Historical coverage', 'التغطية التاريخية'],
  twin_fidelity: ['Twin fidelity', 'دقة التوأم'],
  agent_consensus: ['Agent consensus', 'توافق الوكلاء'],
}

/**
 * How wide the window is, as a share of the estimate.
 *
 * Two things make it wider: low confidence, and distance. A crossing predicted in six
 * minutes is being extrapolated a short way from data that exists; a crossing predicted
 * in twelve hours is mostly a statement about the load shape, and pretending to
 * five-minute precision there would be false.
 */
function relativeWidth(confidence: number, etaMinutes: number): number {
  const fromConfidence = 0.12 + (1 - clamp(confidence / 100, 0, 1)) * 0.55
  const fromHorizon = clamp(etaMinutes / 720, 0, 1) * 0.3
  return clamp(fromConfidence + fromHorizon, 0.1, 0.85)
}

/**
 * Turn the risk trajectory into a probability.
 *
 * Peak risk is the driver — a peak of 90 with a current of 40 is a strong statement that
 * something is coming — but a prediction with no modelled crossing is capped well below
 * certainty no matter how high the risk climbs, because "risk is rising" and "the limit
 * will be crossed" are different claims.
 */
function eventProbability(input: UncertaintyInput): number {
  const { peakRisk, currentRisk, etaMinutes, dnaSimilarity } = input
  const base = clamp(peakRisk / 100, 0, 1)
  const momentum = clamp((peakRisk - currentRisk) / 60, 0, 1) * 0.15
  const dna = clamp(dnaSimilarity / 100, 0, 1) * 0.12
  const raw = base * 0.82 + momentum + dna
  const ceiling = etaMinutes === null ? 0.6 : 0.97
  return round(clamp(raw, 0, ceiling) * 100, 1)
}

// ─────────────────────────────────────────────────────────────────────────────
// The estimator
// ─────────────────────────────────────────────────────────────────────────────

export function estimateUncertainty(input: UncertaintyInput): UncertaintyResult {
  const components: ConfidenceComponent[] = (
    [
      ['data_quality', input.dataConfidence],
      ['model_performance', input.modelConfidence],
      ['dna_match', input.dnaSimilarity],
      ['physics', input.physicsConfidence],
      ['historical_coverage', input.historicalCoverage],
      ['twin_fidelity', input.twinFidelity],
      ['agent_consensus', input.agentConsensus ?? 70],
    ] as Array<[ConfidenceComponent['key'], number]>
  ).map(([key, value]) => ({
    key,
    value: round(clamp(value, 0, 100), 1),
    weight: WEIGHTS[key],
    label: LABELS[key][0],
    labelAr: LABELS[key][1],
  }))

  const weighted = components.reduce((sum, c) => sum + c.value * c.weight, 0)
  const rawConfidence = round(clamp(weighted, 0, 100), 1)

  // ── Degradation (§17) ──────────────────────────────────────────────────────
  // Missing instrumentation is not a small deduction from an average. Three critical
  // sensors offline means the asset is being inferred rather than observed, and the
  // number shown to an operator has to fall far enough that they notice.
  const reasons: Array<{ points: number; en: string; ar: string }> = []

  if (input.missingSensors > 0) {
    const points = Math.min(30, input.missingSensors * 9)
    reasons.push({
      points,
      en: `${input.missingSensors} critical sensor${input.missingSensors > 1 ? 's' : ''} unavailable`,
      ar: `${input.missingSensors} حساس حرج غير متاح`,
    })
  }
  if (input.untrustedSensors > 0) {
    const points = Math.min(14, input.untrustedSensors * 5)
    reasons.push({
      points,
      en: `${input.untrustedSensors} sensor${input.untrustedSensors > 1 ? 's' : ''} below the trust threshold`,
      ar: `${input.untrustedSensors} حساس دون عتبة الثقة`,
    })
  }
  if (input.twinFidelity < 60) {
    reasons.push({
      points: round((60 - input.twinFidelity) * 0.4, 1),
      en: `Twin fidelity of ${round(input.twinFidelity, 0)} % limits what the model can be checked against`,
      ar: `دقة التوأم ${round(input.twinFidelity, 0)}٪ تحدّ مما يمكن التحقق منه`,
    })
  }
  if (input.physicsConfidence < 70) {
    reasons.push({
      points: round((70 - input.physicsConfidence) * 0.45, 1),
      en: 'Predicted trajectory sits near the edge of the physical operating range',
      ar: 'المسار المتوقع قريب من حافة النطاق التشغيلي الفيزيائي',
    })
  }

  const degradedBy = round(
    Math.min(
      rawConfidence,
      reasons.reduce((sum, r) => sum + r.points, 0),
    ),
    1,
  )

  // The weakest-link cap. Even with no explicit degradation reason, confidence cannot
  // exceed the weakest heavily-weighted input by more than a small margin.
  const structural = Math.min(input.dataConfidence, input.modelConfidence, input.physicsConfidence)
  const capped = Math.min(rawConfidence - degradedBy, structural + 10)
  const predictionConfidence = round(clamp(capped, 0, 100), 1)

  const reason = reasons.sort((a, b) => b.points - a.points)[0]

  const probability = eventProbability(input)
  const eta = input.etaMinutes

  let windowLow: number | null = null
  let windowHigh: number | null = null
  if (eta !== null) {
    const width = relativeWidth(predictionConfidence, eta)
    // Asymmetric on purpose. Events of this kind arrive late more often than early —
    // load flattens, a shift ends, the wind picks up — so the upper half of the window
    // is wider than the lower.
    //
    // The floor is applied to the *width*, not to the lower bound: flooring the lower
    // bound at one minute while the estimate is zero produced an inverted window
    // ("between 1 and 0 minutes") for the case that matters most — an asset already past
    // its limit. A zero estimate is a real answer, and its window has to contain it.
    const spread = Math.max(eta * width, 1)
    windowLow = round(Math.max(0, eta - spread * 0.62), 0)
    windowHigh = round(eta + spread * 0.88, 0)
  }

  return {
    probability,
    expectedMinutes: eta === null ? null : round(eta, 0),
    windowLowMinutes: windowLow,
    windowHighMinutes: windowHigh,
    predictionConfidence,
    dataConfidence: round(clamp(input.dataConfidence, 0, 100), 1),
    modelConfidence: round(clamp(input.modelConfidence, 0, 100), 1),
    physicsConfidence: round(clamp(input.physicsConfidence, 0, 100), 1),
    twinFidelity: round(clamp(input.twinFidelity, 0, 100), 1),
    rawConfidence,
    degradedBy,
    degradeReason: reason?.en ?? '',
    degradeReasonAr: reason?.ar ?? '',
    components,
  }
}

/**
 * Calibration: does an 80 %-confidence claim come true 80 % of the time?
 *
 * A model can be accurate and badly calibrated at the same time — right most of the
 * time, but claiming 95 % when it earns 70 %. That gap is what makes a confidence
 * figure trustworthy or decorative, so it is measured rather than assumed (§85).
 */
export interface CalibrationBin {
  /** Lower edge of the confidence bucket, e.g. 70 for the 70–80 band. */
  lower: number
  upper: number
  claimed: number
  observed: number
  sampleCount: number
}

export interface CalibrationReport {
  bins: CalibrationBin[]
  /** Mean |claimed − observed| across populated bins. Lower is better. */
  meanGap: number
  /** Positive means the system claims more confidence than it earns. */
  bias: number
  sampleCount: number
}

export function assessCalibration(
  samples: Array<{ confidence: number; correct: boolean }>,
): CalibrationReport {
  const edges = [0, 50, 60, 70, 80, 90, 100]
  const bins: CalibrationBin[] = []

  for (let i = 0; i < edges.length - 1; i += 1) {
    const lower = edges[i]
    const upper = edges[i + 1]
    const inBin = samples.filter(
      (s) => s.confidence >= lower && (upper === 100 ? s.confidence <= upper : s.confidence < upper),
    )
    if (!inBin.length) continue
    const claimed = inBin.reduce((sum, s) => sum + s.confidence, 0) / inBin.length
    const observed = (inBin.filter((s) => s.correct).length / inBin.length) * 100
    bins.push({
      lower,
      upper,
      claimed: round(claimed, 1),
      observed: round(observed, 1),
      sampleCount: inBin.length,
    })
  }

  const meanGap = bins.length
    ? round(bins.reduce((sum, b) => sum + Math.abs(b.claimed - b.observed), 0) / bins.length, 1)
    : 0
  // Weighted by sample count so a bin holding two points does not swing the verdict.
  const totalWeight = bins.reduce((sum, b) => sum + b.sampleCount, 0)
  const bias = totalWeight
    ? round(
        bins.reduce((sum, b) => sum + (b.claimed - b.observed) * b.sampleCount, 0) / totalWeight,
        1,
      )
    : 0

  return { bins, meanGap, bias, sampleCount: samples.length }
}
