///|
/// Coverage dimensions supported by waveform regression analysis.
pub enum CoverageKind {
  Value
  Transition
  Toggle
  Width
  Activity
  Protocol
} derive(Debug, Eq)

///|
/// One measurable coverage bin.
pub struct CoverageBin {
  group : String
  key : String
  label : String
  kind : CoverageKind
  hits : Int
  target : Int
  first_time : Int64?
  last_time : Int64?
} derive(Debug)

///|
/// An observed point that can be accumulated into coverage.
pub struct CoveragePoint {
  group : String
  key : String
  time : Int64
  weight : Int
} derive(Debug, Eq)

///|
/// Construct an observation point for custom coverage adapters.
pub fn coverage_point(
  group : String,
  key : String,
  time : Int64,
  weight : Int,
) -> CoveragePoint {
  { group, key, time, weight: Int::max(1, weight) }
}

///|
/// A complete coverage report for one trace or regression batch.
pub struct CoverageReport {
  name : String
  bins : Array[CoverageBin]
  observations : Int
  covered : Int
  target : Int
  score : Double
} derive(Debug)

///|
/// Return every coverage dimension for report configuration UIs.
pub fn coverage_kind_catalog() -> Array[CoverageKind] {
  [Value, Transition, Toggle, Width, Activity, Protocol]
}

///|
/// Return a stable name for a coverage dimension.
pub fn coverage_kind_name(kind : CoverageKind) -> String {
  match kind {
    Value => "value"
    Transition => "transition"
    Toggle => "toggle"
    Width => "width"
    Activity => "activity"
    Protocol => "protocol"
  }
}

///|
/// Create a coverage bin with a minimum target of one hit.
pub fn coverage_bin(
  group : String,
  key : String,
  label : String,
  kind : CoverageKind,
  target : Int,
) -> CoverageBin {
  {
    group,
    key,
    label,
    kind,
    hits: 0,
    target: Int::max(1, target),
    first_time: None,
    last_time: None,
  }
}

///|
/// Return whether a bin has met its target.
pub fn CoverageBin::is_covered(self : CoverageBin) -> Bool {
  self.hits >= self.target
}

///|
/// Return a bounded progress ratio for a bin.
pub fn CoverageBin::progress(self : CoverageBin) -> Double {
  Double::min(1.0, self.hits.to_double() / self.target.to_double())
}

///|
/// Return a copy with one observation applied.
pub fn CoverageBin::hit(
  self : CoverageBin,
  time : Int64,
  weight : Int,
) -> CoverageBin {
  let increment = Int::max(1, weight)
  {
    ..self,
    hits: self.hits + increment,
    first_time: match self.first_time {
      Some(value) => Some(Int64::min(value, time))
      None => Some(time)
    },
    last_time: match self.last_time {
      Some(value) => Some(Int64::max(value, time))
      None => Some(time)
    },
  }
}

///|
/// Build value bins for all declarations in a trace.
pub fn value_coverage_bins(trace : Trace) -> Array[CoverageBin] {
  let bins : Array[CoverageBin] = []
  for signal in trace.signals {
    let values = signal_value_categories(trace, signal.id)
    for value in values {
      bins.push(
        coverage_bin(
          signal_path(signal),
          value,
          signal.name + "=" + value,
          Value,
          1,
        ),
      )
    }
    if bins_for_group(bins, signal_path(signal)).length() == 0 {
      bins.push(
        coverage_bin(
          signal_path(signal),
          "no-observation",
          signal.name + " no observation",
          Value,
          1,
        ),
      )
    }
  }
  accumulate_value_bins(trace, bins)
}

///|
/// Build transition bins for each scalar signal.
pub fn transition_coverage_bins(trace : Trace) -> Array[CoverageBin] {
  let bins : Array[CoverageBin] = []
  for signal in trace.signals {
    let group = signal_path(signal)
    for key in ["0->1", "1->0", "0->x", "1->x", "x->0", "x->1", "to-z"] {
      bins.push(
        coverage_bin(group, key, signal.name + " " + key, Transition, 1),
      )
    }
  }
  accumulate_transition_bins(trace, bins)
}

///|
/// Build toggle bins for declarations with meaningful transitions.
pub fn toggle_coverage_bins(trace : Trace) -> Array[CoverageBin] {
  let bins : Array[CoverageBin] = []
  for signal in trace.signals {
    bins.push(
      coverage_bin(
        signal_path(signal),
        "toggle",
        signal.name + " toggled",
        Toggle,
        1,
      ),
    )
    bins.push(
      coverage_bin(
        signal_path(signal),
        "stable",
        signal.name + " stable",
        Toggle,
        1,
      ),
    )
  }
  for signal in trace.signals {
    let group = signal_path(signal)
    let changes = trace.changes.filter(fn(change) { change.id == signal.id })
    let target_toggle = find_bin_index(bins, group, "toggle")
    let target_stable = find_bin_index(bins, group, "stable")
    if changes.length() > 1 {
      if target_toggle is Some(index) {
        bins[index] = bins[index].hit(changes[1].time, changes.length() - 1)
      }
    } else if target_stable is Some(index) {
      bins[index] = bins[index].hit(0L, 1)
    }
  }
  bins
}

///|
/// Build bins that check declaration widths against an expected range.
pub fn width_coverage_bins(
  trace : Trace,
  minimum : Int,
  maximum : Int,
) -> Array[CoverageBin] {
  let low = Int::max(0, minimum)
  let high = Int::max(low, maximum)
  let bins : Array[CoverageBin] = [
    coverage_bin("width", "below", "width below range", Width, 1),
    coverage_bin("width", "inside", "width inside range", Width, 1),
    coverage_bin("width", "above", "width above range", Width, 1),
  ]
  for signal in trace.signals {
    let key = if signal.width < low {
      "below"
    } else if signal.width > high {
      "above"
    } else {
      "inside"
    }
    match find_bin_index(bins, "width", key) {
      Some(index) => bins[index] = bins[index].hit(0L, 1)
      None => ()
    }
  }
  bins
}

///|
/// Build activity bins for each signal based on change density.
pub fn activity_coverage_bins(
  trace : Trace,
  bucket_width : Int64,
  threshold : Int,
) -> Array[CoverageBin] {
  let width = Int64::max(1L, bucket_width)
  let bins : Array[CoverageBin] = []
  for signal in trace.signals {
    bins.push(
      coverage_bin(
        signal_path(signal),
        "idle",
        signal.name + " idle",
        Activity,
        1,
      ),
    )
    bins.push(
      coverage_bin(
        signal_path(signal),
        "active",
        signal.name + " active",
        Activity,
        1,
      ),
    )
    bins.push(
      coverage_bin(
        signal_path(signal),
        "burst",
        signal.name + " burst",
        Activity,
        1,
      ),
    )
    let changes = trace.changes.filter(fn(change) { change.id == signal.id })
    let buckets : Array[Int] = []
    for change in changes {
      let bucket = (change.time / width).to_int()
      if buckets.contains(bucket) {
        continue
      }
      buckets.push(bucket)
    }
    let key = if changes.length() == 0 {
      "idle"
    } else if changes.length() >= Int::max(1, threshold) {
      "burst"
    } else {
      "active"
    }
    match find_bin_index(bins, signal_path(signal), key) {
      Some(index) =>
        bins[index] = bins[index].hit(0L, Int::max(1, buckets.length()))
      None => ()
    }
  }
  bins
}

///|
/// Assemble a report from configured coverage dimensions.
pub fn coverage_report(
  name : String,
  trace : Trace,
  include_values : Bool,
  include_transitions : Bool,
  include_toggles : Bool,
  include_widths : Bool,
  include_activity : Bool,
) -> CoverageReport {
  let bins : Array[CoverageBin] = []
  if include_values {
    append_coverage_bins(bins, value_coverage_bins(trace))
  }
  if include_transitions {
    append_coverage_bins(bins, transition_coverage_bins(trace))
  }
  if include_toggles {
    append_coverage_bins(bins, toggle_coverage_bins(trace))
  }
  if include_widths {
    append_coverage_bins(bins, width_coverage_bins(trace, 1, 256))
  }
  if include_activity {
    append_coverage_bins(
      bins,
      activity_coverage_bins(trace, Int64::max(1L, trace.duration() / 8L), 4),
    )
  }
  coverage_report_from_bins(name, bins)
}

///|
/// Build a report from externally generated or merged bins.
pub fn coverage_report_from_bins(
  name : String,
  bins : Array[CoverageBin],
) -> CoverageReport {
  let covered = bins.count_if(fn(bin) { bin.is_covered() })
  let target = bins.fold(init=0, fn(total, bin) { total + bin.target })
  let observations = bins.fold(init=0, fn(total, bin) { total + bin.hits })
  let score = if bins.length() == 0 {
    1.0
  } else {
    covered.to_double() / bins.length().to_double()
  }
  { name, bins, observations, covered, target, score }
}

///|
/// Merge two reports by group and key while preserving first-seen order.
pub fn merge_coverage_reports(
  left : CoverageReport,
  right : CoverageReport,
) -> CoverageReport {
  let bins = left.bins.copy()
  for incoming in right.bins {
    match find_bin_index(bins, incoming.group, incoming.key) {
      Some(index) => bins[index] = merge_coverage_bin(bins[index], incoming)
      None => bins.push(incoming)
    }
  }
  coverage_report_from_bins(left.name + "+" + right.name, bins)
}

///|
/// Return all bins that remain uncovered.
pub fn CoverageReport::uncovered(self : CoverageReport) -> Array[CoverageBin] {
  self.bins.filter(fn(bin) { !bin.is_covered() })
}

///|
/// Return coverage grouped by dimension.
pub fn CoverageReport::score_by_kind(
  self : CoverageReport,
) -> Array[(String, Double)] {
  let names : Array[String] = []
  let totals : Array[Int] = []
  let covered : Array[Int] = []
  for bin in self.bins {
    let name = coverage_kind_name(bin.kind)
    match find_string_index(names, name) {
      Some(index) => {
        totals[index] += 1
        if bin.is_covered() {
          covered[index] += 1
        }
      }
      None => {
        names.push(name)
        totals.push(1)
        covered.push(if bin.is_covered() { 1 } else { 0 })
      }
    }
  }
  let result : Array[(String, Double)] = []
  for index, name in names {
    result.push(
      (
        name,
        if totals[index] == 0 {
          1.0
        } else {
          covered[index].to_double() / totals[index].to_double()
        },
      ),
    )
  }
  result
}

///|
/// Render report rows for CI summaries.
pub fn CoverageReport::to_text(self : CoverageReport) -> String {
  let rows : Array[String] = [
    "coverage=" + self.name,
    "score=" + self.score.to_string(),
    "covered=" + self.covered.to_string() + "/" + self.bins.length().to_string(),
    "observations=" + self.observations.to_string(),
  ]
  for item in self.score_by_kind() {
    rows.push("kind=" + item.0 + " score=" + item.1.to_string())
  }
  for bin in self.uncovered() {
    rows.push("uncovered=" + bin.group + ":" + bin.key + " label=" + bin.label)
  }
  rows.join("\n")
}

///|
/// Render a report in a stable CSV form.
pub fn CoverageReport::to_csv(self : CoverageReport) -> String {
  let rows : Array[String] = [
    "group,key,label,kind,hits,target,covered,first_time,last_time",
  ]
  for bin in self.bins {
    rows.push(
      csv_cell(bin.group) +
      "," +
      csv_cell(bin.key) +
      "," +
      csv_cell(bin.label) +
      "," +
      coverage_kind_name(bin.kind) +
      "," +
      bin.hits.to_string() +
      "," +
      bin.target.to_string() +
      "," +
      bin.is_covered().to_string() +
      "," +
      optional_int64_text(bin.first_time) +
      "," +
      optional_int64_text(bin.last_time),
    )
  }
  rows.join("\n")
}

///|
/// Render the complete report as JSON.
pub fn CoverageReport::to_json(self : CoverageReport) -> String {
  let body = self.bins
    .map(fn(bin) {
      "{\"group\":\"" +
      csv_cell(bin.group) +
      "\",\"key\":\"" +
      csv_cell(bin.key) +
      "\",\"label\":\"" +
      csv_cell(bin.label) +
      "\",\"kind\":\"" +
      coverage_kind_name(bin.kind) +
      "\",\"hits\":" +
      bin.hits.to_string() +
      ",\"target\":" +
      bin.target.to_string() +
      ",\"covered\":" +
      bin.is_covered().to_string() +
      ",\"first_time\":" +
      optional_int64_json(bin.first_time) +
      ",\"last_time\":" +
      optional_int64_json(bin.last_time) +
      "}"
    })
    .join(",")
  "{\"name\":\"" +
  csv_cell(self.name) +
  "\",\"score\":" +
  self.score.to_string() +
  ",\"covered\":" +
  self.covered.to_string() +
  ",\"target\":" +
  self.target.to_string() +
  ",\"observations\":" +
  self.observations.to_string() +
  ",\"bins\":[" +
  body +
  "]}"
}

///|
/// Return the canonical categories observed for one signal.
fn signal_value_categories(trace : Trace, signal_id : String) -> Array[String] {
  let categories : Array[String] = []
  for change in trace.changes {
    if change.id != signal_id {
      continue
    }
    let key = match change.value {
      Scalar(value) =>
        match logic_bit(value) {
          Zero => "0"
          One => "1"
          Unknown => "x"
          HighImpedance => "z"
        }
      Vector(bits) =>
        if bits.contains("x") || bits.contains("X") {
          "vector-x"
        } else if bits.contains("z") || bits.contains("Z") {
          "vector-z"
        } else {
          "vector-known"
        }
      Real(_) => "real"
    }
    if !categories.contains(key) {
      categories.push(key)
    }
  }
  categories
}

///|
/// Apply value observations to pre-created bins.
fn accumulate_value_bins(
  trace : Trace,
  bins : Array[CoverageBin],
) -> Array[CoverageBin] {
  for change in trace.changes {
    let key = match change.value {
      Scalar(value) =>
        match logic_bit(value) {
          Zero => "0"
          One => "1"
          Unknown => "x"
          HighImpedance => "z"
        }
      Vector(bits) =>
        if bits.contains("x") || bits.contains("X") {
          "vector-x"
        } else if bits.contains("z") || bits.contains("Z") {
          "vector-z"
        } else {
          "vector-known"
        }
      Real(_) => "real"
    }
    match find_bin_by_group_and_key_prefix(bins, change.id, key, trace) {
      Some(index) => bins[index] = bins[index].hit(change.time, 1)
      None => ()
    }
  }
  bins
}

///|
/// Apply scalar transition observations to bins.
fn accumulate_transition_bins(
  trace : Trace,
  bins : Array[CoverageBin],
) -> Array[CoverageBin] {
  for signal in trace.signals {
    let changes = trace.changes.filter(fn(change) { change.id == signal.id })
    for index in 1.. {
          let key = transition_key(before, after)
          match find_bin_index(bins, signal_path(signal), key) {
            Some(bin_index) =>
              bins[bin_index] = bins[bin_index].hit(changes[index].time, 1)
            None => ()
          }
        }
        _ => ()
      }
    }
  }
  bins
}

///|
/// Classify a scalar transition for coverage.
fn transition_key(before : Char, after : Char) -> String {
  let left = match logic_bit(before) {
    Zero => "0"
    One => "1"
    Unknown => "x"
    HighImpedance => "z"
  }
  let right = match logic_bit(after) {
    Zero => "0"
    One => "1"
    Unknown => "x"
    HighImpedance => "z"
  }
  if right == "z" {
    "to-z"
  } else {
    left + "->" + right
  }
}

///|
/// Return bins belonging to a group.
fn bins_for_group(
  bins : Array[CoverageBin],
  group : String,
) -> Array[CoverageBin] {
  bins.filter(fn(bin) { bin.group == group })
}

///|
/// Find a bin by its group and key.
fn find_bin_index(
  bins : Array[CoverageBin],
  group : String,
  key : String,
) -> Int? {
  for index, bin in bins {
    if bin.group == group && bin.key == key {
      return Some(index)
    }
  }
  None
}

///|
/// Find a value bin when the group is a full signal path but changes hold IDs.
fn find_bin_by_group_and_key_prefix(
  bins : Array[CoverageBin],
  signal_id : String,
  key : String,
  trace : Trace,
) -> Int? {
  match find_signal(trace, signal_id) {
    Some(signal) => find_bin_index(bins, signal_path(signal), key)
    None => None
  }
}

///|
/// Merge two bins from separate regression runs.
fn merge_coverage_bin(left : CoverageBin, right : CoverageBin) -> CoverageBin {
  {
    ..left,
    hits: left.hits + right.hits,
    target: Int::max(left.target, right.target),
    first_time: min_optional_int64(left.first_time, right.first_time),
    last_time: max_optional_int64(left.last_time, right.last_time),
  }
}

///|
/// Return the earlier optional timestamp.
fn min_optional_int64(left : Int64?, right : Int64?) -> Int64? {
  match (left, right) {
    (Some(a), Some(b)) => Some(Int64::min(a, b))
    (Some(a), None) => Some(a)
    (None, Some(b)) => Some(b)
    (None, None) => None
  }
}

///|
/// Return the later optional timestamp.
fn max_optional_int64(left : Int64?, right : Int64?) -> Int64? {
  match (left, right) {
    (Some(a), Some(b)) => Some(Int64::max(a, b))
    (Some(a), None) => Some(a)
    (None, Some(b)) => Some(b)
    (None, None) => None
  }
}

///|
/// Append a collection of bins while preserving insertion order.
fn append_coverage_bins(
  target : Array[CoverageBin],
  source : Array[CoverageBin],
) -> Unit {
  for bin in source {
    target.push(bin)
  }
}

///|
/// Find a string position without depending on a collection extension.
fn find_string_index(values : Array[String], needle : String) -> Int? {
  for index, value in values {
    if value == needle {
      return Some(index)
    }
  }
  None
}