///|
/// Per-worker load retained for fairness reports.
pub(all) struct WorkerLoad {
  worker_id : String
  visit_count : Int
  service_minutes : Int
  travel_minutes : Int
  utilization_percent : Int
} derive(Debug, Eq)

///|
/// Compute a worker's load from schedule assignments.
pub fn worker_load(
  worker : Worker,
  assignments : Array[Assignment],
) -> WorkerLoad {
  let own = assignments_for_worker(assignments, worker.id)
  let service = assigned_minutes(assignments, worker.id)
  let mut travel = 0
  for assignment in own {
    travel = travel + assignment.travel_before_minutes
  }
  let utilization = if worker.max_minutes > 0 {
    clamp_int(service * 100 / worker.max_minutes, 0, 1000)
  } else {
    0
  }
  {
    worker_id: worker.id,
    visit_count: own.length(),
    service_minutes: service,
    travel_minutes: travel,
    utilization_percent: utilization,
  }
}

///|
/// Compute loads for all workers in input order.
pub fn all_worker_loads(
  workers : Array[Worker],
  assignments : Array[Assignment],
) -> Array[WorkerLoad] {
  let loads : Array[WorkerLoad] = []
  for worker in workers {
    loads.push(worker_load(worker, assignments))
  }
  loads
}

///|
/// Sum absolute deviations from the average assigned service time.
pub fn fairness_deviation(loads : Array[WorkerLoad]) -> Int {
  if loads.length() == 0 {
    return 0
  }
  let mut total = 0
  for load in loads {
    total = total + load.service_minutes
  }
  let average = total / loads.length()
  let mut deviation = 0
  for load in loads {
    deviation = deviation + abs_int(load.service_minutes - average)
  }
  deviation
}

///|
/// Number of assigned visits in a continuity group handled by one worker.
fn continuity_count_for_worker(
  group : String,
  worker_id : String,
  assignments : Array[Assignment],
  visits : Array[Visit],
) -> Int {
  let mut count = 0
  for assignment in assignments {
    if assignment.worker_id == worker_id {
      match find_visit(visits, assignment.visit_id) {
        Some(visit) =>
          if visit.continuity_group == Some(group) {
            count = count + 1
          }
        None => ()
      }
    }
  }
  count
}

///|
/// Reward additional visits in a group assigned to the same worker.
pub fn continuity_units(
  assignments : Array[Assignment],
  visits : Array[Visit],
  workers : Array[Worker],
) -> Int {
  let groups : Array[String] = []
  for visit in visits {
    match visit.continuity_group {
      Some(group) => push_unique_string(groups, group)
      None => ()
    }
  }
  let mut units = 0
  for group in groups {
    for worker in workers {
      let count = continuity_count_for_worker(
        group,
        worker.id,
        assignments,
        visits,
      )
      if count > 1 {
        units = units + count - 1
      }
    }
  }
  units
}

///|
/// Count assignments that satisfy explicit worker preferences.
pub fn preference_units(
  assignments : Array[Assignment],
  visits : Array[Visit],
) -> Int {
  let mut count = 0
  for assignment in assignments {
    match find_visit(visits, assignment.visit_id) {
      Some(visit) =>
        if visit.prefers(assignment.worker_id) {
          count = count + 1
        }
      None => ()
    }
  }
  count
}

///|
/// Weighted assigned priority across the schedule.
pub fn assigned_priority_units(
  assignments : Array[Assignment],
  visits : Array[Visit],
) -> Int {
  let mut total = 0
  for assignment in assignments {
    match find_visit(visits, assignment.visit_id) {
      Some(visit) => total = total + priority_value(visit.priority)
      None => ()
    }
  }
  total
}

///|
/// Count assignments changed relative to an optional baseline schedule.
pub fn disruption_units(
  assignments : Array[Assignment],
  baseline : Schedule?,
) -> Int {
  match baseline {
    None => 0
    Some(previous) => {
      let mut disruptions = 0
      for assignment in assignments {
        match find_assignment(previous.assignments, assignment.visit_id) {
          Some(old) =>
            if old.worker_id != assignment.worker_id ||
              old.start_minute != assignment.start_minute {
              disruptions = disruptions + 1
            }
          None => disruptions = disruptions + 1
        }
      }
      for old in previous.assignments {
        if find_assignment(assignments, old.visit_id) is None {
          disruptions = disruptions + 1
        }
      }
      disruptions
    }
  }
}

///|
/// Build a transparent multi-dimensional schedule score.
pub fn score_schedule(
  assignments : Array[Assignment],
  workers : Array[Worker],
  visits : Array[Visit],
  policy : SchedulePolicy,
  baseline : Schedule?,
) -> ScheduleScore {
  let priority = assigned_priority_units(assignments, visits) *
    policy.priority_weight
  let travel = total_schedule_travel(assignments) * policy.travel_weight
  let fairness = fairness_deviation(all_worker_loads(workers, assignments)) *
    policy.fairness_weight
  let continuity = continuity_units(assignments, visits, workers) *
    policy.continuity_weight
  let preference = preference_units(assignments, visits) *
    policy.preference_weight
  let disruption = disruption_units(assignments, baseline) *
    policy.disruption_weight
  {
    total: priority + continuity + preference - travel - fairness - disruption,
    assigned_priority: priority,
    travel_penalty: travel,
    fairness_penalty: fairness,
    continuity_bonus: continuity,
    preference_bonus: preference,
    disruption_penalty: disruption,
  }
}

///|
/// Short deterministic score summary.
pub fn score_summary(value : ScheduleScore) -> String {
  "total=\{value.total}, priority=\{value.assigned_priority}, travel=-\{value.travel_penalty}, fairness=-\{value.fairness_penalty}, continuity=+\{value.continuity_bonus}, preference=+\{value.preference_bonus}, disruption=-\{value.disruption_penalty}"
}