///|
pub(all) enum ViolationKind {
  UnknownWorker
  UnknownShift
  DuplicateAssignment
  WorkerUnavailable
  ExplicitlyUnavailable
  MissingRequiredPreference
  OverlappingShift
  InsufficientRest
  WeeklyMinuteLimit
  ConsecutiveShiftLimit
  MissingSkill
  Understaffed
  SkillShortage
} derive(Eq, Show, ToJson)

///|
pub struct ConstraintViolation {
  kind : ViolationKind
  message : String
  worker_id : String?
  shift_id : String?
  hard : Bool
  magnitude : Int
} derive(Eq, Show, ToJson)

///|
pub fn ConstraintViolation::new(
  kind : ViolationKind,
  message : String,
  hard : Bool,
) -> ConstraintViolation {
  { kind, message, worker_id: None, shift_id: None, hard, magnitude: 1 }
}

///|
pub fn ConstraintViolation::for_worker(
  self : ConstraintViolation,
  worker_id : String,
) -> ConstraintViolation {
  { ..self, worker_id: Some(worker_id) }
}

///|
pub fn ConstraintViolation::for_shift(
  self : ConstraintViolation,
  shift_id : String,
) -> ConstraintViolation {
  { ..self, shift_id: Some(shift_id) }
}

///|
pub fn ConstraintViolation::with_magnitude(
  self : ConstraintViolation,
  magnitude : Int,
) -> ConstraintViolation {
  { ..self, magnitude, }
}

///|
pub struct ConstraintReport {
  violations : Array[ConstraintViolation]
  hard_count : Int
  soft_count : Int
} derive(Eq, Show, ToJson)

///|
pub fn ConstraintReport::empty() -> ConstraintReport {
  { violations: [], hard_count: 0, soft_count: 0 }
}

///|
pub fn ConstraintReport::add(
  self : ConstraintReport,
  violation : ConstraintViolation,
) -> ConstraintReport {
  let violations = self.violations.copy()
  violations.push(violation)
  if violation.hard {
    { ..self, violations, hard_count: self.hard_count + 1 }
  } else {
    { ..self, violations, soft_count: self.soft_count + 1 }
  }
}

///|
pub fn ConstraintReport::append(
  self : ConstraintReport,
  other : ConstraintReport,
) -> ConstraintReport {
  let mut result = self
  for violation in other.violations {
    result = result.add(violation)
  }
  result
}

///|
pub fn ConstraintReport::feasible(self : ConstraintReport) -> Bool {
  self.hard_count == 0
}

///|
pub fn worker_assigned_minutes(
  problem : PlanningProblem,
  schedule : Schedule,
  worker_id : String,
) -> Int {
  let mut total = 0
  for assignment in schedule.assignments {
    if assignment.worker_id == worker_id {
      match problem.shift(assignment.shift_id) {
        Some(shift) => total = total + shift.range.duration()
        None => ()
      }
    }
  }
  total
}

///|
pub fn worker_night_count(
  problem : PlanningProblem,
  schedule : Schedule,
  worker_id : String,
) -> Int {
  let mut total = 0
  for assignment in schedule.assignments {
    if assignment.worker_id == worker_id {
      match problem.shift(assignment.shift_id) {
        Some(shift) => if shift.kind.is_night() { total = total + 1 }
        None => ()
      }
    }
  }
  total
}

///|
pub fn worker_weekend_count(
  problem : PlanningProblem,
  schedule : Schedule,
  worker_id : String,
) -> Int {
  let mut total = 0
  for assignment in schedule.assignments {
    if assignment.worker_id == worker_id {
      match problem.shift(assignment.shift_id) {
        Some(shift) => if shift.weekend { total = total + 1 }
        None => ()
      }
    }
  }
  total
}

///|
pub fn assignment_count_for_shift(
  schedule : Schedule,
  shift_id : String,
) -> Int {
  let mut count = 0
  for assignment in schedule.assignments {
    if assignment.shift_id == shift_id {
      count = count + 1
    }
  }
  count
}

///|
fn assigned_shifts_for_worker(
  problem : PlanningProblem,
  schedule : Schedule,
  worker_id : String,
) -> Array[Shift] {
  let shifts : Array[Shift] = []
  for assignment in schedule.assignments {
    if assignment.worker_id == worker_id {
      match problem.shift(assignment.shift_id) {
        Some(shift) => shifts.push(shift)
        None => ()
      }
    }
  }
  shifts
}

///|
fn ranges_overlap_existing(
  problem : PlanningProblem,
  schedule : Schedule,
  worker_id : String,
  candidate : Shift,
) -> Shift? {
  for shift in assigned_shifts_for_worker(problem, schedule, worker_id) {
    if shift.id != candidate.id && shift.range.overlaps(candidate.range) {
      return Some(shift)
    }
  }
  None
}

///|
fn insufficient_rest_with_existing(
  problem : PlanningProblem,
  schedule : Schedule,
  worker : Worker,
  candidate : Shift,
) -> Shift? {
  for shift in assigned_shifts_for_worker(problem, schedule, worker.id) {
    if shift.id != candidate.id && !shift.range.overlaps(candidate.range) {
      let gap = if shift.range.end.value() <= candidate.range.start.value() {
        shift.range.end.distance_to(candidate.range.start)
      } else {
        candidate.range.end.distance_to(shift.range.start)
      }
      if gap < worker.min_rest_minutes {
        return Some(shift)
      }
    }
  }
  None
}

///|
fn consecutive_days_with_candidate(
  problem : PlanningProblem,
  schedule : Schedule,
  worker_id : String,
  candidate : Shift,
) -> Int {
  let days : Array[Int] = [candidate.day_index]
  for shift in assigned_shifts_for_worker(problem, schedule, worker_id) {
    let mut duplicate = false
    for day in days {
      if day == shift.day_index {
        duplicate = true
      }
    }
    if !duplicate {
      days.push(shift.day_index)
    }
  }
  let mut best = 1
  for base in days {
    let mut length = 1
    let mut previous = base
    while true {
      let mut found = false
      for day in days {
        if day == previous + 1 {
          found = true
          previous = day
          length = length + 1
        }
      }
      if !found {
        break
      }
    }
    if length > best {
      best = length
    }
  }
  best
}

///|
fn preference_violation(worker : Worker, shift : Shift) -> ConstraintViolation? {
  match worker.preference_for(shift.id) {
    Some(preference) =>
      match preference.kind {
        Unavailable =>
          Some(
            ConstraintViolation::new(
              ExplicitlyUnavailable,
              worker.name + " marked " + shift.label + " as unavailable",
              true,
            )
            .for_worker(worker.id)
            .for_shift(shift.id),
          )
        _ => None
      }
    None => None
  }
}

///|
pub fn check_candidate(
  problem : PlanningProblem,
  schedule : Schedule,
  worker_id : String,
  shift_id : String,
) -> ConstraintReport {
  let mut report = ConstraintReport::empty()
  let worker = match problem.worker(worker_id) {
    Some(worker) => worker
    None =>
      return report.add(
        ConstraintViolation::new(
          UnknownWorker,
          "unknown worker: " + worker_id,
          true,
        ).for_worker(worker_id),
      )
  }
  let shift = match problem.shift(shift_id) {
    Some(shift) => shift
    None =>
      return report.add(
        ConstraintViolation::new(
          UnknownShift,
          "unknown shift: " + shift_id,
          true,
        ).for_shift(shift_id),
      )
  }
  if schedule.contains(worker_id, shift_id) {
    report = report.add(
      ConstraintViolation::new(
        DuplicateAssignment,
        worker.name + " is already assigned to " + shift.label,
        true,
      )
      .for_worker(worker_id)
      .for_shift(shift_id),
    )
  }
  if !worker.is_available(shift.range) {
    report = report.add(
      ConstraintViolation::new(
        WorkerUnavailable,
        worker.name + " is not available for " + shift.label,
        true,
      )
      .for_worker(worker_id)
      .for_shift(shift_id),
    )
  }
  match preference_violation(worker, shift) {
    Some(violation) => report = report.add(violation)
    None => ()
  }
  match ranges_overlap_existing(problem, schedule, worker_id, shift) {
    Some(other) =>
      report = report.add(
        ConstraintViolation::new(
          OverlappingShift,
          shift.label + " overlaps " + other.label,
          true,
        )
        .for_worker(worker_id)
        .for_shift(shift_id),
      )
    None => ()
  }
  match insufficient_rest_with_existing(problem, schedule, worker, shift) {
    Some(other) =>
      report = report.add(
        ConstraintViolation::new(
          InsufficientRest,
          worker.name + " would not receive enough rest around " + other.label,
          true,
        )
        .for_worker(worker_id)
        .for_shift(shift_id),
      )
    None => ()
  }
  let projected_minutes = worker_assigned_minutes(problem, schedule, worker_id) +
    shift.range.duration()
  if projected_minutes > worker.max_minutes {
    report = report.add(
      ConstraintViolation::new(
        WeeklyMinuteLimit,
        worker.name + " would exceed the maximum minute limit",
        true,
      )
      .for_worker(worker_id)
      .for_shift(shift_id)
      .with_magnitude(projected_minutes - worker.max_minutes),
    )
  }
  let consecutive = consecutive_days_with_candidate(
    problem, schedule, worker_id, shift,
  )
  if consecutive > worker.max_consecutive_shifts {
    report = report.add(
      ConstraintViolation::new(
        ConsecutiveShiftLimit,
        worker.name + " would exceed the consecutive-day limit",
        true,
      )
      .for_worker(worker_id)
      .for_shift(shift_id)
      .with_magnitude(consecutive - worker.max_consecutive_shifts),
    )
  }
  report
}

///|
fn check_assignment_references(
  problem : PlanningProblem,
  schedule : Schedule,
) -> ConstraintReport {
  let mut report = ConstraintReport::empty()
  for assignment in schedule.assignments {
    if problem.worker(assignment.worker_id) is None {
      report = report.add(
        ConstraintViolation::new(
          UnknownWorker,
          "assignment references unknown worker: " + assignment.worker_id,
          true,
        ).for_worker(assignment.worker_id),
      )
    }
    if problem.shift(assignment.shift_id) is None {
      report = report.add(
        ConstraintViolation::new(
          UnknownShift,
          "assignment references unknown shift: " + assignment.shift_id,
          true,
        ).for_shift(assignment.shift_id),
      )
    }
  }
  report
}

///|
fn check_duplicates(schedule : Schedule) -> ConstraintReport {
  let mut report = ConstraintReport::empty()
  for i = 0; i < schedule.assignments.length(); i = i + 1 {
    for j = i + 1; j < schedule.assignments.length(); j = j + 1 {
      let left = schedule.assignments[i]
      let right = schedule.assignments[j]
      if left.worker_id == right.worker_id && left.shift_id == right.shift_id {
        report = report.add(
          ConstraintViolation::new(
            DuplicateAssignment,
            "duplicate assignment for worker " + left.worker_id,
            true,
          )
          .for_worker(left.worker_id)
          .for_shift(left.shift_id),
        )
      }
    }
  }
  report
}

///|
fn check_worker_assignments(
  problem : PlanningProblem,
  schedule : Schedule,
) -> ConstraintReport {
  let mut report = ConstraintReport::empty()
  let incremental = Schedule::empty()
  for assignment in schedule.assignments {
    let candidate_report = check_candidate(
      problem,
      incremental,
      assignment.worker_id,
      assignment.shift_id,
    )
    report = report.append(candidate_report)
    if candidate_report.feasible() {
      incremental.assignments.push(assignment)
    }
  }
  report
}

///|
fn count_qualified_workers(
  problem : PlanningProblem,
  schedule : Schedule,
  shift : Shift,
  requirement : SkillRequirement,
) -> Int {
  let mut count = 0
  for assignment in schedule.assignments {
    if assignment.shift_id == shift.id {
      match problem.worker(assignment.worker_id) {
        Some(worker) =>
          if worker.has_skill(requirement.skill_id, requirement.minimum_level) {
            count = count + 1
          }
        None => ()
      }
    }
  }
  count
}

///|
fn check_coverage(
  problem : PlanningProblem,
  schedule : Schedule,
) -> ConstraintReport {
  let mut report = ConstraintReport::empty()
  for shift in problem.shifts {
    let assigned = assignment_count_for_shift(schedule, shift.id)
    if assigned < shift.required_workers {
      report = report.add(
        ConstraintViolation::new(
          Understaffed,
          shift.label + " is understaffed",
          true,
        )
        .for_shift(shift.id)
        .with_magnitude(shift.required_workers - assigned),
      )
    }
    for requirement in shift.requirements {
      let qualified = count_qualified_workers(
        problem, schedule, shift, requirement,
      )
      if qualified < requirement.count {
        report = report.add(
          ConstraintViolation::new(
            SkillShortage,
            shift.label +
            " lacks qualified workers for skill " +
            requirement.skill_id,
            true,
          )
          .for_shift(shift.id)
          .with_magnitude(requirement.count - qualified),
        )
      }
    }
  }
  report
}

///|
pub fn evaluate_constraints(
  problem : PlanningProblem,
  schedule : Schedule,
) -> ConstraintReport {
  ConstraintReport::empty()
  .append(check_assignment_references(problem, schedule))
  .append(check_duplicates(schedule))
  .append(check_worker_assignments(problem, schedule))
  .append(check_coverage(problem, schedule))
}

///|
pub fn eligible_workers(
  problem : PlanningProblem,
  schedule : Schedule,
  shift_id : String,
) -> Array[String] {
  let result : Array[String] = []
  for worker in problem.workers {
    if check_candidate(problem, schedule, worker.id, shift_id).feasible() {
      result.push(worker.id)
    }
  }
  result
}