///|
/// Public stateless scheduling engine.
pub(all) struct RotaEngine {
  version : String
} derive(Debug, Eq)

///|
/// Construct the stable NeighborRota scheduling engine.
pub fn new_rota_engine() -> RotaEngine {
  { version: "0.1.0" }
}

///|
/// Solve through the engine-style API used in the project application.
pub fn RotaEngine::solve(
  self : RotaEngine,
  request : ScheduleRequest,
) -> Schedule {
  let _version = self.version
  solve(request)
}

///|
/// Construct an empty blocking result for invalid inputs.
fn invalid_schedule(diagnostics : Array[Diagnostic]) -> Schedule {
  {
    schema: SCHEMA_VERSION,
    assignments: [],
    unassigned: [],
    diagnostics,
    score: {
      total: 0,
      assigned_priority: 0,
      travel_penalty: 0,
      fairness_penalty: 0,
      continuity_bonus: 0,
      preference_bonus: 0,
      disruption_penalty: 0,
    },
    feasible: false,
  }
}

///|
/// Convert a rejected candidate search into an unassigned explanation.
fn explain_unassigned(
  visit : Visit,
  search : CandidateSearch,
) -> UnassignedVisit {
  let codes = unique_constraint_codes(search.rejected)
  let details = constraint_details(search.rejected)
  if codes.length() == 0 {
    codes.push("no_candidate")
    details.push("no worker and start-time combination was generated")
  }
  { visit_id: visit.id, reason_codes: sort_strings(codes), details }
}

///|
/// Build a deterministic schedule with urgency-first constructive search.
pub fn solve_constructive(request : ScheduleRequest) -> Schedule {
  let diagnostics = validate_request(request)
  if diagnostics_have_errors(diagnostics) {
    return invalid_schedule(diagnostics)
  }
  let assignments : Array[Assignment] = []
  let unassigned : Array[UnassignedVisit] = []
  for visit in sort_visits_for_scheduling(request.visits) {
    let search = search_candidates(
      visit,
      request.workers,
      assignments,
      request.visits,
      request.policy,
    )
    if search.candidates.length() > 0 {
      assignments.push(search.candidates[0].to_assignment())
    } else {
      unassigned.push(explain_unassigned(visit, search))
    }
  }
  let ordered = sort_schedule_assignments(assignments)
  let score = score_schedule(
    ordered,
    request.workers,
    request.visits,
    request.policy,
    request.baseline,
  )
  let mut feasible = true
  for missing in unassigned {
    match find_visit(request.visits, missing.visit_id) {
      Some(visit) => if visit.required { feasible = false }
      None => feasible = false
    }
  }
  {
    schema: SCHEMA_VERSION,
    assignments: ordered,
    unassigned,
    diagnostics,
    score,
    feasible,
  }
}

///|
/// Remove one assignment by visit id.
pub fn without_assignment(
  assignments : Array[Assignment],
  visit_id : String,
) -> Array[Assignment] {
  let result : Array[Assignment] = []
  for assignment in assignments {
    if assignment.visit_id != visit_id {
      result.push(assignment)
    }
  }
  result
}

///|
/// Replace one assignment by visit id while preserving all others.
pub fn replace_assignment(
  assignments : Array[Assignment],
  replacement : Assignment,
) -> Array[Assignment] {
  let result = without_assignment(assignments, replacement.visit_id)
  result.push(replacement)
  sort_schedule_assignments(result)
}

///|
/// Attempt all alternative candidates for each scheduled visit once.
fn improve_round(schedule : Schedule, request : ScheduleRequest) -> Schedule {
  let mut best = schedule
  for original in schedule.assignments {
    match find_visit(request.visits, original.visit_id) {
      None => ()
      Some(visit) => {
        let remaining = without_assignment(best.assignments, visit.id)
        let search = search_candidates(
          visit,
          request.workers,
          remaining,
          request.visits,
          request.policy,
        )
        let limit = min_int(search.candidates.length(), 12)
        for index = 0; index < limit; index = index + 1 {
          let proposal = replace_assignment(
            remaining,
            search.candidates[index].to_assignment(),
          )
          let score = score_schedule(
            proposal,
            request.workers,
            request.visits,
            request.policy,
            request.baseline,
          )
          if score.total > best.score.total {
            best = { ..best, assignments: proposal, score }
          }
        }
      }
    }
  }
  best
}

///|
/// Improve a constructive schedule without sacrificing determinism.
pub fn improve_schedule(
  schedule : Schedule,
  request : ScheduleRequest,
) -> Schedule {
  let mut current = schedule
  let mut round = 0
  while round < request.policy.local_search_rounds {
    let improved = improve_round(current, request)
    if improved.score.total <= current.score.total {
      break
    }
    current = improved
    round = round + 1
  }
  current
}

///|
/// Main scheduling function: validate, construct and locally improve.
pub fn solve(request : ScheduleRequest) -> Schedule {
  let initial = solve_constructive(request)
  if diagnostics_have_errors(initial.diagnostics) {
    return initial
  }
  improve_schedule(initial, request)
}

///|
/// Verify every assignment independently from the solver implementation.
pub fn audit_schedule(
  schedule : Schedule,
  request : ScheduleRequest,
) -> Array[Diagnostic] {
  let issues : Array[Diagnostic] = []
  let seen_visits : Array[String] = []
  for assignment in schedule.assignments {
    if string_array_contains(seen_visits, assignment.visit_id) {
      issues.push(
        error_diagnostic(
          "audit.duplicate_visit", "visit appears in more than one assignment",
        ).for_entity(assignment.visit_id),
      )
    }
    push_unique_string(seen_visits, assignment.visit_id)
    match
      (
        find_worker(request.workers, assignment.worker_id),
        find_visit(request.visits, assignment.visit_id),
      ) {
      (None, _) =>
        issues.push(
          error_diagnostic(
            "audit.unknown_worker", "assignment references an unknown worker",
          ).for_entity(assignment.visit_id),
        )
      (_, None) =>
        issues.push(
          error_diagnostic(
            "audit.unknown_visit", "assignment references an unknown visit",
          ).for_entity(assignment.visit_id),
        )
      (Some(worker), Some(visit)) => {
        let others = without_assignment(
          schedule.assignments,
          assignment.visit_id,
        )
        let result = evaluate_feasibility(
          worker,
          visit,
          assignment.start_minute,
          others,
          request.visits,
          request.policy,
        )
        for failure in result.failures {
          issues.push(
            error_diagnostic(
              "audit." + constraint_code_name(failure.code),
              failure.message,
            ).for_entity(assignment.visit_id),
          )
        }
      }
    }
  }
  for visit in request.visits {
    if visit.required && !string_array_contains(seen_visits, visit.id) {
      let mut recorded_unassigned = false
      for missing in schedule.unassigned {
        if missing.visit_id == visit.id {
          recorded_unassigned = true
        }
      }
      if !recorded_unassigned {
        issues.push(
          error_diagnostic(
            "audit.missing_required_visit", "required visit is neither assigned nor explained as unassigned",
          ).for_entity(visit.id),
        )
      }
    }
  }
  issues
}