///|
/// Parsed feature rows plus all row-level diagnostics.
pub(all) struct ParsedFeatures {
  features : Array[Feature]
  diagnostics : Array[Diagnostic]
}

///|
/// Parsed observation rows plus all row-level diagnostics.
pub(all) struct ParsedObservations {
  observations : Array[Observation]
  diagnostics : Array[Diagnostic]
}

///|
fn parse_double_or_none(text : String) -> Double? {
  Some(@string.parse_double(text)) catch {
    _ => None
  }
}

///|
fn parse_int_or_none(text : String) -> Int? {
  Some(@string.parse_int(text)) catch {
    _ => None
  }
}

///|
fn csv_fields(line : String) -> Array[String] {
  let fields = []
  for field in line.trim().split(",") {
    fields.push(field.to_owned())
  }
  if fields.length() >= 7 && fields[0].trim().has_prefix("\"") {
    let first = fields[0].trim().to_owned() + "," + fields[1].trim().to_owned()
    let result = [first]
    for field in fields[2:] {
      result.push(field)
    }
    result
  } else {
    fields
  }
}

///|
fn csv_unquote(value : String) -> String {
  let text = value.trim().to_owned()
  let chars = text.to_array()
  if chars.length() >= 2 && chars[0] == '"' && chars[chars.length() - 1] == '"' {
    let inner = []
    for i in 1..<(chars.length() - 1) {
      inner.push(chars[i])
    }
    String::from_array(inner)
  } else {
    text
  }
}

///|
fn feature_kind_or_none(text : String) -> FeatureKind? {
  match text.trim() {
    "hole" => Some(FeatureKind::Hole)
    "shaft" => Some(FeatureKind::Shaft)
    "plane" => Some(FeatureKind::Plane)
    "slot" => Some(FeatureKind::Slot)
    "circle" => Some(FeatureKind::Circle)
    "cylinder" => Some(FeatureKind::Cylinder)
    "datum" => Some(FeatureKind::Datum)
    _ => None
  }
}

///|
fn parse_bool_or_none(text : String) -> Bool? {
  match text.trim() {
    "true" => Some(true)
    "false" => Some(false)
    _ => None
  }
}

///|
/// Parse feature CSV without dropping malformed rows.
pub fn parse_feature_csv_strict(input : String) -> ParsedFeatures {
  let features = []
  let diagnostics = []
  for row, line in input.split("\n") {
    let fields = csv_fields(line.to_owned())
    if line.trim().length() == 0 || fields[0].trim() == "id" {
      continue
    }
    if fields.length() < 6 {
      diagnostics.push(
        error_diagnostic(
          code="csv.row.short",
          message="feature row needs six fields",
          index=row,
        ),
      )
      continue
    }
    let kind = feature_kind_or_none(fields[1])
    if kind is None {
      diagnostics.push(
        error_diagnostic(
          code="csv.kind.unknown",
          message="feature kind is not recognized",
          index=row,
        ),
      )
      continue
    }
    let nominal = parse_double_or_none(fields[2].trim().to_owned())
    let lower = parse_double_or_none(fields[3].trim().to_owned())
    let upper = parse_double_or_none(fields[4].trim().to_owned())
    let datum = parse_bool_or_none(fields[5])
    let numeric_ok = nominal is Some(_) && lower is Some(_) && upper is Some(_)
    if !numeric_ok {
      diagnostics.push(
        error_diagnostic(
          code="csv.number.invalid",
          message="feature numeric field is invalid",
          index=row,
        ),
      )
    }
    if datum is None {
      diagnostics.push(
        error_diagnostic(
          code="csv.boolean.invalid",
          message="feature datum must be true or false",
          index=row,
        ),
      )
    }
    match (nominal, lower, upper, datum) {
      (Some(nominal), Some(lower), Some(upper), Some(datum)) =>
        features.push({
          id: csv_unquote(fields[0]),
          kind: kind.unwrap(),
          nominal,
          lower,
          upper,
          datum,
        })
      _ => ()
    }
  }
  { features, diagnostics }
}

///|
/// Parse observation CSV without dropping malformed rows.
pub fn parse_observation_csv_strict(input : String) -> ParsedObservations {
  let observations = []
  let diagnostics = []
  for row, line in input.split("\n") {
    let fields = csv_fields(line.to_owned())
    if line.trim().length() == 0 || fields[0].trim() == "feature_id" {
      continue
    }
    if fields.length() < 3 {
      diagnostics.push(
        error_diagnostic(
          code="csv.observation.row.short",
          message="observation row needs three fields",
          index=row,
        ),
      )
      continue
    }
    let value = parse_double_or_none(fields[1].trim().to_owned())
    let sample_count = parse_int_or_none(fields[2].trim().to_owned())
    if value is None {
      diagnostics.push(
        error_diagnostic(
          code="csv.observation.value.invalid",
          message="observation value is invalid",
          index=row,
        ),
      )
    }
    if sample_count is None || !sample_count_is_valid(sample_count.unwrap()) {
      diagnostics.push(
        error_diagnostic(
          code="csv.observation.samples.invalid",
          message="observation sample count must be positive",
          index=row,
        ),
      )
    }
    match (value, sample_count) {
      (Some(value), Some(sample_count)) if sample_count_is_valid(sample_count) =>
        observations.push({
          feature_id: fields[0].trim().to_owned(),
          value,
          sample_count,
        })
      _ => ()
    }
  }
  { observations, diagnostics }
}

///|
/// Collect strict feature CSV diagnostics.
pub fn csv_diagnostics(input : String) -> Array[Diagnostic] {
  parse_feature_csv_strict(input).diagnostics
}