///|
/// Explain how a candidate score was accumulated.
pub(all) struct CandidateExplanation {
  occurrences : Int
  score : Int
  average_weight : Int
  first_position : Int
  last_position : Int
  label : String
} derive(Debug, Eq, ToJson)

///|
/// A ranked candidate paired with its evidence.
pub(all) struct RankedCandidate {
  candidate : Candidate
  explanation : CandidateExplanation
} derive(Debug, Eq, ToJson)

///|
/// Aggregate matches into ranked, explainable candidates.
pub fn rank_candidates(
  matches : ArrayView[LexiconMatch],
) -> Array[RankedCandidate] {
  let grouped : Map[String, RankedCandidate] = Map([])
  for hit in matches {
    let key = entry_key({
      keyword: hit.keyword,
      label: hit.label,
      weight: hit.weight,
    })
    match grouped.get(key) {
      Some(old) => {
        let explanation = old.explanation
        let occurrences = explanation.occurrences + 1
        let score = explanation.score + hit.weight
        let span_count = old.candidate.span_count + 1
        grouped[key] = {
          candidate: {
            text: old.candidate.text,
            label: old.candidate.label,
            score,
            span_count,
          },
          explanation: {
            occurrences,
            score,
            average_weight: score / occurrences,
            first_position: if hit.start < explanation.first_position {
              hit.start
            } else {
              explanation.first_position
            },
            last_position: if hit.end > explanation.last_position {
              hit.end
            } else {
              explanation.last_position
            },
            label: explanation.label,
          },
        }
      }
      None =>
        grouped[key] = {
          candidate: {
            text: hit.keyword,
            label: hit.label,
            score: hit.weight,
            span_count: 1,
          },
          explanation: {
            occurrences: 1,
            score: hit.weight,
            average_weight: hit.weight,
            first_position: hit.start,
            last_position: hit.end,
            label: hit.label,
          },
        }
    }
  }
  let output : Array[RankedCandidate] = []
  for _, candidate in grouped {
    output.push(candidate)
  }
  sort_ranked_candidates(output)
}

///|
/// Keep the highest-ranked candidates up to a caller-supplied limit.
pub fn rank_candidates_top(
  matches : ArrayView[LexiconMatch],
  limit : Int,
) -> Array[RankedCandidate] {
  limit_ranked_candidates(rank_candidates(matches), limit)
}

///|
/// Return the evidence for one candidate identity.
pub fn explain_candidate(
  matches : ArrayView[LexiconMatch],
  keyword : String,
  label : String,
) -> CandidateExplanation? {
  for ranked in rank_candidates(matches) {
    if ranked.candidate.text == keyword && ranked.candidate.label == label {
      return Some(ranked.explanation)
    }
  }
  None
}

///|
/// Return a stable TSV report suitable for audit artifacts.
pub fn candidate_report_tsv(candidates : ArrayView[RankedCandidate]) -> String {
  let output = StringBuilder()
  output.write_string(
    "keyword\tlabel\tscore\toccurrences\taverage_weight\tfirst\tlast\n",
  )
  for ranked in candidates {
    let item = ranked.candidate
    let evidence = ranked.explanation
    output.write_string(
      "\{escape_dictionary_field(item.text)}\t\{escape_dictionary_field(item.label)}\t\{item.score}\t\{evidence.occurrences}\t\{evidence.average_weight}\t\{evidence.first_position}\t\{evidence.last_position}\n",
    )
  }
  output.to_string()
}

///|
/// Return the score contribution of one match.
pub fn candidate_contribution(hit : LexiconMatch) -> Candidate {
  { text: hit.keyword, label: hit.label, score: hit.weight, span_count: 1 }
}

///|
/// Return candidates with a minimum accumulated score.
pub fn candidates_at_least(
  candidates : ArrayView[RankedCandidate],
  minimum_score : Int,
) -> Array[RankedCandidate] {
  [
    for item in candidates if item.candidate.score >= minimum_score => item
  ]
}

///|
/// Return candidates whose evidence contains at least this many occurrences.
pub fn candidates_with_occurrences(
  candidates : ArrayView[RankedCandidate],
  minimum_occurrences : Int,
) -> Array[RankedCandidate] {
  [
    for item in candidates if item.explanation.occurrences >=
    minimum_occurrences => item
  ]
}

///|
/// Return true when ranking order is deterministic and non-increasing.
pub fn ranked_candidates_are_ordered(
  candidates : ArrayView[RankedCandidate],
) -> Bool {
  for index in 1.. 0 {
      return false
    }
  }
  true
}

///|
fn sort_ranked_candidates(
  candidates : Array[RankedCandidate],
) -> Array[RankedCandidate] {
  candidates.sort_by(compare_ranked)
  candidates
}

///|
fn limit_ranked_candidates(
  candidates : ArrayView[RankedCandidate],
  limit : Int,
) -> Array[RankedCandidate] {
  let output : Array[RankedCandidate] = []
  let safe_limit = if limit < 0 { 0 } else { limit }
  for item in candidates {
    if output.length() >= safe_limit {
      break
    }
    output.push(item)
  }
  output
}

///|
fn compare_ranked(left : RankedCandidate, right : RankedCandidate) -> Int {
  if left.candidate.score != right.candidate.score {
    right.candidate.score - left.candidate.score
  } else if left.explanation.occurrences != right.explanation.occurrences {
    right.explanation.occurrences - left.explanation.occurrences
  } else if left.candidate.span_count != right.candidate.span_count {
    right.candidate.span_count - left.candidate.span_count
  } else {
    left.candidate.text.compare(right.candidate.text)
  }
}