// The CSV write path: `CsvWriteOptions`, the `format_csv` renderer, the
// `write_csv` entry point, the per-cell rendering (`render_csv_scalar`, which
// is where the opt-in spreadsheet-formula guard applies), and the quoting rule
// (`write_quoted`) — split from `io/csv.mbt` so reading and writing read apart.
// `validate_csv_delimiter` stays beside the reader: both directions reject the
// same delimiter set through it.

///|
/// Options that control how a `DataFrame` is rendered as CSV text.
///
/// - `header` — when `true`, the first emitted row is the column
///   names; when `false`, only data rows are written.
/// - `delimiter` — field separator written between cells.
/// - `null_value` — the literal string written in place of a null
///   cell. The default empty string round-trips with the reader's
///   default `null_values`.
/// - `sanitize_formulas` — when `true`, a `String` cell beginning with
///   `=`, `+`, `-`, `@`, a tab, or a carriage return is prefixed with a
///   single quote so a spreadsheet reads it as text. Deliberately lossy:
///   such a cell reads back with the leading quote. Defaults to `false`.
pub struct CsvWriteOptions {
  header : Bool
  delimiter : Char
  null_value : String
  sanitize_formulas : Bool
}

///|
/// Build write options. Every field has a default: header on, comma
/// delimiter, null cells emitted as the empty string, and no formula
/// sanitisation. `CsvWriteOptions::CsvWriteOptions()` is the all-defaults writer; name only
/// what differs, as in `CsvWriteOptions::CsvWriteOptions(delimiter=';')`.
pub fn CsvWriteOptions::CsvWriteOptions(
  header? : Bool = true,
  delimiter? : Char = ',',
  null_value? : String = "",
  sanitize_formulas? : Bool = false,
) -> CsvWriteOptions {
  { header, delimiter, null_value, sanitize_formulas }
}

///|
/// Render a `DataFrame` as a CSV string.
///
/// - Header (when `options.header == true`): column names joined by
///   `options.delimiter`, each quoted only when it would otherwise be
///   ambiguous.
/// - Each row: cells joined by `options.delimiter`, terminated by `\n`
///   (LF). Null cells are written as `options.null_value`; other cells
///   render via `Scalar::to_string`.
/// - Quoting: a cell containing the delimiter, a double quote, `\r`,
///   or `\n` is wrapped in double quotes; interior double quotes are
///   doubled (`"` → `""`). Cells that need no quoting are written
///   verbatim so the output matches the simple "no quotes" CSV idiom
///   whenever possible. Two cells are quoted anyway: in a single-column frame
///   an empty cell (or an empty column name) is written as `""` rather than a
///   bare blank, so the row is not a blank line the reader would skip; and a
///   first field opening with a byte-order mark is quoted so the mark cannot
///   be read as part of the file's own preamble.
/// - Round-trip caveat: cells render via `Scalar::to_string`, so a `Float`
///   whose value is a whole number is written without a fractional part
///   (`2.0` → `2`) and is re-inferred as `Int` on read — the same dtype
///   narrowing the JSON writer documents. Negative zero is one such whole
///   value: `-0.0` writes as `0`, so its sign (observable under division)
///   does not survive the round-trip. A `String` cell with significant
///   leading / trailing whitespace is written bare (only the delimiter, a
///   quote, `\r`, or `\n` force quoting), so this reader round-trips it
///   (`trim_spaces` off), but an RFC-4180 consumer that trims surrounding
///   whitespace may drop it. A `String` cell whose text equals a reader null
///   token — the default `null_value` is the empty string `""`, so this
///   includes empty strings, plus any custom `null_values` entry — is read
///   back as null and cannot be distinguished from a true null. A `String`
///   cell whose text parses as another dtype is likewise re-inferred on read:
///   inference is content-based with no header type hints, so a column of
///   numeric-looking strings (`"01"`, `"2"`) returns as `Int` / `Float` —
///   dropping any leading zeros — and `"true"` / `"false"` as `Bool`, the
///   dtype-from-content narrowing inherent to typeless CSV (matching Polars'
///   `read_csv`). Quote / escape handling is otherwise round-trip safe.
/// - When `sanitize_formulas` is `true`, a `String` cell beginning with
///   `=`, `+`, `-`, `@`, tab, or carriage return is prefixed with an
///   apostrophe before CSV quoting. This opt-in, intentionally lossy
///   transformation prevents spreadsheet applications from interpreting
///   such cells as formulas. It does not affect headers, nulls, numbers,
///   booleans, or other strings; the default `false` preserves exact output.
/// - Raises `InvalidOperation` if `options.delimiter` is a double quote or a
///   line terminator (`\n` / `\r`): those collide with the hard-coded quote
///   character and the row terminator, so no escaping could make the output
///   frame fields unambiguously (see `validate_csv_delimiter`).
/// - Raises `InvalidOperation` for an `N×0` frame — rows but no columns — with
///   `N > 0`. CSV frames a row by its fields, so a row with no fields is an
///   empty line, and an empty line is exactly what the reader skips: such a
///   frame would write as `N` blank lines and read back as the empty `0×0`
///   frame, losing every row without an error. The format has no spelling for
///   "a row with zero fields", so the write is refused rather than silently
///   made lossy (`validate_csv_delimiter`'s philosophy, applied to the shape).
///   The column-less `0×0` frame has no rows to lose and still writes: the
///   empty string, or the lone `\n` of its (field-less) header row.
pub fn format_csv(
  df : @frame.DataFrame,
  options? : CsvWriteOptions = CsvWriteOptions::CsvWriteOptions(),
) -> String raise @types.DataError {
  validate_csv_delimiter(options.delimiter)
  let buf = StringBuilder::new()
  let names = df.columns()
  if names.is_empty() && df.nrows() > 0 {
    raise @types.DataError::InvalidOperation(
      "CSV cannot represent a frame with rows but no columns (\{df.nrows()}×0): " +
      "a row with no fields is an empty line, which reads back as no rows at all",
    )
  }
  // A single-column frame is the only shape whose rows can render as a bare
  // empty line (any wider row carries a delimiter), which the reader's
  // `skip_empty_lines` would discard — losing the row (or, for the header,
  // the column name). Force such empty cells / names to `""` so the line
  // stays non-empty; the reader maps the quoted-empty token back to null via
  // `null_values`, exactly as it does a bare empty cell in a wider row.
  let single_column = names.length() == 1
  if options.header {
    for i, name in names {
      if i > 0 {
        buf.write_char(options.delimiter)
      }
      write_quoted(
        buf,
        name,
        options.delimiter,
        force_empty_quote=single_column,
        file_start=i == 0,
      )
    }
    buf.write_char('\n')
  }
  let nrows = df.nrows()
  // Materialise the whole frame once (column-major) through the shared
  // `to_scalar_matrix`; `cols[c][r]` is then a plain in-bounds read, so no
  // per-cell `item` raise.
  let cols = df.to_scalar_matrix()
  for r in 0.. 0 {
        buf.write_char(options.delimiter)
      }
      let scalar = cols[c][r]
      let rendered = render_csv_scalar(
        scalar,
        options.null_value,
        options.sanitize_formulas,
      )
      write_quoted(
        buf,
        rendered,
        options.delimiter,
        force_empty_quote=single_column,
        // With no header row the first data cell sits at byte 0 of the file.
        file_start=!options.header && r == 0 && c == 0,
      )
    }
    buf.write_char('\n')
  }
  buf.to_string()
}

///|
/// Write a `DataFrame` to a CSV file. `options` defaults to
/// `CsvWriteOptions::CsvWriteOptions()`, so `write_csv(path, df)` is the default-options
/// write. `IOError` from the file system surfaces as
/// `raise IoError(message)`. A string cell (or column name) holding an
/// unpaired UTF-16 surrogate is refused with `raise InvalidOperation` — the
/// UTF-8 file encoding would swallow the following delimiter and silently
/// shift every later cell boundary.
/// `CsvWriteOptions::CsvWriteOptions(sanitize_formulas=true)` applies the same
/// spreadsheet-safety transformation documented by `format_csv` before writing.
/// Rendering runs through `format_csv` first, so its refusals come first too —
/// an unrepresentable delimiter and the `N×0` shape (rows but no columns) both
/// `raise InvalidOperation` before the file is opened, leaving nothing written.
pub fn write_csv(
  path : String,
  df : @frame.DataFrame,
  options? : CsvWriteOptions = CsvWriteOptions::CsvWriteOptions(),
) -> Unit raise @types.DataError {
  let content = format_csv(df, options~)
  write_text(path, content)
}

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

///|
fn render_csv_scalar(
  scalar : @types.Scalar,
  null_value : String,
  sanitize_formulas : Bool,
) -> String {
  match scalar {
    @types.Scalar::Null => null_value
    @types.Scalar::String(value) =>
      if sanitize_formulas && starts_spreadsheet_formula(value) {
        let buf = StringBuilder::new()
        buf.write_string("'")
        buf.write_string(value)
        buf.to_string()
      } else {
        value
      }
    _ => scalar.to_string()
  }
}

///|
fn starts_spreadsheet_formula(value : String) -> Bool {
  match value.view().get_char(0) {
    Some(ch) =>
      ch == '=' ||
      ch == '+' ||
      ch == '-' ||
      ch == '@' ||
      ch == '\t' ||
      ch == '\r'
    None => false
  }
}

///|
/// Append `cell` to `buf`, quoting only when needed. A cell needs
/// quoting iff it contains the delimiter, a double quote, `\r`, or
/// `\n` — or, for the file's very first field (`file_start`), when it
/// begins with U+FEFF: bare at byte 0 that character would be stripped as
/// a byte-order mark on read-back, silently renaming the column (or
/// deleting the cell's first character); a leading `"` shields it.
/// Interior double quotes are doubled per RFC 4180.
fn write_quoted(
  buf : StringBuilder,
  cell : String,
  delimiter : Char,
  force_empty_quote~ : Bool,
  file_start~ : Bool,
) -> Unit {
  // An empty cell that would otherwise leave its line bare (a single-column
  // row) is written as `""` so the reader cannot mistake it for a skippable
  // blank line. The quoted-empty token maps back to null via `null_values`.
  if force_empty_quote && cell.is_empty() {
    buf.write_string("\"\"")
    return
  }
  let needs_quote = cell
    .iter()
    .any(ch => ch == delimiter || ch == '"' || ch == '\n' || ch == '\r') ||
    (file_start && cell.view().get_char(0) == Some('\u{FEFF}'))
  if !needs_quote {
    buf.write_string(cell)
    return
  }
  buf.write_char('"')
  for ch in cell.iter() {
    if ch == '"' {
      buf.write_char('"')
      buf.write_char('"')
    } else {
      buf.write_char(ch)
    }
  }
  buf.write_char('"')
}