///|
/// Value selection policy for resampling an asynchronous waveform.
pub enum SamplingMode {
  HoldPrevious
  NearestChange
  ExactOnly
  LinearReal
} derive(Debug, Eq)

///|
/// Sampling configuration shared by signal and trace resampling.
pub struct SamplingConfig {
  start : Int64
  end : Int64
  step : Int64
  mode : SamplingMode
  include_unknown : Bool
} derive(Debug)

///|
/// One sampled value with provenance metadata.
pub struct SamplingPoint {
  signal_id : String
  time : Int64
  value : Value?
  source_time : Int64?
  distance : Int64?
} derive(Debug)

///|
/// A sampled trace preserving the source declaration shape.
pub struct SampledTrace {
  source_end : Int64
  config : SamplingConfig
  points : Array[SamplingPoint]
  sample_count : Int
  known_count : Int
} derive(Debug)

///|
/// List sampling modes.
pub fn sampling_mode_catalog() -> Array[SamplingMode] {
  [HoldPrevious, NearestChange, ExactOnly, LinearReal]
}

///|
/// Create a hold-last-value sampling configuration.
pub fn sampling_config(
  start : Int64,
  end : Int64,
  step : Int64,
) -> SamplingConfig {
  {
    start,
    end: Int64::max(start, end),
    step: Int64::max(1L, step),
    mode: HoldPrevious,
    include_unknown: true,
  }
}

///|
/// Select a sampling mode.
pub fn SamplingConfig::with_mode(
  self : SamplingConfig,
  mode : SamplingMode,
) -> SamplingConfig {
  { ..self, mode, }
}

///|
/// Keep or omit points whose value is unknown.
pub fn SamplingConfig::with_unknown(
  self : SamplingConfig,
  keep_unknown : Bool,
) -> SamplingConfig {
  { ..self, include_unknown: keep_unknown }
}

///|
/// Resample every declared signal at a fixed time grid.
pub fn sample_trace(trace : Trace, config : SamplingConfig) -> SampledTrace {
  let points : Array[SamplingPoint] = []
  let mut time = Int64::max(0L, config.start)
  let end = Int64::min(trace.end_time, config.end)
  while time <= end {
    for signal in trace.signals {
      let point = sample_one(trace, signal.id, time, config.mode)
      match point.value {
        Some(value) if !config.include_unknown && value.is_unknown() => ()
        _ => points.push(point)
      }
    }
    time += config.step
  }
  {
    source_end: trace.end_time,
    config,
    sample_count: points.length(),
    known_count: points
    .filter(fn(item) {
      match item.value {
        Some(value) => !value.is_unknown()
        None => false
      }
    })
    .length(),
    points,
  }
}

///|
/// Resample one signal only.
pub fn sample_trace_signal(
  trace : Trace,
  signal_id : String,
  config : SamplingConfig,
) -> Array[SamplingPoint] {
  let result : Array[SamplingPoint] = []
  let mut time = Int64::max(0L, config.start)
  let end = Int64::min(trace.end_time, config.end)
  while time <= end {
    let point = sample_one(trace, signal_id, time, config.mode)
    match point.value {
      Some(value) if !config.include_unknown && value.is_unknown() => ()
      _ => result.push(point)
    }
    time += config.step
  }
  result
}

///|
/// Return points for one timestamp across all signals.
pub fn SampledTrace::at(
  self : SampledTrace,
  time : Int64,
) -> Array[SamplingPoint] {
  self.points.filter(fn(item) { item.time == time })
}

///|
/// Return the number of grid timestamps represented in the result.
pub fn SampledTrace::timestamp_count(self : SampledTrace) -> Int {
  let times : Array[Int64] = []
  for point in self.points {
    if !times.contains(point.time) {
      times.push(point.time)
    }
  }
  times.length()
}

///|
/// Export samples as CSV.
pub fn sampling_points_csv(points : Array[SamplingPoint]) -> String {
  let out = StringBuilder()
  out.write_string("signal,time,value,source_time,distance\n")
  for point in points {
    out.write_string(
      escape_text(point.signal_id) + "," + point.time.to_string() + ",",
    )
    out.write_string(
      match point.value {
        Some(value) => escape_text(value.display())
        None => ""
      },
    )
    out.write_string(
      "," +
      sampling_optional_i64(point.source_time) +
      "," +
      sampling_optional_i64(point.distance) +
      "\n",
    )
  }
  out.to_string()
}

///|
/// Render sampling metadata as JSON.
pub fn SampledTrace::to_json(self : SampledTrace) -> String {
  let points = self.points.map(sampling_point_json).join(",")
  "{\"sample_count\":" +
  self.sample_count.to_string() +
  ",\"known_count\":" +
  self.known_count.to_string() +
  ",\"step\":" +
  self.config.step.to_string() +
  ",\"points\":[" +
  points +
  "]}"
}

///|
/// Find the value according to the selected resampling mode.
fn sample_one(
  trace : Trace,
  signal_id : String,
  time : Int64,
  mode : SamplingMode,
) -> SamplingPoint {
  let candidates = trace.changes.filter(fn(change) { change.id == signal_id })
  let mut previous : Change? = None
  let mut next : Change? = None
  for change in candidates {
    if change.time <= time {
      previous = Some(change)
    }
    if change.time >= time {
      next = Some(change)
      break
    }
  }
  match mode {
    HoldPrevious =>
      match previous {
        Some(change) =>
          {
            signal_id,
            time,
            value: Some(change.value),
            source_time: Some(change.time),
            distance: Some(time - change.time),
          }
        None =>
          { signal_id, time, value: None, source_time: None, distance: None }
      }
    ExactOnly =>
      match candidates.filter(fn(change) { change.time == time })[0:] {
        [change, ..] =>
          {
            signal_id,
            time,
            value: Some(change.value),
            source_time: Some(change.time),
            distance: Some(0L),
          }
        _ => { signal_id, time, value: None, source_time: None, distance: None }
      }
    NearestChange => sampling_nearest(signal_id, time, previous, next)
    LinearReal => sampling_linear(signal_id, time, previous, next)
  }
}

///|
/// Select the closest known change, preferring the previous value on ties.
fn sampling_nearest(
  signal_id : String,
  time : Int64,
  previous : Change?,
  next : Change?,
) -> SamplingPoint {
  match (previous, next) {
    (Some(left), Some(right)) => {
      let left_distance = time - left.time
      let right_distance = right.time - time
      if left_distance <= right_distance {
        {
          signal_id,
          time,
          value: Some(left.value),
          source_time: Some(left.time),
          distance: Some(left_distance),
        }
      } else {
        {
          signal_id,
          time,
          value: Some(right.value),
          source_time: Some(right.time),
          distance: Some(right_distance),
        }
      }
    }
    (Some(left), None) =>
      {
        signal_id,
        time,
        value: Some(left.value),
        source_time: Some(left.time),
        distance: Some(time - left.time),
      }
    (None, Some(right)) =>
      {
        signal_id,
        time,
        value: Some(right.value),
        source_time: Some(right.time),
        distance: Some(right.time - time),
      }
    _ => { signal_id, time, value: None, source_time: None, distance: None }
  }
}

///|
/// Interpolate real values and fall back to nearest for digital values.
fn sampling_linear(
  signal_id : String,
  time : Int64,
  previous : Change?,
  next : Change?,
) -> SamplingPoint {
  match (previous, next) {
    (Some(left), Some(right)) =>
      match (left.value, right.value) {
        (Real(a), Real(b)) if right.time > left.time => {
          let ratio = (time - left.time).to_double() /
            (right.time - left.time).to_double()
          {
            signal_id,
            time,
            value: Some(Real(a + (b - a) * ratio)),
            source_time: Some(left.time),
            distance: Some(time - left.time),
          }
        }
        _ => sampling_nearest(signal_id, time, Some(left), Some(right))
      }
    _ => sampling_nearest(signal_id, time, previous, next)
  }
}

///|
/// Encode an optional timestamp.
fn sampling_optional_i64(value : Int64?) -> String {
  match value {
    Some(item) => item.to_string()
    None => ""
  }
}

///|
/// Encode one sample as JSON.
fn sampling_point_json(item : SamplingPoint) -> String {
  let value = match item.value {
    Some(item) => "\"" + escape_text(item.display()) + "\""
    None => "null"
  }
  "{\"signal\":\"" +
  escape_text(item.signal_id) +
  "\",\"time\":" +
  item.time.to_string() +
  ",\"value\":" +
  value +
  ",\"source_time\":" +
  sampling_optional_json_i64(item.source_time) +
  ",\"distance\":" +
  sampling_optional_json_i64(item.distance) +
  "}"
}

///|
/// Encode an optional integer as JSON.
fn sampling_optional_json_i64(value : Int64?) -> String {
  match value {
    Some(item) => item.to_string()
    None => "null"
  }
}