/**
 * Model drift detection (§82, §83).
 *
 * A model that was good is not a model that is good. Four things can move underneath it,
 * and they call for different responses, so they are detected separately rather than
 * rolled into one "drift" number:
 *
 *   - **Input drift** — the data going in has changed shape. A summer-trained model being
 *     fed a winter distribution is not broken, it is being asked the wrong question.
 *   - **Prediction drift** — the outputs have moved even though the inputs have not.
 *     That is the model changing its mind, which is a real signal.
 *   - **Performance drift** — the errors are growing. The only one of the four that is
 *     unambiguously bad.
 *   - **Twin drift** — the twin the model is checked against has itself moved, so the
 *     performance figures are being measured against a shifting ruler.
 *
 * Nothing here retrains anything. Detection raises a review candidate and stops, because
 * §32 puts a person between a drift signal and a deployed model.
 */

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

export const DRIFT_KINDS = ['input', 'prediction', 'performance', 'twin'] as const
export type DriftKind = (typeof DRIFT_KINDS)[number]

export type DriftSeverity = 'info' | 'watch' | 'review_recommended'

export interface DriftSignal {
  kind: DriftKind
  metricKey: string
  label: string
  labelAr: string
  baseline: number
  observed: number
  deltaPct: number
  threshold: number
  severity: DriftSeverity
  sampleCount: number
  detail: string
  detailAr: string
}

export interface DriftInput {
  /** Reference window — what the model was validated against. */
  baseline: {
    meanAbsPctError: number
    meanPredictedRisk: number
    meanInputLoadPct: number
    meanInputTempC: number
    twinFidelity: number
    sampleCount: number
  }
  /** Recent window. */
  observed: {
    meanAbsPctError: number
    meanPredictedRisk: number
    meanInputLoadPct: number
    meanInputTempC: number
    twinFidelity: number
    sampleCount: number
  }
}

/** Relative change thresholds beyond which each metric is worth a person's attention. */
const THRESHOLDS: Record<string, number> = {
  error: 25,
  predicted_risk: 20,
  input_load: 15,
  input_temp: 18,
  twin_fidelity: 12,
}

/** The minimum window either side before a comparison means anything. */
export const MIN_DRIFT_SAMPLES = 20

function relativeChange(baseline: number, observed: number): number {
  const base = Math.abs(baseline) > 1e-6 ? Math.abs(baseline) : 1
  return round(((observed - baseline) / base) * 100, 2)
}

function severityFor(deltaPct: number, threshold: number, underpowered: boolean): DriftSeverity {
  const magnitude = Math.abs(deltaPct)
  // An underpowered comparison never escalates past "watch". Declaring a model degraded
  // on eleven samples is how a governance process loses its credibility.
  if (underpowered) return magnitude >= threshold ? 'watch' : 'info'
  if (magnitude >= threshold * 1.6) return 'review_recommended'
  if (magnitude >= threshold) return 'watch'
  return 'info'
}

/**
 * Compare two windows and report what moved.
 *
 * Every metric is returned, including the ones that did not move, so the absence of drift
 * is stated rather than inferred from an empty list.
 */
export function detectDrift(input: DriftInput): DriftSignal[] {
  const { baseline, observed } = input
  const underpowered =
    baseline.sampleCount < MIN_DRIFT_SAMPLES || observed.sampleCount < MIN_DRIFT_SAMPLES
  const samples = Math.min(baseline.sampleCount, observed.sampleCount)

  // An empty window is not a measurement of zero. Comparing "3.1 % error last fortnight"
  // against "no predictions verified since" produced a −100 % move and a confident
  // "watch" verdict built on nothing at all, which is worse than saying nothing.
  const empty = baseline.sampleCount === 0 || observed.sampleCount === 0

  const build = (
    kind: DriftKind,
    metricKey: string,
    label: string,
    labelAr: string,
    base: number,
    obs: number,
    threshold: number,
    unit: string,
  ): DriftSignal => {
    const deltaPct = empty ? 0 : relativeChange(base, obs)
    const severity: DriftSeverity = empty ? 'info' : severityFor(deltaPct, threshold, underpowered)
    const direction = deltaPct > 0 ? 'up' : 'down'
    const directionAr = deltaPct > 0 ? 'ارتفاع' : 'انخفاض'
    return {
      kind,
      metricKey,
      label,
      labelAr,
      baseline: round(base, 2),
      observed: round(obs, 2),
      deltaPct,
      threshold,
      severity,
      sampleCount: samples,
      detail: empty
        ? `One of the two windows holds no verified prediction, so there is nothing to compare — ${baseline.sampleCount} in the reference window, ${observed.sampleCount} in the recent one`
        : underpowered
          ? `${round(base, 2)} → ${round(obs, 2)} ${unit} (${direction} ${Math.abs(deltaPct)} %), but only ${samples} paired samples — below the ${MIN_DRIFT_SAMPLES} needed to call it`
          : `${round(base, 2)} → ${round(obs, 2)} ${unit}, ${direction} ${Math.abs(deltaPct)} % against a ${threshold} % threshold`,
      detailAr: empty
        ? `إحدى النافذتين لا تحتوي أي توقع متحقق منه، فلا شيء للمقارنة — ${baseline.sampleCount} في النافذة المرجعية و${observed.sampleCount} في الحديثة`
        : underpowered
          ? `${round(base, 2)} ← ${round(obs, 2)} ${unit} (${directionAr} ${Math.abs(deltaPct)}٪)، لكن ${samples} عينة مقترنة فقط — دون ${MIN_DRIFT_SAMPLES} اللازمة للحكم`
          : `${round(base, 2)} ← ${round(obs, 2)} ${unit}، ${directionAr} ${Math.abs(deltaPct)}٪ مقابل عتبة ${threshold}٪`,
    }
  }

  return [
    build(
      'performance',
      'error',
      'Mean absolute error',
      'متوسط الخطأ المطلق',
      baseline.meanAbsPctError,
      observed.meanAbsPctError,
      THRESHOLDS.error,
      '%',
    ),
    build(
      'prediction',
      'predicted_risk',
      'Mean predicted risk',
      'متوسط الخطر المتوقع',
      baseline.meanPredictedRisk,
      observed.meanPredictedRisk,
      THRESHOLDS.predicted_risk,
      '%',
    ),
    build(
      'input',
      'input_load',
      'Mean input loading',
      'متوسط التحميل المُدخل',
      baseline.meanInputLoadPct,
      observed.meanInputLoadPct,
      THRESHOLDS.input_load,
      '%',
    ),
    build(
      'input',
      'input_temp',
      'Mean input temperature',
      'متوسط الحرارة المُدخلة',
      baseline.meanInputTempC,
      observed.meanInputTempC,
      THRESHOLDS.input_temp,
      '°C',
    ),
    build(
      'twin',
      'twin_fidelity',
      'Twin fidelity',
      'دقة التوأم',
      baseline.twinFidelity,
      observed.twinFidelity,
      THRESHOLDS.twin_fidelity,
      '%',
    ),
  ]
}

export interface DriftVerdict {
  signals: DriftSignal[]
  worst: DriftSignal | null
  /** True when at least one signal has crossed 1.6× its threshold on adequate samples. */
  reviewRecommended: boolean
  /** 0–100. How much the model's stated performance can still be relied on. */
  reliability: number
  summary: string
  summaryAr: string
}

export function summariseDrift(signals: DriftSignal[]): DriftVerdict {
  const ranked = [...signals].sort((a, b) => Math.abs(b.deltaPct) - Math.abs(a.deltaPct))
  const worst = ranked[0] ?? null
  const reviewRecommended = signals.some((s) => s.severity === 'review_recommended')

  // Performance drift costs more reliability than input drift: the first says the model
  // is wrong, the second says it is being used outside its range.
  const penalty = signals.reduce((sum, s) => {
    const weight = s.kind === 'performance' ? 1 : s.kind === 'twin' ? 0.7 : 0.5
    const over = Math.max(0, Math.abs(s.deltaPct) - s.threshold)
    return sum + clamp(over / s.threshold, 0, 1.5) * 22 * weight
  }, 0)

  const reliability = round(clamp(100 - penalty, 0, 100), 1)

  return {
    signals,
    worst,
    reviewRecommended,
    reliability,
    summary: reviewRecommended
      ? `Review recommended: ${worst?.label.toLowerCase()} moved ${Math.abs(worst?.deltaPct ?? 0)} %.`
      : worst && Math.abs(worst.deltaPct) >= worst.threshold
        ? `Watch: ${worst.label.toLowerCase()} moved ${Math.abs(worst.deltaPct)} %.`
        : 'No metric has moved beyond its review threshold.',
    summaryAr: reviewRecommended
      ? `يوصى بالمراجعة: ${worst?.labelAr} تغيّر بنسبة ${Math.abs(worst?.deltaPct ?? 0)}٪.`
      : worst && Math.abs(worst.deltaPct) >= worst.threshold
        ? `مراقبة: ${worst.labelAr} تغيّر بنسبة ${Math.abs(worst.deltaPct)}٪.`
        : 'لم يتجاوز أي مقياس عتبة المراجعة.',
  }
}
