///|
/// Severity used by schedule audit findings.
pub(all) enum AuditSeverity {
  AuditInfo
  AuditWarning
  AuditError
} derive(Eq, Compare, Debug)

///|
/// A stable finding code plus human-readable evidence and remediation.
pub struct AuditFinding {
  severity : AuditSeverity
  code : String
  message : String
  evidence : Array[DateTime]
  suggestion : String
} derive(Eq, Debug)

///|
/// Distribution of recurrence instances over ISO weekdays.
pub struct WeekdayHistogram {
  monday : Int
  tuesday : Int
  wednesday : Int
  thursday : Int
  friday : Int
  saturday : Int
  sunday : Int
} derive(Eq, Debug)

///|
pub fn WeekdayHistogram::empty() -> WeekdayHistogram {
  {
    monday: 0,
    tuesday: 0,
    wednesday: 0,
    thursday: 0,
    friday: 0,
    saturday: 0,
    sunday: 0,
  }
}

///|
pub fn WeekdayHistogram::total(self : WeekdayHistogram) -> Int {
  self.monday +
  self.tuesday +
  self.wednesday +
  self.thursday +
  self.friday +
  self.saturday +
  self.sunday
}

///|
pub fn WeekdayHistogram::weekend_total(self : WeekdayHistogram) -> Int {
  self.saturday + self.sunday
}

///|
fn histogram_add(
  histogram : WeekdayHistogram,
  weekday : Weekday,
) -> WeekdayHistogram {
  match weekday {
    Monday => { ..histogram, monday: histogram.monday + 1 }
    Tuesday => { ..histogram, tuesday: histogram.tuesday + 1 }
    Wednesday => { ..histogram, wednesday: histogram.wednesday + 1 }
    Thursday => { ..histogram, thursday: histogram.thursday + 1 }
    Friday => { ..histogram, friday: histogram.friday + 1 }
    Saturday => { ..histogram, saturday: histogram.saturday + 1 }
    Sunday => { ..histogram, sunday: histogram.sunday + 1 }
  }
}

///|
/// Summary metrics and findings produced for one expanded recurrence set.
pub struct ScheduleAudit {
  occurrence_count : Int
  unique_count : Int
  duplicate_count : Int
  weekend_count : Int
  outside_business_hours_count : Int
  first_occurrence : DateTime?
  last_occurrence : DateTime?
  minimum_gap_seconds : Int64?
  maximum_gap_seconds : Int64?
  average_gap_seconds : Int64?
  longest_daily_streak : Int
  weekday_histogram : WeekdayHistogram
  findings : Array[AuditFinding]
} derive(Eq, Debug)

///|
fn is_weekend(value : DateTime) -> Bool {
  match value.date.weekday() {
    Saturday | Sunday => true
    _ => false
  }
}

///|
fn is_outside_business_hours(
  value : DateTime,
  business_start_hour : Int,
  business_end_hour : Int,
) -> Bool {
  value.time.hour < business_start_hour || value.time.hour >= business_end_hour
}

///|
fn sorted_datetimes(values : Array[DateTime]) -> Array[DateTime] {
  let result = values.copy()
  result.sort()
  result
}

///|
fn unique_datetimes(values : Array[DateTime]) -> Array[DateTime] {
  let result : Array[DateTime] = []
  for value in sorted_datetimes(values) {
    if result.length() == 0 || result[result.length() - 1] != value {
      result.push(value)
    }
  }
  result
}

///|
fn duplicate_evidence(values : Array[DateTime]) -> Array[DateTime] {
  let sorted = sorted_datetimes(values)
  let result : Array[DateTime] = []
  for index = 1; index < sorted.length(); index = index + 1 {
    if sorted[index] == sorted[index - 1] && !result.contains(sorted[index]) {
      result.push(sorted[index])
    }
  }
  result
}

///|
fn gap_statistics(unique : Array[DateTime]) -> (Int64?, Int64?, Int64?) {
  if unique.length() < 2 {
    return (None, None, None)
  }
  let mut minimum = unique[1].to_epoch_second() - unique[0].to_epoch_second()
  let mut maximum = minimum
  let mut total = 0L
  for index = 1; index < unique.length(); index = index + 1 {
    let gap = unique[index].to_epoch_second() -
      unique[index - 1].to_epoch_second()
    minimum = minimum.min(gap)
    maximum = maximum.max(gap)
    total += gap
  }
  (Some(minimum), Some(maximum), Some(total / (unique.length() - 1).to_int64()))
}

///|
fn longest_daily_streak(values : Array[DateTime]) -> Int {
  if values.length() == 0 {
    return 0
  }
  let unique_days : Array[Int] = []
  for value in values {
    let day = value.date.to_epoch_day()
    if !unique_days.contains(day) {
      unique_days.push(day)
    }
  }
  unique_days.sort()
  let mut best = 1
  let mut current = 1
  for index = 1; index < unique_days.length(); index = index + 1 {
    if unique_days[index] == unique_days[index - 1] + 1 {
      current += 1
      best = best.max(current)
    } else {
      current = 1
    }
  }
  best
}

///|
fn finding(
  severity : AuditSeverity,
  code : String,
  message : String,
  evidence : Array[DateTime],
  suggestion : String,
) -> AuditFinding {
  { severity, code, message, evidence, suggestion }
}

///|
/// Analyze concrete occurrence data and return reproducible findings.
pub fn audit_occurrences(
  values : Array[DateTime],
  business_start_hour? : Int = 9,
  business_end_hour? : Int = 18,
  expected_minimum? : Int = 1,
  dense_gap_threshold_seconds? : Int64 = 300L,
) -> ScheduleAudit {
  let unique = unique_datetimes(values)
  let duplicates = duplicate_evidence(values)
  let weekend_values : Array[DateTime] = []
  let outside_values : Array[DateTime] = []
  let mut histogram = WeekdayHistogram::empty()
  for value in unique {
    histogram = histogram_add(histogram, value.date.weekday())
    if is_weekend(value) {
      weekend_values.push(value)
    }
    if is_outside_business_hours(value, business_start_hour, business_end_hour) {
      outside_values.push(value)
    }
  }
  let (minimum_gap, maximum_gap, average_gap) = gap_statistics(unique)
  let findings : Array[AuditFinding] = []
  if unique.length() < expected_minimum {
    findings.push(
      finding(
        AuditError,
        "AUDIT_TOO_FEW_OCCURRENCES",
        "The schedule produces fewer occurrences than expected.",
        unique,
        "Review the date window, COUNT, UNTIL and BY filters.",
      ),
    )
  }
  if duplicates.length() > 0 {
    findings.push(
      finding(
        AuditWarning,
        "AUDIT_DUPLICATE_OCCURRENCE",
        "Multiple recurrence sources produce the same timestamp.",
        duplicates,
        "Remove redundant RRULE or RDATE entries if duplication is unintended.",
      ),
    )
  }
  if weekend_values.length() > 0 {
    findings.push(
      finding(
        AuditInfo,
        "AUDIT_WEEKEND_OCCURRENCE",
        "The schedule includes Saturday or Sunday occurrences.",
        weekend_values,
        "Confirm weekend execution is intended or add weekday filters.",
      ),
    )
  }
  if outside_values.length() > 0 {
    findings.push(
      finding(
        AuditInfo,
        "AUDIT_OUTSIDE_BUSINESS_HOURS",
        "The schedule includes occurrences outside configured business hours.",
        outside_values,
        "Adjust BYHOUR or the audit business-hours window.",
      ),
    )
  }
  match minimum_gap {
    Some(gap) if gap < dense_gap_threshold_seconds =>
      findings.push(
        finding(
          AuditWarning,
          "AUDIT_DENSE_SCHEDULE",
          "Two adjacent occurrences are closer than the configured threshold.",
          unique,
          "Increase INTERVAL or narrow the BYMINUTE/BYSECOND selectors.",
        ),
      )
    _ => ()
  }
  {
    occurrence_count: values.length(),
    unique_count: unique.length(),
    duplicate_count: values.length() - unique.length(),
    weekend_count: weekend_values.length(),
    outside_business_hours_count: outside_values.length(),
    first_occurrence: if unique.length() == 0 {
      None
    } else {
      Some(unique[0])
    },
    last_occurrence: unique.last(),
    minimum_gap_seconds: minimum_gap,
    maximum_gap_seconds: maximum_gap,
    average_gap_seconds: average_gap,
    longest_daily_streak: longest_daily_streak(unique),
    weekday_histogram: histogram,
    findings,
  }
}

///|
/// Expand and audit a recurrence rule in one bounded operation.
pub fn audit_rule(
  start : DateTime,
  rule : RRule,
  options : ExpansionOptions,
  business_start_hour? : Int = 9,
  business_end_hour? : Int = 18,
) -> ScheduleAudit raise ExpansionError {
  audit_occurrences(
    expand_rrule(start, rule, options),
    business_start_hour~,
    business_end_hour~,
  )
}

///|
/// One occurrence represented as a half-open [start, finish) interval.
pub struct EventSlot {
  schedule : String
  ordinal : Int
  start : DateTime
  finish : DateTime
} derive(Eq, Debug)

///|
pub suberror EventSlotError {
  NonPositiveDuration
} derive(Eq, Debug)

///|
pub fn make_event_slots(
  schedule : String,
  starts : Array[DateTime],
  duration : Duration,
) -> Array[EventSlot] raise {
  let seconds = duration.fixed_seconds()
  if seconds <= 0L {
    raise NonPositiveDuration
  }
  let result : Array[EventSlot] = []
  for index, start in starts {
    result.push({
      schedule,
      ordinal: index + 1,
      start,
      finish: start.add_seconds(seconds),
    })
  }
  result
}

///|
pub struct ScheduleCollision {
  left : EventSlot
  right : EventSlot
  overlap_start : DateTime
  overlap_finish : DateTime
  overlap_seconds : Int64
} derive(Eq, Debug)

///|
fn slot_overlaps(left : EventSlot, right : EventSlot) -> Bool {
  left.start.to_epoch_second() < right.finish.to_epoch_second() &&
  right.start.to_epoch_second() < left.finish.to_epoch_second()
}

///|
fn later_datetime(left : DateTime, right : DateTime) -> DateTime {
  if left.to_epoch_second() >= right.to_epoch_second() {
    left
  } else {
    right
  }
}

///|
fn earlier_datetime(left : DateTime, right : DateTime) -> DateTime {
  if left.to_epoch_second() <= right.to_epoch_second() {
    left
  } else {
    right
  }
}

///|
/// Detect interval collisions between two named schedules. Touching endpoints
/// are not collisions because event slots use half-open interval semantics.
pub fn detect_collisions(
  left : Array[EventSlot],
  right : Array[EventSlot],
) -> Array[ScheduleCollision] {
  let result : Array[ScheduleCollision] = []
  for left_slot in left {
    for right_slot in right {
      if slot_overlaps(left_slot, right_slot) {
        let overlap_start = later_datetime(left_slot.start, right_slot.start)
        let overlap_finish = earlier_datetime(
          left_slot.finish,
          right_slot.finish,
        )
        result.push({
          left: left_slot,
          right: right_slot,
          overlap_start,
          overlap_finish,
          overlap_seconds: overlap_finish.to_epoch_second() -
          overlap_start.to_epoch_second(),
        })
      }
    }
  }
  result
}

///|
/// Behavioral impact of replacing one rule with another over a finite horizon.
pub struct ChangeImpact {
  baseline_count : Int
  candidate_count : Int
  added : Array[DateTime]
  removed : Array[DateTime]
  unchanged : Array[DateTime]
  first_divergence : DateTime?
  similarity_permille : Int
} derive(Eq, Debug)

///|
fn first_of_two_arrays(
  left : Array[DateTime],
  right : Array[DateTime],
) -> DateTime? {
  if left.length() == 0 {
    return if right.length() == 0 { None } else { Some(right[0]) }
  }
  if right.length() == 0 {
    return if left.length() == 0 { None } else { Some(left[0]) }
  }
  Some(earlier_datetime(left[0], right[0]))
}

///|
/// Compare rule behavior using occurrence sets rather than textual syntax.
pub fn analyze_rule_change(
  start : DateTime,
  baseline : RRule,
  candidate : RRule,
  options : ExpansionOptions,
) -> ChangeImpact raise ExpansionError {
  let baseline_values = expand_rrule(start, baseline, options)
  let candidate_values = expand_rrule(start, candidate, options)
  let difference = compare_rules(start, baseline, candidate, options)
  let union_count = difference.only_left.length() +
    difference.only_right.length() +
    difference.shared.length()
  let similarity = if union_count == 0 {
    1000
  } else {
    difference.shared.length() * 1000 / union_count
  }
  {
    baseline_count: baseline_values.length(),
    candidate_count: candidate_values.length(),
    added: difference.only_right,
    removed: difference.only_left,
    unchanged: difference.shared,
    first_divergence: first_of_two_arrays(
      difference.only_left,
      difference.only_right,
    ),
    similarity_permille: similarity,
  }
}

///|
pub fn AuditSeverity::label(self : AuditSeverity) -> String {
  match self {
    AuditInfo => "info"
    AuditWarning => "warning"
    AuditError => "error"
  }
}

///|
/// Stable plain-text report intended for terminals, issues and CI logs.
pub fn ScheduleAudit::to_text(self : ScheduleAudit) -> String {
  let lines : Array[String] = [
    "occurrences: " + self.occurrence_count.to_string(),
    "unique: " + self.unique_count.to_string(),
    "duplicates: " + self.duplicate_count.to_string(),
    "weekend: " + self.weekend_count.to_string(),
    "outside-business-hours: " + self.outside_business_hours_count.to_string(),
    "longest-daily-streak: " + self.longest_daily_streak.to_string(),
  ]
  match self.minimum_gap_seconds {
    Some(value) => lines.push("minimum-gap-seconds: " + value.to_string())
    None => lines.push("minimum-gap-seconds: n/a")
  }
  match self.maximum_gap_seconds {
    Some(value) => lines.push("maximum-gap-seconds: " + value.to_string())
    None => lines.push("maximum-gap-seconds: n/a")
  }
  if self.findings.length() == 0 {
    lines.push("findings: none")
  } else {
    lines.push("findings:")
    for item in self.findings {
      lines.push(
        "- [" + item.severity.label() + "] " + item.code + ": " + item.message,
      )
    }
  }
  lines.join("\n")
}