///|
fn parse_int_field(
  value : StringView,
  field : String,
  line_no : Int,
) -> Result[Int, String] {
  let trimmed = value.trim(chars=" \t\r")
  Ok(@strconv.from_str(trimmed)) catch {
    _ => Err("line \{line_no}: invalid integer in \{field}: '\{trimmed}'")
  }
}

///|
fn parse_double_field(
  value : StringView,
  field : String,
  line_no : Int,
) -> Result[Double, String] {
  let trimmed = value.trim(chars=" \t\r")
  Ok(@strconv.from_str(trimmed)) catch {
    _ => Err("line \{line_no}: invalid double in \{field}: '\{trimmed}'")
  }
}

///|
fn normalize_line_end(line : StringView) -> String {
  line.trim(chars=" \r").to_owned()
}

///|
fn parse_tsv_fields(
  source : String,
  expected : Int,
  label : String,
) -> Result[Array[Array[String]], String] {
  let rows : Array[Array[String]] = []
  let mut line_no = 0
  for raw_line in source.split("\n") {
    line_no += 1
    let line = normalize_line_end(raw_line)
    if line.is_empty() || line.get_char(0) == Some('#') {
      continue
    }
    let fields = line
      .split("\t")
      .to_array()
      .map(fn(field) { field.trim(chars=" \r").to_owned() })
    if fields.length() < expected {
      return Err(
        "line \{line_no}: expected at least \{expected} TSV fields for \{label}, got \{fields.length()}",
      )
    }
    rows.push(fields)
  }
  Ok(rows)
}

///|
fn unique_positive_cutoffs(cutoffs : Array[Int]) -> Array[Int] {
  let seen : Map[Int, Unit] = Map([])
  let normalized : Array[Int] = []
  for cutoff in cutoffs {
    if cutoff <= 0 || seen.contains(cutoff) {
      continue
    }
    seen[cutoff] = ()
    normalized.push(cutoff)
  }
  normalized.sort_by(Int::compare)
  if normalized.is_empty() {
    default_cutoffs()
  } else {
    normalized
  }
}

///|
fn metric_name(prefix : String, cutoff : Int) -> String {
  "\{prefix}@\{cutoff}"
}

///|
fn gain_of(relevance : Int, gain_scheme : GainScheme) -> Double {
  if relevance <= 0 {
    0.0
  } else {
    match gain_scheme {
      Linear => Double::from_int(relevance)
      Exp2 => @math.pow(2.0, Double::from_int(relevance)) - 1.0
    }
  }
}

///|
fn reciprocal_rank(index : Int) -> Double {
  1.0 / Double::from_int(index + 1)
}

///|
fn to_ratio(numerator : Int, denominator : Int) -> Double {
  if denominator <= 0 {
    0.0
  } else {
    Double::from_int(numerator) / Double::from_int(denominator)
  }
}

///|
fn group_qrels(items : Array[JudgedDoc]) -> Map[String, Array[JudgedDoc]] {
  let grouped : Map[String, Array[JudgedDoc]] = Map([])
  for item in items {
    let bucket = grouped.get_or_init(item.query_id, fn() { [] })
    bucket.push(item)
  }
  grouped
}

///|
fn group_runs(items : Array[RetrievedDoc]) -> Map[String, Array[RetrievedDoc]] {
  let grouped : Map[String, Array[RetrievedDoc]] = Map([])
  for item in items {
    let bucket = grouped.get_or_init(item.query_id, fn() { [] })
    bucket.push(item)
  }
  for _, bucket in grouped {
    bucket.sort_by(fn(a, b) {
      let by_score = b.score.compare(a.score)
      if by_score == 0 {
        a.doc_id.compare(b.doc_id)
      } else {
        by_score
      }
    })
  }
  grouped
}

///|
fn build_relevance_map(items : Array[JudgedDoc]) -> Map[String, Int] {
  let relevance : Map[String, Int] = Map([])
  for item in items {
    relevance.update(item.doc_id, fn(existing) {
      match existing {
        Some(current) => Some(Int::max(current, item.relevance))
        None => Some(item.relevance)
      }
    })
  }
  relevance
}

///|
fn build_query_relevance_map(items : Array[JudgedDoc]) -> Map[String, Int] {
  let relevance : Map[String, Int] = Map([])
  for item in items {
    let key = "\{item.query_id}:\{item.doc_id}"
    relevance.update(key, fn(existing) {
      match existing {
        Some(current) => Some(Int::max(current, item.relevance))
        None => Some(item.relevance)
      }
    })
  }
  relevance
}

///|
fn sorted_query_ids(
  qrels : Map[String, Array[JudgedDoc]],
  runs : Map[String, Array[RetrievedDoc]],
) -> Array[String] {
  let seen : Map[String, Unit] = Map([])
  let ids : Array[String] = []
  for query_id, _ in qrels {
    if !seen.contains(query_id) {
      seen[query_id] = ()
      ids.push(query_id)
    }
  }
  for query_id, _ in runs {
    if !seen.contains(query_id) {
      seen[query_id] = ()
      ids.push(query_id)
    }
  }
  ids.sort()
  ids
}