///|
/// A closed interval in which ordinary cron occurrences are suppressed.
pub struct ScheduleBlackout {
  range : DateTimeRange
  reason : String
} derive(Eq, Debug)

///|
pub fn ScheduleBlackout::new(
  range : DateTimeRange,
  reason? : String = "",
) -> ScheduleBlackout {
  { range, reason }
}

///|
pub fn ScheduleBlackout::contains(
  self : ScheduleBlackout,
  at : UtcDateTime,
) -> Bool {
  self.range.contains(at)
}

///|
pub fn ScheduleBlackout::overlaps(
  self : ScheduleBlackout,
  range : DateTimeRange,
) -> Bool {
  self.range.overlaps(range)
}

///|
/// A one-off occurrence that is independent of the cron expression.
pub struct ScheduleInclusion {
  at : UtcDateTime
  reason : String
} derive(Eq, Debug)

///|
pub fn ScheduleInclusion::new(
  at : UtcDateTime,
  reason? : String = "",
) -> ScheduleInclusion {
  { at, reason }
}

///|
/// Explanation of why a policy does or does not fire at a minute.
pub(all) enum PolicyDecision {
  Included(String)
  Scheduled
  Excluded(String)
  Disabled
  NotScheduled
} derive(Eq, Debug)

///|
/// Cron plus operational exceptions. Explicit inclusions have the highest
/// priority, allowing a controlled one-off run during a blackout or while the
/// recurring schedule is disabled.
pub struct SchedulePolicy {
  cron : Cron
  enabled : Bool
  blackouts : Array[ScheduleBlackout]
  inclusions : Array[ScheduleInclusion]
} derive(Eq, Debug)

///|
pub fn SchedulePolicy::new(
  cron : Cron,
  enabled? : Bool = true,
) -> Result[SchedulePolicy, CronError] {
  match cron.validate() {
    Err(error) => Err(error)
    Ok(_) => Ok({ cron, enabled, blackouts: [], inclusions: [] })
  }
}

///|
pub fn SchedulePolicy::with_enabled(
  self : SchedulePolicy,
  enabled : Bool,
) -> SchedulePolicy {
  { ..self, enabled, }
}

///|
pub fn SchedulePolicy::with_cron(
  self : SchedulePolicy,
  cron : Cron,
) -> Result[SchedulePolicy, CronError] {
  match cron.validate() {
    Err(error) => Err(error)
    Ok(_) => Ok({ ..self, cron, })
  }
}

///|
pub fn SchedulePolicy::blackout_count(self : SchedulePolicy) -> Int {
  self.blackouts.length()
}

///|
pub fn SchedulePolicy::inclusion_count(self : SchedulePolicy) -> Int {
  self.inclusions.length()
}

///|
pub fn SchedulePolicy::all_blackouts(
  self : SchedulePolicy,
) -> Array[ScheduleBlackout] {
  self.blackouts.copy()
}

///|
pub fn SchedulePolicy::all_inclusions(
  self : SchedulePolicy,
) -> Array[ScheduleInclusion] {
  self.inclusions.copy()
}

///|
fn sort_blackouts(values : Array[ScheduleBlackout]) -> Unit {
  for index in 1.. Unit {
  for index in 1.. SchedulePolicy {
  let values = self.blackouts.copy()
  let mut duplicate = false
  for existing in values {
    if existing == blackout {
      duplicate = true
    }
  }
  if !duplicate {
    values.push(blackout)
    sort_blackouts(values)
  }
  { ..self, blackouts: values }
}

///|
/// Remove every blackout whose interval equals `range`.
pub fn SchedulePolicy::remove_blackout(
  self : SchedulePolicy,
  range : DateTimeRange,
) -> SchedulePolicy {
  let values : Array[ScheduleBlackout] = []
  for blackout in self.blackouts {
    if blackout.range != range {
      values.push(blackout)
    }
  }
  { ..self, blackouts: values }
}

///|
/// Add or replace a one-off inclusion at the same minute.
pub fn SchedulePolicy::add_inclusion(
  self : SchedulePolicy,
  inclusion : ScheduleInclusion,
) -> SchedulePolicy {
  let values : Array[ScheduleInclusion] = []
  let mut replaced = false
  for existing in self.inclusions {
    if existing.at == inclusion.at {
      if !replaced {
        values.push(inclusion)
        replaced = true
      }
    } else {
      values.push(existing)
    }
  }
  if !replaced {
    values.push(inclusion)
  }
  sort_inclusions(values)
  { ..self, inclusions: values }
}

///|
pub fn SchedulePolicy::remove_inclusion(
  self : SchedulePolicy,
  at : UtcDateTime,
) -> SchedulePolicy {
  let values : Array[ScheduleInclusion] = []
  for inclusion in self.inclusions {
    if inclusion.at != at {
      values.push(inclusion)
    }
  }
  { ..self, inclusions: values }
}

///|
pub fn SchedulePolicy::inclusion_at(
  self : SchedulePolicy,
  at : UtcDateTime,
) -> ScheduleInclusion? {
  for inclusion in self.inclusions {
    if inclusion.at == at {
      return Some(inclusion)
    }
  }
  None
}

///|
pub fn SchedulePolicy::blackout_at(
  self : SchedulePolicy,
  at : UtcDateTime,
) -> ScheduleBlackout? {
  for blackout in self.blackouts {
    if blackout.contains(at) {
      return Some(blackout)
    }
  }
  None
}

///|
pub fn SchedulePolicy::decision_at(
  self : SchedulePolicy,
  at : UtcDateTime,
) -> PolicyDecision {
  match self.inclusion_at(at) {
    Some(inclusion) => return Included(inclusion.reason)
    None => ()
  }
  if !self.enabled {
    return Disabled
  }
  match self.blackout_at(at) {
    Some(blackout) => Excluded(blackout.reason)
    None => if self.cron.matches_at(at) { Scheduled } else { NotScheduled }
  }
}

///|
pub fn SchedulePolicy::matches_at(
  self : SchedulePolicy,
  at : UtcDateTime,
) -> Bool {
  match self.decision_at(at) {
    Included(_) | Scheduled => true
    _ => false
  }
}

///|
fn SchedulePolicy::next_inclusion_on_or_after(
  self : SchedulePolicy,
  from : UtcDateTime,
) -> UtcDateTime? {
  for inclusion in self.inclusions {
    if inclusion.at >= from {
      return Some(inclusion.at)
    }
  }
  None
}

///|
fn SchedulePolicy::previous_inclusion_on_or_before(
  self : SchedulePolicy,
  from : UtcDateTime,
) -> UtcDateTime? {
  let mut result : UtcDateTime? = None
  for inclusion in self.inclusions {
    if inclusion.at <= from {
      result = Some(inclusion.at)
    } else {
      break
    }
  }
  result
}

///|
fn earlier_time(left : UtcDateTime?, right : UtcDateTime?) -> UtcDateTime? {
  match (left, right) {
    (Some(a), Some(b)) => Some(if a <= b { a } else { b })
    (Some(a), None) => Some(a)
    (None, Some(b)) => Some(b)
    (None, None) => None
  }
}

///|
fn later_time(left : UtcDateTime?, right : UtcDateTime?) -> UtcDateTime? {
  match (left, right) {
    (Some(a), Some(b)) => Some(if a >= b { a } else { b })
    (Some(a), None) => Some(a)
    (None, Some(b)) => Some(b)
    (None, None) => None
  }
}

///|
/// First effective occurrence at or after `from`.
pub fn SchedulePolicy::next_on_or_after(
  self : SchedulePolicy,
  from : UtcDateTime,
) -> UtcDateTime? {
  let inclusion = self.next_inclusion_on_or_after(from)
  if !self.enabled {
    return inclusion
  }
  let mut scheduled = self.cron.next_on_or_after(from)
  for _ in 0..<10000 {
    match scheduled {
      Some(value) =>
        match self.blackout_at(value) {
          Some(blackout) => {
            let next_minute = blackout.range.end.add_minutes(1)
            scheduled = match next_minute {
              Some(cursor) => self.cron.next_on_or_after(cursor)
              None => None
            }
          }
          None => break
        }
      None => break
    }
  }
  earlier_time(inclusion, scheduled)
}

///|
pub fn SchedulePolicy::next_after(
  self : SchedulePolicy,
  from : UtcDateTime,
) -> UtcDateTime? {
  match from.add_minutes(1) {
    Some(cursor) => self.next_on_or_after(cursor)
    None => None
  }
}

///|
/// Last effective occurrence at or before `from`.
pub fn SchedulePolicy::previous_on_or_before(
  self : SchedulePolicy,
  from : UtcDateTime,
) -> UtcDateTime? {
  let inclusion = self.previous_inclusion_on_or_before(from)
  if !self.enabled {
    return inclusion
  }
  let mut scheduled = self.cron.previous_on_or_before(from)
  for _ in 0..<10000 {
    match scheduled {
      Some(value) =>
        match self.blackout_at(value) {
          Some(blackout) => {
            let previous_minute = blackout.range.start.add_minutes(-1)
            scheduled = match previous_minute {
              Some(cursor) => self.cron.previous_on_or_before(cursor)
              None => None
            }
          }
          None => break
        }
      None => break
    }
  }
  later_time(inclusion, scheduled)
}

///|
pub fn SchedulePolicy::previous_before(
  self : SchedulePolicy,
  from : UtcDateTime,
) -> UtcDateTime? {
  match from.add_minutes(-1) {
    Some(cursor) => self.previous_on_or_before(cursor)
    None => None
  }
}

///|
/// Materialize effective occurrences in an inclusive range.
pub fn SchedulePolicy::occurrences(
  self : SchedulePolicy,
  range : DateTimeRange,
  limit? : Int = 10000,
) -> Array[UtcDateTime] {
  let result : Array[UtcDateTime] = []
  if limit <= 0 {
    return result
  }
  let mut current = self.next_on_or_after(range.start)
  for ;; {
    if result.length() >= limit {
      break
    }
    match current {
      Some(value) if value <= range.end => {
        result.push(value)
        current = self.next_after(value)
      }
      _ => break
    }
  }
  result
}

///|
pub fn SchedulePolicy::count_occurrences(
  self : SchedulePolicy,
  range : DateTimeRange,
  limit? : Int = 100000,
) -> OccurrenceCount {
  let values = self.occurrences(range, limit~)
  let truncated = if values.length() < limit || values.length() == 0 {
    false
  } else {
    match self.next_after(values[values.length() - 1]) {
      Some(next) => next <= range.end
      None => false
    }
  }
  { count: values.length(), truncated }
}

///|
/// Summarize how exceptions affect a bounded operational window.
pub struct PolicyAudit {
  effective_occurrences : Int
  base_occurrences : Int
  suppressed_occurrences : Int
  included_occurrences : Int
  truncated : Bool
} derive(Eq, Debug)

///|
pub fn SchedulePolicy::audit(
  self : SchedulePolicy,
  range : DateTimeRange,
  limit? : Int = 100000,
) -> PolicyAudit {
  let base = if self.enabled {
    self.cron.count_occurrences(range, limit~)
  } else {
    { count: 0, truncated: false }
  }
  let effective = self.count_occurrences(range, limit~)
  let mut included = 0
  for inclusion in self.inclusions {
    if range.contains(inclusion.at) {
      included += 1
    }
  }
  let retained_base = effective.count - included
  {
    effective_occurrences: effective.count,
    base_occurrences: base.count,
    suppressed_occurrences: if base.count > retained_base {
      base.count - retained_base
    } else {
      0
    },
    included_occurrences: included,
    truncated: base.truncated || effective.truncated,
  }
}

///|
/// Blackouts intersecting a query window.
pub fn SchedulePolicy::blackouts_in(
  self : SchedulePolicy,
  range : DateTimeRange,
) -> Array[ScheduleBlackout] {
  let result : Array[ScheduleBlackout] = []
  for blackout in self.blackouts {
    if blackout.overlaps(range) {
      result.push(blackout)
    }
  }
  result
}

///|
/// Inclusions contained by a query window.
pub fn SchedulePolicy::inclusions_in(
  self : SchedulePolicy,
  range : DateTimeRange,
) -> Array[ScheduleInclusion] {
  let result : Array[ScheduleInclusion] = []
  for inclusion in self.inclusions {
    if range.contains(inclusion.at) {
      result.push(inclusion)
    }
  }
  result
}

///|
/// Copy the policy without operational exceptions.
pub fn SchedulePolicy::clear_exceptions(
  self : SchedulePolicy,
) -> SchedulePolicy {
  { ..self, blackouts: [], inclusions: [] }
}