///|
/// Coarse operational frequency classification.
pub(all) enum FrequencyClass {
  Dormant
  Sparse
  Moderate
  Frequent
  Intense
} derive(Eq, Debug)

///|
pub fn FrequencyClass::label(self : FrequencyClass) -> String {
  match self {
    Dormant => "dormant"
    Sparse => "sparse"
    Moderate => "moderate"
    Frequent => "frequent"
    Intense => "intense"
  }
}

///|
/// Number of occurrences assigned to one UTC hour of day.
pub struct HourlyBucket {
  hour : Int
  count : Int
} derive(Eq, Debug)

///|
/// Number of occurrences assigned to one UTC weekday.
pub struct WeekdayBucket {
  weekday : Int
  count : Int
} derive(Eq, Debug)

///|
/// Bounded statistics for one named schedule.
pub struct ScheduleStatistics {
  name : String
  enabled : Bool
  occurrence_count : Int
  truncated : Bool
  first : UtcDateTime?
  last : UtcDateTime?
  active_days : Int
  minimum_gap_minutes : Int?
  maximum_gap_minutes : Int?
  average_gap_minutes : Int?
  frequency : FrequencyClass
  hourly : Array[HourlyBucket]
  weekdays : Array[WeekdayBucket]
} derive(Eq, Debug)

///|
fn classify_frequency(
  count : Int,
  gap : OccurrenceGapSummary?,
) -> FrequencyClass {
  if count == 0 {
    Dormant
  } else if count == 1 {
    Sparse
  } else {
    match gap {
      Some(summary) =>
        if summary.average_minutes >= 1440 {
          Sparse
        } else if summary.average_minutes >= 60 {
          Moderate
        } else if summary.average_minutes >= 5 {
          Frequent
        } else {
          Intense
        }
      None => Sparse
    }
  }
}

///|
fn hourly_buckets(values : Array[UtcDateTime]) -> Array[HourlyBucket] {
  let counts = Array::make(24, 0)
  for value in values {
    counts[value.hour] += 1
  }
  let result : Array[HourlyBucket] = []
  for hour in 0..<24 {
    result.push({ hour, count: counts[hour] })
  }
  result
}

///|
fn weekday_buckets(values : Array[UtcDateTime]) -> Array[WeekdayBucket] {
  let counts = Array::make(7, 0)
  for value in values {
    counts[value.weekday()] += 1
  }
  let result : Array[WeekdayBucket] = []
  for weekday in 0..<7 {
    result.push({ weekday, count: counts[weekday] })
  }
  result
}

///|
fn count_active_days(values : Array[UtcDateTime]) -> Int {
  let mut count = 0
  let mut previous : UtcDate? = None
  for value in values {
    let date = value.date()
    if previous != Some(date) {
      count += 1
      previous = Some(date)
    }
  }
  count
}

///|
/// Analyze one named schedule in an inclusive operational window.
pub fn analyze_schedule(
  schedule : NamedSchedule,
  range : DateTimeRange,
  limit? : Int = 100000,
) -> ScheduleStatistics {
  if !schedule.enabled || limit <= 0 {
    return {
      name: schedule.name,
      enabled: schedule.enabled,
      occurrence_count: 0,
      truncated: false,
      first: None,
      last: None,
      active_days: 0,
      minimum_gap_minutes: None,
      maximum_gap_minutes: None,
      average_gap_minutes: None,
      frequency: Dormant,
      hourly: hourly_buckets([]),
      weekdays: weekday_buckets([]),
    }
  }
  let query = OccurrenceQuery::new(range, limit).unwrap()
  let values = schedule.cron.occurrences(query)
  let truncated = if values.length() < limit || values.length() == 0 {
    false
  } else {
    match schedule.cron.next_after(values[values.length() - 1]) {
      Some(next) => next <= range.end
      None => false
    }
  }
  let gap = schedule.cron.gap_summary(range, sample_limit=limit)
  {
    name: schedule.name,
    enabled: true,
    occurrence_count: values.length(),
    truncated,
    first: values.get(0),
    last: values.get(values.length() - 1),
    active_days: count_active_days(values),
    minimum_gap_minutes: gap.map(summary => summary.minimum_minutes),
    maximum_gap_minutes: gap.map(summary => summary.maximum_minutes),
    average_gap_minutes: gap.map(summary => summary.average_minutes),
    frequency: classify_frequency(values.length(), gap),
    hourly: hourly_buckets(values),
    weekdays: weekday_buckets(values),
  }
}

///|
pub fn ScheduleStatistics::busiest_hour(
  self : ScheduleStatistics,
) -> HourlyBucket? {
  if self.occurrence_count == 0 {
    return None
  }
  let mut best = self.hourly[0]
  for bucket in self.hourly {
    if bucket.count > best.count {
      best = bucket
    }
  }
  Some(best)
}

///|
pub fn ScheduleStatistics::busiest_weekday(
  self : ScheduleStatistics,
) -> WeekdayBucket? {
  if self.occurrence_count == 0 {
    return None
  }
  let mut best = self.weekdays[0]
  for bucket in self.weekdays {
    if bucket.count > best.count {
      best = bucket
    }
  }
  Some(best)
}

///|
pub fn ScheduleStatistics::hour_count(
  self : ScheduleStatistics,
  hour : Int,
) -> Int {
  if hour < 0 || hour > 23 {
    return 0
  }
  self.hourly[hour].count
}

///|
pub fn ScheduleStatistics::weekday_count(
  self : ScheduleStatistics,
  weekday : Int,
) -> Int {
  if weekday < 0 || weekday > 6 {
    return 0
  }
  self.weekdays[weekday].count
}

///|
/// Pairwise collision statistics.
pub struct CollisionStatistics {
  left_name : String
  right_name : String
  count : Int
  first : UtcDateTime?
  last : UtcDateTime?
  truncated : Bool
} derive(Eq, Debug)

///|
pub fn analyze_collision(
  left : NamedSchedule,
  right : NamedSchedule,
  range : DateTimeRange,
  limit? : Int = 10000,
) -> CollisionStatistics {
  if !left.enabled || !right.enabled || limit <= 0 {
    return {
      left_name: left.name,
      right_name: right.name,
      count: 0,
      first: None,
      last: None,
      truncated: false,
    }
  }
  let values = left.cron.collisions_with(right.cron, range, limit~)
  let truncated = if values.length() < limit || values.length() == 0 {
    false
  } else {
    let cursor = values[values.length() - 1].at
    match left.cron.next_after(cursor) {
      Some(next) => next <= range.end && right.cron.matches_at(next)
      None => false
    }
  }
  {
    left_name: left.name,
    right_name: right.name,
    count: values.length(),
    first: values.get(0).map(item => item.at),
    last: values.get(values.length() - 1).map(item => item.at),
    truncated,
  }
}

///|
/// One minute carrying the maximum number of simultaneous events.
pub struct PeakMinute {
  at : UtcDateTime
  schedule_names : Array[String]
} derive(Eq, Debug)

///|
/// Aggregate event load for one UTC calendar date.
pub struct DailyLoad {
  date : UtcDate
  event_count : Int
  active_minutes : Int
  peak_concurrency : Int
} derive(Eq, Debug)

///|
/// Reviewer-facing health classification derived from bounded evidence.
pub(all) enum ReportHealth {
  Healthy
  NeedsAttention
  Critical
} derive(Eq, Debug)

///|
pub fn ReportHealth::label(self : ReportHealth) -> String {
  match self {
    Healthy => "healthy"
    NeedsAttention => "needs-attention"
    Critical => "critical"
  }
}

///|
/// Aggregate operational report for a schedule registry.
pub struct ScheduleReport {
  range : DateTimeRange
  schedules : Array[ScheduleStatistics]
  collisions : Array[CollisionStatistics]
  daily_load : Array[DailyLoad]
  total_events : Int
  peak : PeakMinute?
  truncated : Bool
} derive(Eq, Debug)

///|
fn build_daily_load(batches : Array[EventBatch]) -> Array[DailyLoad] {
  let result : Array[DailyLoad] = []
  for batch in batches {
    let date = batch.at.date()
    let concurrency = batch.schedule_names.length()
    if result.length() == 0 || result[result.length() - 1].date != date {
      result.push({
        date,
        event_count: concurrency,
        active_minutes: 1,
        peak_concurrency: concurrency,
      })
    } else {
      let last = result.length() - 1
      let previous = result[last]
      result[last] = {
        date,
        event_count: previous.event_count + concurrency,
        active_minutes: previous.active_minutes + 1,
        peak_concurrency: if concurrency > previous.peak_concurrency {
          concurrency
        } else {
          previous.peak_concurrency
        },
      }
    }
  }
  result
}

///|
pub fn ScheduleBook::report(
  self : ScheduleBook,
  range : DateTimeRange,
  per_schedule_limit? : Int = 100000,
  per_pair_limit? : Int = 10000,
) -> ScheduleReport {
  let statistics : Array[ScheduleStatistics] = []
  let collisions : Array[CollisionStatistics] = []
  let entries = self.all()
  let mut total_events = 0
  let mut truncated = false
  for entry in entries {
    let stats = analyze_schedule(entry, range, limit=per_schedule_limit)
    total_events += stats.occurrence_count
    if stats.truncated {
      truncated = true
    }
    statistics.push(stats)
  }
  for left_index in 0.. 0 {
        collisions.push(stats)
      }
      if stats.truncated {
        truncated = true
      }
    }
  }
  let batches = self.event_batches(range, per_schedule_limit~)
  let mut peak : PeakMinute? = None
  for batch in batches {
    match peak {
      Some(current) =>
        if batch.schedule_names.length() > current.schedule_names.length() {
          peak = Some({ at: batch.at, schedule_names: batch.schedule_names })
        }
      None =>
        peak = Some({ at: batch.at, schedule_names: batch.schedule_names })
    }
  }
  {
    range,
    schedules: statistics,
    collisions,
    daily_load: build_daily_load(batches),
    total_events,
    peak,
    truncated,
  }
}

///|
pub fn ScheduleReport::schedule_count(self : ScheduleReport) -> Int {
  self.schedules.length()
}

///|
pub fn ScheduleReport::collision_pair_count(self : ScheduleReport) -> Int {
  self.collisions.length()
}

///|
pub fn ScheduleReport::active_day_count(self : ScheduleReport) -> Int {
  self.daily_load.length()
}

///|
pub fn ScheduleReport::peak_concurrency(self : ScheduleReport) -> Int {
  match self.peak {
    Some(peak) => peak.schedule_names.length()
    None => 0
  }
}

///|
pub fn ScheduleReport::average_events_per_active_day(
  self : ScheduleReport,
) -> Int {
  if self.daily_load.length() == 0 {
    0
  } else {
    self.total_events / self.daily_load.length()
  }
}

///|
pub fn ScheduleReport::busiest_day(self : ScheduleReport) -> DailyLoad? {
  if self.daily_load.length() == 0 {
    return None
  }
  let mut best = self.daily_load[0]
  for load in self.daily_load {
    if load.event_count > best.event_count {
      best = load
    }
  }
  Some(best)
}

///|
pub fn ScheduleReport::load_on(
  self : ScheduleReport,
  date : UtcDate,
) -> DailyLoad? {
  for load in self.daily_load {
    if load.date == date {
      return Some(load)
    }
  }
  None
}

///|
pub fn ScheduleReport::health(self : ScheduleReport) -> ReportHealth {
  if self.truncated || self.peak_concurrency() >= 5 {
    return Critical
  }
  if self.collision_pair_count() > 0 ||
    self.dormant_names().length() > 0 ||
    self.intense_names().length() > 0 {
    NeedsAttention
  } else {
    Healthy
  }
}

///|
/// Deterministic action hints based only on evidence contained in the report.
pub fn ScheduleReport::recommendations(self : ScheduleReport) -> Array[String] {
  let result : Array[String] = []
  if self.truncated {
    result.push(
      "increase report limits or shorten the window before making decisions",
    )
  }
  let dormant = self.dormant_names()
  if dormant.length() > 0 {
    result.push(
      "review " +
      dormant.length().to_string() +
      " dormant or disabled schedule(s)",
    )
  }
  let intense = self.intense_names()
  if intense.length() > 0 {
    result.push(
      "review " + intense.length().to_string() + " intense schedule(s)",
    )
  }
  if self.collision_pair_count() > 0 {
    result.push(
      "stagger " +
      self.collision_pair_count().to_string() +
      " colliding schedule pair(s)",
    )
  }
  if self.peak_concurrency() >= 3 {
    result.push(
      "capacity-test the peak concurrency of " +
      self.peak_concurrency().to_string(),
    )
  }
  if result.length() == 0 {
    result.push("no bounded-window scheduling risks detected")
  }
  result
}

///|
pub fn ScheduleReport::statistics_for(
  self : ScheduleReport,
  name : String,
) -> ScheduleStatistics? {
  for statistics in self.schedules {
    if statistics.name == name {
      return Some(statistics)
    }
  }
  None
}

///|
pub fn ScheduleReport::collision_for(
  self : ScheduleReport,
  left_name : String,
  right_name : String,
) -> CollisionStatistics? {
  for collision in self.collisions {
    let direct = collision.left_name == left_name &&
      collision.right_name == right_name
    let reverse = collision.left_name == right_name &&
      collision.right_name == left_name
    if direct || reverse {
      return Some(collision)
    }
  }
  None
}

///|
pub fn ScheduleReport::dormant_names(self : ScheduleReport) -> Array[String] {
  let result : Array[String] = []
  for statistics in self.schedules {
    if statistics.frequency == Dormant {
      result.push(statistics.name)
    }
  }
  result
}

///|
pub fn ScheduleReport::intense_names(self : ScheduleReport) -> Array[String] {
  let result : Array[String] = []
  for statistics in self.schedules {
    if statistics.frequency == Intense {
      result.push(statistics.name)
    }
  }
  result
}

///|
pub fn ScheduleReport::colliding_names(self : ScheduleReport) -> Array[String] {
  let result : Array[String] = []
  for collision in self.collisions {
    if !string_array_contains(result, collision.left_name) {
      result.push(collision.left_name)
    }
    if !string_array_contains(result, collision.right_name) {
      result.push(collision.right_name)
    }
  }
  result
}

///|
fn string_array_contains(values : Array[String], value : String) -> Bool {
  for item in values {
    if item == value {
      return true
    }
  }
  false
}

///|
fn option_time_text(value : UtcDateTime?) -> String {
  match value {
    Some(at) => at.to_iso8601()
    None => "-"
  }
}

///|
/// Stable text suitable for CI artifacts and command-line diagnostics.
pub fn ScheduleReport::to_text(self : ScheduleReport) -> String {
  let writer = StringBuilder::new()
  writer.write_string("MoonCron schedule report\n")
  writer.write_string(
    "window: " +
    self.range.start.to_iso8601() +
    " .. " +
    self.range.end.to_iso8601() +
    "\n",
  )
  writer.write_string("schedules: " + self.schedule_count().to_string() + "\n")
  writer.write_string("events: " + self.total_events.to_string() + "\n")
  writer.write_string("health: " + self.health().label() + "\n")
  writer.write_string(
    "active days: " + self.active_day_count().to_string() + "\n",
  )
  writer.write_string(
    "collision pairs: " + self.collision_pair_count().to_string() + "\n",
  )
  match self.peak {
    Some(peak) =>
      writer.write_string(
        "peak: " +
        peak.at.to_iso8601() +
        " (" +
        peak.schedule_names.length().to_string() +
        " schedules)\n",
      )
    None => writer.write_string("peak: -\n")
  }
  writer.write_string("\nSchedule details:\n")
  for statistics in self.schedules {
    writer.write_string("- ")
    writer.write_string(statistics.name)
    writer.write_string(": ")
    writer.write_string(statistics.frequency.label())
    writer.write_string(", occurrences=")
    writer.write_string(statistics.occurrence_count.to_string())
    writer.write_string(", active_days=")
    writer.write_string(statistics.active_days.to_string())
    writer.write_string(", first=")
    writer.write_string(option_time_text(statistics.first))
    writer.write_string(", last=")
    writer.write_string(option_time_text(statistics.last))
    if statistics.truncated {
      writer.write_string(", truncated")
    }
    writer.write_char('\n')
  }
  if self.collisions.length() > 0 {
    writer.write_string("\nCollisions:\n")
    for collision in self.collisions {
      writer.write_string("- ")
      writer.write_string(collision.left_name)
      writer.write_string(" + ")
      writer.write_string(collision.right_name)
      writer.write_string(": ")
      writer.write_string(collision.count.to_string())
      writer.write_string(", first=")
      writer.write_string(option_time_text(collision.first))
      writer.write_char('\n')
    }
  }
  writer.write_string("\nRecommendations:\n")
  for recommendation in self.recommendations() {
    writer.write_string("- ")
    writer.write_string(recommendation)
    writer.write_char('\n')
  }
  writer.to_string()
}