///|
fn route_candidate_score(
  breakdown : RouteBreakdown,
  risk_penalty : Double,
) -> Double {
  breakdown.total_eta_seconds +
  abs_double(risk_penalty) *
  (1.0 - breakdown.confidence) *
  breakdown.total_eta_seconds
}

///|
/// Build route candidates from multiple profiles.
pub fn make_route_candidates(
  routes : Array[(String, RouteProfile)],
  speed~ : Double,
  congestion~ : Double,
  risk_penalty? : Double = 0.25,
) -> Array[RouteCandidate] {
  let candidates : Array[RouteCandidate] = []
  for item in routes {
    let route_id = item.0
    let route = item.1
    let breakdown = forecast_breakdown(route, speed~, congestion~)
    let risk = (1.0 - breakdown.confidence) * breakdown.total_eta_seconds
    candidates.push({
      route_id,
      route,
      breakdown,
      score: route_candidate_score(breakdown, risk_penalty),
      risk,
    })
  }
  candidates
}

///|
/// Select the lowest-risk-adjusted ETA candidate.
pub fn select_route(candidates : Array[RouteCandidate]) -> RouteSelection {
  if candidates.length() == 0 {
    { selected: None, alternatives: [], reason: "no route candidates" }
  } else {
    let sorted = candidates.copy()
    sorted.sort_by((left, right) => compare_double(left.score, right.score))
    let alternatives : Array[RouteCandidate] = []
    for candidate in sorted[1:] {
      alternatives.push(candidate)
    }
    {
      selected: Some(sorted[0]),
      alternatives,
      reason: "minimum ETA-risk score",
    }
  }
}

///|
/// Return route candidates in score order.
pub fn rank_routes(candidates : Array[RouteCandidate]) -> Array[RouteCandidate] {
  let ranked = candidates.copy()
  ranked.sort_by((left, right) => compare_double(left.score, right.score))
  ranked
}

///|
/// Return candidate IDs in rank order.
pub fn route_ids(candidates : Array[RouteCandidate]) -> Array[String] {
  let ids : Array[String] = []
  for candidate in rank_routes(candidates) {
    ids.push(candidate.route_id)
  }
  ids
}

///|
/// Return routes that are not dominated by both ETA and risk.
pub fn pareto_routes(
  candidates : Array[RouteCandidate],
) -> Array[RouteCandidate] {
  let result : Array[RouteCandidate] = []
  for candidate in candidates {
    let dominated = candidates.any(other => {
      other.breakdown.total_eta_seconds < candidate.breakdown.total_eta_seconds &&
      other.risk < candidate.risk &&
      other.route_id != candidate.route_id
    })
    if !dominated {
      result.push(candidate)
    }
  }
  result
}

///|
/// Add a fixed operational risk budget to a candidate score.
pub fn with_risk_budget(
  candidate : RouteCandidate,
  budget~ : Double,
) -> RouteCandidate {
  let extra = abs_double(budget) * candidate.risk
  { ..candidate, score: candidate.breakdown.total_eta_seconds + extra }
}

///|
/// Compare selected route to the fastest raw ETA route.
pub fn selection_risk_premium(selection : RouteSelection) -> Double {
  match selection.selected {
    None => 0.0
    Some(selected) =>
      if selection.alternatives.length() == 0 {
        0.0
      } else {
        let fastest = selection.alternatives.fold(
          init=selected.breakdown.total_eta_seconds,
          (current, candidate) => {
            if candidate.breakdown.total_eta_seconds < current {
              candidate.breakdown.total_eta_seconds
            } else {
              current
            }
          },
        )
        safe_div(selected.breakdown.total_eta_seconds - fastest, fastest, 0.0)
      }
  }
}