///|
fn split_csv_line(line : String) -> ParseOutcome[Array[String]] {
  let fields : Array[String] = []
  let mut current = StringBuilder()
  let mut quoted = false
  let mut escaped = false
  for char in line {
    if escaped {
      current.write_char(char)
      escaped = false
    } else if char == '\\' && quoted {
      escaped = true
    } else if char == '"' {
      quoted = !quoted
    } else if char == ',' && !quoted {
      fields.push(current.to_string())
      current = StringBuilder()
    } else {
      current.write_char(char)
    }
  }
  if quoted || escaped {
    ParseFailed(InvalidSyntax(line=0, message="unterminated CSV quote"))
  } else {
    fields.push(current.to_string())
    Parsed(fields)
  }
}

///|
fn csv_header_index(headers : Array[String], name : String) -> Int? {
  for index, header in headers {
    if header.trim().to_owned() == name {
      return Some(index)
    }
  }
  None
}

///|
fn token_at(fields : Array[String], index : Int) -> String? {
  if index < 0 || index >= fields.length() {
    None
  } else {
    Some(fields[index].trim().to_owned())
  }
}

///|
fn parse_csv_number(
  fields : Array[String],
  index : Int,
  line : Int,
  field : String,
) -> ParseOutcome[Double] {
  match token_at(fields, index) {
    None => ParseFailed(MissingField(line~, field~))
    Some(token) =>
      match parse_double_token(token) {
        None => ParseFailed(InvalidNumber(line~, field~, value=token))
        Some(value) => Parsed(value)
      }
  }
}

///|
fn parse_csv_row(
  fields : Array[String],
  line : Int,
  timestamp_index : Int,
  distance_index : Int,
  elapsed_index : Int,
) -> ParseOutcome[Observation] {
  let timestamp = match
    parse_csv_number(fields, timestamp_index, line, "timestamp") {
    Parsed(value) => value
    ParseFailed(error) => return ParseFailed(error)
  }
  let distance = match
    parse_csv_number(fields, distance_index, line, "distance") {
    Parsed(value) => value
    ParseFailed(error) => return ParseFailed(error)
  }
  let elapsed = match parse_csv_number(fields, elapsed_index, line, "elapsed") {
    Parsed(value) => value
    ParseFailed(error) => return ParseFailed(error)
  }
  Parsed({ timestamp, distance, elapsed })
}

///|
/// Parse a CSV document with a required timestamp/distance/elapsed header.
pub fn parse_csv_observations(csv : String) -> ObservationParseResult {
  if csv.trim().is_empty() {
    return parse_error_result(EmptyInput, 0)
  }
  let lines : Array[String] = []
  for raw in csv.split("\n") {
    let line = raw.trim_end(chars="\r").to_owned()
    if !line.trim().is_empty() {
      lines.push(line)
    }
  }
  if lines.length() == 0 {
    return parse_error_result(EmptyInput, 0)
  }
  let header = match split_csv_line(lines[0]) {
    ParseFailed(error) => return parse_error_result(error, 1)
    Parsed(fields) => fields.map(field => field.trim().to_owned().to_lower())
  }
  let timestamp_index = match csv_header_index(header, "timestamp") {
    None =>
      return parse_error_result(MissingField(line=1, field="timestamp"), 1)
    Some(index) => index
  }
  let distance_index = match csv_header_index(header, "distance") {
    None => return parse_error_result(MissingField(line=1, field="distance"), 1)
    Some(index) => index
  }
  let elapsed_index = match csv_header_index(header, "elapsed") {
    None => return parse_error_result(MissingField(line=1, field="elapsed"), 1)
    Some(index) => index
  }
  let observations : Array[Observation] = []
  for line_index in 1.. return parse_error_result(error, line_index + 1)
      Parsed(fields) => {
        if fields.length() < header.length() {
          return parse_error_result(
            ExtraField(line=line_index + 1),
            line_index + 1,
          )
        }
        match
          parse_csv_row(
            fields,
            line_index + 1,
            timestamp_index,
            distance_index,
            elapsed_index,
          ) {
          ParseFailed(error) => return parse_error_result(error, line_index + 1)
          Parsed(observation) => observations.push(observation)
        }
      }
    }
  }
  {
    observations: normalize_observations(observations),
    error: None,
    lines_read: lines.length(),
  }
}

///|
/// Encode observations as a portable CSV document.
pub fn encode_csv_observations(observations : Array[Observation]) -> String {
  let builder = StringBuilder()
  builder.write_string("timestamp,distance,elapsed\n")
  for observation in observations {
    builder.write_string(
      "\{observation.timestamp},\{observation.distance},\{observation.elapsed}\n",
    )
  }
  builder.to_string()
}

///|
/// Parse a CSV document into a dataset with optional target columns.
pub fn parse_csv_dataset(
  csv : String,
  name? : String = "csv",
) -> ParseOutcome[ObservationDataset] {
  let parsed = parse_csv_observations(csv)
  match parsed.error {
    Some(error) => ParseFailed(error)
    None => {
      let rows : Array[DatasetRow] = []
      for observation in parsed.observations {
        rows.push({ observation, actual_remaining: None, weight: 1.0 })
      }
      Parsed({ rows, name })
    }
  }
}