///|
/// Stable validation codes suitable for CLI and web clients.
pub(all) enum ValidationCode {
  InvalidHorizon
  InvalidSlotMinutes
  SeriesLengthMismatch
  SeriesSlotMismatch
  NegativeSeriesValue
  InvalidTaskId
  DuplicateTaskId
  InvalidTaskPower
  InvalidTaskDuration
  InvalidTaskWindow
  FixedTaskOutsideWindow
  InvalidInterruptionRule
  InvalidBatteryCapacity
  InvalidBatteryState
  InvalidBatteryPower
  InvalidBatteryEfficiency
  InvalidBatteryReserve
  InvalidOutageWindow
  InvalidGridLimit
  InvalidObjectiveWeights
  ImpossibleRequiredTask
  PeakCapacityRisk
  MissingTariffVariation
  MissingCarbonVariation
  ExportWithoutCredit
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn ValidationCode::label(self : ValidationCode) -> String {
  match self {
    InvalidHorizon => "invalid-horizon"
    InvalidSlotMinutes => "invalid-slot-minutes"
    SeriesLengthMismatch => "series-length-mismatch"
    SeriesSlotMismatch => "series-slot-mismatch"
    NegativeSeriesValue => "negative-series-value"
    InvalidTaskId => "invalid-task-id"
    DuplicateTaskId => "duplicate-task-id"
    InvalidTaskPower => "invalid-task-power"
    InvalidTaskDuration => "invalid-task-duration"
    InvalidTaskWindow => "invalid-task-window"
    FixedTaskOutsideWindow => "fixed-task-outside-window"
    InvalidInterruptionRule => "invalid-interruption-rule"
    InvalidBatteryCapacity => "invalid-battery-capacity"
    InvalidBatteryState => "invalid-battery-state"
    InvalidBatteryPower => "invalid-battery-power"
    InvalidBatteryEfficiency => "invalid-battery-efficiency"
    InvalidBatteryReserve => "invalid-battery-reserve"
    InvalidOutageWindow => "invalid-outage-window"
    InvalidGridLimit => "invalid-grid-limit"
    InvalidObjectiveWeights => "invalid-objective-weights"
    ImpossibleRequiredTask => "impossible-required-task"
    PeakCapacityRisk => "peak-capacity-risk"
    MissingTariffVariation => "missing-tariff-variation"
    MissingCarbonVariation => "missing-carbon-variation"
    ExportWithoutCredit => "export-without-credit"
  }
}

///|
pub(all) enum IssueLevel {
  Error
  Warning
  Advice
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn IssueLevel::label(self : IssueLevel) -> String {
  match self {
    Error => "error"
    Warning => "warning"
    Advice => "advice"
  }
}

///|
pub(all) struct ValidationIssue {
  code : ValidationCode
  level : IssueLevel
  path : String
  message : String
  hint : String
  slot : Int?
  task_id : String?
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn ValidationIssue::error(
  code : ValidationCode,
  path : String,
  message : String,
  hint? : String = "correct the input before optimization",
) -> ValidationIssue {
  { code, level: Error, path, message, hint, slot: None, task_id: None }
}

///|
pub fn ValidationIssue::warning(
  code : ValidationCode,
  path : String,
  message : String,
  hint? : String = "review this value",
) -> ValidationIssue {
  { code, level: Warning, path, message, hint, slot: None, task_id: None }
}

///|
pub fn ValidationIssue::advice(
  code : ValidationCode,
  path : String,
  message : String,
  hint : String,
) -> ValidationIssue {
  { code, level: Advice, path, message, hint, slot: None, task_id: None }
}

///|
pub fn ValidationIssue::for_task(
  self : ValidationIssue,
  task_id : String,
) -> ValidationIssue {
  { ..self, task_id: Some(task_id) }
}

///|
pub fn ValidationIssue::at_slot(
  self : ValidationIssue,
  slot : Int,
) -> ValidationIssue {
  { ..self, slot: Some(slot) }
}

///|
pub(all) struct ValidationReport {
  issues : Array[ValidationIssue]
  checked_tasks : Int
  checked_slots : Int
  estimated_required_peak_w : Int
  estimated_required_energy_wh : Int
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn ValidationReport::empty() -> ValidationReport {
  {
    issues: [],
    checked_tasks: 0,
    checked_slots: 0,
    estimated_required_peak_w: 0,
    estimated_required_energy_wh: 0,
  }
}

///|
pub fn ValidationReport::error_count(self : ValidationReport) -> Int {
  let mut count = 0
  for issue in self.issues {
    if issue.level == Error {
      count = count + 1
    }
  }
  count
}

///|
pub fn ValidationReport::warning_count(self : ValidationReport) -> Int {
  let mut count = 0
  for issue in self.issues {
    if issue.level == Warning {
      count = count + 1
    }
  }
  count
}

///|
pub fn ValidationReport::advice_count(self : ValidationReport) -> Int {
  let mut count = 0
  for issue in self.issues {
    if issue.level == Advice {
      count = count + 1
    }
  }
  count
}

///|
pub fn ValidationReport::is_valid(self : ValidationReport) -> Bool {
  self.error_count() == 0
}

///|
pub fn ValidationReport::has_code(
  self : ValidationReport,
  code : ValidationCode,
) -> Bool {
  for issue in self.issues {
    if issue.code == code {
      return true
    }
  }
  false
}

///|
pub fn ValidationReport::messages(self : ValidationReport) -> Array[String] {
  self.issues.map(issue => {
    issue.level.label() + ": " + issue.path + ": " + issue.message
  })
}

///|
fn push_issue(issues : Array[ValidationIssue], issue : ValidationIssue) -> Unit {
  issues.push(issue)
}

///|
fn is_blank(value : String) -> Bool {
  value.trim().length() == 0
}

///|
fn valid_slot_minutes(value : Int) -> Bool {
  value == 1 ||
  value == 5 ||
  value == 10 ||
  value == 15 ||
  value == 20 ||
  value == 30 ||
  value == 60
}

///|
fn validate_series(
  series : IntSeries,
  expected_slots : Int,
  expected_slot_minutes : Int,
  path : String,
  allow_negative : Bool,
  issues : Array[ValidationIssue],
) -> Unit {
  if series.length() != expected_slots {
    push_issue(
      issues,
      ValidationIssue::error(
        SeriesLengthMismatch,
        path + ".values",
        "expected " +
        expected_slots.to_string() +
        " slots but found " +
        series.length().to_string(),
        hint="make every time series match horizon_slots",
      ),
    )
  }
  if series.slot_minutes != expected_slot_minutes {
    push_issue(
      issues,
      ValidationIssue::error(
        SeriesSlotMismatch,
        path + ".slot_minutes",
        "series resolution does not match the planning resolution",
        hint="resample the series before planning",
      ),
    )
  }
  if !allow_negative {
    for slot, value in series.values {
      if value < 0 {
        push_issue(
          issues,
          ValidationIssue::error(
            NegativeSeriesValue,
            path + ".values",
            "negative values are not allowed",
          ).at_slot(slot),
        )
      }
    }
  }
}

///|
fn validate_task(
  task : LoadTask,
  horizon : Int,
  slot_minutes : Int,
  issues : Array[ValidationIssue],
) -> Unit {
  let path = "tasks[" + task.id + "]"
  if is_blank(task.id) {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidTaskId,
        path + ".id",
        "task id must not be blank",
      ).for_task(task.id),
    )
  }
  if task.power_w <= 0 {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidTaskPower,
        path + ".power_w",
        "task power must be positive",
      ).for_task(task.id),
    )
  }
  if task.duration_slots <= 0 {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidTaskDuration,
        path + ".duration_slots",
        "task duration must be positive",
      ).for_task(task.id),
    )
  }
  if task.earliest_start < 0 ||
    task.latest_end > horizon ||
    task.latest_end <= task.earliest_start {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidTaskWindow,
        path,
        "task window must be inside the planning horizon",
        hint="adjust earliest_start and latest_end",
      ).for_task(task.id),
    )
  }
  if task.duration_slots > task.window_slots() {
    let issue = ValidationIssue::error(
      ImpossibleRequiredTask,
      path,
      "task duration is longer than its available window",
      hint="increase the window or shorten the task",
    ).for_task(task.id)
    if task.mode.may_skip() {
      push_issue(issues, { ..issue, level: Warning })
    } else {
      push_issue(issues, issue)
    }
  }
  if task.mode == Fixed &&
    (
      task.fixed_start < task.earliest_start ||
      task.fixed_start + task.duration_slots > task.latest_end
    ) {
    push_issue(
      issues,
      ValidationIssue::error(
        FixedTaskOutsideWindow,
        path + ".fixed_start",
        "fixed execution interval is outside its declared window",
      ).for_task(task.id),
    )
  }
  if task.mode == Interruptible {
    if task.minimum_run_slots <= 0 ||
      task.minimum_run_slots > task.duration_slots ||
      task.maximum_interruptions < 0 {
      push_issue(
        issues,
        ValidationIssue::error(
          InvalidInterruptionRule,
          path,
          "interruptible task has inconsistent segment constraints",
        ).for_task(task.id),
      )
    }
  } else if task.maximum_interruptions != 0 {
    push_issue(
      issues,
      ValidationIssue::warning(
        InvalidInterruptionRule,
        path + ".maximum_interruptions",
        "maximum_interruptions is ignored for a non-interruptible task",
        hint="set maximum_interruptions to zero",
      ).for_task(task.id),
    )
  }
  if task.energy_wh(slot_minutes) <= 0 &&
    task.power_w > 0 &&
    task.duration_slots > 0 {
    push_issue(
      issues,
      ValidationIssue::warning(
        InvalidTaskDuration,
        path,
        "task energy rounds to zero at this time resolution",
        hint="use a finer slot duration or larger energy unit",
      ).for_task(task.id),
    )
  }
}

///|
fn validate_duplicate_ids(
  tasks : Array[LoadTask],
  issues : Array[ValidationIssue],
) -> Unit {
  let seen : Map[String, Int] = Map([])
  for task in tasks {
    let count = seen.get(task.id).unwrap_or(0) + 1
    seen[task.id] = count
    if count == 2 {
      push_issue(
        issues,
        ValidationIssue::error(
          DuplicateTaskId,
          "tasks",
          "task id '" + task.id + "' occurs more than once",
          hint="assign a stable unique id to every task",
        ).for_task(task.id),
      )
    }
  }
}

///|
fn validate_battery(
  battery : BatterySpec,
  issues : Array[ValidationIssue],
) -> Unit {
  let path = "battery"
  if battery.capacity_wh <= 0 ||
    battery.maximum_wh <= 0 ||
    battery.maximum_wh > battery.capacity_wh {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidBatteryCapacity,
        path + ".capacity_wh",
        "battery capacity and maximum state must be positive and consistent",
      ),
    )
  }
  if battery.minimum_wh < 0 ||
    battery.initial_wh < battery.minimum_wh ||
    battery.initial_wh > battery.maximum_wh {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidBatteryState,
        path + ".initial_wh",
        "initial state must be inside the battery state bounds",
      ),
    )
  }
  if battery.reserve_wh < battery.minimum_wh ||
    battery.reserve_wh > battery.maximum_wh {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidBatteryReserve,
        path + ".reserve_wh",
        "reserve must be inside the usable state range",
      ),
    )
  }
  if battery.maximum_charge_w <= 0 || battery.maximum_discharge_w <= 0 {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidBatteryPower,
        path,
        "charge and discharge limits must be positive",
      ),
    )
  }
  if battery.charge_efficiency_permille <= 0 ||
    battery.charge_efficiency_permille > 1000 ||
    battery.discharge_efficiency_permille <= 0 ||
    battery.discharge_efficiency_permille > 1000 {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidBatteryEfficiency,
        path,
        "battery efficiencies must be between 1 and 1000 permille",
      ),
    )
  }
}

///|
fn validate_outages(
  outages : Array[OutageEvent],
  horizon : Int,
  issues : Array[ValidationIssue],
) -> Unit {
  for outage in outages {
    let path = "outages[" + outage.id + "]"
    if outage.start_slot < 0 ||
      outage.end_slot > horizon ||
      outage.end_slot <= outage.start_slot {
      push_issue(
        issues,
        ValidationIssue::error(
          InvalidOutageWindow,
          path,
          "outage interval must be non-empty and inside the horizon",
        ),
      )
    }
    if outage.grid_limit_w < 0 {
      push_issue(
        issues,
        ValidationIssue::error(
          InvalidGridLimit,
          path + ".grid_limit_w",
          "outage grid limit cannot be negative",
        ),
      )
    }
  }
}

///|
fn estimate_required_peak(input : PlanningInput) -> Int {
  let slots = Array::make(input.horizon_slots, 0)
  for task in input.tasks {
    if task.mode == Fixed && task.is_required() {
      let start = task.fixed_start
      let end = start + task.duration_slots
      for slot = start; slot < end; slot = slot + 1 {
        if slot >= 0 && slot < slots.length() {
          slots[slot] = slots[slot] + task.power_w
        }
      }
    }
  }
  let mut peak = 0
  for slot, fixed_w in slots {
    let value = fixed_w + input.base_load_w.at(slot)
    if value > peak {
      peak = value
    }
  }
  peak
}

///|
fn validate_capacity_risk(
  input : PlanningInput,
  required_peak : Int,
  issues : Array[ValidationIssue],
) -> Unit {
  for slot = 0; slot < input.horizon_slots; slot = slot + 1 {
    let limit = input.grid_limit_at(slot) + input.solar_w.at(slot)
    let battery_support = match input.battery {
      Some(battery) => battery.maximum_discharge_w
      None => 0
    }
    if input.base_load_w.at(slot) > limit + battery_support {
      push_issue(
        issues,
        ValidationIssue::warning(
          PeakCapacityRisk,
          "grid_limit_w",
          "base load alone may exceed available supply",
          hint="increase capacity, add storage, or mark load shedding priorities",
        ).at_slot(slot),
      )
    }
  }
  let maximum_battery_support = match input.battery {
    Some(battery) => battery.maximum_discharge_w
    None => 0
  }
  let maximum_supply = input.grid_limit_w.maximum() +
    input.solar_w.maximum() +
    maximum_battery_support
  if required_peak > maximum_supply {
    push_issue(
      issues,
      ValidationIssue::warning(
        PeakCapacityRisk,
        "tasks",
        "simultaneous fixed tasks may exceed maximum supply capacity",
        hint="move fixed tasks or increase the connection limit",
      ),
    )
  }
}

///|
fn validate_signal_quality(
  input : PlanningInput,
  issues : Array[ValidationIssue],
) -> Unit {
  if input.tariff_micro_per_kwh.minimum() ==
    input.tariff_micro_per_kwh.maximum() {
    push_issue(
      issues,
      ValidationIssue::advice(
        MissingTariffVariation,
        "tariff_micro_per_kwh",
        "tariff is constant, so load shifting cannot reduce energy cost",
        "add time-of-use prices or choose a carbon/comfort objective",
      ),
    )
  }
  if input.carbon_g_per_kwh.minimum() == input.carbon_g_per_kwh.maximum() {
    push_issue(
      issues,
      ValidationIssue::advice(
        MissingCarbonVariation,
        "carbon_g_per_kwh",
        "carbon intensity is constant across the horizon",
        "provide a time-varying carbon forecast for carbon-aware scheduling",
      ),
    )
  }
  if input.allow_grid_export && input.export_credit_micro_per_kwh <= 0 {
    push_issue(
      issues,
      ValidationIssue::warning(
        ExportWithoutCredit,
        "export_credit_micro_per_kwh",
        "grid export is enabled without a positive export credit",
        hint="set the feed-in credit or disable export",
      ),
    )
  }
}

///|
/// Validate a complete planning request without modifying it.
pub fn validate(input : PlanningInput) -> ValidationReport {
  let issues : Array[ValidationIssue] = []
  if input.horizon_slots <= 0 || input.horizon_slots > 10000 {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidHorizon,
        "horizon_slots",
        "horizon must contain between 1 and 10000 slots",
      ),
    )
  }
  if !valid_slot_minutes(input.slot_minutes) {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidSlotMinutes,
        "slot_minutes",
        "slot duration must be one of 1, 5, 10, 15, 20, 30, or 60 minutes",
      ),
    )
  }
  validate_series(
    input.tariff_micro_per_kwh,
    input.horizon_slots,
    input.slot_minutes,
    "tariff_micro_per_kwh",
    false,
    issues,
  )
  validate_series(
    input.carbon_g_per_kwh,
    input.horizon_slots,
    input.slot_minutes,
    "carbon_g_per_kwh",
    false,
    issues,
  )
  validate_series(
    input.solar_w,
    input.horizon_slots,
    input.slot_minutes,
    "solar_w",
    false,
    issues,
  )
  validate_series(
    input.base_load_w,
    input.horizon_slots,
    input.slot_minutes,
    "base_load_w",
    false,
    issues,
  )
  validate_series(
    input.grid_limit_w,
    input.horizon_slots,
    input.slot_minutes,
    "grid_limit_w",
    false,
    issues,
  )
  validate_duplicate_ids(input.tasks, issues)
  for task in input.tasks {
    validate_task(task, input.horizon_slots, input.slot_minutes, issues)
  }
  match input.battery {
    Some(battery) => validate_battery(battery, issues)
    None => ()
  }
  validate_outages(input.outages, input.horizon_slots, issues)
  if input.weights.total() <= 0 {
    push_issue(
      issues,
      ValidationIssue::error(
        InvalidObjectiveWeights,
        "weights",
        "at least one objective weight must be positive",
      ),
    )
  }
  let required_peak = estimate_required_peak(input)
  validate_capacity_risk(input, required_peak, issues)
  validate_signal_quality(input, issues)
  let mut required_energy = 0
  for task in input.tasks {
    if task.is_required() {
      required_energy = required_energy + task.energy_wh(input.slot_minutes)
    }
  }
  {
    issues,
    checked_tasks: input.tasks.length(),
    checked_slots: input.horizon_slots,
    estimated_required_peak_w: required_peak,
    estimated_required_energy_wh: required_energy,
  }
}

///|
/// Return a repaired copy for safe, explicitly documented defaults.
/// Structural errors such as impossible windows are intentionally not hidden.
pub fn apply_safe_defaults(input : PlanningInput) -> PlanningInput {
  let weights = if input.weights.total() <= 0 {
    ObjectiveWeights::balanced()
  } else {
    input.weights.normalized()
  }
  let battery = match input.battery {
    None => None
    Some(value) => {
      let minimum = if value.minimum_wh < 0 { 0 } else { value.minimum_wh }
      let maximum = if value.maximum_wh > value.capacity_wh {
        value.capacity_wh
      } else {
        value.maximum_wh
      }
      let reserve = if value.reserve_wh < minimum {
        minimum
      } else if value.reserve_wh > maximum {
        maximum
      } else {
        value.reserve_wh
      }
      Some({
        ..value,
        minimum_wh: minimum,
        maximum_wh: maximum,
        initial_wh: if value.initial_wh < minimum {
          minimum
        } else if value.initial_wh > maximum {
          maximum
        } else {
          value.initial_wh
        },
        reserve_wh: reserve,
        charge_efficiency_permille: if value.charge_efficiency_permille <= 0 ||
          value.charge_efficiency_permille > 1000 {
          950
        } else {
          value.charge_efficiency_permille
        },
        discharge_efficiency_permille: if value.discharge_efficiency_permille <=
          0 ||
          value.discharge_efficiency_permille > 1000 {
          950
        } else {
          value.discharge_efficiency_permille
        },
      })
    }
  }
  { ..input, weights, battery }
}