///|
fn csv_record(
  records : Array[Array[String]],
  row : Array[String],
) -> Unit raise FcaError {
  if row.length() > 129 || records.length() >= 4097 {
    raise Budget("CSV dimensions exceed 4096 objects or 128 attributes")
  }
  records.push(row.copy())
}

///|
/// State machine: 0 field-start, 1 unquoted, 2 quoted, 3 after closing quote.
/// Commas and CRLF/LF delimit outside quotes; doubled quotes escape inside quotes.
fn csv_table(text : String) -> Array[Array[String]] raise FcaError {
  check_text_size(text)
  if text.length() == 0 {
    raise Invalid("CSV requires a header record")
  }
  let chars = text.to_array()
  let records : Array[Array[String]] = []
  let row : Array[String] = []
  let field = StringBuilder()
  let mut size = 0
  let mut state = 0
  let mut pending = false
  let mut i = 0
  while i < chars.length() {
    let c = chars[i]
    if state == 2 {
      if c == '"' {
        if i + 1 < chars.length() && chars[i + 1] == '"' {
          field.write_char('"')
          size = size + 1
          i = i + 1
        } else {
          state = 3
        }
      } else {
        field.write_char(c)
        size = size + c.utf16_len()
      }
    } else if c == ',' || c == '\n' || c == '\r' {
      row.push(field.to_string())
      if row.length() > 129 {
        raise Budget("CSV row exceeds 129 fields")
      }
      field.reset()
      size = 0
      state = 0
      if c == ',' {
        pending = true
      } else {
        if c == '\r' {
          if i + 1 >= chars.length() || chars[i + 1] != '\n' {
            raise Invalid("CSV bare carriage return outside quotes")
          }
          i = i + 1
        }
        csv_record(records, row)
        row.clear()
        pending = false
      }
    } else if c == '"' {
      if state != 0 {
        raise Invalid("CSV quote is only allowed at the start of a field")
      }
      state = 2
      pending = true
    } else {
      if state == 3 {
        raise Invalid("CSV has content after a closing quote")
      }
      field.write_char(c)
      size = size + c.utf16_len()
      state = 1
      pending = true
    }
    if size > 4096 {
      raise Budget("CSV field exceeds 4096 UTF-16 code units")
    }
    i = i + 1
  }
  if state == 2 {
    raise Invalid("CSV contains an unterminated quoted field")
  }
  if pending {
    row.push(field.to_string())
    csv_record(records, row)
  }
  if records.is_empty() {
    raise Invalid("CSV requires a header record")
  }
  records
}

///|
/// Incidence CSV profile: literal first header 'object', then unique attributes; cells 0/1.
/// Quoted multiline labels are supported; no whitespace trimming or truthy coercion.
pub fn parse_csv(text : String) -> Context raise FcaError {
  let records = csv_table(text)
  let header = records[0]
  if header.is_empty() || header[0] != "object" {
    raise Invalid("CSV first header must be object")
  }
  let attributes : Array[String] = []
  for a = 1; a < header.length(); a = a + 1 {
    attributes.push(header[a])
  }
  let objects : Array[String] = []
  let rows : Array[Array[Bool]] = []
  for o = 1; o < records.length(); o = o + 1 {
    let record = records[o]
    if record.length() != header.length() {
      raise Invalid("CSV contains a ragged record")
    }
    objects.push(record[0])
    let row : Array[Bool] = []
    for a = 1; a < record.length(); a = a + 1 {
      match record[a] {
        "0" => row.push(false)
        "1" => row.push(true)
        _ => raise Invalid("CSV incidence cells must be exactly 0 or 1")
      }
    }
    rows.push(row)
  }
  context(objects, attributes, rows)
}

///|
fn csv_quote(value : String) -> String {
  "\"" + value.replace_all(old="\"", new="\"\"") + "\""
}

///|
/// Dataset export, not spreadsheet-safe sanitization: labels are preserved, including formulas.
pub fn Context::to_csv(self : Context) -> String {
  let header = [csv_quote("object")]
  for name in self.attribute_names {
    header.push(csv_quote(name))
  }
  let records = [header.join(",")]
  for o = 0; o < self.object_count(); o = o + 1 {
    let row = [csv_quote(self.object_names[o])]
    for cell in self.incidence[o] {
      row.push(if cell { "1" } else { "0" })
    }
    records.push(row.join(","))
  }
  records.join("\r\n") + "\r\n"
}