///|
pub fn parse_annotation_csv(
  text : String,
) -> Array[Annotation] raise VisionFormatError {
  let lines = non_empty_lines(text)
  if lines.is_empty() {
    raise VisionFormatError::EmptyInput
  }
  let start = if lines[0].to_lower().contains("label") { 1 } else { 0 }
  let annotations = Array::new()
  for i in start.. String {
  let rows = ["sec,nsec,frame_id,label,x,y,width,height,confidence"]
  for item in annotations {
    rows.push(
      "\{item.stamp.sec},\{item.stamp.nsec},\{item.frame_id},\{item.label},\{item.bbox.x},\{item.bbox.y},\{item.bbox.width},\{item.bbox.height},\{item.confidence}",
    )
  }
  rows.join("\n")
}

///|
fn[T] nearest_by_stamp(
  stamp : Stamp,
  items : ArrayView[T],
  stamp_of : (T) -> Stamp,
  tolerance_ns : Int64,
) -> (T, Int64)? {
  let mut best : T? = None
  let mut best_offset = tolerance_ns + 1L
  for item in items {
    let offset = abs_i64(
      stamp.to_nanoseconds() - stamp_of(item).to_nanoseconds(),
    )
    if offset <= tolerance_ns && offset < best_offset {
      best = Some(item)
      best_offset = offset
    }
  }
  match best {
    Some(item) => Some((item, best_offset))
    None => None
  }
}

///|
fn annotations_near(
  stamp : Stamp,
  annotations : ArrayView[Annotation],
  tolerance_ns : Int64,
) -> (Array[Annotation], Int64) {
  let found = Array::new()
  let mut max_offset = 0L
  for item in annotations {
    let offset = abs_i64(stamp.to_nanoseconds() - item.stamp.to_nanoseconds())
    if offset <= tolerance_ns {
      found.push(item)
      if offset > max_offset {
        max_offset = offset
      }
    }
  }
  (found, max_offset)
}

///|
pub fn synchronize(
  images : ArrayView[ImageFrameRef],
  trajectory : ArrayView[TrajectorySample],
  depth : ArrayView[DepthMetadata],
  annotations : ArrayView[Annotation],
  tolerance_ns~ : Int64,
) -> SyncReport {
  let matches = Array::new()
  let unmatched_images = Array::new()
  let unmatched_trajectory = trajectory.to_owned()
  let unmatched_depth = depth.to_owned()
  let unmatched_annotations = annotations.to_owned()
  for image in images {
    let traj = nearest_by_stamp(
      image.stamp,
      trajectory,
      fn(item) { item.stamp },
      tolerance_ns,
    )
    let dep = nearest_by_stamp(
      image.stamp,
      depth,
      fn(item) { item.stamp },
      tolerance_ns,
    )
    let (anns, ann_offset) = annotations_near(
      image.stamp,
      annotations,
      tolerance_ns,
    )
    let mut max_offset = ann_offset
    let traj_item = match traj {
      Some((item, offset)) => {
        if offset > max_offset {
          max_offset = offset
        }
        unmatched_trajectory.retain(fn(sample) { sample.stamp != item.stamp })
        Some(item)
      }
      None => None
    }
    let depth_item = match dep {
      Some((item, offset)) => {
        if offset > max_offset {
          max_offset = offset
        }
        unmatched_depth.retain(fn(sample) { sample.stamp != item.stamp })
        Some(item)
      }
      None => None
    }
    for ann in anns {
      unmatched_annotations.retain(fn(item) {
        item.stamp != ann.stamp || item.label != ann.label
      })
    }
    if traj_item is None && depth_item is None && anns.is_empty() {
      unmatched_images.push(image)
    } else {
      matches.push({
        image,
        trajectory: traj_item,
        depth: depth_item,
        annotations: anns,
        max_abs_offset_ns: max_offset,
      })
    }
  }
  {
    matches,
    unmatched_images,
    unmatched_trajectory,
    unmatched_depth,
    unmatched_annotations,
  }
}

///|
pub fn sync_report_summary(report : SyncReport) -> String {
  [
    "matches=\{report.matches.length()}",
    "unmatched_images=\{report.unmatched_images.length()}",
    "unmatched_trajectory=\{report.unmatched_trajectory.length()}",
    "unmatched_depth=\{report.unmatched_depth.length()}",
    "unmatched_annotations=\{report.unmatched_annotations.length()}",
  ].join(", ")
}