///|
/// Frequency ordered from the smallest to the largest recurrence period.
pub enum Frequency {
  Secondly
  Minutely
  Hourly
  Daily
  Weekly
  Monthly
  Yearly
} derive(Eq, Compare, Debug)

///|
pub fn Frequency::code(self : Frequency) -> String {
  match self {
    Secondly => "SECONDLY"
    Minutely => "MINUTELY"
    Hourly => "HOURLY"
    Daily => "DAILY"
    Weekly => "WEEKLY"
    Monthly => "MONTHLY"
    Yearly => "YEARLY"
  }
}

///|
pub fn frequency_from_code(code : String) -> Frequency? {
  match code.to_upper() {
    "SECONDLY" => Some(Secondly)
    "MINUTELY" => Some(Minutely)
    "HOURLY" => Some(Hourly)
    "DAILY" => Some(Daily)
    "WEEKLY" => Some(Weekly)
    "MONTHLY" => Some(Monthly)
    "YEARLY" => Some(Yearly)
    _ => None
  }
}

///|
/// A weekday optionally qualified with an ordinal such as 1MO or -1FR.
pub struct ByDay {
  ordinal : Int?
  weekday : Weekday
} derive(Eq, Compare, Debug)

///|
pub fn ByDay::new(weekday : Weekday, ordinal? : Int) -> ByDay {
  { ordinal, weekday }
}

///|
pub fn ByDay::to_string(self : ByDay) -> String {
  match self.ordinal {
    Some(value) => value.to_string() + self.weekday.code()
    None => self.weekday.code()
  }
}

///|
/// A fully typed subset of RFC 5545 RECUR. Empty arrays mean that the
/// corresponding BY rule is absent and DTSTART supplies the default.
pub struct RRule {
  frequency : Frequency
  interval : Int
  count : Int?
  until : DateTime?
  week_start : Weekday
  by_second : Array[Int]
  by_minute : Array[Int]
  by_hour : Array[Int]
  by_day : Array[ByDay]
  by_month_day : Array[Int]
  by_year_day : Array[Int]
  by_week_number : Array[Int]
  by_month : Array[Int]
  by_set_position : Array[Int]
} derive(Eq, Debug)

///|
pub fn RRule::new(frequency : Frequency) -> RRule {
  {
    frequency,
    interval: 1,
    count: None,
    until: None,
    week_start: Monday,
    by_second: [],
    by_minute: [],
    by_hour: [],
    by_day: [],
    by_month_day: [],
    by_year_day: [],
    by_week_number: [],
    by_month: [],
    by_set_position: [],
  }
}

///|
pub(all) enum Severity {
  Error
  Warning
  Information
} derive(Eq, Compare, Debug)

///|
/// Machine-readable and human-friendly diagnostic returned by validation.
pub struct Diagnostic {
  severity : Severity
  code : String
  field : String
  message : String
  suggestion : String?
} derive(Eq, Debug)

///|
fn diagnostic(
  severity : Severity,
  code : String,
  field : String,
  message : String,
  suggestion? : String,
) -> Diagnostic {
  { severity, code, field, message, suggestion }
}

///|
pub suberror RRuleParseError {
  EmptyRule
  InvalidPart(String)
  DuplicateField(String)
  MissingFrequency
  UnknownFrequency(String)
  InvalidInteger(String, String)
  InvalidWeekday(String)
  InvalidUntil(String)
  InvalidValue(String, String)
} derive(Eq, Debug)

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

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

///|
fn canonical_ints(values : Array[Int]) -> Array[Int] {
  let result : Array[Int] = []
  for value in values {
    if !array_contains(result, value) {
      result.push(value)
    }
  }
  result.sort()
  result
}

///|
fn canonical_days(values : Array[ByDay]) -> Array[ByDay] {
  let result : Array[ByDay] = []
  for value in values {
    if !byday_contains(result, value) {
      result.push(value)
    }
  }
  result.sort()
  result
}

///|
fn join_ints(values : Array[Int]) -> String {
  values.map(value => value.to_string()).join(",")
}

///|
fn join_days(values : Array[ByDay]) -> String {
  values.map(value => value.to_string()).join(",")
}

///|
pub fn RRule::normalize(self : RRule) -> RRule {
  {
    frequency: self.frequency,
    interval: self.interval,
    count: self.count,
    until: self.until,
    week_start: self.week_start,
    by_second: canonical_ints(self.by_second),
    by_minute: canonical_ints(self.by_minute),
    by_hour: canonical_ints(self.by_hour),
    by_day: canonical_days(self.by_day),
    by_month_day: canonical_ints(self.by_month_day),
    by_year_day: canonical_ints(self.by_year_day),
    by_week_number: canonical_ints(self.by_week_number),
    by_month: canonical_ints(self.by_month),
    by_set_position: canonical_ints(self.by_set_position),
  }
}

///|
pub fn RRule::to_string(self : RRule) -> String {
  let rule = self.normalize()
  let parts : Array[String] = ["FREQ=" + rule.frequency.code()]
  if rule.interval != 1 {
    parts.push("INTERVAL=" + rule.interval.to_string())
  }
  match rule.count {
    Some(value) => parts.push("COUNT=" + value.to_string())
    None => ()
  }
  match rule.until {
    Some(value) => parts.push("UNTIL=" + value.to_iso_string())
    None => ()
  }
  if rule.week_start != Monday {
    parts.push("WKST=" + rule.week_start.code())
  }
  if rule.by_second.length() > 0 {
    parts.push("BYSECOND=" + join_ints(rule.by_second))
  }
  if rule.by_minute.length() > 0 {
    parts.push("BYMINUTE=" + join_ints(rule.by_minute))
  }
  if rule.by_hour.length() > 0 {
    parts.push("BYHOUR=" + join_ints(rule.by_hour))
  }
  if rule.by_day.length() > 0 {
    parts.push("BYDAY=" + join_days(rule.by_day))
  }
  if rule.by_month_day.length() > 0 {
    parts.push("BYMONTHDAY=" + join_ints(rule.by_month_day))
  }
  if rule.by_year_day.length() > 0 {
    parts.push("BYYEARDAY=" + join_ints(rule.by_year_day))
  }
  if rule.by_week_number.length() > 0 {
    parts.push("BYWEEKNO=" + join_ints(rule.by_week_number))
  }
  if rule.by_month.length() > 0 {
    parts.push("BYMONTH=" + join_ints(rule.by_month))
  }
  if rule.by_set_position.length() > 0 {
    parts.push("BYSETPOS=" + join_ints(rule.by_set_position))
  }
  parts.join(";")
}

///|
fn validate_range(
  diagnostics : Array[Diagnostic],
  field : String,
  values : Array[Int],
  minimum : Int,
  maximum : Int,
  zero_allowed? : Bool = true,
) -> Unit {
  for value in values {
    if value < minimum || value > maximum || (!zero_allowed && value == 0) {
      diagnostics.push(
        diagnostic(
          Error,
          "RRULE_RANGE",
          field,
          "value " + value.to_string() + " is outside the allowed range",
          suggestion="use values from " +
            minimum.to_string() +
            " through " +
            maximum.to_string(),
        ),
      )
    }
  }
}

///|
pub fn RRule::validate(self : RRule) -> Array[Diagnostic] {
  let diagnostics : Array[Diagnostic] = []
  if self.interval < 1 {
    diagnostics.push(
      diagnostic(
        Error,
        "RRULE_INTERVAL",
        "INTERVAL",
        "INTERVAL must be positive",
        suggestion="use INTERVAL=1 or greater",
      ),
    )
  }
  match self.count {
    Some(value) if value < 1 =>
      diagnostics.push(
        diagnostic(
          Error,
          "RRULE_COUNT",
          "COUNT",
          "COUNT must be positive",
          suggestion="remove COUNT or use COUNT=1 or greater",
        ),
      )
    _ => ()
  }
  if self.count is Some(_) && self.until is Some(_) {
    diagnostics.push(
      diagnostic(
        Error,
        "RRULE_BOUND_CONFLICT",
        "COUNT,UNTIL",
        "COUNT and UNTIL are mutually exclusive",
        suggestion="keep only one recurrence bound",
      ),
    )
  }
  validate_range(diagnostics, "BYSECOND", self.by_second, 0, 60)
  validate_range(diagnostics, "BYMINUTE", self.by_minute, 0, 59)
  validate_range(diagnostics, "BYHOUR", self.by_hour, 0, 23)
  validate_range(
    diagnostics,
    "BYMONTHDAY",
    self.by_month_day,
    -31,
    31,
    zero_allowed=false,
  )
  validate_range(
    diagnostics,
    "BYYEARDAY",
    self.by_year_day,
    -366,
    366,
    zero_allowed=false,
  )
  validate_range(
    diagnostics,
    "BYWEEKNO",
    self.by_week_number,
    -53,
    53,
    zero_allowed=false,
  )
  validate_range(diagnostics, "BYMONTH", self.by_month, 1, 12)
  validate_range(
    diagnostics,
    "BYSETPOS",
    self.by_set_position,
    -366,
    366,
    zero_allowed=false,
  )
  if self.by_set_position.length() > 0 &&
    self.by_second.length() == 0 &&
    self.by_minute.length() == 0 &&
    self.by_hour.length() == 0 &&
    self.by_day.length() == 0 &&
    self.by_month_day.length() == 0 &&
    self.by_year_day.length() == 0 &&
    self.by_week_number.length() == 0 &&
    self.by_month.length() == 0 {
    diagnostics.push(
      diagnostic(
        Error,
        "RRULE_SETPOS_CONTEXT",
        "BYSETPOS",
        "BYSETPOS requires another BY rule",
        suggestion="add a BYDAY, BYMONTHDAY, or another BY rule",
      ),
    )
  }
  for day in self.by_day {
    match day.ordinal {
      Some(0) =>
        diagnostics.push(
          diagnostic(
            Error,
            "RRULE_BYDAY_ZERO",
            "BYDAY",
            "weekday ordinal cannot be zero",
          ),
        )
      Some(value) if value < -53 || value > 53 =>
        diagnostics.push(
          diagnostic(
            Error,
            "RRULE_BYDAY_RANGE",
            "BYDAY",
            "weekday ordinal is outside -53 through 53",
          ),
        )
      Some(_) if self.frequency != Monthly && self.frequency != Yearly =>
        diagnostics.push(
          diagnostic(
            Error,
            "RRULE_BYDAY_CONTEXT",
            "BYDAY",
            "weekday ordinals are only valid with MONTHLY or YEARLY frequency",
          ),
        )
      _ => ()
    }
  }
  if self.by_week_number.length() > 0 && self.frequency != Yearly {
    diagnostics.push(
      diagnostic(
        Error,
        "RRULE_WEEKNO_CONTEXT",
        "BYWEEKNO",
        "BYWEEKNO is only valid with YEARLY frequency",
      ),
    )
  }
  if self.by_year_day.length() > 0 &&
    (
      self.frequency == Daily ||
      self.frequency == Weekly ||
      self.frequency == Monthly
    ) {
    diagnostics.push(
      diagnostic(
        Error,
        "RRULE_YEARDAY_CONTEXT",
        "BYYEARDAY",
        "BYYEARDAY is not valid with DAILY, WEEKLY, or MONTHLY frequency",
      ),
    )
  }
  diagnostics
}

///|
pub fn RRule::is_valid(self : RRule) -> Bool {
  for item in self.validate() {
    if item.severity == Error {
      return false
    }
  }
  true
}