///|
/// Per-column statistical summary, returned as a `DataFrame` with one
/// row per source column. The result schema is fixed and dtype-pure —
/// every cell has the dtype declared below, regardless of the source
/// frame's shape or column dtypes:
///
/// - `column` (`String`): source column name, in declaration order
/// - `dtype` (`String`): source column dtype rendered via `DataType::to_string`
/// - `count` (`Int`): non-null cell count
/// - `null_count` (`Int`): null cell count
/// - `n_unique` (`Int`): distinct non-null value count
/// - `mean` (`Float`, nullable): arithmetic mean for numeric columns;
///   `Null` for non-numeric or empty / all-null numeric columns
/// - `min` (`String`, nullable): minimum cell rendered via
///   `Scalar::to_string`; `Null` for empty / all-null columns
/// - `max` (`String`, nullable): maximum cell rendered via
///   `Scalar::to_string`; `Null` for empty / all-null columns
///
/// `min` / `max` are rendered as `String` so the summary can carry
/// extrema for every dtype in a single column without forcing a
/// uniform value type. A `Float` extremum renders via `Double::to_string`,
/// which drops the decimal point of a whole value (`1.0` → `"1"`), so a
/// `Float` column's extrema can read like an `Int`'s — the `dtype` column
/// disambiguates. The per-column reductions (`min`, `max`) keep the
/// original dtype if a caller needs it typed.
///
/// This is a deliberately reduced summary: it omits the standard deviation,
/// variance, and quantiles (`25%` / `50%` / `75%`) that Polars' `describe`
/// reports. The per-column kernels expose those directly (`Series` std /
/// variance / median) for callers who need them.
///
/// Because `min` / `max` skip `NaN` (Polars' regular extrema) while `count`
/// and `n_unique` treat `NaN` as a present value, an all-`NaN` `Float` column
/// reports a non-zero `count` and an `n_unique` of `1` (every `NaN` folds into
/// one distinct bucket) but `Null` `min` / `max` — these can legitimately
/// disagree, in addition to the empty / all-null case noted above.
///
/// Raises only because the summary frame is built through the fallible
/// `DataFrame::DataFrame`; the output columns are hardcoded-unique and equal
/// length, so the raise is forwarded, never actually taken. The 0-column
/// case collapses to a `0×8` frame: zero source columns ⇒ zero output
/// rows, but the eight schema columns are still present so downstream
/// code can rely on the result's column layout.
pub fn DataFrame::describe(
  self : DataFrame,
) -> DataFrame raise @types.DataError {
  let names : Array[String] = []
  let dtypes : Array[String] = []
  let counts : Array[Int64] = []
  let null_counts : Array[Int64] = []
  let n_uniques : Array[Int64] = []
  let means : Array[Double?] = []
  let mins : Array[String?] = []
  let maxs : Array[String?] = []
  for s in self.column_series() {
    names.push(s.name())
    dtypes.push(s.dtype().to_string())
    counts.push(s.count().to_int64())
    null_counts.push(s.null_count().to_int64())
    n_uniques.push(s.n_unique().to_int64())
    // `mean_opt` is the total form of `Series::mean` — `None` exactly where
    // `mean` would raise (a non-numeric column, or an empty / all-null
    // numeric one), `Some(mean)` otherwise. The aggregate view wants that
    // single null cell either way (the per-column API keeps the
    // distinction), so it reads the total accessor and skips the error
    // catch, like the `min` / `max` reads just below.
    means.push(s.mean_opt())
    // `min` / `max` are total; they return `Scalar::Null` to
    // signal "no value", which `render_extremum` maps to a null cell and
    // otherwise renders via `Scalar::to_string`.
    mins.push(render_extremum(s.min()))
    maxs.push(render_extremum(s.max()))
  }
  // The output columns share a length (`self.ncols()`) and the names are
  // hardcoded-unique, so `DataFrame::DataFrame` always succeeds; the raise is
  // forwarded rather than caught.
  DataFrame::DataFrame([
    Series::from_strings("column", names),
    Series::from_strings("dtype", dtypes),
    Series::from_ints("count", counts),
    Series::from_ints("null_count", null_counts),
    Series::from_ints("n_unique", n_uniques),
    Series::from_float_options("mean", means),
    Series::from_string_options("min", mins),
    Series::from_string_options("max", maxs),
  ])
}

///|
/// Map a `Series::min` / `Series::max` result into a nullable
/// `String` cell. `Scalar::Null` becomes a null cell; every concrete
/// variant renders via `Scalar::to_string`.
fn render_extremum(scalar : @types.Scalar) -> String? {
  if scalar.is_null() {
    None
  } else {
    Some(scalar.to_string())
  }
}