///|
/// Split a YOLO row on Unicode whitespace without accepting empty columns.
fn yolo_fields(line : StringView) -> Array[String] {
  let fields : Array[String] = []
  let current = StringBuilder()
  for character in line.iter() {
    if character.is_whitespace() {
      if !current.is_empty() {
        fields.push(current.to_string())
        current.reset()
      }
    } else {
      current.write_char(character)
    }
  }
  if !current.is_empty() {
    fields.push(current.to_string())
  }
  fields
}

///|
fn yolo_number(value : String, location : String) -> Double raise EvalError {
  let number = @string.parse_double(value) catch {
    _ => raise InvalidInput(location + " must be a number")
  }
  if !finite(number) {
    raise InvalidInput(location + " must be finite")
  }
  number
}

///|
fn yolo_fraction(
  value : String,
  location : String,
  positive : Bool,
) -> Double raise EvalError {
  let number = yolo_number(value, location)
  if number > 1.0 || number < 0.0 || (positive && number == 0.0) {
    let range = if positive { "(0,1]" } else { "[0,1]" }
    raise InvalidInput(location + " must be normalized to " + range)
  }
  number
}

///|
/// Parse six-column YOLO prediction TXT for one image:
/// class_index center_x center_y width height confidence.
/// Coordinates are normalized; category_ids maps zero-based YOLO class indices
/// to category IDs in the dataset. Blank lines are ignored. IDs are assigned
/// from first_detection_id in row order, excluding blank lines.
/// Derived boxes may cross image bounds and are reported by evaluation warnings.
pub fn parse_yolo_detections(
  source : String,
  dataset : Dataset,
  image_id : Int,
  category_ids : Array[Int],
  first_detection_id? : Int = 0,
) -> Array[Detection] raise EvalError {
  let mut selected_image : Image? = None
  for image in dataset.images {
    if image.id == image_id {
      selected_image = Some(image)
      break
    }
  }
  let image = match selected_image {
    Some(value) => value
    None =>
      raise InvalidInput("YOLO image_id \{image_id} is not in the dataset")
  }
  for index, category_id in category_ids {
    let mut known = false
    for category in dataset.categories {
      if category.id == category_id {
        known = true
        break
      }
    }
    if !known {
      raise InvalidInput(
        "YOLO category_ids[\{index}] \{category_id} is not in the dataset",
      )
    }
  }
  let detections : Array[Detection] = []
  let mut line_number = 0
  for line in source.split("\n") {
    line_number += 1
    let fields = yolo_fields(line)
    if fields.is_empty() {
      continue
    }
    let location = "YOLO line \{line_number}"
    if fields.length() != 6 {
      raise InvalidInput(
        location + " must have six columns: class cx cy w h score",
      )
    }
    let class_index = @string.parse_int(fields[0], base=10) catch {
      _ => raise InvalidInput(location + " class_index must be an integer")
    }
    if class_index < 0 || class_index >= category_ids.length() {
      raise InvalidInput(location + " class_index \{class_index} is not mapped")
    }
    let center_x = yolo_fraction(fields[1], location + " center_x", false)
    let center_y = yolo_fraction(fields[2], location + " center_y", false)
    let width = yolo_fraction(fields[3], location + " width", true)
    let height = yolo_fraction(fields[4], location + " height", true)
    let score = yolo_fraction(fields[5], location + " score", false)
    let next_id = first_detection_id.to_int64() + detections.length().to_int64()
    if next_id > 2147483647L {
      raise InvalidInput(location + " detection ID exceeds Int range")
    }
    let x1 = (center_x - width / 2.0) * image.width.to_double()
    let y1 = (center_y - height / 2.0) * image.height.to_double()
    let x2 = (center_x + width / 2.0) * image.width.to_double()
    let y2 = (center_y + height / 2.0) * image.height.to_double()
    let box = Box::new(x1, y1, x2, y2) catch {
      error => raise InvalidInput(location + " box: " + error.message())
    }
    detections.push(
      Detection::new(
        next_id.to_int(),
        image_id,
        category_ids[class_index],
        box,
        score,
      ),
    )
  }
  detections
}