// Markdown table rendering for `DataFrame`. The output follows the
// GitHub-flavored pipe-table layout (header / dashed separator / data
// rows) and pads every column to the maximum width of its header and
// rendered cells so the source text is also pleasant to read in a
// fixed-width terminal. Null cells render as the empty string,
// delegating to `Scalar::to_string`.
//
// GFM tables specifically, because they are the lingua
// franca of MoonFrame's two output targets — `README.md` snippets and
// JupyterBook-style notebooks — and they round-trip through every
// Markdown parser we care about without a dedicated renderer
// dependency. The renderer is a `DataFrame` method rather than an `io`
// entry point (the IO boundary: pure rendering lives in `frame`, only
// external serialisation stays in `io`); it remains a small,
// self-contained, dependency-free function.

///|
/// Render a `DataFrame` as a GitHub-flavored Markdown table.
///
/// The output is three blocks of pipe-bounded rows:
///   1. header — column names, one cell per column;
///   2. separator — dashes sized to each column;
///   3. data — one row per record, in declaration order.
///
/// Column widths are aligned to `max(header, every rendered cell)`,
/// with a 3-character minimum so the dash separator never collapses
/// below the GFM-required floor. Null cells render as the empty string
/// (matching `Scalar::to_string`), so a null and a genuine empty-string
/// cell render identically — the renderer carries no null marker.
///
/// A frame with nothing to tabulate degrades gracefully:
///   - 0 columns → the empty string, at any height. A GFM table is built out
///     of cells, so a column-less frame has none to draw — including the `N×0`
///     frame, whose rows are real (`is_empty()` is `false`) but carry no
///     fields. The rendering cannot show them; `shape()` is what reports the
///     height of a column-less result;
///   - N columns / 0 rows → header + separator with no data rows.
///
/// `max_rows` caps the output at its first rows: when `self` has more rows
/// than that, the truncated count is appended below the table as
/// `... (N more rows)`, separated by a blank line — per the GFM tables
/// extension a table only ends at an empty line, so a banner glued to the
/// last row would parse as one more (single-cell) table ROW rather than
/// the paragraph below it the wording promises. A negative `max_rows` is
/// clamped to 0, so a frame that has rows still renders as header +
/// separator + the `... (N more rows)` banner; only a genuinely 0-row
/// frame is header + separator alone. Omitting `max_rows` renders every row.
///
/// Total — pure string assembly over already-validated frame data.
pub fn DataFrame::to_markdown(self : DataFrame, max_rows? : Int) -> String {
  let nrows = self.nrows()
  let shown = match max_rows {
    None => nrows
    Some(limit) => @order.clamp_take(limit, nrows)
  }
  let body = format_markdown(self, shown)
  if shown < nrows {
    "\{body}\n\{more_rows_banner(nrows - shown)}\n"
  } else {
    body
  }
}

// ── internals ──────────────────────────────────────────────────────────

///|
/// Render the first `shown` rows of `df` as a Markdown table. The
/// header / separator are emitted whenever `df` has at least one
/// column, regardless of `shown` — a header-only table is still
/// useful shape information for callers limiting output.
fn format_markdown(df : DataFrame, shown : Int) -> String {
  // `raw_names` supplies the column count and the unescaped header text;
  // `names` is the GFM-escaped form used for both width computation and
  // rendering, so a column name containing `|` / newline can't corrupt the
  // table. Cell values come from the `cells` matrix below, not a name lookup.
  let raw_names = df.columns()
  let ncols = raw_names.length()
  if ncols == 0 {
    return ""
  }
  let names = raw_names.map(escape_md_cell)
  // Pre-render every visible cell, GFM-escaped, so column widths can be
  // computed in one forward pass before any layout. `table_cell_matrix` gives
  // the raw per-row strings; escaping each here means the width alignment runs
  // on the escaped form, accounting for the `|` → `\|` / `\` → `\\` expansions.
  let cells : Array[Array[String]] = table_cell_matrix(df, shown).map(row => {
    row.map(escape_md_cell)
  })
  let widths : Array[Int] = names.mapi((c, name) => {
    let mut w = char_count(name)
    for r in 0.. w {
        w = cw
      }
    }
    // GFM requires at least three dashes per separator cell — pad to
    // a 3-char minimum so a short header (e.g. "a") doesn't produce
    // `|-|` which some lenient parsers still accept but stricter ones
    // (cmark, pulldown-cmark) reject.
    if w < 3 {
      w = 3
    }
    w
  })
  let buf = StringBuilder::new()
  write_row(buf, names, widths)
  // Separator row: dashes of `widths[c]` per column, padded with one
  // space on each side to match the data-row layout.
  buf.write_char('|')
  for c in 0.. Unit {
  buf.write_char('|')
  for c, cell in row {
    buf.write_char(' ')
    buf.write_string(cell)
    let pad = widths[c] - char_count(cell)
    for _ in 0.. Int {
  s.iter().count()
}

///|
/// Escape a value for a GFM table cell. A literal backslash is doubled
/// (`\` → `\\`) and a literal `|` is rewritten to `\|`, so neither a bare
/// pipe nor a backslash sitting just before a pipe can split the cell
/// into extra columns. (Without the backslash rule, a cell containing
/// `\|` would emit `\\|` — an escaped backslash followed by a *bare*,
/// table-splitting pipe.) CR / LF collapse to a single space so a
/// multi-line value can't terminate the table row mid-cell. Everything
/// else passes through. Width alignment runs on the escaped form (callers
/// escape before measuring), so the two-character expansions are
/// accounted for.
fn escape_md_cell(s : String) -> String {
  let buf = StringBuilder::new()
  for ch in s.iter() {
    match ch {
      '\\' => {
        buf.write_char('\\')
        buf.write_char('\\')
      }
      '|' => {
        buf.write_char('\\')
        buf.write_char('|')
      }
      '\n' | '\r' => buf.write_char(' ')
      _ => buf.write_char(ch)
    }
  }
  buf.to_string()
}