///|
/// Reusable immutable feature transformations for model pipelines.
pub enum FeatureTransform {
  Identity
  MinMax(lower~ : Double, upper~ : Double)
  ZScore(center~ : Double, scale~ : Double)
  Clip(lower~ : Double, upper~ : Double)
  Threshold(cutoff~ : Double)
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn FeatureTransform::apply(
  self : FeatureTransform,
  value : Double,
) -> Double {
  match self {
    Identity => value
    MinMax(lower~, upper~) =>
      if upper == lower {
        0.0
      } else {
        (value - lower) / (upper - lower)
      }
    ZScore(center~, scale~) =>
      if scale == 0.0 {
        0.0
      } else {
        (value - center) / scale
      }
    Clip(lower~, upper~) => value.max(lower).min(upper)
    Threshold(cutoff~) => if value >= cutoff { 1.0 } else { 0.0 }
  }
}

///|
pub fn FeatureTransform::apply_all(
  self : FeatureTransform,
  values : Array[Double],
) -> Array[Double] {
  values.map(fn(value) { self.apply(value) })
}

///|
pub fn FeatureTransform::inverse(
  self : FeatureTransform,
  value : Double,
) -> Double {
  match self {
    Identity => value
    MinMax(lower~, upper~) => lower + value * (upper - lower)
    ZScore(center~, scale~) => center + value * scale
    Clip(..) => value
    Threshold(_) => value
  }
}

///|
pub struct FeatureColumn {
  name : String
  transform : FeatureTransform
  values : Array[Double]
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn FeatureColumn::new(
  name : String,
  values : Array[Double],
) -> FeatureColumn {
  { name, transform: Identity, values: values.copy() }
}

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

///|
pub fn FeatureColumn::values(self : FeatureColumn) -> Array[Double] {
  self.transform.apply_all(self.values)
}

///|
pub fn FeatureColumn::raw_values(self : FeatureColumn) -> Array[Double] {
  self.values.copy()
}

///|
pub fn FeatureColumn::fit_minmax(self : FeatureColumn) -> FeatureColumn {
  if self.values.length() == 0 {
    return self
  }
  let lower = self.values.fold(init=self.values[0], (current, value) => {
    current.min(value)
  })
  let upper = self.values.fold(init=self.values[0], (current, value) => {
    current.max(value)
  })
  { ..self, transform: MinMax(lower~, upper~) }
}

///|
pub fn FeatureColumn::fit_zscore(self : FeatureColumn) -> FeatureColumn {
  {
    ..self,
    transform: ZScore(
      center=mean(self.values),
      scale=variance(self.values).sqrt(),
    ),
  }
}

///|
pub fn FeatureColumn::clip(
  self : FeatureColumn,
  lower : Double,
  upper : Double,
) -> FeatureColumn {
  { ..self, transform: Clip(lower~, upper~) }
}

///|
pub fn FeatureColumn::threshold(
  self : FeatureColumn,
  cutoff : Double,
) -> FeatureColumn {
  { ..self, transform: Threshold(cutoff~) }
}

///|
pub fn FeatureColumn::missing_count(
  self : FeatureColumn,
  is_missing : (Double) -> Bool,
) -> Int {
  self.values.fold(init=0, (count, value) => {
    if is_missing(value) {
      count + 1
    } else {
      count
    }
  })
}

///|
pub fn FeatureColumn::fill(
  self : FeatureColumn,
  replacement : Double,
  is_missing : (Double) -> Bool,
) -> FeatureColumn {
  {
    ..self,
    values: self.values.map(fn(value) {
      if is_missing(value) {
        replacement
      } else {
        value
      }
    }),
  }
}

///|
pub struct FeaturePipeline {
  columns : Array[FeatureColumn]
  fitted : Bool
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn FeaturePipeline::new(columns : Array[FeatureColumn]) -> FeaturePipeline {
  { columns: columns.copy(), fitted: false }
}

///|
pub fn FeaturePipeline::column_count(self : FeaturePipeline) -> Int {
  self.columns.length()
}

///|
pub fn FeaturePipeline::fit_minmax(self : FeaturePipeline) -> FeaturePipeline {
  {
    columns: self.columns.map(fn(column) { column.fit_minmax() }),
    fitted: true,
  }
}

///|
pub fn FeaturePipeline::fit_zscore(self : FeaturePipeline) -> FeaturePipeline {
  {
    columns: self.columns.map(fn(column) { column.fit_zscore() }),
    fitted: true,
  }
}

///|
pub fn FeaturePipeline::columns(self : FeaturePipeline) -> Array[FeatureColumn] {
  self.columns.copy()
}

///|
pub fn FeaturePipeline::transform(self : FeaturePipeline) -> Dataset {
  if self.columns.length() == 0 {
    abort("FeaturePipeline: columns must not be empty")
  }
  let names = self.columns.map(fn(column) { column.name() })
  let length = self.columns[0].values().length()
  let rows = []
  for row_index in 0.. Array[Double] {
  if row.length() != self.column_count() {
    abort("FeaturePipeline: row width mismatch")
  }
  row.mapi(fn(index, value) { self.columns[index].transform.apply(value) })
}

///|
pub fn FeaturePipeline::with_column(
  self : FeaturePipeline,
  column : FeatureColumn,
) -> FeaturePipeline {
  let columns = self.columns.copy()
  columns.push(column)
  { columns, fitted: false }
}

///|
pub fn FeaturePipeline::drop(
  self : FeaturePipeline,
  name : String,
) -> FeaturePipeline {
  {
    columns: self.columns.filter(fn(column) { column.name() != name }),
    fitted: false,
  }
}

///|
pub fn FeaturePipeline::describe(self : FeaturePipeline) -> Array[OnlineStats] {
  self.columns.map(fn(column) { OnlineStats::new().push_all(column.values()) })
}

///|
pub fn polynomial_features(
  values : Array[Double],
  degree : Int,
) -> Array[Double] {
  if degree < 0 {
    abort("polynomial_features: degree must be non-negative")
  }
  let result = []
  for value in values {
    let mut power = 1.0
    for _ in 0..<=degree {
      result.push(power)
      power = power * value
    }
  }
  result
}

///|
pub fn pairwise_products(values : Array[Double]) -> Array[Double] {
  let result = []
  for left in 0.. Array[Double] {
  if index < 0 || index >= categories || categories <= 0 {
    abort("one_hot: invalid category")
  }
  let result = Array::make(categories, 0.0)
  result[index] = 1.0
  result
}

///|
pub fn one_hot_all(
  indices : Array[Int],
  categories : Int,
) -> Array[Array[Double]] {
  indices.map(fn(index) { one_hot(index, categories) })
}

///|
pub fn interaction_matrix(
  left : Array[Double],
  right : Array[Double],
) -> DenseMatrix {
  outer_product(left, right)
}

///|
pub fn impute_mean(
  values : Array[Double],
  is_missing : (Double) -> Bool,
) -> Array[Double] {
  let observed = values.filter(fn(value) { !is_missing(value) })
  if observed.length() == 0 {
    abort("impute_mean: no observed values")
  }
  let replacement = mean(observed)
  values.map(fn(value) { if is_missing(value) { replacement } else { value } })
}

///|
pub fn impute_median(
  values : Array[Double],
  is_missing : (Double) -> Bool,
) -> Array[Double] {
  let observed = values.filter(fn(value) { !is_missing(value) })
  if observed.length() == 0 {
    abort("impute_median: no observed values")
  }
  let replacement = median(observed)
  values.map(fn(value) { if is_missing(value) { replacement } else { value } })
}

///|
pub fn winsorize(
  values : Array[Double],
  lower_probability : Double,
  upper_probability : Double,
) -> Array[Double] {
  let lower = quantile(values, lower_probability)
  let upper = quantile(values, upper_probability)
  values.map(fn(value) { value.max(lower).min(upper) })
}

///|
pub fn rank_features(matrix : Array[Array[Double]]) -> Array[Int] {
  if matrix.length() == 0 {
    return []
  }
  let scores = Array::make(matrix[0].length(), 0.0)
  for row in matrix {
    for index, value in row {
      scores[index] = scores[index] + value.abs()
    }
  }
  rank_descending(scores)
}