///|
pub struct ClassMetrics {
  class_id : Int
  mut expected : Int
  mut actual : Int
  mut matched : Int
  mut mean_iou_per_1000 : Int
} derive(Debug, Eq)

///|
pub fn ClassMetrics::new(class_id~ : Int) -> ClassMetrics {
  { class_id, expected: 0, actual: 0, matched: 0, mean_iou_per_1000: 0 }
}

///|
pub fn ClassMetrics::precision(self : ClassMetrics) -> Int {
  if self.actual == 0 {
    1000
  } else {
    self.matched * 1000 / self.actual
  }
}

///|
pub fn ClassMetrics::recall(self : ClassMetrics) -> Int {
  if self.expected == 0 {
    1000
  } else {
    self.matched * 1000 / self.expected
  }
}

///|
pub fn ClassMetrics::f1(self : ClassMetrics) -> Int {
  let p = self.precision()
  let r = self.recall()
  if p + r == 0 {
    0
  } else {
    2 * p * r / (p + r)
  }
}

///|
pub struct EvaluationReport {
  mut images : Int
  classes : Array[ClassMetrics]
  mut mask : BinaryMetrics
  mean_image_iou_per_1000 : Int
} derive(Debug, Eq)

///|
pub fn EvaluationReport::new() -> EvaluationReport {
  {
    images: 0,
    classes: [],
    mask: BinaryMetrics::new(
      true_positive=0,
      false_positive=0,
      false_negative=0,
      true_negative=0,
    ),
    mean_image_iou_per_1000: 0,
  }
}

///|
fn find_class(classes : Array[ClassMetrics], class_id : Int) -> Int {
  for i, metrics in classes {
    if metrics.class_id == class_id {
      return i
    }
  }
  -1
}

///|
fn ensure_class(report : EvaluationReport, class_id : Int) -> ClassMetrics {
  let index = find_class(report.classes, class_id)
  if index >= 0 {
    report.classes[index]
  } else {
    let metrics = ClassMetrics::new(class_id~)
    report.classes.push(metrics)
    metrics
  }
}

///|
pub fn EvaluationReport::add_detection_image(
  self : EvaluationReport,
  expected : Array[Box2D],
  actual : Array[Box2D],
  threshold_per_1000 : Int,
) -> Unit {
  self.images += 1
  let matches = match_detections(expected, actual, threshold_per_1000)
  for expected_box in expected {
    let metrics = ensure_class(self, expected_box.label.id)
    metrics.expected += 1
  }
  for actual_box in actual {
    let metrics = ensure_class(self, actual_box.label.id)
    metrics.actual += 1
  }
  for item in matches {
    let metrics = ensure_class(self, item.expected_id)
    metrics.matched += 1
    metrics.mean_iou_per_1000 += item.iou_per_1000
  }
  for metrics in self.classes {
    if metrics.matched > 0 {
      metrics.mean_iou_per_1000 = metrics.mean_iou_per_1000 / metrics.matched
    }
  }
}

///|
pub fn EvaluationReport::add_mask_image(
  self : EvaluationReport,
  expected : Mask,
  actual : Mask,
  positive : Int,
) -> Unit {
  let metrics = evaluate_mask(expected, actual, positive)
  self.mask = BinaryMetrics::new(
    true_positive=self.mask.true_positive + metrics.true_positive,
    false_positive=self.mask.false_positive + metrics.false_positive,
    false_negative=self.mask.false_negative + metrics.false_negative,
    true_negative=self.mask.true_negative + metrics.true_negative,
  )
}

///|
pub fn EvaluationReport::macro_precision(self : EvaluationReport) -> Int {
  if self.classes.length() == 0 {
    1000
  } else {
    let mut total = 0
    for metrics in self.classes {
      total += metrics.precision()
    }
    total / self.classes.length()
  }
}

///|
pub fn EvaluationReport::macro_recall(self : EvaluationReport) -> Int {
  if self.classes.length() == 0 {
    1000
  } else {
    let mut total = 0
    for metrics in self.classes {
      total += metrics.recall()
    }
    total / self.classes.length()
  }
}

///|
pub fn EvaluationReport::macro_f1(self : EvaluationReport) -> Int {
  if self.classes.length() == 0 {
    1000
  } else {
    let mut total = 0
    for metrics in self.classes {
      total += metrics.f1()
    }
    total / self.classes.length()
  }
}

///|
pub fn EvaluationReport::to_json(self : EvaluationReport) -> String {
  let sb = StringBuilder()
  sb.write_string(
    "{\"images\":\{self.images},\"macro_precision\":\{self.macro_precision()},",
  )
  sb.write_string(
    "\"macro_recall\":\{self.macro_recall()},\"macro_f1\":\{self.macro_f1()},",
  )
  sb.write_string("\"mask_f1\":\{self.mask.f1()},\"classes\":[")
  for i, metrics in self.classes {
    if i > 0 {
      sb.write_string(",")
    }
    sb.write_string(
      "{\"id\":\{metrics.class_id},\"expected\":\{metrics.expected},\"actual\":\{metrics.actual},\"matched\":\{metrics.matched},\"mean_iou\":\{metrics.mean_iou_per_1000}}",
    )
  }
  sb.write_string("]}")
  sb.to_string()
}

///|
pub struct ThresholdPoint {
  threshold : Int
  precision : Int
  recall : Int
  f1 : Int
} derive(Debug, Eq)

///|
pub fn detection_threshold_curve(
  expected : Array[Box2D],
  actual : Array[Box2D],
) -> Array[ThresholdPoint] {
  let out : Array[ThresholdPoint] = []
  for threshold in [100, 300, 500, 700, 900] {
    let metrics = evaluate_detections(expected, actual, threshold)
    let precision = metrics.precision()
    let recall = metrics.recall()
    let f1 = if precision + recall == 0 {
      0
    } else {
      2 * precision * recall / (precision + recall)
    }
    out.push({ threshold, precision, recall, f1 })
  }
  out
}

///|
pub fn best_detection_threshold(
  expected : Array[Box2D],
  actual : Array[Box2D],
) -> Int {
  let curve = detection_threshold_curve(expected, actual)
  let mut best_threshold = 0
  let mut best_f1 = -1
  for point in curve {
    if point.f1 > best_f1 {
      best_f1 = point.f1
      best_threshold = point.threshold
    }
  }
  best_threshold
}

///|
pub fn evaluate_scene_pair(
  expected : SceneOutput,
  actual : SceneOutput,
) -> EvaluationReport {
  let report = EvaluationReport::new()
  report.add_detection_image(expected.boxes, actual.boxes, 500)
  report.add_mask_image(expected.mask, actual.mask, 1)
  report
}

///|
pub fn evaluate_scene_batch(
  expected : Array[SceneOutput],
  actual : Array[SceneOutput],
) -> EvaluationReport {
  let report = EvaluationReport::new()
  let count = min_i(expected.length(), actual.length())
  for i in 0.. ValidationReport {
  let result = ValidationReport::new()
  if report.images < 0 {
    result.error("evaluation image count cannot be negative")
  }
  for metrics in report.classes {
    if metrics.matched > metrics.expected || metrics.matched > metrics.actual {
      result.error("class match count exceeds available annotations")
    }
  }
  result
}