///|
/// Checked errors for the compact route exchange format.
pub(all) suberror RouteEncodingError {
  EmptyRoute
  InvalidCustomerId(String)
  UnknownCustomerId(Int)
  DuplicateCustomerId(Int)
} derive(Debug, Eq, ToJson)

///|
fn route_label_text(instance : VrpInstance, route : RoutePlan) -> String {
  route.stops.map(fn(index) { "\{instance.label_of(index)}" }).join(",")
}

///|
/// Encodes routes as `id,id;id,id`, using external customer labels.
pub fn encode_routes(instance : VrpInstance, solution : Solution) -> String {
  solution.routes.map(fn(route) { route_label_text(instance, route) }).join(";")
}

///|
/// Returns a stable route signature for equality/regression assertions.
pub fn solution_signature(
  instance : VrpInstance,
  solution : Solution,
) -> String {
  let routes = encode_routes(instance, solution)
  let unassigned = solution.unassigned
    .map(fn(index) { "\{instance.label_of(index)}" })
    .join(",")
  "routes=\{routes};unassigned=\{unassigned}"
}

///|
fn parse_label(token : String) -> Int raise RouteEncodingError {
  @strconv.from_str(token[:]) catch {
    _ => raise RouteEncodingError::InvalidCustomerId(token)
  }
}

///|
fn route_from_labels(
  instance : VrpInstance,
  text : String,
  seen : Array[Int],
) -> Array[Int] raise RouteEncodingError {
  let route : Array[Int] = []
  for token in text.split(",") {
    let cleaned = token.trim().to_owned()
    if cleaned.is_empty() {
      raise RouteEncodingError::EmptyRoute
    }
    let label = parse_label(cleaned)
    match instance.index_of_label(label) {
      None => raise RouteEncodingError::UnknownCustomerId(label)
      Some(index) => {
        if seen.contains(index) {
          raise RouteEncodingError::DuplicateCustomerId(label)
        }
        seen.push(index)
        route.push(index)
      }
    }
  }
  route
}

///|
/// Decodes the compact route format and assigns every omitted customer to the
/// returned solution's unassigned list.
pub fn decode_routes(
  instance : VrpInstance,
  text : String,
  config : SolveConfig,
) -> Solution raise RouteEncodingError {
  let routes : Array[Array[Int]] = []
  let seen : Array[Int] = []
  for route_text in text.split(";") {
    let cleaned = route_text.trim().to_owned()
    if cleaned.is_empty() {
      continue
    }
    routes.push(route_from_labels(instance, cleaned, seen))
  }
  let unassigned = all_customer_indices(instance).filter(fn(index) {
    !seen.contains(index)
  })
  build_solution(instance, routes, unassigned, config)
}

///|
/// Returns one line of route-level metrics for lightweight logging.
pub fn route_metrics_text(route : RoutePlan) -> String {
  let metrics = route.metrics
  "stops=\{route.stops.length()} load=\{metrics.load} distance=\{metrics.distance} duration=\{metrics.duration} waiting=\{metrics.waiting_time} feasible=\{metrics.feasible}"
}

///|
/// Returns a stable identity string for cache keys and benchmark manifests.
pub fn instance_signature(instance : VrpInstance) -> String {
  let depot = instance.depot
  "\{instance.name}|\{instance.variant.name()}|\{instance.metric.name()}|\{instance.customer_count()}|\{depot.x}|\{depot.y}|\{instance.vehicle.capacity}|\{instance.vehicle.fleet_size}"
}

///|
pub struct CostBreakdown {
  distance : Double
  capacity_penalty : Double
  time_penalty : Double
  duration_penalty : Double
  unassigned_penalty : Double
  objective : Double
} derive(Debug, ToJson)

///|
pub fn CostBreakdown::distance(self : CostBreakdown) -> Double {
  self.distance
}

///|
pub fn CostBreakdown::capacity_penalty(self : CostBreakdown) -> Double {
  self.capacity_penalty
}

///|
pub fn CostBreakdown::time_penalty(self : CostBreakdown) -> Double {
  self.time_penalty
}

///|
pub fn CostBreakdown::duration_penalty(self : CostBreakdown) -> Double {
  self.duration_penalty
}

///|
pub fn CostBreakdown::unassigned_penalty(self : CostBreakdown) -> Double {
  self.unassigned_penalty
}

///|
pub fn CostBreakdown::objective(self : CostBreakdown) -> Double {
  self.objective
}

///|
/// Explains the penalized objective without exposing the private formula.
pub fn Solution::cost_breakdown(
  self : Solution,
  config : SolveConfig,
) -> CostBreakdown {
  let capacity_penalty = self.total_capacity_violation * config.capacity_penalty
  let time_penalty = self.total_time_violation * config.time_penalty
  let duration_penalty = self.total_duration_violation * config.duration_penalty
  let unassigned_penalty = Double::from_int(self.unassigned.length()) *
    config.unassigned_penalty
  {
    distance: self.total_distance,
    capacity_penalty,
    time_penalty,
    duration_penalty,
    unassigned_penalty,
    objective: self.objective,
  }
}