// CSV parsing for the `csv` import command. Kept pure (no IO) so it can be
// unit-tested directly; the reverse direction (a sheet -> CSV) is `csv_field`
// in render.mbt.

///|
/// Parse RFC 4180 CSV text into rows of string fields.
///
/// Handles quoted fields (`"..."`), doubled-quote escapes (`""` -> `"`),
/// commas and newlines embedded inside quotes, and both `LF` and `CRLF` line
/// endings. A trailing newline does not produce an extra empty row, and empty
/// input yields no rows. A leading UTF-8 BOM (`U+FEFF`) is stripped.
fn parse_csv(text : String) -> Array[Array[String]] {
  let rows : Array[Array[String]] = []
  let row : Array[String] = []
  let field = StringBuilder()
  let chars : Array[Char] = []
  for c in text {
    chars.push(c)
  }
  // Strip a leading byte-order mark if present.
  let start = if chars.length() > 0 && chars[0] == '\u{FEFF}' { 1 } else { 0 }
  let n = chars.length()
  let mut i = start
  let mut in_quotes = false
  // Whether the current record has started (a field char, quote, or comma seen
  // since the last row terminator) — used to decide the final flush.
  let mut pending = false
  while i < n {
    let c = chars[i]
    if in_quotes {
      if c == '"' {
        if i + 1 < n && chars[i + 1] == '"' {
          field.write_char('"')
          i += 1
        } else {
          in_quotes = false
        }
      } else {
        field.write_char(c)
      }
    } else if c == '"' {
      in_quotes = true
      pending = true
    } else if c == ',' {
      row.push(field.to_string())
      field.reset()
      pending = true
    } else if c == '\n' {
      row.push(field.to_string())
      field.reset()
      rows.push(row.copy())
      row.clear()
      pending = false
    } else if c == '\r' {
      // Part of a CRLF terminator; the following '\n' does the work.
      ()
    } else {
      field.write_char(c)
      pending = true
    }
    i += 1
  }
  // Flush a final record that had no trailing newline.
  if pending {
    row.push(field.to_string())
    rows.push(row.copy())
  }
  rows
}

// Tests for the CSV parser used by the `csv` import command.

///|
/// Render parsed rows compactly so mismatches are easy to read in `inspect`.
fn show_rows(rows : Array[Array[String]]) -> String {
  rows
  .map(fn(row) { "[" + row.map(fn(f) { "<\{f}>" }).join("|") + "]" })
  .join("\n")
}

///|
test "parse_csv: basic rows and empty fields" {
  inspect(
    show_rows(parse_csv("a,b,c\n1,,3")),
    content=(
      #|[||]
      #|[<1>|<>|<3>]
    ),
  )
}

///|
test "parse_csv: quoted fields with commas, quotes, and newlines" {
  // "a,b"  -> a,b in one field; "a""b" -> a"b; a quoted embedded newline.
  inspect(
    show_rows(parse_csv("\"a,b\",\"a\"\"b\",\"line1\nline2\"")),
    content=(
      #|[||]
    ),
  )
}

///|
test "parse_csv: CRLF endings and trailing newline (no extra row)" {
  inspect(
    show_rows(parse_csv("a,b\r\nc,d\r\n")),
    content=(
      #|[|]
      #|[|]
    ),
  )
}

///|
test "parse_csv: trailing comma yields a trailing empty field" {
  inspect(show_rows(parse_csv("a,")), content="[|<>]")
}

///|
test "parse_csv: empty input yields no rows" {
  inspect(parse_csv("").length(), content="0")
  inspect(parse_csv("\n").length(), content="1")
}

///|
test "parse_csv: leading BOM is stripped" {
  inspect(show_rows(parse_csv("\u{FEFF}a,b")), content="[|]")
}

///|
/// Round-trip property: rows serialized with `csv_field` (the `rows` command's
/// encoder) and re-parsed reproduce the original rows.
test "parse_csv round-trips csv_field output" {
  let originals = [
    ["plain", "with,comma", "with\"quote"],
    ["embedded\nnewline", "", "trailing "],
    ["carriage\rreturn", "crlf\r\ninside", "1"],
  ]
  let encoded = originals
    .map(fn(row) { row.map(csv_field).join(",") })
    .join("\n")
  inspect(show_rows(parse_csv(encoded)) == show_rows(originals), content="true")
}