///|
/// One feasible placement before it becomes an assignment.
pub(all) struct Candidate {
  worker_id : String
  visit_id : String
  start_minute : Int
  end_minute : Int
  travel_before_minutes : Int
  raw_score : Int
  reasons : Array[String]
} derive(Debug, Eq)

///|
/// Candidate search result retains rejected evaluations for diagnostics.
pub(all) struct CandidateSearch {
  candidates : Array[Candidate]
  rejected : Array[FeasibilityResult]
} derive(Debug, Eq)

///|
/// Soft score for one feasible candidate.
pub fn candidate_score(
  worker : Worker,
  visit : Visit,
  start_minute : Int,
  feasibility : FeasibilityResult,
  assignments : Array[Assignment],
  policy : SchedulePolicy,
) -> Int {
  let priority = priority_value(visit.priority) * policy.priority_weight
  let preference = if visit.prefers(worker.id) {
    policy.preference_weight
  } else {
    0
  }
  let travel = feasibility.travel_before_minutes * policy.travel_weight
  let load = assigned_minutes(assignments, worker.id) * policy.fairness_weight
  let early = (start_minute - visit.window.start_minute) / 5
  priority + preference - travel - load - early
}

///|
/// Explanations attached to one accepted candidate.
pub fn candidate_reasons(
  worker : Worker,
  visit : Visit,
  feasibility : FeasibilityResult,
) -> Array[String] {
  let reasons : Array[String] = []
  reasons.push("all hard constraints satisfied")
  if visit.prefers(worker.id) {
    reasons.push("worker is preferred by the service request")
  }
  if visit.required_skills.length() > 0 {
    reasons.push("worker satisfies every required skill")
  } else {
    reasons.push("visit has no specialist skill requirement")
  }
  if feasibility.travel_before_minutes == 0 {
    reasons.push("no travel time is required before the visit")
  } else {
    reasons.push(
      "estimated travel before visit is \{feasibility.travel_before_minutes} minutes",
    )
  }
  if worker.home.zone == visit.location.zone {
    reasons.push("visit is in the worker home service zone")
  }
  reasons
}

///|
/// Generate deterministic candidate start points for a visit.
pub fn candidate_start_minutes(
  visit : Visit,
  step_minutes? : Int = 15,
) -> Array[Int] {
  let result : Array[Int] = []
  let step = if step_minutes <= 0 { 15 } else { step_minutes }
  let latest = visit.window.latest_start(visit.duration_minutes)
  let mut minute = visit.window.start_minute
  while minute <= latest {
    result.push(minute)
    minute = minute + step
  }
  if result.length() > 0 && result[result.length() - 1] != latest {
    result.push(latest)
  }
  result
}

///|
/// Search every worker and start point for one visit.
pub fn search_candidates(
  visit : Visit,
  workers : Array[Worker],
  assignments : Array[Assignment],
  visits : Array[Visit],
  policy : SchedulePolicy,
) -> CandidateSearch {
  let candidates : Array[Candidate] = []
  let rejected : Array[FeasibilityResult] = []
  for worker in workers {
    for start_minute in candidate_start_minutes(visit) {
      let feasibility = evaluate_feasibility(
        worker, visit, start_minute, assignments, visits, policy,
      )
      if feasibility.feasible {
        candidates.push({
          worker_id: worker.id,
          visit_id: visit.id,
          start_minute,
          end_minute: start_minute + visit.duration_minutes,
          travel_before_minutes: feasibility.travel_before_minutes,
          raw_score: candidate_score(
            worker, visit, start_minute, feasibility, assignments, policy,
          ),
          reasons: candidate_reasons(worker, visit, feasibility),
        })
      } else {
        rejected.push(feasibility)
      }
    }
  }
  { candidates: sort_candidates(candidates), rejected }
}

///|
/// Sort by descending score with stable worker, start and visit tie breakers.
pub fn sort_candidates(values : Array[Candidate]) -> Array[Candidate] {
  let sorted = copy_array(values)
  for index = 1; index < sorted.length(); index = index + 1 {
    let current = sorted[index]
    let mut position = index
    while position > 0 {
      let previous = sorted[position - 1]
      let current_before = current.raw_score > previous.raw_score ||
        (
          current.raw_score == previous.raw_score &&
          current.worker_id < previous.worker_id
        ) ||
        (
          current.raw_score == previous.raw_score &&
          current.worker_id == previous.worker_id &&
          current.start_minute < previous.start_minute
        ) ||
        (
          current.raw_score == previous.raw_score &&
          current.worker_id == previous.worker_id &&
          current.start_minute == previous.start_minute &&
          current.visit_id < previous.visit_id
        )
      if !current_before {
        break
      }
      sorted[position] = previous
      position = position - 1
    }
    sorted[position] = current
  }
  sorted
}

///|
/// Convert one candidate to the public assignment representation.
pub fn Candidate::to_assignment(self : Candidate) -> Assignment {
  {
    visit_id: self.visit_id,
    worker_id: self.worker_id,
    start_minute: self.start_minute,
    end_minute: self.end_minute,
    travel_before_minutes: self.travel_before_minutes,
    score: self.raw_score,
    reasons: copy_array(self.reasons),
  }
}