///|
/// Signal-quality indices for deciding whether an HRV result is publishable.

///|
/// Quality components on a common zero-to-one scale.
pub(all) struct SignalQualityComponents {
  range_score : Double
  continuity_score : Double
  stationarity_score : Double
  coverage_score : Double
  plausibility_score : Double
} derive(FromJson, ToJson, Debug, Eq)

///|
/// Weighted quality score and decision.
pub(all) struct SignalQualitySummary {
  components : SignalQualityComponents
  score : Double
  grade : String
  usable : Bool
  reasons : Array[String]
} derive(FromJson, ToJson, Debug, Eq)

///|
/// Quality-score weights.
pub(all) struct SignalQualityWeights {
  range : Double
  continuity : Double
  stationarity : Double
  coverage : Double
  plausibility : Double
} derive(FromJson, ToJson, Debug, Eq)

///|
/// Balanced default weights for daily RR data.
pub fn SignalQualityWeights::default() -> SignalQualityWeights {
  {
    range: 0.25,
    continuity: 0.25,
    stationarity: 0.20,
    coverage: 0.20,
    plausibility: 0.10,
  }
}

///|
/// Score physiological-range compliance.
pub fn range_quality_score(validation : IntervalValidation) -> Double {
  if validation.total == 0 {
    0.0
  } else {
    validation.valid.to_double() / validation.total.to_double()
  }
}

///|
/// Score continuity using large successive changes.
pub fn continuity_quality_score(
  intervals : Array[Double],
  threshold_ms : Double,
) -> Double {
  successive_change_quality(intervals, threshold_ms).clamp(min=0.0, max=1.0)
}

///|
/// Score local stationarity by comparing first and second half means.
pub fn stationarity_quality_score(intervals : Array[Double]) -> Double {
  if intervals.length() < 4 {
    return 0.0
  }
  let split = intervals.length() / 2
  let left = []
  let right = []
  for i in 0.. Double {
  if sample_count <= 0 || target <= 0 {
    return 0.0
  }
  if sample_count < minimum {
    sample_count.to_double() / minimum.to_double() * 0.5
  } else if sample_count >= target {
    1.0
  } else {
    sample_count.to_double() / target.to_double()
  }
}

///|
/// Score plausibility of the median and spread.
pub fn plausibility_quality_score(
  intervals : Array[Double],
  config : HrvConfig,
) -> Double {
  if intervals.length() == 0 {
    return 0.0
  }
  let median = median_value(intervals)
  let spread = median_absolute_deviation(intervals)
  if classify_interval(median, config) != Normal {
    return 0.0
  }
  if spread <= 80.0 {
    1.0
  } else {
    (1.0 - (spread - 80.0) / 400.0).clamp(min=0.0, max=1.0)
  }
}

///|
/// Combine components using explicit weights.
pub fn weighted_signal_quality(
  components : SignalQualityComponents,
  weights : SignalQualityWeights,
) -> Double {
  let weight_sum = weights.range +
    weights.continuity +
    weights.stationarity +
    weights.coverage +
    weights.plausibility
  if weight_sum == 0.0 {
    0.0
  } else {
    (
      components.range_score * weights.range +
      components.continuity_score * weights.continuity +
      components.stationarity_score * weights.stationarity +
      components.coverage_score * weights.coverage +
      components.plausibility_score * weights.plausibility
    ) /
    weight_sum
  }
}

///|
/// Convert a score to a stable quality grade.
pub fn signal_quality_grade(score : Double) -> String {
  if score >= 0.90 {
    "excellent"
  } else if score >= 0.75 {
    "good"
  } else if score >= 0.55 {
    "usable"
  } else {
    "poor"
  }
}

///|
/// Evaluate an RR stream with explainable components.
pub fn evaluate_signal_quality(
  intervals : Array[Double],
  config : HrvConfig,
  weights : SignalQualityWeights,
  minimum_beats : Int,
  target_beats : Int,
) -> SignalQualitySummary {
  let validation = validate_intervals(intervals, config)
  let components : SignalQualityComponents = {
    range_score: range_quality_score(validation),
    continuity_score: continuity_quality_score(intervals, 50.0),
    stationarity_score: stationarity_quality_score(intervals),
    coverage_score: coverage_quality_score(
      intervals.length(),
      minimum_beats,
      target_beats,
    ),
    plausibility_score: plausibility_quality_score(intervals, config),
  }
  let score = weighted_signal_quality(components, weights)
  let reasons = []
  if components.range_score < 0.85 {
    reasons.push("physiological_range")
  }
  if components.continuity_score < 0.80 {
    reasons.push("successive_jump")
  }
  if components.stationarity_score < 0.50 {
    reasons.push("non_stationary")
  }
  if components.coverage_score < 0.50 {
    reasons.push("short_recording")
  }
  if components.plausibility_score < 0.50 {
    reasons.push("implausible_spread")
  }
  {
    components,
    score,
    grade: signal_quality_grade(score),
    usable: score >= 0.55 && components.range_score >= 0.70,
    reasons,
  }
}

///|
/// Return a fixed-order quality feature vector.
pub fn signal_quality_feature_vector(
  summary : SignalQualitySummary,
) -> Array[Double] {
  [
    summary.components.range_score,
    summary.components.continuity_score,
    summary.components.stationarity_score,
    summary.components.coverage_score,
    summary.components.plausibility_score,
    summary.score,
    if summary.usable {
      1.0
    } else {
      0.0
    },
  ]
}

///|
/// Compare two quality summaries.
pub fn quality_score_delta(
  left : SignalQualitySummary,
  right : SignalQualitySummary,
) -> Double {
  left.score - right.score
}

///|
/// Return a human-readable explanation of a quality summary.
pub fn signal_quality_explanation(summary : SignalQualitySummary) -> String {
  if summary.usable {
    "signal is usable for HRV scoring (" + summary.grade + ")"
  } else if summary.reasons.length() == 0 {
    "signal did not reach the configured quality threshold"
  } else {
    "signal quality requires review: " + join_quality_reasons(summary.reasons)
  }
}

///|
fn join_quality_reasons(reasons : Array[String]) -> String {
  let mut result = ""
  for i in 0.. 0 {
      result += ", "
    }
    result += reasons[i]
  }
  result
}

///|
/// Return whether quality changes are material.
pub fn quality_change_is_material(
  left : SignalQualitySummary,
  right : SignalQualitySummary,
  threshold : Double,
) -> Bool {
  let bound = if threshold < 0.0 { 0.0 } else { threshold }
  absolute_difference(left.score, right.score) >= bound
}

///|
/// Create a score from a cleaning result.
pub fn quality_from_cleaning(
  result : CleaningResult,
  config : HrvConfig,
) -> SignalQualitySummary {
  evaluate_signal_quality(
    result.intervals,
    config,
    SignalQualityWeights::default(),
    30,
    300,
  )
}

///|
/// Return the fraction of clean contiguous runs longer than a threshold.
pub fn long_run_coverage(
  profile : ArtifactProfile,
  minimum_run : Int,
) -> Double {
  if profile.sample_count == 0 {
    return 0.0
  }
  let threshold = if minimum_run < 1 { 1 } else { minimum_run }
  let mut covered = 0
  let mut cursor = 0
  for run in artifact_runs(profile) {
    let gap = run.start - cursor
    if gap >= threshold {
      covered += gap
    }
    cursor = run.end
  }
  let tail = profile.sample_count - cursor
  if tail >= threshold {
    covered += tail
  }
  covered.to_double() / profile.sample_count.to_double()
}

///|
/// Return a quality summary with a custom run-coverage reason.
pub fn evaluate_quality_with_runs(
  intervals : Array[Double],
  config : HrvConfig,
  minimum_run : Int,
) -> SignalQualitySummary {
  let profile = profile_artifacts(intervals, config)
  let weights : SignalQualityWeights = {
    range: 0.25,
    continuity: 0.35,
    stationarity: 0.20,
    coverage: 0.0,
    plausibility: 0.20,
  }
  let base = evaluate_signal_quality(intervals, config, weights, 30, 300)
  let run_score = long_run_coverage(profile, minimum_run)
  let adjusted_components = {
    range_score: base.components.range_score,
    continuity_score: base.components.continuity_score * 0.7 + run_score * 0.3,
    stationarity_score: base.components.stationarity_score,
    coverage_score: run_score,
    plausibility_score: base.components.plausibility_score,
  }
  let score = weighted_signal_quality(adjusted_components, weights)
  {
    components: adjusted_components,
    score,
    grade: signal_quality_grade(score),
    usable: score >= 0.55 && run_score >= 0.50,
    reasons: base.reasons,
  }
}