///|
/// Stable codes emitted when a worker cannot take one candidate visit.
pub(all) enum ConstraintCode {
  WorkerUnavailable
  WorkerForbidden
  MissingSkill
  OutsideAvailability
  TimeConflict
  InsufficientTravelBefore
  InsufficientTravelAfter
  BreakViolation
  MaxMinutesExceeded
  MaxVisitsExceeded
  VisitWindowViolation
} derive(Debug, Eq)

///|
/// One failed hard constraint with a human explanation.
pub(all) struct ConstraintFailure {
  code : ConstraintCode
  message : String
  worker_id : String
  visit_id : String
} derive(Debug, Eq)

///|
/// Complete feasibility result for one candidate placement.
pub(all) struct FeasibilityResult {
  feasible : Bool
  failures : Array[ConstraintFailure]
  travel_before_minutes : Int
  travel_after_minutes : Int
} derive(Debug, Eq)

///|
/// Stable machine name for constraint codes.
pub fn constraint_code_name(code : ConstraintCode) -> String {
  match code {
    WorkerUnavailable => "worker_unavailable"
    WorkerForbidden => "worker_forbidden"
    MissingSkill => "missing_skill"
    OutsideAvailability => "outside_availability"
    TimeConflict => "time_conflict"
    InsufficientTravelBefore => "insufficient_travel_before"
    InsufficientTravelAfter => "insufficient_travel_after"
    BreakViolation => "break_violation"
    MaxMinutesExceeded => "max_minutes_exceeded"
    MaxVisitsExceeded => "max_visits_exceeded"
    VisitWindowViolation => "visit_window_violation"
  }
}

///|
/// Build a contextual hard-constraint failure.
fn constraint_failure(
  code : ConstraintCode,
  message : String,
  worker_id : String,
  visit_id : String,
) -> ConstraintFailure {
  { code, message, worker_id, visit_id }
}

///|
/// Check every required skill and retain all missing requirements.
pub fn missing_skills(worker : Worker, visit : Visit) -> Array[Skill] {
  let missing : Array[Skill] = []
  for required in visit.required_skills {
    if !worker.has_skill(required) {
      missing.push(required)
    }
  }
  missing
}

///|
/// Whether an interval overlaps any existing worker assignment.
pub fn interval_conflicts(
  assignments : Array[Assignment],
  worker_id : String,
  interval : TimeWindow,
) -> Bool {
  for assignment in assignments {
    if assignment.worker_id == worker_id &&
      interval.overlaps(
        time_window(assignment.start_minute, assignment.end_minute),
      ) {
      return true
    }
  }
  false
}

///|
/// Evaluate a candidate against all hard constraints.
pub fn evaluate_feasibility(
  worker : Worker,
  visit : Visit,
  start_minute : Int,
  assignments : Array[Assignment],
  visits : Array[Visit],
  policy : SchedulePolicy,
) -> FeasibilityResult {
  let failures : Array[ConstraintFailure] = []
  let end_minute = start_minute + visit.duration_minutes
  let interval = time_window(start_minute, end_minute)
  if worker.unavailable {
    failures.push(
      constraint_failure(
        WorkerUnavailable,
        "worker is marked unavailable",
        worker.id,
        visit.id,
      ),
    )
  }
  if visit.forbids(worker.id) {
    failures.push(
      constraint_failure(
        WorkerForbidden,
        "worker is explicitly forbidden for this visit",
        worker.id,
        visit.id,
      ),
    )
  }
  for required in missing_skills(worker, visit) {
    failures.push(
      constraint_failure(
        MissingSkill,
        "missing skill \{required.name} at level \{required.level}",
        worker.id,
        visit.id,
      ),
    )
  }
  if !visit.window.contains(interval) {
    failures.push(
      constraint_failure(
        VisitWindowViolation,
        "candidate interval falls outside the visit time window",
        worker.id,
        visit.id,
      ),
    )
  }
  if !worker.is_available(interval) {
    failures.push(
      constraint_failure(
        OutsideAvailability,
        "candidate interval falls outside worker availability",
        worker.id,
        visit.id,
      ),
    )
  }
  if interval_conflicts(assignments, worker.id, interval) {
    failures.push(
      constraint_failure(
        TimeConflict,
        "candidate interval overlaps an existing assignment",
        worker.id,
        visit.id,
      ),
    )
  }
  let before = previous_assignment(assignments, worker.id, start_minute)
  let before_location = previous_location(
    worker, assignments, visits, start_minute,
  )
  let travel_before = travel_minutes(before_location, visit.location, policy)
  match before {
    Some(previous) => {
      let available_gap = start_minute - previous.end_minute
      let required_gap = travel_before + worker.min_break_minutes
      if available_gap < required_gap {
        failures.push(
          constraint_failure(
            InsufficientTravelBefore,
            "previous assignment leaves \{available_gap} minutes but \{required_gap} are required",
            worker.id,
            visit.id,
          ),
        )
      }
    }
    None => {
      let first_available = earliest_containing_start(worker, interval)
      match first_available {
        Some(available_start) =>
          if start_minute - available_start < travel_before {
            failures.push(
              constraint_failure(
                InsufficientTravelBefore,
                "worker cannot reach the first visit from home after availability begins",
                worker.id,
                visit.id,
              ),
            )
          }
        None => ()
      }
    }
  }
  let after = next_assignment(assignments, worker.id, end_minute)
  let mut travel_after = 0
  match after {
    Some(next) =>
      match assignment_location(next, visits) {
        Some(next_location) => {
          travel_after = travel_minutes(visit.location, next_location, policy)
          let available_gap = next.start_minute - end_minute
          let required_gap = travel_after + worker.min_break_minutes
          if available_gap < required_gap {
            failures.push(
              constraint_failure(
                InsufficientTravelAfter,
                "next assignment leaves \{available_gap} minutes but \{required_gap} are required",
                worker.id,
                visit.id,
              ),
            )
          }
        }
        None => ()
      }
    None => ()
  }
  if assigned_minutes(assignments, worker.id) + visit.duration_minutes >
    worker.max_minutes {
    failures.push(
      constraint_failure(
        MaxMinutesExceeded,
        "assignment would exceed worker maximum service minutes",
        worker.id,
        visit.id,
      ),
    )
  }
  if assignments_for_worker(assignments, worker.id).length() + 1 >
    worker.max_visits {
    failures.push(
      constraint_failure(
        MaxVisitsExceeded,
        "assignment would exceed worker maximum visit count",
        worker.id,
        visit.id,
      ),
    )
  }
  {
    feasible: failures.length() == 0,
    failures,
    travel_before_minutes: travel_before,
    travel_after_minutes: travel_after,
  }
}

///|
/// Earliest start of an availability window that contains an interval.
pub fn earliest_containing_start(
  worker : Worker,
  interval : TimeWindow,
) -> Int? {
  let mut earliest : Int? = None
  for window in worker.availability {
    if window.contains(interval) {
      match earliest {
        None => earliest = Some(window.start_minute)
        Some(value) =>
          if window.start_minute < value {
            earliest = Some(window.start_minute)
          }
      }
    }
  }
  earliest
}

///|
/// Collect unique failure codes from several rejected candidates.
pub fn unique_constraint_codes(
  results : Array[FeasibilityResult],
) -> Array[String] {
  let codes : Array[String] = []
  for result in results {
    for failure in result.failures {
      push_unique_string(codes, constraint_code_name(failure.code))
    }
  }
  codes
}

///|
/// Collect failure descriptions while preserving evaluation order.
pub fn constraint_details(results : Array[FeasibilityResult]) -> Array[String] {
  let details : Array[String] = []
  for result in results {
    for failure in result.failures {
      let value = "\{failure.worker_id}: \{failure.message}"
      push_unique_string(details, value)
    }
  }
  details
}