///|
/// A compact multi-day staff scheduling model.
///
/// Each slot is an integer variable containing the assigned worker. The
/// builder adds per-day uniqueness, workload bounds, and optional rotation
/// constraints while leaving the model open for application-specific rules.
pub struct ScheduleProblem {
  solver : Solver
  slots : Array[Int]
  people : Int
  days : Int
  shifts_per_day : Int
  minimum_load : Int
  maximum_load : Int
}

///|
/// Create a balanced schedule model.
pub fn balanced_schedule(
  people : Int,
  days : Int,
  shifts_per_day : Int,
) -> ScheduleProblem? {
  if people < 1 || days < 1 || shifts_per_day < 1 || shifts_per_day > people {
    return None
  }
  let total_slots = days * shifts_per_day
  let minimum_load = total_slots / people
  let maximum_load = if total_slots % people == 0 {
    minimum_load
  } else {
    minimum_load + 1
  }
  let solver = new_solver()
  let slots : Array[Int] = []
  for slot in 0.. Bool {
  if shift < 0 || shift >= self.shifts_per_day {
    return false
  }
  if self.days < 2 {
    return true
  }
  for day in 0..<(self.days - 1) {
    let left = self.slots[day * self.shifts_per_day + shift]
    let right = self.slots[(day + 1) * self.shifts_per_day + shift]
    self.solver.add_constraint(not_equal(left, right))
  }
  true
}

///|
/// Add a rule that two workers cannot share a day.
pub fn ScheduleProblem::avoid_pair(
  self : ScheduleProblem,
  first : Int,
  second : Int,
) -> Bool {
  if first < 0 || first >= self.people || second < 0 || second >= self.people {
    return false
  }
  if first == second {
    return false
  }
  for day in 0.. Bool {
  if day < 0 || day >= self.days || shift < 0 || shift >= self.shifts_per_day {
    return false
  }
  if person < 0 || person >= self.people {
    return false
  }
  self.solver.assign(self.slot(day, shift), person)
}

///|
/// Return a slot variable identifier.
pub fn ScheduleProblem::slot(
  self : ScheduleProblem,
  day : Int,
  shift : Int,
) -> Int {
  if day < 0 || day >= self.days || shift < 0 || shift >= self.shifts_per_day {
    abort("schedule slot is outside the problem dimensions")
  }
  self.slots[day * self.shifts_per_day + shift]
}

///|
/// Read the configured workload lower bound.
pub fn ScheduleProblem::minimum_load(self : ScheduleProblem) -> Int {
  self.minimum_load
}

///|
/// Read the configured workload upper bound.
pub fn ScheduleProblem::maximum_load(self : ScheduleProblem) -> Int {
  self.maximum_load
}

///|
/// Solve the schedule once.
pub fn ScheduleProblem::solve(self : ScheduleProblem) -> Solution? {
  self.solver.solve()
}

///|
/// Enumerate schedule solutions.
pub fn ScheduleProblem::solve_all(
  self : ScheduleProblem,
  limit : Int,
) -> Array[Solution] {
  self.solver.limit(limit)
  self.solver.solve_all()
}

///|
/// Return solver counters for the most recent schedule solve.
pub fn ScheduleProblem::stats(self : ScheduleProblem) -> SearchStats {
  self.solver.stats()
}

///|
/// Return the underlying model for additional constraints.
pub fn ScheduleProblem::solver(self : ScheduleProblem) -> Solver {
  self.solver
}

///|
/// Return all slot identifiers in day-major order.
pub fn ScheduleProblem::slot_ids(self : ScheduleProblem) -> Array[Int] {
  self.slots.copy()
}

///|
/// Convert a solution to a day-by-day integer matrix.
pub fn ScheduleProblem::assignments(
  self : ScheduleProblem,
  solution : Solution,
) -> Array[Array[Int]] {
  let result : Array[Array[Int]] = []
  for day in 0.. String {
  let builder = StringBuilder()
  for day in 0.. 0 {
      builder.write_char('\n')
    }
    builder.write_string("day \{day}: ")
    for shift in 0.. 0 {
        builder.write_string(" | ")
      }
      builder.write_string(
        "shift \{shift}=worker \{solution.get(self.slot(day, shift))}",
      )
    }
  }
  builder.to_string()
}

///|
/// Return whether an assignment covers every slot exactly once.
pub fn ScheduleProblem::is_complete(
  self : ScheduleProblem,
  solution : Solution,
) -> Bool {
  for slot in self.slots {
    let value = solution.get(slot)
    if value < 0 || value >= self.people {
      return false
    }
  }
  true
}

///|
/// A small weighted preference used by application code to rank schedules.
pub struct SchedulePreference {
  day : Int
  shift : Int
  preferred_person : Int
  penalty : Int
}

///|
/// Construct a preference record.
pub fn schedule_preference(
  day : Int,
  shift : Int,
  preferred_person : Int,
  penalty : Int,
) -> SchedulePreference {
  { day, shift, preferred_person, penalty }
}

///|
/// Score a solution by summing penalties for preference violations.
pub fn ScheduleProblem::score(
  self : ScheduleProblem,
  solution : Solution,
  preferences : Array[SchedulePreference],
) -> Int {
  let mut score = 0
  for preference in preferences {
    if preference.day < 0 ||
      preference.day >= self.days ||
      preference.shift < 0 ||
      preference.shift >= self.shifts_per_day {
      continue
    }
    if solution.get(self.slot(preference.day, preference.shift)) !=
      preference.preferred_person {
      score += preference.penalty
    }
  }
  score
}

///|
/// Return a canonical small rotating schedule used by docs and benchmarks.
pub fn rotating_schedule(
  people : Int,
  days : Int,
  shifts : Int,
) -> ScheduleProblem? {
  match balanced_schedule(people, days, shifts) {
    None => None
    Some(problem) => {
      if people > 1 {
        ignore(problem.avoid_same_shift(0))
      }
      Some(problem)
    }
  }
}

///|
/// Build a schedule from a fixed pattern and verify its dimensions.
pub fn schedule_from_pattern(
  people : Int,
  pattern : Array[Array[Int]],
) -> ScheduleProblem? {
  if pattern.length() == 0 || people < 1 {
    return None
  }
  let shifts = pattern[0].length()
  if shifts == 0 || shifts > people {
    return None
  }
  let problem = match balanced_schedule(people, pattern.length(), shifts) {
    Some(value) => value
    None => return None
  }
  for day, row in pattern {
    if row.length() != shifts {
      return None
    }
    for shift, person in row {
      if !problem.fix(day, shift, person) {
        return None
      }
    }
  }
  Some(problem)
}