///|
/// Runtime limits and deterministic heuristics for the scheduler.
pub(all) struct SolverConfig {
  maximum_candidates_per_task : Int
  prefer_solar : Bool
  allow_grid_charging : Bool
  preserve_reserve_outside_outage : Bool
  capacity_violation_penalty : Int
  outage_violation_penalty : Int
  optional_skip_threshold : Int
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn SolverConfig::default() -> SolverConfig {
  {
    maximum_candidates_per_task: 256,
    prefer_solar: true,
    allow_grid_charging: true,
    preserve_reserve_outside_outage: true,
    capacity_violation_penalty: 100000,
    outage_violation_penalty: 1000000,
    optional_skip_threshold: 0,
  }
}

///|
/// Diagnostic score for a candidate task placement.
pub(all) struct CandidatePlacement {
  task_id : String
  start_slot : Int
  end_slot : Int
  energy_cost_component : Int
  carbon_component : Int
  comfort_component : Int
  resilience_component : Int
  capacity_excess_w : Int
  solar_overlap_wh : Int
  total_score : Int
  feasible : Bool
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn CandidatePlacement::duration_slots(self : CandidatePlacement) -> Int {
  self.end_slot - self.start_slot
}

///|
fn absolute(value : Int) -> Int {
  if value < 0 {
    -value
  } else {
    value
  }
}

///|
fn minimum(a : Int, b : Int) -> Int {
  if a < b {
    a
  } else {
    b
  }
}

///|
fn maximum(a : Int, b : Int) -> Int {
  if a > b {
    a
  } else {
    b
  }
}

///|
fn clamp(value : Int, lower : Int, upper : Int) -> Int {
  if value < lower {
    lower
  } else if value > upper {
    upper
  } else {
    value
  }
}

///|
fn battery_power_support(input : PlanningInput) -> Int {
  match input.battery {
    Some(battery) => battery.maximum_discharge_w
    None => 0
  }
}

///|
fn candidate_supply_w(input : PlanningInput, slot : Int) -> Int {
  input.grid_limit_at(slot) +
  input.solar_w.at(slot) +
  battery_power_support(input)
}

///|
fn task_sort_order(a : LoadTask, b : LoadTask) -> Int {
  if a.mode == Fixed && b.mode != Fixed {
    return -1
  }
  if b.mode == Fixed && a.mode != Fixed {
    return 1
  }
  if a.priority.rank() > b.priority.rank() {
    return -1
  }
  if a.priority.rank() < b.priority.rank() {
    return 1
  }
  let a_slack = a.window_slots() - a.duration_slots
  let b_slack = b.window_slots() - b.duration_slots
  if a_slack < b_slack {
    return -1
  }
  if a_slack > b_slack {
    return 1
  }
  if a.power_w > b.power_w {
    -1
  } else if a.power_w < b.power_w {
    1
  } else {
    a.id.compare(b.id)
  }
}

///|
fn sorted_tasks(tasks : Array[LoadTask]) -> Array[LoadTask] {
  let result = tasks.map(task => task)
  result.sort_by(task_sort_order)
  result
}

///|
fn slot_energy_wh(power_w : Int, slot_minutes : Int) -> Int {
  power_w * slot_minutes / 60
}

///|
fn objective_slot_score(
  input : PlanningInput,
  task : LoadTask,
  slot : Int,
  current_load_w : Int,
) -> (Int, Int, Int, Int, Int, Int) {
  let weights = input.weights.normalized()
  let added_load_w = current_load_w + task.power_w
  let solar_w = input.solar_w.at(slot)
  let previous_grid_w = maximum(0, current_load_w - solar_w)
  let next_grid_w = maximum(0, added_load_w - solar_w)
  let incremental_grid_w = next_grid_w - previous_grid_w
  let energy_wh = slot_energy_wh(incremental_grid_w, input.slot_minutes)
  let cost_component = energy_wh * input.tariff_micro_per_kwh.at(slot) / 100000
  let carbon_component = energy_wh * input.carbon_g_per_kwh.at(slot) / 1000
  let comfort_component = absolute(slot - task.preferred_start) *
    task.comfort_penalty_per_slot
  let supply_w = candidate_supply_w(input, slot)
  let capacity_excess_w = maximum(0, added_load_w - supply_w)
  let resilience_component = if input.has_outage_at(slot) {
    match task.priority {
      Critical => 0
      High => task.power_w / 4
      Normal => task.power_w
      Low => task.power_w * 3
    }
  } else {
    0
  }
  let solar_overlap_wh = slot_energy_wh(
    minimum(task.power_w, maximum(0, solar_w - current_load_w)),
    input.slot_minutes,
  )
  let total = cost_component * weights.cost +
    carbon_component * weights.carbon +
    comfort_component * weights.comfort +
    resilience_component * weights.resilience -
    solar_overlap_wh * (weights.cost + weights.carbon) / 10
  (
    cost_component, carbon_component, comfort_component, resilience_component, capacity_excess_w,
    total,
  )
}

///|
/// Evaluate a contiguous placement against the current provisional load.
pub fn evaluate_candidate(
  input : PlanningInput,
  task : LoadTask,
  start_slot : Int,
  current_load_w : Array[Int],
  config? : SolverConfig = SolverConfig::default(),
) -> CandidatePlacement {
  let end_slot = start_slot + task.duration_slots
  let mut cost = 0
  let mut carbon = 0
  let mut comfort = 0
  let mut resilience = 0
  let mut excess = 0
  let mut score = 0
  let mut solar_overlap_wh = 0
  let mut feasible = start_slot >= task.earliest_start &&
    end_slot <= task.latest_end &&
    start_slot >= 0 &&
    end_slot <= input.horizon_slots
  if feasible {
    for slot = start_slot; slot < end_slot; slot = slot + 1 {
      let current = current_load_w[slot]
      let (
        slot_cost,
        slot_carbon,
        slot_comfort,
        slot_resilience,
        slot_excess,
        slot_score,
      ) = objective_slot_score(input, task, slot, current)
      cost = cost + slot_cost
      carbon = carbon + slot_carbon
      comfort = comfort + slot_comfort
      resilience = resilience + slot_resilience
      excess = excess + slot_excess
      score = score + slot_score
      let spare_solar = maximum(0, input.solar_w.at(slot) - current)
      solar_overlap_wh = solar_overlap_wh +
        slot_energy_wh(minimum(spare_solar, task.power_w), input.slot_minutes)
      if slot_excess > 0 {
        score = score + slot_excess * config.capacity_violation_penalty
        if task.is_required() {
          feasible = false
        }
      }
      if input.has_outage_at(slot) && task.priority != Critical {
        score = score + config.outage_violation_penalty
      }
    }
  }
  {
    task_id: task.id,
    start_slot,
    end_slot,
    energy_cost_component: cost,
    carbon_component: carbon,
    comfort_component: comfort,
    resilience_component: resilience,
    capacity_excess_w: excess,
    solar_overlap_wh,
    total_score: score,
    feasible,
  }
}

///|
pub fn candidate_placements(
  input : PlanningInput,
  task : LoadTask,
  current_load_w : Array[Int],
  config? : SolverConfig = SolverConfig::default(),
) -> Array[CandidatePlacement] {
  let candidates : Array[CandidatePlacement] = []
  let first = if task.mode == Fixed {
    task.fixed_start
  } else {
    task.earliest_start
  }
  let last = if task.mode == Fixed {
    task.fixed_start
  } else {
    task.latest_start()
  }
  let mut examined = 0
  for start = first; start <= last; start = start + 1 {
    if examined >= config.maximum_candidates_per_task {
      break
    }
    candidates.push(
      evaluate_candidate(input, task, start, current_load_w, config~),
    )
    examined = examined + 1
  }
  candidates.sort_by(fn(a, b) {
    if a.feasible && !b.feasible {
      -1
    } else if b.feasible && !a.feasible {
      1
    } else if a.total_score < b.total_score {
      -1
    } else if a.total_score > b.total_score {
      1
    } else if a.start_slot < b.start_slot {
      -1
    } else if a.start_slot > b.start_slot {
      1
    } else {
      0
    }
  })
  candidates
}

///|
fn placement_reason(
  input : PlanningInput,
  task : LoadTask,
  placement : CandidatePlacement,
) -> String {
  if task.mode == Fixed {
    return "fixed by user at slot " + placement.start_slot.to_string()
  }
  if placement.solar_overlap_wh > 0 {
    return "selected slot " +
      placement.start_slot.to_string() +
      " to use " +
      placement.solar_overlap_wh.to_string() +
      " Wh of local solar"
  }
  if placement.start_slot == task.preferred_start {
    return "kept the preferred start because it is feasible and competitive"
  }
  let tariff = input.tariff_micro_per_kwh.at(placement.start_slot)
  "moved from preferred slot " +
  task.preferred_start.to_string() +
  " to slot " +
  placement.start_slot.to_string() +
  " with tariff " +
  tariff.to_string()
}

///|
fn apply_contiguous_task(
  input : PlanningInput,
  task : LoadTask,
  placement : CandidatePlacement,
  load_w : Array[Int],
  schedule : Array[ScheduleEntry],
) -> Unit {
  for slot = placement.start_slot; slot < placement.end_slot; slot = slot + 1 {
    load_w[slot] = load_w[slot] + task.power_w
  }
  schedule.push({
    task_id: task.id,
    task_name: task.name,
    start_slot: placement.start_slot,
    end_slot: placement.end_slot,
    power_w: task.power_w,
    energy_wh: task.energy_wh(input.slot_minutes),
    priority: task.priority,
    reason: placement_reason(input, task, placement),
  })
}

///|
fn interruptible_slot_score(
  input : PlanningInput,
  task : LoadTask,
  slot : Int,
  load_w : Array[Int],
) -> Int {
  let (_, _, _, _, excess, score) = objective_slot_score(
    input,
    task,
    slot,
    load_w[slot],
  )
  score + excess * 100000
}

///|
fn schedule_interruptible(
  input : PlanningInput,
  task : LoadTask,
  load_w : Array[Int],
  schedule : Array[ScheduleEntry],
) -> Bool {
  let slots : Array[(Int, Int)] = []
  for slot = task.earliest_start; slot < task.latest_end; slot = slot + 1 {
    slots.push((slot, interruptible_slot_score(input, task, slot, load_w)))
  }
  slots.sort_by(fn(a, b) {
    if a.1 < b.1 {
      -1
    } else if a.1 > b.1 {
      1
    } else {
      a.0 - b.0
    }
  })
  if slots.length() < task.duration_slots {
    return false
  }
  let chosen : Array[Int] = []
  for index = 0; index < task.duration_slots; index = index + 1 {
    chosen.push(slots[index].0)
  }
  chosen.sort()
  let mut interruptions = 0
  for index = 1; index < chosen.length(); index = index + 1 {
    if chosen[index] != chosen[index - 1] + 1 {
      interruptions = interruptions + 1
    }
  }
  if interruptions > task.maximum_interruptions {
    return false
  }
  let mut segment_start = chosen[0]
  let mut previous = chosen[0]
  for index = 0; index <= chosen.length(); index = index + 1 {
    let is_end = index == chosen.length()
    if !is_end && index > 0 && chosen[index] == previous + 1 {
      previous = chosen[index]
      continue
    }
    if index > 0 || is_end {
      let end_slot = previous + 1
      for slot = segment_start; slot < end_slot; slot = slot + 1 {
        load_w[slot] = load_w[slot] + task.power_w
      }
      schedule.push({
        task_id: task.id,
        task_name: task.name,
        start_slot: segment_start,
        end_slot,
        power_w: task.power_w,
        energy_wh: slot_energy_wh(
          task.power_w,
          (end_slot - segment_start) * input.slot_minutes,
        ),
        priority: task.priority,
        reason: "selected low-impact interruptible segment",
      })
    }
    if !is_end {
      segment_start = chosen[index]
      previous = chosen[index]
    }
  }
  true
}

///|
fn build_provisional_schedule(
  input : PlanningInput,
  config : SolverConfig,
) -> (Array[Int], Array[ScheduleEntry], Array[String], Array[Explanation]) {
  let load_w = input.base_load_w.copy_values()
  let schedule : Array[ScheduleEntry] = []
  let skipped : Array[String] = []
  let explanations : Array[Explanation] = []
  for task in sorted_tasks(input.tasks) {
    if task.mode == Interruptible {
      if schedule_interruptible(input, task, load_w, schedule) {
        explanations.push(
          Explanation::info(
            "INTERRUPTIBLE_PLACED",
            task.id,
            "task was split into feasible low-impact segments",
          ),
        )
      } else if task.mode.may_skip() {
        skipped.push(task.id)
      } else {
        skipped.push(task.id)
        explanations.push(
          Explanation::warning(
            "INTERRUPTIBLE_INFEASIBLE",
            task.id,
            "no segment pattern satisfies the interruption limit",
          ),
        )
      }
      continue
    }
    let candidates = candidate_placements(input, task, load_w, config~)
    if candidates.length() == 0 {
      skipped.push(task.id)
      explanations.push(
        Explanation::warning(
          "NO_CANDIDATE",
          task.id,
          "task has no candidate start inside its window",
        ),
      )
      continue
    }
    let best = candidates[0]
    let skip_threshold = if config.optional_skip_threshold > 0 {
      config.optional_skip_threshold
    } else {
      task.skip_penalty
    }
    if task.mode.may_skip() &&
      (!best.feasible || best.total_score > skip_threshold) {
      skipped.push(task.id)
      explanations.push(
        Explanation::info(
          "OPTIONAL_SKIPPED",
          task.id,
          "optional task was skipped because every placement costs more than its skip penalty",
        ),
      )
    } else {
      apply_contiguous_task(input, task, best, load_w, schedule)
      explanations.push(
        Explanation::info(
          "TASK_PLACED",
          task.id,
          placement_reason(input, task, best),
          slot=best.start_slot,
        ),
      )
      if !best.feasible {
        explanations.push(
          Explanation::warning(
            "CAPACITY_RISK",
            task.id,
            "best placement may require load curtailment or additional supply",
            slot=best.start_slot,
          ),
        )
      }
    }
  }
  (load_w, schedule, skipped, explanations)
}

///|
fn sorted_signal(values : Array[Int]) -> Array[Int] {
  let result = values.map(value => value)
  result.sort()
  result
}

///|
fn tariff_thresholds(input : PlanningInput) -> (Int, Int) {
  if input.horizon_slots == 0 {
    return (0, 0)
  }
  let values = sorted_signal(input.tariff_micro_per_kwh.values)
  let low = values[values.length() / 3]
  let high = values[values.length() * 2 / 3]
  (low, high)
}

///|
fn critical_load_at(schedule : Array[ScheduleEntry], slot : Int) -> Int {
  let mut total = 0
  for entry in schedule {
    if entry.priority == Critical && entry.contains(slot) {
      total = total + entry.power_w
    }
  }
  total
}

///|
fn charge_from_power(
  battery : BatterySpec,
  state_wh : Int,
  requested_power_w : Int,
  slot_minutes : Int,
) -> (Int, Int) {
  let power = minimum(requested_power_w, battery.maximum_charge_w)
  let room_wh = maximum(0, battery.maximum_wh - state_wh)
  let input_energy_wh = slot_energy_wh(power, slot_minutes)
  let stored_wh = input_energy_wh * battery.charge_efficiency_permille / 1000
  if stored_wh <= room_wh {
    (power, state_wh + stored_wh)
  } else if room_wh == 0 {
    (0, state_wh)
  } else {
    let needed_input_wh = room_wh * 1000 / battery.charge_efficiency_permille
    let adjusted_power = needed_input_wh * 60 / slot_minutes
    (minimum(power, adjusted_power), battery.maximum_wh)
  }
}

///|
fn discharge_to_power(
  battery : BatterySpec,
  state_wh : Int,
  requested_power_w : Int,
  slot_minutes : Int,
  lower_bound_wh : Int,
) -> (Int, Int) {
  let power = minimum(requested_power_w, battery.maximum_discharge_w)
  let available_stored_wh = maximum(0, state_wh - lower_bound_wh)
  let deliverable_wh = available_stored_wh *
    battery.discharge_efficiency_permille /
    1000
  let requested_output_wh = slot_energy_wh(power, slot_minutes)
  if requested_output_wh <= deliverable_wh {
    let withdrawn_wh = requested_output_wh *
      1000 /
      battery.discharge_efficiency_permille
    (power, maximum(lower_bound_wh, state_wh - withdrawn_wh))
  } else if deliverable_wh <= 0 {
    (0, state_wh)
  } else {
    let adjusted_power = deliverable_wh * 60 / slot_minutes
    (minimum(power, adjusted_power), lower_bound_wh)
  }
}

///|
struct DispatchResult {
  grid_w : Array[Int]
  solar_used_w : Array[Int]
  unserved_w : Array[Int]
  battery_state_wh : Array[Int]
  battery_steps : Array[BatteryStep]
  metrics : PlanMetrics
  explanations : Array[Explanation]
} derive(Debug)

///|
fn dispatch_energy(
  input : PlanningInput,
  load_w : Array[Int],
  schedule : Array[ScheduleEntry],
  skipped : Array[String],
  config : SolverConfig,
) -> DispatchResult {
  let slots = input.horizon_slots
  let grid_w = Array::make(slots, 0)
  let solar_used_w = Array::make(slots, 0)
  let unserved_w = Array::make(slots, 0)
  let battery_state_wh = Array::make(slots + 1, 0)
  let battery_steps : Array[BatteryStep] = []
  let explanations : Array[Explanation] = []
  let (low_tariff, high_tariff) = tariff_thresholds(input)
  let mut state_wh = match input.battery {
    Some(battery) => battery.clamp_state(battery.initial_wh)
    None => 0
  }
  battery_state_wh[0] = state_wh
  let mut imported_energy_wh = 0
  let mut exported_energy_wh = 0
  let mut solar_used_wh = 0
  let mut solar_curtailed_wh = 0
  let mut battery_charged_wh = 0
  let mut battery_discharged_wh = 0
  let mut cost_micro = 0
  let mut export_credit_micro = 0
  let mut carbon_g = 0
  let mut unserved_energy_wh = 0
  let mut critical_unserved_wh = 0
  let mut peak_grid_w = 0
  for slot = 0; slot < slots; slot = slot + 1 {
    let demand_w = load_w[slot]
    let available_solar_w = input.solar_w.at(slot)
    let direct_solar_w = minimum(demand_w, available_solar_w)
    solar_used_w[slot] = direct_solar_w
    solar_used_wh = solar_used_wh +
      slot_energy_wh(direct_solar_w, input.slot_minutes)
    let mut remaining_demand_w = demand_w - direct_solar_w
    let mut surplus_solar_w = available_solar_w - direct_solar_w
    let before_wh = state_wh
    let mut battery_power_w = 0
    let mut battery_source = "idle"
    let mut battery_reason = "no dispatch required"
    match input.battery {
      None => ()
      Some(battery) => {
        if surplus_solar_w > 0 {
          let (charge_w, after_wh) = charge_from_power(
            battery,
            state_wh,
            surplus_solar_w,
            input.slot_minutes,
          )
          if charge_w > 0 {
            battery_power_w = charge_w
            battery_source = "solar"
            battery_reason = "stored surplus rooftop solar"
            surplus_solar_w = surplus_solar_w - charge_w
            battery_charged_wh = battery_charged_wh +
              maximum(0, after_wh - state_wh)
            state_wh = after_wh
          }
        }
        let outage = input.has_outage_at(slot)
        let grid_limit = input.grid_limit_at(slot)
        let must_discharge = remaining_demand_w > grid_limit
        let high_price = input.tariff_micro_per_kwh.at(slot) >= high_tariff
        if remaining_demand_w > 0 && (outage || must_discharge || high_price) {
          let lower_bound = if outage || !config.preserve_reserve_outside_outage {
            battery.minimum_wh
          } else {
            battery.reserve_wh
          }
          let (discharge_w, after_wh) = discharge_to_power(
            battery,
            state_wh,
            remaining_demand_w,
            input.slot_minutes,
            lower_bound,
          )
          if discharge_w > 0 {
            battery_power_w = -discharge_w
            battery_source = "battery"
            battery_reason = if outage {
              "supplied load during grid outage"
            } else if must_discharge {
              "kept grid import below the connection limit"
            } else {
              "discharged during a high-tariff slot"
            }
            remaining_demand_w = remaining_demand_w - discharge_w
            battery_discharged_wh = battery_discharged_wh +
              maximum(0, state_wh - after_wh)
            state_wh = after_wh
          }
        }
        if config.allow_grid_charging &&
          remaining_demand_w <= input.grid_limit_at(slot) &&
          input.tariff_micro_per_kwh.at(slot) <= low_tariff &&
          state_wh < battery.reserve_wh &&
          !input.has_outage_at(slot) {
          let spare_grid_w = maximum(
            0,
            input.grid_limit_at(slot) - remaining_demand_w,
          )
          let (charge_w, after_wh) = charge_from_power(
            battery,
            state_wh,
            spare_grid_w,
            input.slot_minutes,
          )
          if charge_w > 0 {
            battery_power_w = battery_power_w + charge_w
            remaining_demand_w = remaining_demand_w + charge_w
            battery_source = "grid"
            battery_reason = "charged to reserve during a low-tariff slot"
            battery_charged_wh = battery_charged_wh +
              maximum(0, after_wh - state_wh)
            state_wh = after_wh
          }
        }
        battery_steps.push({
          slot,
          state_before_wh: before_wh,
          power_w: battery_power_w,
          state_after_wh: state_wh,
          source: battery_source,
          reason: battery_reason,
        })
      }
    }
    let limit_w = input.grid_limit_at(slot)
    let import_w = minimum(remaining_demand_w, limit_w)
    grid_w[slot] = import_w
    if import_w > peak_grid_w {
      peak_grid_w = import_w
    }
    let import_wh = slot_energy_wh(import_w, input.slot_minutes)
    imported_energy_wh = imported_energy_wh + import_wh
    cost_micro = cost_micro +
      import_wh * input.tariff_micro_per_kwh.at(slot) / 1000
    carbon_g = carbon_g + import_wh * input.carbon_g_per_kwh.at(slot) / 1000
    let missing_w = maximum(0, remaining_demand_w - import_w)
    unserved_w[slot] = missing_w
    let missing_wh = slot_energy_wh(missing_w, input.slot_minutes)
    unserved_energy_wh = unserved_energy_wh + missing_wh
    let critical_w = critical_load_at(schedule, slot)
    critical_unserved_wh = critical_unserved_wh +
      slot_energy_wh(minimum(missing_w, critical_w), input.slot_minutes)
    if missing_w > 0 {
      explanations.push(
        Explanation::warning(
          "UNSERVED_LOAD",
          "slot-" + slot.to_string(),
          missing_w.to_string() + " W could not be supplied",
          slot~,
        ),
      )
    }
    if surplus_solar_w > 0 {
      let surplus_wh = slot_energy_wh(surplus_solar_w, input.slot_minutes)
      if input.allow_grid_export && !input.has_outage_at(slot) {
        exported_energy_wh = exported_energy_wh + surplus_wh
        export_credit_micro = export_credit_micro +
          surplus_wh * input.export_credit_micro_per_kwh / 1000
      } else {
        solar_curtailed_wh = solar_curtailed_wh + surplus_wh
      }
    }
    battery_state_wh[slot + 1] = state_wh
  }
  let total_demand_wh = load_w.fold(init=0, fn(total, power) {
    total + slot_energy_wh(power, input.slot_minutes)
  })
  let served_wh = maximum(0, total_demand_wh - unserved_energy_wh)
  let resilience_permille = if total_demand_wh == 0 {
    1000
  } else {
    served_wh * 1000 / total_demand_wh
  }
  let metrics : PlanMetrics = {
    imported_energy_wh,
    exported_energy_wh,
    solar_used_wh,
    solar_curtailed_wh,
    battery_charged_wh,
    battery_discharged_wh,
    cost_micro,
    export_credit_micro,
    carbon_g,
    comfort_penalty: 0,
    unserved_energy_wh,
    critical_unserved_wh,
    completed_tasks: input.tasks.length() - skipped.length(),
    skipped_tasks: skipped.length(),
    peak_grid_w,
    resilience_permille,
    score: 0L,
  }
  {
    grid_w,
    solar_used_w,
    unserved_w,
    battery_state_wh,
    battery_steps,
    metrics,
    explanations,
  }
}

///|
fn comfort_penalty(
  input : PlanningInput,
  schedule : Array[ScheduleEntry],
  skipped : Array[String],
) -> Int {
  let mut penalty = 0
  for task in input.tasks {
    let mut first_start : Int? = None
    for entry in schedule {
      if entry.task_id == task.id {
        match first_start {
          None => first_start = Some(entry.start_slot)
          Some(value) =>
            if entry.start_slot < value {
              first_start = Some(entry.start_slot)
            }
        }
      }
    }
    match first_start {
      Some(start) =>
        penalty = penalty +
          absolute(start - task.preferred_start) * task.comfort_penalty_per_slot
      None =>
        if skipped.contains(task.id) {
          penalty = penalty + task.skip_penalty
        }
    }
  }
  penalty
}

///|
fn calculate_plan_score(
  metrics : PlanMetrics,
  weights : ObjectiveWeights,
) -> Int64 {
  let normalized = weights.normalized()
  let cost_term = metrics.net_cost_micro() / 1000 * normalized.cost
  let carbon_term = metrics.carbon_g * normalized.carbon
  let comfort_term = metrics.comfort_penalty * normalized.comfort
  let resilience_term = (1000 - metrics.resilience_permille) *
    normalized.resilience *
    100
  let wear_term = (metrics.battery_charged_wh + metrics.battery_discharged_wh) *
    normalized.battery_wear
  cost_term.to_int64() +
  carbon_term.to_int64() +
  comfort_term.to_int64() +
  resilience_term.to_int64() +
  wear_term.to_int64()
}

///|
fn required_task_skipped(
  input : PlanningInput,
  skipped : Array[String],
) -> Bool {
  for task in input.tasks {
    if task.is_required() && skipped.contains(task.id) {
      return true
    }
  }
  false
}

///|
fn finish_explanations(
  input : PlanningInput,
  metrics : PlanMetrics,
) -> Array[Explanation] {
  let result : Array[Explanation] = []
  if metrics.solar_used_wh > 0 {
    result.push(
      Explanation::info(
        "SOLAR_USED",
        "energy-balance",
        metrics.solar_used_wh.to_string() + " Wh of local solar served demand",
      ),
    )
  }
  if metrics.battery_discharged_wh > 0 {
    result.push(
      Explanation::info(
        "BATTERY_SUPPORT",
        "battery",
        metrics.battery_discharged_wh.to_string() +
        " Wh of stored energy supported the plan",
      ),
    )
  }
  if metrics.solar_curtailed_wh > 0 {
    result.push(
      Explanation::warning(
        "SOLAR_CURTAILED",
        "solar",
        metrics.solar_curtailed_wh.to_string() +
        " Wh of surplus solar could not be used or exported",
      ),
    )
  }
  if metrics.critical_unserved_wh > 0 {
    result.push(
      Explanation::warning(
        "CRITICAL_UNSERVED",
        "resilience",
        metrics.critical_unserved_wh.to_string() +
        " Wh of critical demand remained unserved",
      ),
    )
  }
  if input.outages.length() > 0 {
    result.push(
      Explanation::info(
        "OUTAGE_EVALUATED",
        "resilience",
        input.outages.length().to_string() + " outage event(s) were evaluated",
      ),
    )
  }
  result
}

///|
/// Produce a deterministic energy plan for a validated input.
pub fn solve(
  original_input : PlanningInput,
  config? : SolverConfig = SolverConfig::default(),
) -> PlanResult {
  let validation = validate(original_input)
  if !validation.is_valid() {
    let explanations = validation.issues.map(issue => {
      Explanation::warning(
        "VALIDATION_" + issue.code.label(),
        issue.path,
        issue.message + "; " + issue.hint,
        slot?=issue.slot,
      )
    })
    return {
      ..PlanResult::empty(
        original_input.title,
        maximum(0, original_input.horizon_slots),
        original_input.slot_minutes,
      ),
      explanations,
    }
  }
  let input = apply_safe_defaults(original_input)
  let (load_w, schedule, skipped, scheduling_explanations) = build_provisional_schedule(
    input, config,
  )
  let dispatch = dispatch_energy(input, load_w, schedule, skipped, config)
  let comfort = comfort_penalty(input, schedule, skipped)
  let metrics_without_score = { ..dispatch.metrics, comfort_penalty: comfort }
  let score = calculate_plan_score(metrics_without_score, input.weights)
  let metrics = { ..metrics_without_score, score, }
  let required_skipped = required_task_skipped(input, skipped)
  let status = if required_skipped || metrics.critical_unserved_wh > 0 {
    Infeasible
  } else if metrics.unserved_energy_wh > 0 || skipped.length() > 0 {
    FeasibleWithCurtailment
  } else {
    Feasible
  }
  let explanations = scheduling_explanations
  for item in dispatch.explanations {
    explanations.push(item)
  }
  for item in finish_explanations(input, metrics) {
    explanations.push(item)
  }
  {
    title: input.title,
    status,
    slot_minutes: input.slot_minutes,
    horizon_slots: input.horizon_slots,
    schedule,
    battery_steps: dispatch.battery_steps,
    load_w,
    grid_w: dispatch.grid_w,
    solar_used_w: dispatch.solar_used_w,
    unserved_w: dispatch.unserved_w,
    battery_state_wh: dispatch.battery_state_wh,
    skipped_task_ids: skipped,
    metrics,
    explanations,
  }
}

///|
/// Solve the same scenario for all supported policies.
pub fn solve_policy_set(
  input : PlanningInput,
  config? : SolverConfig = SolverConfig::default(),
) -> Array[(PlanningPolicy, PlanResult)] {
  let policies = [
    Balanced,
    LowestCost,
    LowestCarbon,
    HighestComfort,
    HighestResilience,
  ]
  policies.map(policy => {
    let weights = ObjectiveWeights::for_policy(policy)
    (policy, solve({ ..input, weights, }, config~))
  })
}

///|
fn dominates(a : PlanMetrics, b : PlanMetrics) -> Bool {
  let no_worse = a.net_cost_micro() <= b.net_cost_micro() &&
    a.carbon_g <= b.carbon_g &&
    a.comfort_penalty <= b.comfort_penalty &&
    a.unserved_energy_wh <= b.unserved_energy_wh
  let strictly_better = a.net_cost_micro() < b.net_cost_micro() ||
    a.carbon_g < b.carbon_g ||
    a.comfort_penalty < b.comfort_penalty ||
    a.unserved_energy_wh < b.unserved_energy_wh
  no_worse && strictly_better
}

///|
/// Remove policy results that are worse on every reported objective.
pub fn pareto_frontier(
  results : Array[(PlanningPolicy, PlanResult)],
) -> Array[(PlanningPolicy, PlanResult)] {
  let frontier : Array[(PlanningPolicy, PlanResult)] = []
  for index, candidate in results {
    let mut dominated = false
    for other_index, other in results {
      if index != other_index && dominates(other.1.metrics, candidate.1.metrics) {
        dominated = true
        break
      }
    }
    if !dominated {
      frontier.push(candidate)
    }
  }
  frontier
}

///|
/// Create a simple preferred-time baseline without changing the input contract.
pub fn solve_preferred_baseline(input : PlanningInput) -> PlanResult {
  let tasks = input.tasks.map(task => {
    if task.mode == Fixed {
      task
    } else {
      let start = clamp(
        task.preferred_start,
        task.earliest_start,
        task.latest_start(),
      )
      {
        ..task,
        mode: Fixed,
        fixed_start: start,
        earliest_start: start,
        latest_end: start + task.duration_slots,
      }
    }
  })
  solve({ ..input, title: input.title + " preferred-time baseline", tasks }, config={
    ..SolverConfig::default(),
    allow_grid_charging: false,
    preserve_reserve_outside_outage: true,
  })
}

///|
pub(all) struct PlanComparison {
  baseline : PlanResult
  optimized : PlanResult
  cost_saving_micro : Int
  carbon_saving_g : Int
  peak_reduction_w : Int
  resilience_gain_permille : Int
  recommendation : String
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn compare_with_baseline(
  input : PlanningInput,
  config? : SolverConfig = SolverConfig::default(),
) -> PlanComparison {
  let baseline = solve_preferred_baseline(input)
  let optimized = solve(input, config~)
  let cost_saving = baseline.metrics.net_cost_micro() -
    optimized.metrics.net_cost_micro()
  let carbon_saving = baseline.metrics.carbon_g - optimized.metrics.carbon_g
  let peak_reduction = baseline.metrics.peak_grid_w -
    optimized.metrics.peak_grid_w
  let resilience_gain = optimized.metrics.resilience_permille -
    baseline.metrics.resilience_permille
  let recommendation = if optimized.status == Infeasible {
    "increase supply capacity, battery reserve, or task flexibility"
  } else if cost_saving > 0 && carbon_saving > 0 {
    "adopt the optimized plan: it lowers both cost and carbon"
  } else if resilience_gain > 0 {
    "adopt the optimized plan for stronger outage coverage"
  } else if cost_saving > 0 {
    "adopt the optimized plan for lower energy cost"
  } else {
    "keep the preferred plan unless the optimized timing is acceptable"
  }
  {
    baseline,
    optimized,
    cost_saving_micro: cost_saving,
    carbon_saving_g: carbon_saving,
    peak_reduction_w: peak_reduction,
    resilience_gain_permille: resilience_gain,
    recommendation,
  }
}