///|
/// Detect operational alerts in a stream summary.
pub fn detect_alerts(
  summary : StreamSummary,
  confidence_threshold? : Double = 0.2,
  eta_jump_threshold? : Double = 120.0,
) -> Array[ForecastAlert] {
  let alerts : Array[ForecastAlert] = []
  let confidence_limit = clamp(confidence_threshold, 0.0, 1.0)
  let jump_limit = abs_double(eta_jump_threshold)
  let mut previous_eta = -1.0
  for snapshot in summary.snapshots {
    if snapshot.result.confidence < confidence_limit {
      alerts.push({
        kind: LowConfidence,
        update: snapshot.update,
        severity: clamp(confidence_limit - snapshot.result.confidence, 0.0, 1.0),
        message: "confidence below operating threshold",
      })
    }
    if previous_eta >= 0.0 &&
      abs_double(snapshot.result.eta_seconds - previous_eta) > jump_limit {
      alerts.push({
        kind: EtaJump,
        update: snapshot.update,
        severity: clamp(
          abs_double(snapshot.result.eta_seconds - previous_eta) /
          (jump_limit + 0.0001),
          0.0,
          1.0,
        ),
        message: "ETA changed sharply between updates",
      })
    }
    if snapshot.result.remaining_distance > 0.0 &&
      snapshot.result.smoothed_speed <= 0.5 {
      alerts.push({
        kind: StalledProgress,
        update: snapshot.update,
        severity: 1.0,
        message: "progress appears stalled",
      })
    }
    if snapshot.result.remaining_distance <= 0.0 {
      alerts.push({
        kind: RouteComplete,
        update: snapshot.update,
        severity: 0.0,
        message: "route is complete",
      })
    }
    previous_eta = snapshot.result.eta_seconds
  }
  alerts
}

///|
/// Return alert counts by kind in a fixed order.
pub fn alert_counts(alerts : Array[ForecastAlert]) -> Array[Int] {
  let counts = [0, 0, 0, 0, 0]
  for alert in alerts {
    let index = match alert.kind {
      LowConfidence => 0
      EtaJump => 1
      StalledProgress => 2
      RouteComplete => 3
      InvalidObservation => 4
    }
    counts[index] += 1
  }
  counts
}

///|
/// Return the highest-severity alert.
pub fn highest_alert(alerts : Array[ForecastAlert]) -> ForecastAlert? {
  if alerts.length() == 0 {
    None
  } else {
    let mut highest = alerts[0]
    for alert in alerts[1:] {
      if alert.severity > highest.severity {
        highest = alert
      }
    }
    Some(highest)
  }
}

///|
/// Render alerts for a log line.
pub fn format_alert(alert : ForecastAlert) -> String {
  let kind = match alert.kind {
    LowConfidence => "low_confidence"
    EtaJump => "eta_jump"
    StalledProgress => "stalled_progress"
    RouteComplete => "route_complete"
    InvalidObservation => "invalid_observation"
  }
  "\{kind} update=\{alert.update} severity=\{alert.severity} \{alert.message}"
}

///|
/// Render every alert on its own line.
pub fn format_alerts(alerts : Array[ForecastAlert]) -> String {
  let builder = StringBuilder()
  for alert in alerts {
    builder.write_string("\{format_alert(alert)}\n")
  }
  builder.to_string()
}

///|
/// Return whether alerts contain a hard operational fault.
fn hard_alert_kind(kind : AlertKind) -> Bool {
  match kind {
    InvalidObservation => true
    StalledProgress => true
    _ => false
  }
}

///|
/// Return whether alerts contain a hard operational fault.
pub fn has_hard_alert(alerts : Array[ForecastAlert]) -> Bool {
  alerts.any(alert => hard_alert_kind(alert.kind))
}

///|
/// Return a route stream summary augmented with invalid-input alerts.
pub fn monitor_observations(
  observations : Array[Observation],
  route : RouteProfile,
) -> (StreamSummary, Array[ForecastAlert]) {
  let summary = replay_stream(observations, route)
  let alerts = detect_alerts(summary)
  let mut previous_time = -1.0
  for observation in observations {
    if observation.timestamp < 0.0 ||
      observation.timestamp < previous_time ||
      observation.distance < 0.0 {
      alerts.push({
        kind: InvalidObservation,
        update: 0,
        severity: 1.0,
        message: "observation rejected by monitor",
      })
    }
    previous_time = observation.timestamp
  }
  (summary, alerts)
}