///|
/// A contiguous run of one waveform token.
pub struct SignalRun {
  signal_id : String
  start : Int64
  end : Int64
  token : String
  duration : Int64
  change_count : Int
} derive(Debug)

///|
/// A burst or idle segment derived from aggregate activity.
pub struct ActivitySegment {
  start : Int64
  end : Int64
  changes : Int
  active : Bool
  density : Double
} derive(Debug)

///|
/// Distribution statistics for one signal.
pub struct TraceDistribution {
  signal_id : String
  sample_count : Int
  distinct_tokens : Int
  minimum_run : Int64?
  maximum_run : Int64?
  mean_run : Double?
  median_run : Int64?
  p95_run : Int64?
  entropy : Double
  transition_rate : Double
} derive(Debug)

///|
/// Options controlling activity segmentation.
pub struct ActivitySegmentOptions {
  bucket_width : Int64
  active_threshold : Int
  include_empty : Bool
} derive(Debug)

///|
/// Create an activity segmentation policy.
pub fn activity_segment_options(bucket_width : Int64) -> ActivitySegmentOptions {
  {
    bucket_width: Int64::max(1L, bucket_width),
    active_threshold: 1,
    include_empty: true,
  }
}

///|
/// Set the minimum changes required for an active bucket.
pub fn ActivitySegmentOptions::with_threshold(
  self : ActivitySegmentOptions,
  threshold : Int,
) -> ActivitySegmentOptions {
  { ..self, active_threshold: Int::max(1, threshold) }
}

///|
/// Include or omit zero-change buckets.
pub fn ActivitySegmentOptions::with_empty(
  self : ActivitySegmentOptions,
  include_empty : Bool,
) -> ActivitySegmentOptions {
  { ..self, include_empty, }
}

///|
/// Extract stable runs for one signal, including the final run.
pub fn signal_runs(trace : Trace, signal_id : String) -> Array[SignalRun] {
  let result : Array[SignalRun] = []
  let mut active : SignalRun? = None
  for change in trace.changes {
    if change.id != signal_id {
      continue
    }
    let token = change.value.display()
    match active {
      Some(run) if run.token == token =>
        active = Some({
          ..run,
          end: change.time,
          duration: Int64::max(0L, change.time - run.start),
          change_count: run.change_count + 1,
        })
      Some(run) => {
        result.push(run)
        active = Some({
          signal_id,
          start: change.time,
          end: trace.end_time,
          token,
          duration: Int64::max(0L, trace.end_time - change.time),
          change_count: 1,
        })
      }
      None =>
        active = Some({
          signal_id,
          start: change.time,
          end: trace.end_time,
          token,
          duration: Int64::max(0L, trace.end_time - change.time),
          change_count: 1,
        })
    }
  }
  match active {
    Some(run) => result.push(run)
    None => ()
  }
  result
}

///|
/// Calculate a distribution report for one signal.
pub fn signal_distribution(
  trace : Trace,
  signal_id : String,
) -> TraceDistribution {
  let runs = signal_runs(trace, signal_id)
  let durations = runs.map(fn(item) { item.duration })
  durations.sort()
  let distinct = runs
    .map(fn(item) { item.token })
    .fold(init=[], fn(values, token) {
      if !values.contains(token) {
        values.push(token)
      }
      values
    })
  let total = durations.fold(init=0L, fn(sum, item) { sum + item })
  let mean = if durations.length() == 0 {
    None
  } else {
    Some(total.to_double() / durations.length().to_double())
  }
  let median = trace_percentile(durations, 0.50)
  let p95 = trace_percentile(durations, 0.95)
  let min = trace_minimum(durations)
  let max = trace_maximum(durations)
  let transitions = Int64::max(0L, runs.length().to_int64() - 1L)
  let rate = if trace.end_time > 0L {
    transitions.to_double() / trace.end_time.to_double()
  } else {
    0.0
  }
  {
    signal_id,
    sample_count: trace.changes
    .filter(fn(item) { item.id == signal_id })
    .length(),
    distinct_tokens: distinct.length(),
    minimum_run: min,
    maximum_run: max,
    mean_run: mean,
    median_run: median,
    p95_run: p95,
    entropy: value_entropy(trace, signal_id),
    transition_rate: rate,
  }
}

///|
/// Calculate distributions for every declared signal.
pub fn all_signal_distributions(trace : Trace) -> Array[TraceDistribution] {
  trace.signals.map(fn(signal) { signal_distribution(trace, signal.id) })
}

///|
/// Divide a trace into fixed activity segments.
pub fn activity_segments(
  trace : Trace,
  options : ActivitySegmentOptions,
) -> Array[ActivitySegment] {
  let result : Array[ActivitySegment] = []
  let width = Int64::max(1L, options.bucket_width)
  let bucket_count = if trace.end_time <= 0L {
    0
  } else {
    ((trace.end_time + width - 1L) / width).to_int()
  }
  for index in 0..= start && change.time < end })
      .length()
    if options.include_empty || count > 0 {
      let density = if end > start {
        count.to_double() / (end - start).to_double()
      } else {
        0.0
      }
      result.push({
        start,
        end,
        changes: count,
        active: count >= options.active_threshold,
        density,
      })
    }
  }
  result
}

///|
/// Merge adjacent segments with the same activity state.
pub fn merge_activity_segments(
  segments : Array[ActivitySegment],
) -> Array[ActivitySegment] {
  let result : Array[ActivitySegment] = []
  for segment in segments {
    match result.last() {
      Some(previous) if previous.active == segment.active &&
        previous.end == segment.start =>
        result[result.length() - 1] = {
          start: previous.start,
          end: segment.end,
          changes: previous.changes + segment.changes,
          active: previous.active,
          density: if segment.end > previous.start {
            (previous.changes + segment.changes).to_double() /
            (segment.end - previous.start).to_double()
          } else {
            0.0
          },
        }
      _ => result.push(segment)
    }
  }
  result
}

///|
/// Return the busiest segment, if the trace contains one.
pub fn busiest_activity_segment(
  segments : Array[ActivitySegment],
) -> ActivitySegment? {
  segments.fold(init=None, fn(current, item) {
    match current {
      Some(previous) if item.density > previous.density => Some(item)
      Some(previous) => Some(previous)
      None => Some(item)
    }
  })
}

///|
/// Compute a histogram of run durations using a fixed bucket size.
pub fn run_duration_histogram(
  trace : Trace,
  signal_id : String,
  bucket : Int64,
) -> Array[(Int64, Int)] {
  let width = Int64::max(1L, bucket)
  let result : Array[(Int64, Int)] = []
  for run in signal_runs(trace, signal_id) {
    let key = run.duration / width * width
    let mut found = false
    for index, item in result {
      if item.0 == key {
        result[index] = (key, item.1 + 1)
        found = true
        break
      }
    }
    if !found {
      result.push((key, 1))
    }
  }
  result.sort_by(fn(left, right) {
    if left.0 < right.0 {
      -1
    } else if left.0 > right.0 {
      1
    } else {
      0
    }
  })
  result
}

///|
/// Export distribution data as CSV.
pub fn trace_distributions_csv(items : Array[TraceDistribution]) -> String {
  let out = StringBuilder()
  out.write_string(
    "signal,samples,distinct,min,max,mean,median,p95,entropy,transition_rate\n",
  )
  for item in items {
    out.write_string(
      escape_text(item.signal_id) +
      "," +
      item.sample_count.to_string() +
      "," +
      item.distinct_tokens.to_string() +
      ",",
    )
    out.write_string(
      trace_optional_i64(item.minimum_run) +
      "," +
      trace_optional_i64(item.maximum_run) +
      ",",
    )
    out.write_string(
      trace_optional_double(item.mean_run) +
      "," +
      trace_optional_i64(item.median_run) +
      "," +
      trace_optional_i64(item.p95_run) +
      ",",
    )
    out.write_string(
      item.entropy.to_string() + "," + item.transition_rate.to_string() + "\n",
    )
  }
  out.to_string()
}

///|
/// Export activity segments as CSV.
pub fn activity_segments_csv(segments : Array[ActivitySegment]) -> String {
  let out = StringBuilder()
  out.write_string("start,end,changes,active,density\n")
  for item in segments {
    out.write_string(
      item.start.to_string() +
      "," +
      item.end.to_string() +
      "," +
      item.changes.to_string() +
      "," +
      item.active.to_string() +
      "," +
      item.density.to_string() +
      "\n",
    )
  }
  out.to_string()
}

///|
/// Render one distribution in a compact text line.
pub fn TraceDistribution::to_text(self : TraceDistribution) -> String {
  self.signal_id +
  " samples=" +
  self.sample_count.to_string() +
  " distinct=" +
  self.distinct_tokens.to_string() +
  " median=" +
  trace_optional_i64(self.median_run) +
  " p95=" +
  trace_optional_i64(self.p95_run) +
  " entropy=" +
  self.entropy.to_string()
}

///|
/// Return an inclusive percentile from sorted values.
fn trace_percentile(values : Array[Int64], fraction : Double) -> Int64? {
  if values.length() == 0 {
    return None
  }
  let normalized = if fraction < 0.0 {
    0.0
  } else if fraction > 1.0 {
    1.0
  } else {
    fraction
  }
  let position = (values.length() - 1).to_double() * normalized
  Some(values[position.to_int()])
}

///|
/// Minimum of a sorted non-empty array.
fn trace_minimum(values : Array[Int64]) -> Int64? {
  if values.length() == 0 {
    None
  } else {
    Some(values[0])
  }
}

///|
/// Maximum of a sorted non-empty array.
fn trace_maximum(values : Array[Int64]) -> Int64? {
  if values.length() == 0 {
    None
  } else {
    Some(values[values.length() - 1])
  }
}

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

///|
/// Encode an optional floating point value.
fn trace_optional_double(value : Double?) -> String {
  match value {
    Some(item) => item.to_string()
    None => ""
  }
}