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

/**
 * The sensor layer (5.0 §4–§7).
 *
 * Three things live here, and one rule governs all of them.
 *
 * **The rule: a sensor anomaly is a statement about the data, never about the plant.** A
 * frozen reading means the reading stopped changing. It does not mean the transformer
 * stopped changing — it means the platform no longer knows. Every finding this file
 * produces is typed `data_anomaly`, and promoting one to an electrical conclusion requires
 * evidence from somewhere else. Getting this backwards is how a monitoring system
 * generates its own emergency.
 *
 * The three things:
 *
 * 1. **Health detection** over a window of readings — frozen, drift, spike, noise, packet
 *    loss, staleness, timestamp drift, impossible values, disagreement between sensors
 *    measuring the same quantity, and calibration deviation.
 * 2. **Fault injection**, for demonstration and simulation only. The kinds a real feed
 *    fails in, applied to a synthetic stream so the platform's response to a failing
 *    instrument can be watched.
 * 3. **Provenance**, the chain from an instrument to a recommendation, so "which sensors
 *    are behind this decision" has an answer rather than an assurance.
 */

// ─────────────────────────────────────────────────────────────────────────────
// Sensor kinds
// ─────────────────────────────────────────────────────────────────────────────

export const SENSOR_KINDS = [
  'voltage',
  'current',
  'temperature',
  'frequency',
  'power_meter',
  'protection_relay',
  'weather',
  'vibration',
  'partial_discharge',
] as const

export type SensorKind = (typeof SENSOR_KINDS)[number]

/** Canonical unit and the range outside which a reading is not a measurement. */
export const SENSOR_SPEC: Record<SensorKind, { unit: string; min: number; max: number; label: string; labelAr: string }> = {
  voltage: { unit: 'kV', min: 0, max: 800, label: 'Voltage', labelAr: 'الجهد' },
  current: { unit: 'A', min: 0, max: 8_000, label: 'Current', labelAr: 'التيار' },
  temperature: { unit: '°C', min: -40, max: 200, label: 'Temperature', labelAr: 'الحرارة' },
  frequency: { unit: 'Hz', min: 45, max: 65, label: 'Frequency', labelAr: 'التردد' },
  power_meter: { unit: 'MW', min: -5_000, max: 5_000, label: 'Power', labelAr: 'القدرة' },
  protection_relay: { unit: 'state', min: 0, max: 1, label: 'Protection', labelAr: 'الحماية' },
  weather: { unit: '°C', min: -30, max: 70, label: 'Weather', labelAr: 'الطقس' },
  vibration: { unit: 'mm/s', min: 0, max: 30, label: 'Vibration', labelAr: 'الاهتزاز' },
  partial_discharge: { unit: 'pC', min: 0, max: 10_000, label: 'Partial discharge', labelAr: 'التفريغ الجزئي' },
}

// ─────────────────────────────────────────────────────────────────────────────
// Health detection (§6)
// ─────────────────────────────────────────────────────────────────────────────

export type AnomalyKind =
  | 'frozen'
  | 'drift'
  | 'spike'
  | 'noise'
  | 'packet_loss'
  | 'stale'
  | 'timestamp_drift'
  | 'impossible'
  | 'disagreement'
  | 'calibration_deviation'

export interface SensorAnomaly {
  kind: AnomalyKind
  /**
   * Always `data_anomaly`. Present as a field rather than implied, so a consumer has to
   * read it — and so the day an electrical classification is added, every existing caller
   * keeps working and the new one is explicit.
   */
  classification: 'data_anomaly'
  severity: 'info' | 'warning' | 'critical'
  /** 0–100. How much this finding should reduce trust in the instrument. */
  confidencePenalty: number
  message: string
  messageAr: string
  detail: Record<string, number | string>
}

export interface Reading {
  ts: number
  value: number
  /** When the platform received it, if that differs from when it was measured. */
  receivedTs?: number
}

export interface SensorWindow {
  sensorCode: string
  kind: SensorKind
  unit: string
  readings: Reading[]
  /** Expected reporting interval in minutes. Freshness is judged against this, not a constant. */
  samplingIntervalMin: number
  /** The instant the window was evaluated at. Passed in, never read from the clock. */
  now: number
  /** Declared accuracy, in the sensor's own unit. */
  accuracy?: number
  lastCalibratedAt?: number | null
  /** Readings from other instruments measuring the same quantity on the same asset. */
  peers?: Array<{ sensorCode: string; value: number }>
}

export interface SensorHealth {
  sensorCode: string
  /** 0–100. Falls as anomalies accumulate, capped by the worst single one. */
  healthScore: number
  status: 'healthy' | 'noisy' | 'drifting' | 'stale' | 'offline' | 'low_trust'
  anomalies: SensorAnomaly[]
  /** Minutes since the most recent reading. */
  dataAgeMin: number
  /** Fraction of expected readings that actually arrived, 0–100. */
  completenessPct: number
  latest: Reading | null
  /** Present only when the window holds enough points to say anything. */
  meanValue: number | null
  stdDev: number | null
}

const FROZEN_MIN_POINTS = 5
const NOISE_MIN_POINTS = 8

/** Middle value. Unlike a mean, a handful of wild readings cannot move it. */
function median(values: number[]): number {
  if (values.length === 0) return 0
  const sorted = [...values].sort((a, b) => a - b)
  const middle = Math.floor(sorted.length / 2)
  return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}

/**
 * Median absolute deviation, scaled to be comparable with a standard deviation.
 *
 * The estimator spike detection needs: a stream containing large spikes has a large
 * standard deviation *because of them*, so nothing in it ever looks like an outlier. The
 * MAD is unmoved by up to half the sample being wrong.
 */
function madScaled(values: number[]): number {
  if (values.length === 0) return 0
  const centre = median(values)
  return 1.4826 * median(values.map((value) => Math.abs(value - centre)))
}

/**
 * Scatter of consecutive differences.
 *
 * Insensitive to a slow ramp — successive differences on a straight line are constant —
 * and sensitive to noise. That is what makes it the right yardstick for drift: it measures
 * everything the drift is not.
 */
function differenceScatter(values: number[]): number {
  if (values.length < 3) return 0
  const differences: number[] = []
  for (let i = 1; i < values.length; i += 1) differences.push(values[i] - values[i - 1])
  const mean = differences.reduce((sum, value) => sum + value, 0) / differences.length
  return Math.sqrt(
    differences.reduce((sum, value) => sum + (value - mean) ** 2, 0) / Math.max(1, differences.length - 1),
  )
}

function anomaly(
  kind: AnomalyKind,
  severity: SensorAnomaly['severity'],
  confidencePenalty: number,
  message: string,
  messageAr: string,
  detail: Record<string, number | string> = {},
): SensorAnomaly {
  return { kind, classification: 'data_anomaly', severity, confidencePenalty, message, messageAr, detail }
}

/**
 * Assess one instrument over a window.
 *
 * Every threshold is relative to the sensor's own declared behaviour — its sampling
 * interval, its accuracy, its range — rather than to a fleet-wide constant. A weather
 * station reporting hourly is not stale at fifty minutes and a vibration probe reporting
 * every ten seconds is.
 */
export function assessSensorHealth(window: SensorWindow): SensorHealth {
  const spec = SENSOR_SPEC[window.kind]
  const readings = [...window.readings].sort((a, b) => a.ts - b.ts)
  const anomalies: SensorAnomaly[] = []

  const latest = readings[readings.length - 1] ?? null
  const dataAgeMin = latest ? round((window.now - latest.ts) / 60_000, 2) : Number.POSITIVE_INFINITY

  // ── Staleness ─────────────────────────────────────────────────────────────
  const staleAfterMin = Math.max(1, window.samplingIntervalMin) * 3
  if (!latest) {
    anomalies.push(
      anomaly('stale', 'critical', 100, 'No readings at all in this window.', 'لا توجد قراءات إطلاقًا في هذه النافذة.'),
    )
  } else if (dataAgeMin > staleAfterMin) {
    anomalies.push(
      anomaly(
        'stale',
        dataAgeMin > staleAfterMin * 3 ? 'critical' : 'warning',
        clamp((dataAgeMin / staleAfterMin) * 25, 10, 90),
        `Last reading is ${round(dataAgeMin, 1)} minutes old against an expected ${window.samplingIntervalMin}-minute cadence.`,
        `آخر قراءة عمرها ${round(dataAgeMin, 1)} دقيقة مقابل وتيرة متوقعة كل ${window.samplingIntervalMin} دقيقة.`,
        { dataAgeMin: round(dataAgeMin, 2), staleAfterMin },
      ),
    )
  }

  // ── Packet loss ───────────────────────────────────────────────────────────
  const spanMin = readings.length > 1 ? (readings[readings.length - 1].ts - readings[0].ts) / 60_000 : 0
  const expected = spanMin > 0 ? Math.max(1, Math.round(spanMin / Math.max(0.1, window.samplingIntervalMin)) + 1) : readings.length
  const completenessPct = expected > 0 ? round(clamp((readings.length / expected) * 100, 0, 100), 1) : 0

  if (expected > 3 && completenessPct < 85) {
    anomalies.push(
      anomaly(
        'packet_loss',
        completenessPct < 50 ? 'critical' : 'warning',
        clamp(100 - completenessPct, 10, 80),
        `${readings.length} of about ${expected} expected readings arrived.`,
        `وصلت ${readings.length} قراءة من نحو ${expected} متوقعة.`,
        { received: readings.length, expected, completenessPct },
      ),
    )
  }

  // ── Timestamp integrity ───────────────────────────────────────────────────
  const future = readings.filter((reading) => reading.ts > window.now + 60_000)
  if (future.length > 0) {
    anomalies.push(
      anomaly(
        'timestamp_drift',
        'warning',
        35,
        `${future.length} readings are stamped in the future. The instrument's clock is wrong, so its ordering cannot be trusted.`,
        `${future.length} قراءة مؤرخة في المستقبل. ساعة الجهاز خاطئة، فلا يمكن الوثوق بترتيبه.`,
        { count: future.length },
      ),
    )
  }

  const lagged = readings.filter(
    (reading) => reading.receivedTs !== undefined && reading.receivedTs - reading.ts > window.samplingIntervalMin * 60_000 * 4,
  )
  if (lagged.length > 0) {
    anomalies.push(
      anomaly(
        'timestamp_drift',
        'info',
        12,
        `${lagged.length} readings arrived far later than they were measured.`,
        `${lagged.length} قراءة وصلت متأخرة كثيرًا عن وقت قياسها.`,
        { count: lagged.length },
      ),
    )
  }

  // ── Impossible values ─────────────────────────────────────────────────────
  const impossible = readings.filter(
    (reading) => !Number.isFinite(reading.value) || reading.value < spec.min || reading.value > spec.max,
  )
  if (impossible.length > 0) {
    anomalies.push(
      anomaly(
        'impossible',
        'critical',
        70,
        `${impossible.length} readings fall outside what this instrument can physically measure.`,
        `${impossible.length} قراءة خارج ما يمكن لهذا الجهاز قياسه فيزيائيًا.`,
        { count: impossible.length, min: spec.min, max: spec.max },
      ),
    )
  }

  // ── Statistics over the plausible readings only ───────────────────────────
  const values = readings
    .map((reading) => reading.value)
    .filter((value) => Number.isFinite(value) && value >= spec.min && value <= spec.max)

  const meanValue = values.length > 0 ? round(values.reduce((sum, value) => sum + value, 0) / values.length, 4) : null
  const stdDev =
    values.length > 1 && meanValue !== null
      ? round(
          Math.sqrt(values.reduce((sum, value) => sum + (value - meanValue) ** 2, 0) / (values.length - 1)),
          5,
        )
      : null

  // ── Frozen ────────────────────────────────────────────────────────────────
  // A reading that has not moved by more than the instrument can resolve is not a steady
  // reading; it is an instrument that has stopped reporting a new one.
  const resolution = Math.max(window.accuracy ?? 0.01, (spec.max - spec.min) * 1e-5)
  if (values.length >= FROZEN_MIN_POINTS) {
    const tail = values.slice(-FROZEN_MIN_POINTS)
    const spread = Math.max(...tail) - Math.min(...tail)
    if (spread < resolution) {
      anomalies.push(
        anomaly(
          'frozen',
          'warning',
          55,
          `The last ${FROZEN_MIN_POINTS} readings vary by less than the instrument can resolve. The value is not steady — it has stopped changing.`,
          `آخر ${FROZEN_MIN_POINTS} قراءات تتغير بأقل مما يستطيع الجهاز تمييزه. القيمة ليست مستقرة — بل توقفت عن التغير.`,
          { spread: round(spread, 6), resolution: round(resolution, 6) },
        ),
      )
    }
  }

  // ── Noise ─────────────────────────────────────────────────────────────────
  if (values.length >= NOISE_MIN_POINTS && stdDev !== null && meanValue !== null) {
    const relative = Math.abs(meanValue) > 1e-6 ? stdDev / Math.abs(meanValue) : stdDev
    if (relative > 0.15) {
      anomalies.push(
        anomaly(
          'noise',
          relative > 0.3 ? 'warning' : 'info',
          clamp(relative * 120, 10, 60),
          `Scatter of ${round(relative * 100, 1)} % around the mean, well beyond this instrument's stated accuracy.`,
          `تشتت بنسبة ${round(relative * 100, 1)}٪ حول المتوسط، أبعد بكثير من دقة الجهاز المعلنة.`,
          { relativeStdDev: round(relative, 4) },
        ),
      )
    }
  }

  // ── Spike ─────────────────────────────────────────────────────────────────
  // Measured against the median and the MAD rather than the mean and the standard
  // deviation: spikes inflate a standard deviation until nothing exceeds it, which is how
  // a spiky stream hides its own spikes.
  if (values.length >= NOISE_MIN_POINTS) {
    const centre = median(values)
    const scatter = madScaled(values)
    if (scatter > 0) {
      const outliers = values.filter((value) => Math.abs(value - centre) > 6 * scatter)
      if (outliers.length > 0) {
        anomalies.push(
          anomaly(
            'spike',
            'warning',
            40,
            `${outliers.length} readings sit more than six robust deviations from the median.`,
            `${outliers.length} قراءة تبعد أكثر من ستة انحرافات متينة عن الوسيط.`,
            { count: outliers.length, median: round(centre, 4), mad: round(scatter, 5) },
          ),
        )
      }
    }
  }

  // ── Drift ─────────────────────────────────────────────────────────────────
  // A steady one-directional march. Measured against the scatter of *consecutive
  // differences*, not against the scatter of the values: a drifting signal's variance is
  // dominated by the drift, so comparing the shift to it would be comparing the drift to
  // itself. Successive differences on a ramp are near-constant, which makes their scatter
  // a measure of everything the drift is not.
  if (values.length >= NOISE_MIN_POINTS) {
    const half = Math.floor(values.length / 2)
    const early = values.slice(0, half)
    const late = values.slice(-half)
    const earlyMean = early.reduce((sum, value) => sum + value, 0) / early.length
    const lateMean = late.reduce((sum, value) => sum + value, 0) / late.length
    const shift = Math.abs(lateMean - earlyMean)
    const stepScatter = differenceScatter(values)
    // The floor keeps a perfectly clean ramp — where the step scatter is zero — detectable.
    const yardstick = Math.max(stepScatter, (window.accuracy ?? 0.01) * 0.5)

    if (shift > 6 * yardstick) {
      anomalies.push(
        anomaly(
          'drift',
          'warning',
          45,
          `The mean moved by ${round(shift, 3)} ${window.unit} across the window, ${round(shift / yardstick, 1)} times the step-to-step scatter.`,
          `تحرك المتوسط بمقدار ${round(shift, 3)} ${window.unit} عبر النافذة، أي ${round(shift / yardstick, 1)} أضعاف تشتت الخطوة.`,
          { shift: round(shift, 4), stepScatter: round(stepScatter, 5) },
        ),
      )
    }
  }

  // ── Disagreement with peers ───────────────────────────────────────────────
  if (window.peers && window.peers.length > 0 && latest && Number.isFinite(latest.value)) {
    const peerValues = window.peers.map((peer) => peer.value).filter(Number.isFinite)
    if (peerValues.length > 0) {
      const peerMean = peerValues.reduce((sum, value) => sum + value, 0) / peerValues.length
      const scale = Math.max(Math.abs(peerMean), (spec.max - spec.min) * 0.01)
      const gap = Math.abs(latest.value - peerMean) / scale
      if (gap > 0.12) {
        anomalies.push(
          anomaly(
            'disagreement',
            gap > 0.3 ? 'warning' : 'info',
            clamp(gap * 130, 10, 65),
            `Reads ${round(gap * 100, 1)} % away from the other instruments measuring the same quantity. One of them is wrong; this does not say which.`,
            `يقرأ بفارق ${round(gap * 100, 1)}٪ عن بقية الأجهزة التي تقيس الكمية نفسها. أحدها مخطئ، وهذا لا يحدد أيها.`,
            { gapPct: round(gap * 100, 2), peers: peerValues.length },
          ),
        )
      }
    }
  }

  // ── Calibration ───────────────────────────────────────────────────────────
  if (window.lastCalibratedAt) {
    const monthsSince = (window.now - window.lastCalibratedAt) / (30 * 24 * 60 * 60_000)
    if (monthsSince > 24) {
      anomalies.push(
        anomaly(
          'calibration_deviation',
          monthsSince > 48 ? 'warning' : 'info',
          clamp((monthsSince - 24) * 1.8, 5, 50),
          `Last calibrated ${Math.round(monthsSince)} months ago. Accumulated drift is no longer bounded by the stated accuracy.`,
          `آخر معايرة قبل ${Math.round(monthsSince)} شهرًا. الانحراف المتراكم لم يعد محدودًا بالدقة المعلنة.`,
          { monthsSinceCalibration: round(monthsSince, 1) },
        ),
      )
    }
  }

  // ── Score ─────────────────────────────────────────────────────────────────
  // Capped by the worst single finding rather than averaged with it, the same rule the
  // rest of the platform uses: an instrument reporting impossible values is not
  // three-quarters healthy because everything else about it looks fine.
  const worstPenalty = anomalies.reduce((worst, item) => Math.max(worst, item.confidencePenalty), 0)
  const summedPenalty = anomalies.reduce((sum, item) => sum + item.confidencePenalty * 0.5, 0)
  const healthScore = round(clamp(100 - Math.max(worstPenalty, summedPenalty), 0, 100), 1)

  return {
    sensorCode: window.sensorCode,
    healthScore,
    status: statusFor(healthScore, anomalies),
    anomalies,
    dataAgeMin: Number.isFinite(dataAgeMin) ? dataAgeMin : -1,
    completenessPct,
    latest,
    meanValue,
    stdDev,
  }
}

function statusFor(healthScore: number, anomalies: SensorAnomaly[]): SensorHealth['status'] {
  const has = (kind: AnomalyKind) => anomalies.some((item) => item.kind === kind)
  if (has('stale') && anomalies.find((item) => item.kind === 'stale')?.severity === 'critical') return 'offline'
  if (has('drift')) return 'drifting'
  if (has('noise') || has('spike')) return 'noisy'
  if (has('stale')) return 'stale'
  if (healthScore < 55) return 'low_trust'
  return 'healthy'
}

// ─────────────────────────────────────────────────────────────────────────────
// Fault injection (§7)
// ─────────────────────────────────────────────────────────────────────────────

export const SENSOR_FAULT_KINDS = [
  'offline',
  'frozen',
  'spike',
  'drift',
  'noise',
  'delay',
  'loss',
  'wrong_unit',
  'bad_timestamp',
  'partial_data',
] as const

export type SensorFaultKind = (typeof SENSOR_FAULT_KINDS)[number]

export interface SensorFaultEffect {
  kind: SensorFaultKind
  label: string
  labelAr: string
  /** What an operator should expect the health assessment to report. */
  expectedAnomalies: AnomalyKind[]
}

export const SENSOR_FAULTS: Record<SensorFaultKind, SensorFaultEffect> = {
  offline: { kind: 'offline', label: 'Offline', labelAr: 'غير متصل', expectedAnomalies: ['stale'] },
  frozen: { kind: 'frozen', label: 'Frozen value', labelAr: 'قيمة متجمدة', expectedAnomalies: ['frozen'] },
  spike: { kind: 'spike', label: 'Spike', labelAr: 'قفزة', expectedAnomalies: ['spike'] },
  drift: { kind: 'drift', label: 'Drift', labelAr: 'انحراف', expectedAnomalies: ['drift'] },
  noise: { kind: 'noise', label: 'Noise', labelAr: 'ضجيج', expectedAnomalies: ['noise'] },
  delay: { kind: 'delay', label: 'Delayed delivery', labelAr: 'تأخر التسليم', expectedAnomalies: ['timestamp_drift'] },
  loss: { kind: 'loss', label: 'Packet loss', labelAr: 'فقد حزم', expectedAnomalies: ['packet_loss'] },
  wrong_unit: { kind: 'wrong_unit', label: 'Wrong unit', labelAr: 'وحدة خاطئة', expectedAnomalies: ['impossible'] },
  bad_timestamp: { kind: 'bad_timestamp', label: 'Bad timestamp', labelAr: 'طابع زمني خاطئ', expectedAnomalies: ['timestamp_drift'] },
  partial_data: { kind: 'partial_data', label: 'Partial data', labelAr: 'بيانات جزئية', expectedAnomalies: ['packet_loss'] },
}

/**
 * Apply a fault to a stream of readings.
 *
 * Deterministic: the deviation for each reading is derived from its own index, so the same
 * fault on the same stream produces the same corrupted stream every time. A demonstration
 * that could not be repeated would not be a demonstration.
 *
 * This transforms a *stream*. It has no access to an asset, a twin or a live state, and
 * the isolation test asserts that the surfaces calling it cannot reach those either.
 */
export function injectSensorFault(
  readings: Reading[],
  fault: SensorFaultKind,
  kind: SensorKind,
): Reading[] {
  const spec = SENSOR_SPEC[kind]
  const span = spec.max - spec.min

  switch (fault) {
    case 'offline':
      return []

    case 'frozen': {
      const held = readings[0]?.value ?? 0
      return readings.map((reading) => ({ ...reading, value: held }))
    }

    case 'spike':
      // Every seventh reading thrown far out of band.
      return readings.map((reading, index) =>
        index % 7 === 3 ? { ...reading, value: reading.value + span * 0.4 } : reading,
      )

    case 'drift':
      // A steady one-directional march, the failure a calibration check is meant to catch.
      return readings.map((reading, index) => ({
        ...reading,
        value: reading.value + (index / Math.max(1, readings.length - 1)) * span * 0.12,
      }))

    case 'noise':
      // Alternating deviation rather than a random one: repeatable, and it produces the
      // same scatter statistics a genuinely noisy instrument would.
      return readings.map((reading, index) => ({
        ...reading,
        value: reading.value + (index % 2 === 0 ? 1 : -1) * span * 0.05 * (1 + (index % 5) / 5),
      }))

    case 'delay':
      return readings.map((reading) => ({ ...reading, receivedTs: reading.ts + 45 * 60_000 }))

    case 'loss':
      return readings.filter((_, index) => index % 3 !== 1)

    case 'wrong_unit':
      // The classic integration failure: kilo- against unit-, or Fahrenheit against Celsius.
      return readings.map((reading) => ({ ...reading, value: reading.value * 1_000 }))

    case 'bad_timestamp':
      return readings.map((reading, index) =>
        index % 4 === 0 ? { ...reading, ts: reading.ts + 6 * 60 * 60_000 } : reading,
      )

    case 'partial_data': {
      // A gap in the middle rather than a truncated tail. Truncating would read as an
      // instrument that stopped — which is `offline` — where partial data is a feed that
      // is still arriving with pieces missing from it.
      const start = Math.floor(readings.length * 0.2)
      const end = Math.ceil(readings.length * 0.8)
      return readings.filter((_, index) => index < start || index >= end)
    }

    default:
      return readings
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Provenance (§5)
// ─────────────────────────────────────────────────────────────────────────────

export const PROVENANCE_STAGES = [
  'sensor',
  'gateway',
  'connector',
  'mapping',
  'validation',
  'twin_asset',
  'physics_engine',
  'risk_engine',
  'recommendation',
  'proof',
] as const

export type ProvenanceStage = (typeof PROVENANCE_STAGES)[number]

export interface ProvenanceNode {
  stage: ProvenanceStage
  /** The identifier at this stage — a sensor code, a connector code, an asset code. */
  id: string
  label: string
  labelAr: string
  /** 0–100. The confidence this stage contributes to the chain. */
  confidence: number
  /** Where this stage's numbers came from. */
  kind: 'measured' | 'estimated' | 'simulated' | 'derived'
  detail: string
  detailAr: string
}

export interface ProvenanceChain {
  /** The decision or recommendation this chain explains. */
  subject: string
  nodes: ProvenanceNode[]
  /** Every sensor that contributed, so "which instruments?" has an answer. */
  sensors: string[]
  /**
   * The chain's confidence, capped by its weakest link rather than averaged.
   * A perfect model reading a failed instrument is not a confident answer.
   */
  overallConfidence: number
  weakestStage: ProvenanceStage | null
  weakestConfidence: number
}

/**
 * Assemble the chain and score it.
 *
 * The cap is the point. A recommendation resting on a drifting sensor cannot be more
 * trustworthy than that sensor, however good the physics above it — and an average would
 * let nine confident stages hide the one that has failed.
 */
export function buildProvenanceChain(subject: string, nodes: ProvenanceNode[]): ProvenanceChain {
  const ordered = [...nodes].sort(
    (a, b) => PROVENANCE_STAGES.indexOf(a.stage) - PROVENANCE_STAGES.indexOf(b.stage),
  )

  const weakest = ordered.reduce<ProvenanceNode | null>(
    (worst, node) => (!worst || node.confidence < worst.confidence ? node : worst),
    null,
  )

  const mean =
    ordered.length > 0 ? ordered.reduce((sum, node) => sum + node.confidence, 0) / ordered.length : 0

  return {
    subject,
    nodes: ordered,
    sensors: ordered.filter((node) => node.stage === 'sensor').map((node) => node.id),
    overallConfidence: round(Math.min(mean, weakest?.confidence ?? 0), 1),
    weakestStage: weakest?.stage ?? null,
    weakestConfidence: round(weakest?.confidence ?? 0, 1),
  }
}
