///|
/// Why an expansion can fail even though the rule parsed.
pub(all) suberror ExpandError {
  /// The rule uses a feature outside this package's supported boundary.
  Unsupported(feature~ : String)
  /// The rule is syntactically fine but semantically invalid with the
  /// `DTSTART` it was given; these checks were deferred from parsing
  /// because only expansion knows the `DTSTART`.
  Invalid(message~ : String)
} derive(Debug, Eq)

///|
pub extend ExpandError with @moonbitlang/core/debug.Debug::{to_repr}

///|
pub extend ExpandError with Eq::{not_equal, equal}

///|
pub impl Show for ExpandError with fn output(self, logger) {
  match self {
    Unsupported(feature=f) =>
      logger.write_string("unsupported RRULE feature in expansion: \{f}")
    Invalid(message=m) => logger.write_string("invalid recurrence rule: \{m}")
  }
}

///|
pub extend ExpandError with Show::{to_string, output}

///|
/// A safety bound on how many rule periods `expand` walks before giving
/// up, so a rule whose `BY*` clauses can never match (say
/// `BYMONTH=2;BYMONTHDAY=30`) terminates with an empty result instead of
/// spinning forever.
const MAX_PERIODS : Int = 10_000

///|
/// Expand a parsed [`Rule`] against its `DTSTART` into the occurrence
/// date-times of the series, in chronological order (RFC 5545 §3.3.10).
///
/// Supports the calendar-grade RFC 5545 rules in this project: `DAILY` /
/// `WEEKLY` / `MONTHLY` / `YEARLY`, ordinal `BYDAY`, positive and negative
/// `BYMONTHDAY`, `BYSETPOS`, `BYMONTH`, and `WKST`. The semantic
/// checks that need the `DTSTART` (`COUNT` + `UNTIL` exclusivity, `UNTIL`
/// value-type agreement) happen here.
///
/// Occurrences are wall-clock arithmetic: every occurrence carries the
/// `DTSTART`'s clock time, UTC offset, zone spelling, and all-day flag
/// unchanged (see [`@model.IcalDateTime::on_date`]), so a series crossing
/// a DST change keeps the `DTSTART` offset — the documented `ZoneTable`
/// boundary, not a silent guess. A `DTSTART` that does not match the rule
/// is not forced into the series: the first occurrence is the first
/// matching date, exactly as the reference corpus behaves.
///
/// `COUNT` counts occurrences from the first match (RFC 5545 counts the
/// `DTSTART` only when it matches); `UNTIL` bounds the series
/// inclusively, compared by instant. `limit` caps how many occurrences
/// are returned — the safety valve for unbounded rules — and never
/// truncates a `COUNT`/`UNTIL` rule that ends within it.
///
/// # Example
/// ```mbt nocheck
/// fn test_example() raise {
///   let rule = @rrule.parse_rule("FREQ=DAILY;COUNT=3")
///   let dtstart = @model.parse_single_date_time(
///     "20260901T090000Z",
///     @model.ZoneTable::empty(),
///   )
///   let occurrences = @rrule.expand(rule, dtstart)
///   assert_eq(occurrences.length(), 3)
///   assert_eq(occurrences[2].to_string(), "2026-09-03T09:00:00Z")
/// }
/// ```
pub fn expand(
  rule : Rule,
  dtstart : @model.IcalDateTime,
  limit? : Int = 200,
) -> Array[@model.IcalDateTime] raise {
  check_expandable(rule, dtstart)
  let normalized = NormalizedRule::new(rule)
  let rule = normalized.rule
  let start = plain_date_of(dtstart.wall)
  let until_instant : Int64? = match rule.until {
    Some(u) => Some(u.instant_seconds())
    None => None
  }
  let out : Array[@model.IcalDateTime] = []
  let mut done = false
  let mut period = 0
  while !done && period < MAX_PERIODS {
    for day in candidates_for(normalized, start, period) {
      if day < start {
        continue
      }
      let occ = dtstart.on_date(day)
      let past_until = match until_instant {
        Some(u) => occ.instant_seconds() > u
        None => false
      }
      if past_until {
        // Candidates are chronological, so everything after this
        // occurrence is past UNTIL too — the series is finished.
        done = true
        break
      }
      out.push(occ)
      match rule.count {
        Some(cap) =>
          if out.length() >= cap {
            done = true
            break
          }
        None => ()
      }
      if out.length() >= limit {
        done = true
        break
      }
    }
    period = period + 1
  }
  out
}

///|
/// Normalize BYMONTH and BYMONTHDAY once for all periods. Cache the realized
/// day numbers for every possible month length; negative days depend on that
/// length, but not on the particular year or month.
priv struct NormalizedRule {
  rule : Rule
  monthdays_by_length : Array[Array[Int]]
}

///|
fn NormalizedRule::new(rule : Rule) -> NormalizedRule {
  let bymonth = rule.bymonth.copy()
  let bymonthday = rule.bymonthday.copy()
  bymonth.sort()
  bymonthday.sort()
  let sorted_rule = { ..rule, bymonth, bymonthday, }
  let monthdays_by_length : Array[Array[Int]] = []
  for days_in_month in 28..<=31 {
    let days : Array[Int] = []
    for raw_day in bymonthday {
      let day = if raw_day > 0 { raw_day } else { days_in_month + raw_day + 1 }
      if day >= 1 && day <= days_in_month {
        days.push(day)
      }
    }
    days.sort()
    monthdays_by_length.push(days)
  }
  { rule: sorted_rule, monthdays_by_length, }
}

///|
/// The gate every expansion passes first: DTSTART-dependent semantic checks
/// deferred from parsing happen here, along with RFC placement constraints.
fn check_expandable(rule : Rule, dtstart : @model.IcalDateTime) -> Unit raise {
  for entry in rule.byday {
    if entry.ordinal != 0 && (rule.freq is Daily || rule.freq is Weekly) {
      raise ExpandError::Invalid(
        message="ordinal BYDAY is only valid with MONTHLY or YEARLY",
      )
    }
  }
  if rule.bysetpos.length() > 0 &&
    rule.byday.length() == 0 &&
    rule.bymonthday.length() == 0 &&
    rule.bymonth.length() == 0 {
    raise ExpandError::Invalid(message="BYSETPOS requires another BY rule")
  }
  match (rule.count, rule.until) {
    (Some(_), Some(_)) =>
      raise ExpandError::Invalid(
        message="COUNT and UNTIL are mutually exclusive",
      )
    _ => ()
  }
  match rule.until {
    Some(u) =>
      if u.all_day != dtstart.all_day {
        raise ExpandError::Invalid(
          message="UNTIL must have the same value type as DTSTART",
        )
      }
    None => ()
  }
}

///|
/// The candidate dates of period `period`, in chronological order. How
/// much of the calendar one period covers is what the frequency means:
/// one interval-step of days, weeks, months, or years.
fn candidates_for(
  normalized : NormalizedRule,
  start : @time.PlainDate,
  period : Int,
) -> Array[@time.PlainDate] raise {
  let rule = normalized.rule
  let all = match rule.freq {
    Daily => daily_candidates(rule, start, period)
    Weekly => weekly_candidates(rule, start, period)
    Monthly => monthly_candidates(normalized, start, period)
    Yearly => yearly_candidates(normalized, start, period)
  }
  apply_setpos(all, rule.bysetpos)
}

///|
/// Select positions from one frequency period after all other BY filters.
/// Positive positions count from one; negative positions count backwards.
fn apply_setpos(
  candidates : Array[@time.PlainDate],
  positions : Array[Int],
) -> Array[@time.PlainDate] {
  if positions.length() == 0 {
    return candidates
  }
  let out : Array[@time.PlainDate] = []
  for position in positions {
    let index = if position > 0 {
      position - 1
    } else {
      candidates.length() + position
    }
    if index >= 0 &&
      index < candidates.length() &&
      !out.contains(candidates[index]) {
      out.push(candidates[index])
    }
  }
  out.sort_by((a, b) => Compare::compare(a, b))
  out
}

///|
/// `DAILY`: one date per period, the interval step from `DTSTART`, kept
/// only if it survives every present `BY*` filter.
fn daily_candidates(
  rule : Rule,
  start : @time.PlainDate,
  period : Int,
) -> Array[@time.PlainDate] raise {
  let day = start.add_days((period * rule.interval).to_int64())
  if passes_filters(rule, day) {
    [day]
  } else {
    []
  }
}

///|
/// `WEEKLY`: the seven days of the `WKST`-aligned week that is
/// `period * interval` weeks from `DTSTART`'s week, filtered to the
/// `BYDAY` weekdays — or, with no `BYDAY`, to `DTSTART`'s own weekday.
/// `WKST` decides which week a date belongs to, which is why it changes
/// the result near a week boundary.
fn weekly_candidates(
  rule : Rule,
  start : @time.PlainDate,
  period : Int,
) -> Array[@time.PlainDate] raise {
  let into_week = (weekday_num(start.weekday()) - weekday_num(rule.wkst) + 7) %
    7
  let week0 = start.add_days(-into_week.to_int64())
  let base = week0.add_days((period * rule.interval * 7).to_int64())
  let out : Array[@time.PlainDate] = []
  for i in 0..<7 {
    let day = base.add_days(i.to_int64())
    let wanted = if rule.byday.length() > 0 {
      weekday_selected(rule.byday, day.weekday())
    } else {
      day.weekday() == start.weekday()
    }
    if wanted && passes_filters(rule, day) {
      out.push(day)
    }
  }
  out
}

///|
/// `MONTHLY`: the month `period * interval` months on. Within it, the
/// `BYMONTHDAY` days and the `BYDAY` weekdays combine — both present
/// means dates matching both, either alone means its own dates, and
/// neither falls back to `DTSTART`'s day-of-month (skipped in months too
/// short for it). `BYMONTH` drops the whole month when it does not
/// match. Days missing from the calendar (February 30) are skipped, not
/// clamped: RFC 5545 has no such date to emit.
fn monthly_candidates(
  normalized : NormalizedRule,
  start : @time.PlainDate,
  period : Int,
) -> Array[@time.PlainDate] raise {
  let rule = normalized.rule
  let months = start.year() * 12 + (start.month() - 1) + period * rule.interval
  let year = months / 12
  let month = months % 12 + 1
  if rule.bymonth.length() > 0 && !int_selected(rule.bymonth, month) {
    return []
  }
  let days = month_days(normalized, start, year, month)
  let out : Array[@time.PlainDate] = []
  for day in days {
    out.push(@time.PlainDate::of(year, month, day))
  }
  out
}

///|
/// `YEARLY`: the year `period * interval` years on. Months come from
/// `BYMONTH`, or every month when `BYDAY`/`BYMONTHDAY` need a whole year
/// to range over, or just `DTSTART`'s month when nothing constrains it.
/// Inside each month the same day selection as `MONTHLY` applies, so the
/// `DTSTART` fallback yields the anniversary date.
fn yearly_candidates(
  normalized : NormalizedRule,
  start : @time.PlainDate,
  period : Int,
) -> Array[@time.PlainDate] raise {
  let rule = normalized.rule
  let year = start.year() + period * rule.interval
  if rule.bymonth.length() == 0 &&
    rule.bymonthday.length() == 0 &&
    has_ordinal_byday(rule.byday) {
    let out : Array[@time.PlainDate] = []
    for month in 1..<=12 {
      let dim = @time.PlainDate::of(year, month, 1).days_in_month()
      for day in 1..<=dim {
        let date = @time.PlainDate::of(year, month, day)
        if weekday_selected_in_year(rule.byday, date) {
          out.push(date)
        }
      }
    }
    return out
  }
  let months : Array[Int] = []
  if rule.bymonth.length() > 0 {
    for month in rule.bymonth {
      months.push(month)
    }
  } else if rule.byday.length() > 0 || rule.bymonthday.length() > 0 {
    for month in 1..<=12 {
      months.push(month)
    }
  } else {
    months.push(start.month())
  }
  let out : Array[@time.PlainDate] = []
  for month in months {
    for day in month_days(normalized, start, year, month) {
      out.push(@time.PlainDate::of(year, month, day))
    }
  }
  out
}

///|
/// The day numbers of one month under this rule: `BYMONTHDAY` ∩ `BYDAY`
/// when both are present, either alone, or `DTSTART`'s day as the
/// fallback. Ascending, and never a day the calendar lacks.
fn month_days(
  normalized : NormalizedRule,
  start : @time.PlainDate,
  year : Int,
  month : Int,
) -> Array[Int] raise {
  let rule = normalized.rule
  let dim = @time.PlainDate::of(year, month, 1).days_in_month()
  let out : Array[Int] = []
  if rule.bymonthday.length() > 0 {
    for day in normalized.monthdays_by_length[dim - 28] {
      let weekday = @time.PlainDate::of(year, month, day).weekday()
      if rule.byday.length() == 0 ||
        weekday_selected_in_month(rule.byday, year, month, day, weekday) {
        out.push(day)
      }
    }
  } else if rule.byday.length() > 0 {
    for day in 1..<=dim {
      let weekday = @time.PlainDate::of(year, month, day).weekday()
      if weekday_selected_in_month(rule.byday, year, month, day, weekday) {
        out.push(day)
      }
    }
  } else if start.day() <= dim {
    out.push(start.day())
  }
  out
}

///|
/// Whether a `DAILY`/`WEEKLY` candidate survives every present `BY*`
/// filter. Frequencies below monthly cannot generate from `BYMONTHDAY`,
/// so it limits here instead.
fn passes_filters(rule : Rule, day : @time.PlainDate) -> Bool {
  if rule.bymonth.length() > 0 && !int_selected(rule.bymonth, day.month()) {
    false
  } else if rule.bymonthday.length() > 0 &&
    !int_selected(rule.bymonthday, day.day()) {
    false
  } else if rule.byday.length() > 0 &&
    !weekday_selected(rule.byday, day.weekday()) {
    false
  } else {
    true
  }
}

///|
/// Monday as 0 through Sunday as 6 — the arithmetic week-position of a
/// weekday, whatever spelling it came in as.
fn weekday_num(weekday : @time.Weekday) -> Int {
  match weekday {
    @time.Weekday::Monday => 0
    @time.Weekday::Tuesday => 1
    @time.Weekday::Wednesday => 2
    @time.Weekday::Thursday => 3
    @time.Weekday::Friday => 4
    @time.Weekday::Saturday => 5
    @time.Weekday::Sunday => 6
  }
}

///|
/// Whether one of the plain `BYDAY` entries names this weekday.
fn weekday_selected(entries : Array[Byday], weekday : @time.Weekday) -> Bool {
  for entry in entries {
    if entry.ordinal == 0 && entry.weekday == weekday {
      return true
    }
  }
  false
}

///|
fn weekday_selected_in_month(
  entries : Array[Byday],
  year : Int,
  month : Int,
  day : Int,
  weekday : @time.Weekday,
) -> Bool raise {
  let dim = @time.PlainDate::of(year, month, 1).days_in_month()
  for entry in entries {
    if entry.weekday == weekday {
      if entry.ordinal == 0 {
        return true
      }
      let ordinal = if entry.ordinal > 0 {
        (day - 1) / 7 + 1
      } else {
        -((dim - day) / 7 + 1)
      }
      if ordinal == entry.ordinal {
        return true
      }
    }
  }
  false
}

///|
fn weekday_selected_in_year(
  entries : Array[Byday],
  date : @time.PlainDate,
) -> Bool raise {
  for entry in entries {
    if entry.weekday == date.weekday() {
      if entry.ordinal == 0 {
        return true
      }
      let jan1 = @time.PlainDate::of(date.year(), 1, 1)
      let dec31 = @time.PlainDate::of(date.year(), 12, 31)
      let ordinal = if entry.ordinal > 0 {
        (date.to_unix_day() - jan1.to_unix_day()).to_int() / 7 + 1
      } else {
        -((dec31.to_unix_day() - date.to_unix_day()).to_int() / 7 + 1)
      }
      if ordinal == entry.ordinal {
        return true
      }
    }
  }
  false
}

///|
fn has_ordinal_byday(entries : Array[Byday]) -> Bool {
  for entry in entries {
    if entry.ordinal != 0 {
      return true
    }
  }
  false
}

///|
/// Whether a normalized, ascending clause list contains the value.
fn int_selected(list : Array[Int], value : Int) -> Bool {
  list.binary_search(value) is Ok(_)
}

///|
/// The calendar date of a wall-clock value.
fn plain_date_of(wall : @time.PlainDateTime) -> @time.PlainDate raise {
  @time.PlainDate::of(wall.year(), wall.month(), wall.day())
}