///|
/// Comparison policy for regression waveform checks.
pub enum RegressionComparison {
  ExactValues
  TolerantTimes
  ShapeOnly
  ActivityOnly
} derive(Debug, Eq)

///|
/// One regression difference with a stable classification.
pub struct RegressionFinding {
  signal_id : String
  time : Int64
  left : Value?
  right : Value?
  kind : String
  distance : Int64
} derive(Debug)

///|
/// Regression comparison result.
pub struct RegressionReportV2 {
  comparison : RegressionComparison
  findings : Array[RegressionFinding]
  compared_signals : Int
  compared_changes : Int
  passed : Bool
  score : Int
} derive(Debug)

///|
/// List comparison policies.
pub fn regression_comparison_catalog() -> Array[RegressionComparison] {
  [ExactValues, TolerantTimes, ShapeOnly, ActivityOnly]
}

///|
/// Compare two traces by aligned signal identifiers.
pub fn compare_regression_v2(
  left : Trace,
  right : Trace,
  comparison : RegressionComparison,
  tolerance : Int64,
) -> RegressionReportV2 {
  let findings : Array[RegressionFinding] = []
  let ids = union_signal_ids(left, right)
  let limit = Int64::max(0L, tolerance)
  let mut compared = 0
  for id in ids {
    let left_changes = left.changes.filter(fn(item) { item.id == id })
    let right_changes = right.changes.filter(fn(item) { item.id == id })
    compared += Int::max(left_changes.length(), right_changes.length())
    match comparison {
      ShapeOnly =>
        if left_changes.length() != right_changes.length() {
          findings.push({
            signal_id: id,
            time: 0L,
            left: None,
            right: None,
            kind: "change-count",
            distance: (left_changes.length() - right_changes.length()).to_int64(),
          })
        }
      ActivityOnly =>
        if (left_changes.length() == 0) != (right_changes.length() == 0) {
          findings.push({
            signal_id: id,
            time: 0L,
            left: None,
            right: None,
            kind: "activity-presence",
            distance: 0L,
          })
        }
      ExactValues | TolerantTimes =>
        regression_compare_signal(
          left_changes, right_changes, id, comparison, limit, findings,
        )
    }
  }
  let passed = findings.length() == 0
  {
    comparison,
    findings,
    compared_signals: ids.length(),
    compared_changes: compared,
    passed,
    score: if compared == 0 {
      100
    } else {
      Int::max(0, 100 - findings.length() * 100 / compared)
    },
  }
}

///|
/// Return the first finding for a signal.
pub fn RegressionReportV2::for_signal(
  self : RegressionReportV2,
  signal_id : String,
) -> Array[RegressionFinding] {
  self.findings.filter(fn(item) { item.signal_id == signal_id })
}

///|
/// Return whether a comparison passed.
pub fn RegressionReportV2::is_success(self : RegressionReportV2) -> Bool {
  self.passed
}

///|
/// Export regression findings as CSV.
pub fn regression_findings_csv(findings : Array[RegressionFinding]) -> String {
  let out = StringBuilder()
  out.write_string("signal,time,left,right,kind,distance\n")
  for item in findings {
    out.write_string(
      escape_text(item.signal_id) +
      "," +
      item.time.to_string() +
      "," +
      regression_optional_value(item.left) +
      "," +
      regression_optional_value(item.right) +
      "," +
      item.kind +
      "," +
      item.distance.to_string() +
      "\n",
    )
  }
  out.to_string()
}

///|
/// Render regression summary.
pub fn RegressionReportV2::to_text(self : RegressionReportV2) -> String {
  "passed=" +
  self.passed.to_string() +
  " score=" +
  self.score.to_string() +
  " signals=" +
  self.compared_signals.to_string() +
  " changes=" +
  self.compared_changes.to_string() +
  " findings=" +
  self.findings.length().to_string()
}

///|
/// Render regression summary as JSON.
pub fn RegressionReportV2::to_json(self : RegressionReportV2) -> String {
  "{\"comparison\":\"" +
  regression_comparison_name(self.comparison) +
  "\",\"passed\":" +
  self.passed.to_string() +
  ",\"score\":" +
  self.score.to_string() +
  ",\"signals\":" +
  self.compared_signals.to_string() +
  ",\"changes\":" +
  self.compared_changes.to_string() +
  ",\"findings\":[" +
  self.findings.map(regression_finding_json).join(",") +
  "]}"
}

///|
/// Compare changes with optional timestamp tolerance.
fn regression_compare_signal(
  left : Array[Change],
  right : Array[Change],
  signal_id : String,
  comparison : RegressionComparison,
  tolerance : Int64,
  findings : Array[RegressionFinding],
) -> Unit {
  let count = Int::max(left.length(), right.length())
  for index in 0.. {
        let time_distance = (a.time - b.time).abs()
        let time_bad = (comparison is ExactValues && time_distance != 0L) ||
          (comparison is TolerantTimes && time_distance > tolerance)
        if time_bad || a.value != b.value {
          findings.push({
            signal_id,
            time: Int64::min(a.time, b.time),
            left: Some(a.value),
            right: Some(b.value),
            kind: if a.value != b.value {
              "value"
            } else {
              "time"
            },
            distance: time_distance,
          })
        }
      }
      (Some(a), None) =>
        findings.push({
          signal_id,
          time: a.time,
          left: Some(a.value),
          right: None,
          kind: "missing-right",
          distance: 0L,
        })
      (None, Some(b)) =>
        findings.push({
          signal_id,
          time: b.time,
          left: None,
          right: Some(b.value),
          kind: "missing-left",
          distance: 0L,
        })
      _ => ()
    }
  }
}

///|
/// Name a comparison policy.
pub fn regression_comparison_name(value : RegressionComparison) -> String {
  match value {
    ExactValues => "exact-values"
    TolerantTimes => "tolerant-times"
    ShapeOnly => "shape-only"
    ActivityOnly => "activity-only"
  }
}

///|
/// Encode an optional value for CSV.
fn regression_optional_value(value : Value?) -> String {
  match value {
    Some(item) => escape_text(item.display())
    None => ""
  }
}

///|
/// Encode one regression finding as JSON.
fn regression_finding_json(item : RegressionFinding) -> String {
  "{\"signal\":\"" +
  escape_text(item.signal_id) +
  "\",\"time\":" +
  item.time.to_string() +
  ",\"left\":" +
  regression_optional_json(item.left) +
  ",\"right\":" +
  regression_optional_json(item.right) +
  ",\"kind\":\"" +
  item.kind +
  "\",\"distance\":" +
  item.distance.to_string() +
  "}"
}

///|
/// Encode an optional value as JSON.
fn regression_optional_json(value : Value?) -> String {
  match value {
    Some(item) => value_json(item)
    None => "null"
  }
}