///|
priv struct YoloManifestJson {
  category_ids : Array[Int]
  images : Array[Json]
} derive(FromJson)

///|
priv struct YoloFileJson {
  image_id : Int
  file : String
} derive(FromJson)

///|
/// One TXT file, relative to its manifest directory.
pub struct YoloFile {
  image_id : Int
  file : String
}

///|
pub fn YoloFile::image_id(self : YoloFile) -> Int {
  self.image_id
}

///|
pub fn YoloFile::file(self : YoloFile) -> String {
  self.file
}

///|
/// Class mapping and ordered per-image prediction files.
pub struct YoloManifest {
  category_ids : Array[Int]
  images : Array[YoloFile]
}

///|
pub fn YoloManifest::category_ids(self : YoloManifest) -> Array[Int] {
  self.category_ids.copy()
}

///|
pub fn YoloManifest::images(self : YoloManifest) -> Array[YoloFile] {
  self.images.copy()
}

///|
fn valid_yolo_file_path(path : String) -> Bool {
  if path.is_empty() ||
    path.has_prefix("/") ||
    path.contains("\\") ||
    path.contains(":") {
    return false
  }
  for part in path.split("/") {
    if part == "" || part == "." || part == ".." {
      return false
    }
  }
  true
}

///|
/// Parse a multi-image YOLO manifest against a ground-truth dataset.
/// Relative paths use forward slashes; . and .. segments are rejected.
pub fn parse_yolo_manifest(
  source : String,
  dataset : Dataset,
) -> YoloManifest raise EvalError {
  let json = @json.parse(source) catch {
    _ => raise InvalidInput("YOLO manifest is not valid JSON")
  }
  let raw : YoloManifestJson = @json.from_json(json) catch {
    _ =>
      raise InvalidInput(
        "YOLO manifest needs category_ids and images with valid field types",
      )
  }
  if raw.category_ids.is_empty() {
    raise InvalidInput("YOLO manifest category_ids must be nonempty")
  }
  let known_images : Map[Int, Bool] = Map([])
  let known_categories : Map[Int, Bool] = Map([])
  for image in dataset.images {
    known_images[image.id] = true
  }
  for category in dataset.categories {
    known_categories[category.id] = true
  }
  for index, category_id in raw.category_ids {
    if !known_categories.contains(category_id) {
      raise InvalidInput(
        "YOLO manifest category_ids[\{index}] \{category_id} is not in the dataset",
      )
    }
  }
  let seen_images : Map[Int, Bool] = Map([])
  let seen_files : Map[String, Bool] = Map([])
  let images : Array[YoloFile] = []
  for index, entry in raw.images {
    let file : YoloFileJson = @json.from_json(entry) catch {
      _ =>
        raise InvalidInput("YOLO manifest images[\{index}] has invalid fields")
    }
    if !known_images.contains(file.image_id) {
      raise InvalidInput(
        "YOLO manifest images[\{index}].image_id \{file.image_id} is not in the dataset",
      )
    }
    if seen_images.contains(file.image_id) {
      raise InvalidInput(
        "YOLO manifest images[\{index}].image_id \{file.image_id} is duplicated",
      )
    }
    if !valid_yolo_file_path(file.file) {
      raise InvalidInput(
        "YOLO manifest images[\{index}].file must be a relative path without . or .. segments, backslashes, or a drive prefix",
      )
    }
    if seen_files.contains(file.file) {
      raise InvalidInput(
        "YOLO manifest images[\{index}].file \{file.file} is duplicated",
      )
    }
    seen_images[file.image_id] = true
    seen_files[file.file] = true
    images.push({ image_id: file.image_id, file: file.file, })
  }
  { category_ids: raw.category_ids.copy(), images, }
}