///|
/// Time-series utilities for sampled process measurements.
pub(all) struct TimePoint {
  timestamp : Double
  value : Double
  quality : DataQuality
} derive(Debug, Eq, ToJson)

///|
pub(all) struct TimeSeries {
  name : String
  unit : String
  points : Array[TimePoint]
  sampling_interval : Double
} derive(Debug, Eq, ToJson)

///|
pub(all) struct TrendResult {
  slope : Double
  intercept : Double
  r_squared : Double
  first_value : Double
  last_value : Double
  direction : String
} derive(Debug, Eq, ToJson)

///|
pub(all) struct WindowSummary {
  start : Double
  end : Double
  count : Int
  mean : Double
  minimum : Double
  maximum : Double
  standard_deviation : Double
} derive(Debug, Eq, ToJson)

///|
pub fn TimeSeries::new(
  name~ : String,
  unit~ : String,
  timestamps : ArrayView[Double],
  values : ArrayView[Double],
  interval? : Double = 0.0,
) -> TimeSeries raise @domain.ExtractionError {
  if timestamps.length() != values.length() || timestamps.length() == 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="time series arrays are invalid",
    )
  }
  let points = []
  let mut previous = timestamps[0]
  for index in 0.. 0 && timestamps[index] <= previous {
      raise @domain.ExtractionError::UnsortedData(index~)
    }
    previous = timestamps[index]
    points.push({
      timestamp: timestamps[index],
      value: values[index],
      quality: Accepted,
    })
  }
  let sampling = if interval > 0.0 {
    interval
  } else if points.length() > 1 {
    (points[points.length() - 1].timestamp - points[0].timestamp) /
    (points.length() - 1).to_double()
  } else {
    0.0
  }
  { name, unit, points, sampling_interval: sampling }
}

///|
pub fn TimeSeries::timestamps(self : TimeSeries) -> Array[Double] {
  [
    for point in self.points => point.timestamp
  ]
}

///|
pub fn TimeSeries::values(self : TimeSeries) -> Array[Double] {
  [
    for point in self.points => point.value
  ]
}

///|
pub fn TimeSeries::mark_quality(
  self : TimeSeries,
  predicate : (Double) -> DataQuality,
) -> TimeSeries {
  let points = [
    for point in self.points => {
      {
        timestamp: point.timestamp,
        value: point.value,
        quality: predicate(point.value),
      }
    }
  ]
  {
    name: self.name,
    unit: self.unit,
    points,
    sampling_interval: self.sampling_interval,
  }
}

///|
pub fn TimeSeries::valid_points(self : TimeSeries) -> Array[TimePoint] {
  let points = []
  for point in self.points {
    if point.quality == Accepted {
      points.push(point)
    }
  }
  points
}

///|
pub fn TimeSeries::slice(
  self : TimeSeries,
  start : Double,
  end : Double,
) -> TimeSeries raise @domain.ExtractionError {
  if start > end {
    raise @domain.ExtractionError::InvalidArgument(
      message="time slice is reversed",
    )
  }
  let points = []
  for point in self.points {
    if point.timestamp >= start && point.timestamp <= end {
      points.push(point)
    }
  }
  if points.length() == 0 {
    raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
  }
  {
    name: self.name,
    unit: self.unit,
    points,
    sampling_interval: self.sampling_interval,
  }
}

///|
pub fn TimeSeries::mean(self : TimeSeries) -> Double {
  let points = self.valid_points()
  if points.length() == 0 {
    return 0.0
  }
  let mut total = 0.0
  for point in points {
    total += point.value
  }
  total / points.length().to_double()
}

///|
pub fn TimeSeries::minimum(self : TimeSeries) -> Double {
  let points = self.valid_points()
  if points.length() == 0 {
    return 0.0
  }
  let mut result = points[0].value
  for point in points {
    result = result.min(point.value)
  }
  result
}

///|
pub fn TimeSeries::maximum(self : TimeSeries) -> Double {
  let points = self.valid_points()
  if points.length() == 0 {
    return 0.0
  }
  let mut result = points[0].value
  for point in points {
    result = result.max(point.value)
  }
  result
}

///|
pub fn TimeSeries::variance(self : TimeSeries) -> Double {
  let points = self.valid_points()
  if points.length() == 0 {
    return 0.0
  }
  let average = self.mean()
  let mut total = 0.0
  for point in points {
    let difference = point.value - average
    total += difference * difference
  }
  total / points.length().to_double()
}

///|
pub fn TimeSeries::standard_deviation(self : TimeSeries) -> Double {
  self.variance().sqrt()
}

///|
pub fn TimeSeries::linear_trend(self : TimeSeries) -> TrendResult {
  let points = self.valid_points()
  if points.length() == 0 {
    return {
      slope: 0.0,
      intercept: 0.0,
      r_squared: 0.0,
      first_value: 0.0,
      last_value: 0.0,
      direction: "flat",
    }
  }
  let mean_x = points.fold(init=0.0, (sum, point) => sum + point.timestamp) /
    points.length().to_double()
  let mean_y = points.fold(init=0.0, (sum, point) => sum + point.value) /
    points.length().to_double()
  let mut covariance = 0.0
  let mut variance_x = 0.0
  let mut variance_y = 0.0
  for point in points {
    let dx = point.timestamp - mean_x
    let dy = point.value - mean_y
    covariance += dx * dy
    variance_x += dx * dx
    variance_y += dy * dy
  }
  let slope = if variance_x == 0.0 { 0.0 } else { covariance / variance_x }
  let intercept = mean_y - slope * mean_x
  let correlation = if variance_x == 0.0 || variance_y == 0.0 {
    0.0
  } else {
    covariance / (variance_x * variance_y).sqrt()
  }
  {
    slope,
    intercept,
    r_squared: correlation * correlation,
    first_value: points[0].value,
    last_value: points[points.length() - 1].value,
    direction: if slope > 1.0e-12 {
      "increasing"
    } else if slope < -1.0e-12 {
      "decreasing"
    } else {
      "flat"
    },
  }
}

///|
pub fn moving_average(
  series : TimeSeries,
  window : Int,
) -> TimeSeries raise @domain.ExtractionError {
  if window < 1 || window > series.points.length() {
    raise @domain.ExtractionError::InvalidArgument(
      message="moving-average window is invalid",
    )
  }
  let points = []
  for index in 0.. TimeSeries raise @domain.ExtractionError {
  if window < 1 || window > series.points.length() {
    raise @domain.ExtractionError::InvalidArgument(
      message="moving-sum window is invalid",
    )
  }
  let points = []
  for index in 0.. Array[WindowSummary] raise @domain.ExtractionError {
  if window < 1 || window > series.points.length() {
    raise @domain.ExtractionError::InvalidArgument(
      message="rolling-summary window is invalid",
    )
  }
  let summaries = []
  for index in 0.. Array[TimePoint] {
  let changes = []
  for index in 1..= threshold {
      changes.push(current)
    }
  }
  changes
}

///|
pub fn detect_spikes(
  series : TimeSeries,
  threshold : Double,
) -> Array[TimePoint] {
  let spikes = []
  let average = series.mean()
  let deviation = series.standard_deviation()
  for point in series.points {
    if deviation > 0.0 && (point.value - average).abs() >= threshold * deviation {
      spikes.push(point)
    }
  }
  spikes
}

///|
pub fn autocorrelation(
  series : TimeSeries,
  lag : Int,
) -> Double raise @domain.ExtractionError {
  let points = series.valid_points()
  if lag < 0 || lag >= points.length() {
    raise @domain.ExtractionError::OutOfRange(
      name="lag",
      value=lag.to_double(),
      lower=0.0,
      upper=(points.length() - 1).to_double(),
    )
  }
  let average = series.mean()
  let mut numerator = 0.0
  let mut denominator = 0.0
  for index in 0.. TimeSeries raise @domain.ExtractionError {
  if interval <= 0.0 || series.points.length() == 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="resampling interval is invalid",
    )
  }
  let start = series.points[0].timestamp
  let end = series.points[series.points.length() - 1].timestamp
  let count = ((end - start) / interval).to_int() + 1
  let points = []
  for index in 0.. String {
  let lines = ["timestamp,value,quality"]
  for point in series.points {
    let quality = match point.quality {
      Accepted => "accepted"
      Suspect(reason~) => "suspect:" + reason
      Rejected(reason~) => "rejected:" + reason
    }
    lines.push(
      point.timestamp.to_string() +
      "," +
      point.value.to_string() +
      "," +
      quality,
    )
  }
  lines.join("\n")
}

///|
pub fn time_series_markdown(series : TimeSeries) -> String {
  let trend = series.linear_trend()
  let lines = [
    "# " + series.name,
    "",
    "Unit: " + series.unit,
    "Sampling interval: " + series.sampling_interval.to_string(),
    "Trend: " + trend.direction,
    "",
    "| Time | Value | Quality |",
    "|---:|---:|---|",
  ]
  for point in series.points {
    let quality = match point.quality {
      Accepted => "accepted"
      Suspect(reason~) => "suspect:" + reason
      Rejected(reason~) => "rejected:" + reason
    }
    lines.push(
      "| " +
      point.timestamp.to_string() +
      " | " +
      point.value.to_string() +
      " | " +
      quality +
      " |",
    )
  }
  lines.join("\n")
}