///|
/// A row of measured process data with optional operating metadata.
pub(all) struct MeasurementRow {
  id : String
  x : Double
  y : Double
  temperature_kelvin : Double
  pressure_pascal : Double
  replicate : Int
  quality : DataQuality
} derive(Debug, Eq, ToJson)

///|
pub(all) enum DataQuality {
  Accepted
  Suspect(reason~ : String)
  Rejected(reason~ : String)
} derive(Debug, Eq, ToJson)

///|
pub fn MeasurementRow::new(
  id~ : String,
  x~ : Double,
  y~ : Double,
  temperature_kelvin~ : Double,
  pressure_pascal~ : Double,
  replicate? : Int = 1,
) -> MeasurementRow raise @domain.ExtractionError {
  if id.trim().length() == 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="measurement id cannot be blank",
    )
  }
  @domain.require_finite("measurement x", x)
  @domain.require_finite("measurement y", y)
  @domain.require_positive("measurement temperature", temperature_kelvin)
  @domain.require_positive("measurement pressure", pressure_pascal)
  if replicate < 1 {
    raise @domain.ExtractionError::InvalidArgument(
      message="replicate number must be positive",
    )
  }
  {
    id,
    x,
    y,
    temperature_kelvin,
    pressure_pascal,
    replicate,
    quality: Accepted,
  }
}

///|
pub fn MeasurementRow::mark(
  self : MeasurementRow,
  quality : DataQuality,
) -> MeasurementRow {
  {
    id: self.id,
    x: self.x,
    y: self.y,
    temperature_kelvin: self.temperature_kelvin,
    pressure_pascal: self.pressure_pascal,
    replicate: self.replicate,
    quality,
  }
}

///|
pub fn MeasurementRow::is_usable(self : MeasurementRow) -> Bool {
  match self.quality {
    Accepted => true
    Suspect(_) => true
    Rejected(_) => false
  }
}

///|
pub fn MeasurementRow::relative_volatility(self : MeasurementRow) -> Double {
  if self.x <= 0.0 {
    0.0
  } else {
    self.y / self.x
  }
}

///|
pub fn MeasurementRow::temperature_celsius(self : MeasurementRow) -> Double {
  self.temperature_kelvin - 273.15
}

///|
pub(all) struct MeasurementTable {
  name : String
  rows : Array[MeasurementRow]
  x_label : String
  y_label : String
} derive(Debug, Eq, ToJson)

///|
pub fn MeasurementTable::new(
  name~ : String,
  rows~ : ArrayView[MeasurementRow],
  x_label? : String = "x",
  y_label? : String = "y",
) -> MeasurementTable raise @domain.ExtractionError {
  if name.trim().length() == 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="measurement table name cannot be blank",
    )
  }
  if rows.length() == 0 {
    raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
  }
  if x_label.trim().length() == 0 || y_label.trim().length() == 0 {
    raise @domain.ExtractionError::InvalidArgument(
      message="measurement labels cannot be blank",
    )
  }
  { name, rows: rows.to_owned(), x_label, y_label }
}

///|
pub fn MeasurementTable::name(self : MeasurementTable) -> String {
  self.name
}

///|
pub fn MeasurementTable::rows(self : MeasurementTable) -> Array[MeasurementRow] {
  self.rows.copy()
}

///|
pub fn MeasurementTable::usable_rows(
  self : MeasurementTable,
) -> Array[MeasurementRow] {
  [
    for row in self.rows if row.is_usable() => row
  ]
}

///|
pub fn MeasurementTable::accepted_count(self : MeasurementTable) -> Int {
  self.rows.fold(init=0, (count, row) => {
    if row.quality == Accepted {
      count + 1
    } else {
      count
    }
  })
}

///|
pub fn MeasurementTable::rejected_count(self : MeasurementTable) -> Int {
  self.rows.fold(init=0, (count, row) => {
    match row.quality {
      Rejected(_) => count + 1
      _ => count
    }
  })
}

///|
pub fn MeasurementTable::x_values(self : MeasurementTable) -> Array[Double] {
  [
    for row in self.rows => row.x
  ]
}

///|
pub fn MeasurementTable::y_values(self : MeasurementTable) -> Array[Double] {
  [
    for row in self.rows => row.y
  ]
}

///|
pub fn MeasurementTable::filter_x(
  self : MeasurementTable,
  lower~ : Double,
  upper~ : Double,
) -> MeasurementTable raise @domain.ExtractionError {
  @domain.require_finite("filter lower", lower)
  @domain.require_finite("filter upper", upper)
  if upper < lower {
    raise @domain.ExtractionError::InvalidArgument(
      message="filter upper bound is below lower bound",
    )
  }
  let selected = [
    for row in self.rows if row.x >= lower && row.x <= upper => row
  ]
  MeasurementTable::new(
    name=self.name,
    rows=selected,
    x_label=self.x_label,
    y_label=self.y_label,
  )
}

///|
pub fn MeasurementTable::mark_outliers(
  self : MeasurementTable,
  y_lower~ : Double,
  y_upper~ : Double,
) -> MeasurementTable raise @domain.ExtractionError {
  @domain.require_finite("outlier lower", y_lower)
  @domain.require_finite("outlier upper", y_upper)
  if y_upper < y_lower {
    raise @domain.ExtractionError::InvalidArgument(
      message="outlier upper bound is below lower bound",
    )
  }
  let rows = [
    for row in self.rows => {
      if row.y < y_lower {
        row.mark(Suspect(reason="below accepted response range"))
      } else if row.y > y_upper {
        row.mark(Suspect(reason="above accepted response range"))
      } else {
        row
      }
    }
  ]
  MeasurementTable::new(
    name=self.name,
    rows~,
    x_label=self.x_label,
    y_label=self.y_label,
  )
}

///|
fn insertion_sort_rows(rows : Array[MeasurementRow]) -> Array[MeasurementRow] {
  let sorted : Array[MeasurementRow] = []
  for row in rows {
    let mut position = sorted.length()
    for index, other in sorted {
      if row.x < other.x {
        position = index
        break
      }
    }
    sorted.push(row)
    let mut index = sorted.length() - 1
    while index > position {
      let value = sorted[index]
      sorted[index] = sorted[index - 1]
      sorted[index - 1] = value
      index -= 1
    }
  }
  sorted
}

///|
pub fn MeasurementTable::sorted_by_x(
  self : MeasurementTable,
) -> MeasurementTable raise @domain.ExtractionError {
  MeasurementTable::new(
    name=self.name,
    rows=insertion_sort_rows(self.rows.copy()),
    x_label=self.x_label,
    y_label=self.y_label,
  )
}

///|
pub fn MeasurementTable::deduplicate_x(
  self : MeasurementTable,
  tolerance? : Double = 1.0e-12,
) -> MeasurementTable raise @domain.ExtractionError {
  @domain.require_positive("deduplication tolerance", tolerance)
  let sorted = insertion_sort_rows(self.rows.copy())
  let result : Array[MeasurementRow] = []
  for row in sorted {
    if result.length() == 0 ||
      (row.x - result[result.length() - 1].x).abs() > tolerance {
      result.push(row)
    } else {
      let previous = result[result.length() - 1]
      let average = (previous.y + row.y) / 2.0
      result[result.length() - 1] = {
        id: previous.id + "+" + row.id,
        x: (previous.x + row.x) / 2.0,
        y: average,
        temperature_kelvin: (
          previous.temperature_kelvin + row.temperature_kelvin
        ) /
        2.0,
        pressure_pascal: (previous.pressure_pascal + row.pressure_pascal) / 2.0,
        replicate: previous.replicate + row.replicate,
        quality: Suspect(reason="duplicate x values averaged"),
      }
    }
  }
  MeasurementTable::new(
    name=self.name,
    rows=result,
    x_label=self.x_label,
    y_label=self.y_label,
  )
}

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

///|
fn statistics(
  values : ArrayView[Double],
) -> ColumnStatistics raise @domain.ExtractionError {
  if values.length() == 0 {
    raise @domain.ExtractionError::InsufficientData(required=1, actual=0)
  }
  let mut minimum = values[0]
  let mut maximum = values[0]
  let mut sum = 0.0
  for value in values {
    @domain.require_finite("column value", value)
    minimum = minimum.min(value)
    maximum = maximum.max(value)
    sum += value
  }
  let mean = sum / values.length().to_double()
  let variance = values.fold(init=0.0, (total, value) => {
      total + (value - mean) * (value - mean)
    }) /
    values.length().to_double()
  {
    count: values.length(),
    minimum,
    maximum,
    mean,
    standard_deviation: variance.sqrt(),
    missing_count: 0,
  }
}

///|
pub fn MeasurementTable::x_statistics(
  self : MeasurementTable,
) -> ColumnStatistics raise @domain.ExtractionError {
  statistics(self.x_values())
}

///|
pub fn MeasurementTable::y_statistics(
  self : MeasurementTable,
) -> ColumnStatistics raise @domain.ExtractionError {
  statistics(self.y_values())
}

///|
pub(all) struct TableQuality {
  rows : Int
  usable_rows : Int
  rejected_rows : Int
  duplicate_x_groups : Int
  monotone_x : Bool
  finite_values : Bool
  message : String
} derive(Debug, Eq, ToJson)

///|
pub fn MeasurementTable::quality_report(
  self : MeasurementTable,
) -> TableQuality {
  let mut monotone = true
  let mut finite = true
  let mut duplicate_groups = 0
  let sorted = insertion_sort_rows(self.rows.copy())
  for index, row in sorted {
    if row.x.is_nan() || row.x.is_inf() || row.y.is_nan() || row.y.is_inf() {
      finite = false
    }
    if index > 0 {
      if row.x < sorted[index - 1].x {
        monotone = false
      }
      if row.x == sorted[index - 1].x {
        duplicate_groups += 1
      }
    }
  }
  let usable = self.usable_rows().length()
  let message = if !finite {
    "non-finite values detected"
  } else if !monotone {
    "x coordinates are not monotone"
  } else if duplicate_groups > 0 {
    "duplicate x coordinates detected"
  } else {
    "table is internally consistent"
  }
  {
    rows: self.rows.length(),
    usable_rows: usable,
    rejected_rows: self.rejected_count(),
    duplicate_x_groups: duplicate_groups,
    monotone_x: monotone,
    finite_values: finite,
    message,
  }
}

///|
pub fn MeasurementTable::moving_average(
  self : MeasurementTable,
  window~ : Int,
) -> Array[Double] raise @domain.ExtractionError {
  if window < 1 || window > self.rows.length() {
    raise @domain.ExtractionError::InvalidArgument(
      message="moving-average window must fit table",
    )
  }
  let values = self.y_values()
  [
    for index in 0.. {
      let start = (index + 1 - window).max(0)
      let stop = index + 1
      let mut sum = 0.0
      for cursor in start.. Double raise @domain.ExtractionError {
  let xs = self.x_values()
  let ys = self.y_values()
  let x_mean = statistics(xs).mean
  let y_mean = statistics(ys).mean
  let mut numerator = 0.0
  let mut x_sum = 0.0
  let mut y_sum = 0.0
  for index in 0.. String {
  let lines = [
    "id,\{self.x_label},\{self.y_label},temperature_kelvin,pressure_pascal,replicate,quality",
  ]
  for row in self.rows {
    let quality = match row.quality {
      Accepted => "accepted"
      Suspect(reason~) => "suspect:\{reason}"
      Rejected(reason~) => "rejected:\{reason}"
    }
    lines.push(
      "\{row.id},\{row.x},\{row.y},\{row.temperature_kelvin},\{row.pressure_pascal},\{row.replicate},\{quality}",
    )
  }
  lines.join("\n")
}

///|
/// Resample a monotone table onto evenly spaced x coordinates.
pub fn resample_linear(
  table : MeasurementTable,
  lower~ : Double,
  upper~ : Double,
  count~ : Int,
) -> Array[MeasurementRow] raise @domain.ExtractionError {
  if count < 2 {
    raise @domain.ExtractionError::InsufficientData(required=2, actual=count)
  }
  let sorted = table.sorted_by_x()
  let rows = sorted.rows
  if upper <= lower || lower < rows[0].x || upper > rows[rows.length() - 1].x {
    raise @domain.ExtractionError::OutOfRange(
      name="resampling range",
      value=lower.min(upper),
      lower=rows[0].x,
      upper=rows[rows.length() - 1].x,
    )
  }
  [
    for index in 0.. {
      let x = lower +
        (upper - lower) * index.to_double() / (count - 1).to_double()
      let mut right = 1
      while right < rows.length() && rows[right].x < x {
        right += 1
      }
      let left = right - 1
      let fraction = if rows[right].x == rows[left].x {
        0.0
      } else {
        (x - rows[left].x) / (rows[right].x - rows[left].x)
      }
      let y = rows[left].y + fraction * (rows[right].y - rows[left].y)
      MeasurementRow::new(
        id="resampled-{index}",
        x~,
        y~,
        temperature_kelvin=rows[left].temperature_kelvin +
          fraction *
          (rows[right].temperature_kelvin - rows[left].temperature_kelvin),
        pressure_pascal=rows[left].pressure_pascal +
          fraction * (rows[right].pressure_pascal - rows[left].pressure_pascal),
      )
    }
  ]
}

///|
pub fn merge_tables(
  first : MeasurementTable,
  second : MeasurementTable,
  name~ : String,
) -> MeasurementTable raise @domain.ExtractionError {
  let rows = first.rows.copy()
  for row in second.rows {
    rows.push(row)
  }
  MeasurementTable::new(
    name~,
    rows~,
    x_label=first.x_label,
    y_label=first.y_label,
  )
}