// Statistics on a `Series`. Split from `series.mbt` so the structural
// surface stays narrow; everything here is a pure reduction over the
// underlying column.

///|
/// Count of non-null cells.
pub fn Series::count(self : Series) -> Int {
  self.len() - self.null_count()
}

///|
/// Sum of non-null cells.
///
/// - `Int` series → `Scalar::Int(sum)`, accumulated in 64-bit `Int64`
///   (so only sums past 2^63 overflow, not the old 32-bit ceiling).
/// - `Float` series → `Scalar::Float(sum)`, accumulated in 64-bit
///   `Double`. A `NaN` cell is a valid value, not missing, so it
///   participates and **propagates**: any non-null `NaN` makes the sum
///   `NaN` (Polars semantics; only `Null` is skipped).
/// - Empty / all-null numeric series → `Scalar::Int(0)` or
///   `Scalar::Float(0.0)` (additive identity). An all-`NaN` `Float` series
///   sums to `NaN`, not the identity — every cell is a present value.
/// - Non-numeric series (`Bool`, `String`) → `raise TypeMismatch`.
pub fn Series::sum(self : Series) -> @types.Scalar raise @types.DataError {
  // Numeric fast path: a bitmap-free tight fold over the raw array — no
  // `validity().to_bools()` materialisation and no per-slot `if valid[i]`
  // branch (the `NumericColumn` reduction win, S1). Total there: every dtype
  // a `NumericColumn` holds is numeric, so it never raises.
  if self.storage is @column.ColumnStorage::Numeric(nc) {
    return nc.sum()
  }
  // General `Builtin` backend: the shared `reduce_arith` fold over the whole
  // column (`0 ..< len`) — `Int` / `Float` reduce over the validity mask,
  // every other dtype raises `TypeMismatch`, all from the one kernel `agg`
  // and `eval_agg` also reduce through.
  reduce_arith(self.storage, want_mean=false)(self.len(), k => k)
}

///|
/// Arithmetic mean of non-null cells, returned as `Double`.
///
/// `Int` columns accumulate the numerator in `Double` (the mean's output
/// type), so a large-magnitude column cannot wrap past 2^63 and flip the
/// mean's sign; `Float` columns accumulate in `Double`. The division is
/// performed in `Double`. (`Series::sum`, by contrast, keeps the `Int` dtype
/// and so accumulates in `Int64`, overflowing past 2^63.)
///
/// The denominator is the **non-null** count (`Series::count`), so a
/// `Float` `NaN` is a present value that both counts toward the divisor
/// and **propagates**: any non-null `NaN` makes the mean `NaN` (Polars
/// semantics; only `Null` is skipped).
///
/// - Empty / all-null numeric series → `raise InvalidOperation(...)`.
/// - Non-numeric series → `raise TypeMismatch(...)`.
pub fn Series::mean(self : Series) -> Double raise @types.DataError {
  // Numeric fast path: bitmap-free fold with an all-valid denominator
  // (`len`). The empty case raises here, at the `Series` surface, with the
  // same `InvalidOperation` message the `Builtin` path below produces — so the
  // public error depends on the logical failure (empty / all-null), not on the
  // storage backend. `NumericColumn::mean` keeps its own column-precise wording
  // for direct `@column` callers; this guard means it is only reached non-empty.
  if self.storage is @column.ColumnStorage::Numeric(nc) {
    if self.len() == 0 {
      raise @types.DataError::InvalidOperation("mean of empty/all-null series")
    }
    return nc.mean()
  }
  // General `Builtin` backend: the shared `reduce_arith` mean reduces the
  // whole column to a `Scalar`, raising `TypeMismatch` for a non-numeric
  // dtype (before producing a value) and returning `Scalar::Null` for an
  // empty / all-null column — which this surface translates to
  // `InvalidOperation`, the distinction `mean_opt` / the grouped reducer
  // resolve differently.
  match reduce_arith(self.storage, want_mean=true)(self.len(), k => k) {
    @types.Scalar::Float(m) => m
    _ =>
      raise @types.DataError::InvalidOperation("mean of empty/all-null series")
  }
}

///|
/// Total arithmetic mean: `Some(mean)` for a numeric series with at least
/// one non-null cell, `None` for a non-numeric, empty, or all-null series —
/// i.e. `None` exactly where `Series::mean` raises (`TypeMismatch` /
/// `InvalidOperation`). This is the total form `frame`'s `DataFrame::describe`
/// summarises with, mirroring the total `min` / `max`;
/// `Series::mean` remains the raising public form that distinguishes the two
/// failure causes, and this folds both of them into `None`. A `Float` `NaN` is
/// summed in and propagates to `Some(NaN)`, matching `mean` (Polars — only
/// `Null` is missing). Exercised directly by this package's tests and, across
/// the package boundary, by `DataFrame::describe` (which summarises numeric,
/// non-numeric, and all-null columns, so both arms below are reached).
#doc(hidden)
#internal(engine, "MoonFrame execution engine API")
pub fn Series::mean_opt(self : Series) -> Double? {
  Some(self.mean()) catch {
    _ => None
  }
}

///|
/// Minimum non-null cell as a `Scalar`, by the natural order of the
/// series' dtype (Polars' `Series.min`). Total — every dtype has an
/// order, so it never fails (unlike the `Result`-wrapped form this
/// replaces).
///
/// - Empty / all-null → `Scalar::Null`.
/// - `Float` NaN is skipped (treated as missing), matching `sort` and
///   Polars' regular `min` / `max`, which ignore `NaN` — distinct from the
///   propagating `nan_min` / `nan_max`. (This differs from `sum` / `mean`,
///   where `NaN` propagates — exactly as in Polars.) A series of only NaN
///   (and/or nulls) → `Scalar::Null`.
/// - `Bool` order: `false < true`.
pub fn Series::min(self : Series) -> @types.Scalar {
  // Numeric fast path: bitmap-free extremum fold (NaN still skipped, empty
  // → `Null`). No validity materialisation.
  if self.storage is @column.ColumnStorage::Numeric(nc) {
    return nc.min()
  }
  // General `Builtin` backend: the shared `reduce_extremum` fold over the
  // whole column — total over every dtype (`Bool` `false < true`, `String`
  // lexicographic), the same kernel the grouped / scoped `min` reduce
  // through, with `NaN` skipped and empty / all-null → `Null`.
  reduce_extremum(self.storage, want_min=true)(self.len(), k => k)
}

///|
/// Maximum non-null cell as a `Scalar` (Polars' `Series.max`). Mirrors
/// `min` with the order reversed; the `Float` NaN and empty-series rules
/// are identical. Total — unlike the `Result`-wrapped form this
/// replaces.
pub fn Series::max(self : Series) -> @types.Scalar {
  if self.storage is @column.ColumnStorage::Numeric(nc) {
    return nc.max()
  }
  reduce_extremum(self.storage, want_min=false)(self.len(), k => k)
}

///|
/// Number of distinct non-null values. Cells are keyed by the same
/// composite-key normalisation `group_by` / `join` use (`key_cell`), so the
/// distinct count agrees with grouping cell-for-cell: two cells collide
/// exactly when they would share a `group_by` group. Two `Float`
/// normalisations follow from that shared key — every `NaN` collapses into
/// one bucket (it is a value, not missing, so it is one distinct value, the
/// rule that also puts every `NaN` key in one `group_by` group), and `-0.0`
/// folds into `+0.0` (they are IEEE-equal and share a group, hence one
/// distinct value). Keying by `Scalar::to_string` would instead let the
/// `-0.0` rendering split them, diverging from `group_by`.
pub fn Series::n_unique(self : Series) -> Int {
  // Folds through the same `count_distinct` core the `n_unique` reduction uses
  // (reduce.mbt), keying cells by `key_cell` — the `KeyCell` encoding
  // `group_by` / `join` hash on — so the distinct count agrees with grouping
  // rather than diverging on `Double::to_string`'s `-0.0` rendering. Reading the
  // typed column directly also avoids materialising an `Array[Scalar]`.
  count_distinct(self.storage)(self.len(), k => k)
}

// The remaining five reductions (`std` / `variance` / `median` / `first` /
// `last`) fold straight through the shared `reduce.mbt` kernel — the same one
// the grouped `agg` and scoped `eval_agg` reduce through — so a whole-series
// reduction agrees with the per-group and per-scope forms cell for cell, and
// only one piece of code decides what a null or a `NaN` does to them. The four
// above are the exception `reduce.mbt` names: each dispatches a `Numeric` column
// to a second implementation of those rules, and the parity suite rather than
// the type system is what keeps the two in agreement. These take no such fast
// path — the kernel already skips validity materialisation for an all-valid
// `Numeric` column (`reduce_present`), and these are the colder reductions.

///|
/// Sample standard deviation (`ddof = 1`, Polars' default) of the non-null
/// cells, as `Double` — the square root of `variance`. Numeric only (`Int`
/// widens to `Double`). Computed by Welford's algorithm, so a finite-variance
/// window of near-`Double`-max values still yields a finite result; a `NaN` is
/// a present value that **propagates** (only `Null` is skipped).
///
/// - Fewer than two non-null cells (empty, single-value, all-null) → `raise
///   InvalidOperation(...)`: the sample denominator `cnt - 1` has no value.
/// - Non-numeric series → `raise TypeMismatch(...)`.
pub fn Series::std(self : Series) -> Double raise @types.DataError {
  match reduce_variance(self.storage, want_std=true)(self.len(), k => k) {
    @types.Scalar::Float(v) => v
    _ =>
      raise @types.DataError::InvalidOperation(
        "std of a series with fewer than two non-null values",
      )
  }
}

///|
/// Sample variance (`ddof = 1`) of the non-null cells, as `Double` — `std`
/// squared, sharing its rules (numeric only, `NaN` propagates, fewer than two
/// non-null cells raise `InvalidOperation`).
pub fn Series::variance(self : Series) -> Double raise @types.DataError {
  match reduce_variance(self.storage, want_std=false)(self.len(), k => k) {
    @types.Scalar::Float(v) => v
    _ =>
      raise @types.DataError::InvalidOperation(
        "variance of a series with fewer than two non-null values",
      )
  }
}

///|
/// Median of the non-null cells, as `Double` — the middle of the sorted values,
/// or the mean of the two middles for an even count (Polars). Numeric only
/// (`Int` widens to `Double`). Unlike `sum` / `mean`, a `NaN` is **skipped**
/// (treated as missing, the order-statistic rule `sort` / `min` / `max` follow).
///
/// - Empty / all-null / all-`NaN` series → `raise InvalidOperation(...)`.
/// - Non-numeric series → `raise TypeMismatch(...)`.
pub fn Series::median(self : Series) -> Double raise @types.DataError {
  match reduce_median(self.storage)(self.len(), k => k) {
    @types.Scalar::Float(v) => v
    _ =>
      raise @types.DataError::InvalidOperation(
        "median of an empty/all-null series",
      )
  }
}

///|
/// First cell as a `Scalar`, in row order — positional, so it skips nothing (a
/// present `NaN` is returned verbatim). Total (Polars' `Series.first`): an empty
/// series, or a leading **null** cell, yields `Scalar::Null`.
pub fn Series::first(self : Series) -> @types.Scalar {
  reduce_first_last(self.storage, want_first=true)(self.len(), k => k)
}

///|
/// Last cell as a `Scalar`, in row order — the trailing mirror of `first`, with
/// the same positional (skip-nothing) rule and `Scalar::Null` for an empty
/// series or a trailing null cell.
pub fn Series::last(self : Series) -> @types.Scalar {
  reduce_first_last(self.storage, want_first=false)(self.len(), k => k)
}