///|
/// Parse an `RRULE` property value (RFC 5545 §3.3.10) into a [`Rule`].
///
/// This is the syntax layer only. Every clause inside the project's support
/// boundary (docs/development.html §08) is parsed into a typed field; the
/// clauses outside it — the `HOURLY` / `MINUTELY` / `SECONDLY` frequencies
/// and the `BYWEEKNO` / `BYYEARDAY` / `BYHOUR` / `BYMINUTE` / `BYSECOND`
/// clauses — are rejected with an explicit [`@text.ParseError::BadRule`]
/// rather than silently dropped.
///
/// The `RRULE:` property-name prefix may be present or absent, so both a
/// bare property value and a whole content line parse. Clause names and
/// values match case-insensitively (real feeds mix cases). Each clause may
/// appear at most once, and `FREQ` must appear exactly once.
///
/// Two deliberate deviations from the RFC grammar, in the direction of
/// tolerating real feeds: `FREQ` is not required to be the first clause,
/// and no `DTSTART`-dependent semantic checks (COUNT + UNTIL exclusivity,
/// UNTIL value-type agreement) happen here — those belong to expansion
/// (S3), which is the only place that knows the `DTSTART`.
///
/// # Example
/// ```mbt nocheck
/// fn test_example() raise {
///   let rule = @rrule.parse_rule("FREQ=WEEKLY;BYDAY=TU,TH;COUNT=10")
///   assert_true(rule.freq is @rrule.Freq::Weekly)
///   assert_eq(rule.count, Some(10))
///   assert_eq(rule.byday.length(), 2)
/// }
/// ```
pub fn parse_rule(rule : String) -> Rule raise {
  let body = without_rrule_prefix(rule)
  let mut freq : Freq? = None
  let mut interval : Int? = None
  let mut count : Int? = None
  let mut until : @model.IcalDateTime? = None
  let mut byday : Array[Byday] = []
  let mut bymonthday : Array[Int] = []
  let mut bymonth : Array[Int] = []
  let mut bysetpos : Array[Int] = []
  let mut wkst : @time.Weekday? = None
  let seen : Array[String] = []
  for part in split_on(body, ';') {
    if part == "" {
      raise bad_rule(rule, "empty rule part")
    }
    let (name, value) = match split_at_equals(part) {
      Some(pair) => pair
      None => raise bad_rule(rule, "malformed rule part: \{part}")
    }
    if name == "" {
      raise bad_rule(rule, "malformed rule part: \{part}")
    }
    let key = String::to_upper(name)
    if seen.contains(key) {
      raise bad_rule(rule, "duplicate clause: \{key}")
    }
    seen.push(key)
    match key {
      "FREQ" => freq = Some(parse_freq(value, rule))
      "INTERVAL" => interval = Some(positive_clause("INTERVAL", value, rule))
      "COUNT" => count = Some(positive_clause("COUNT", value, rule))
      "UNTIL" => until = Some(parse_until(value, rule))
      "BYDAY" => byday = parse_byday_list(value, rule)
      "BYMONTHDAY" =>
        bymonthday = parse_int_list(value, "BYMONTHDAY", rule, 31, true)
      "BYMONTH" => bymonth = parse_int_list(value, "BYMONTH", rule, 12, false)
      "BYSETPOS" =>
        bysetpos = parse_int_list(value, "BYSETPOS", rule, 366, true)
      "WKST" => wkst = Some(parse_weekday_code(value, rule))
      "BYWEEKNO" | "BYYEARDAY" | "BYHOUR" | "BYMINUTE" | "BYSECOND" =>
        raise bad_rule(rule, "unsupported RRULE clause: \{key}")
      _ => raise bad_rule(rule, "unknown RRULE clause: \{key}")
    }
  }
  match freq {
    Some(f) =>
      {
        freq: f,
        interval: interval.unwrap_or(1),
        count,
        until,
        byday,
        bymonthday,
        bymonth,
        bysetpos,
        wkst: wkst.unwrap_or(@time.Weekday::Monday),
      }
    None => raise bad_rule(rule, "missing FREQ clause")
  }
}

///|
/// Build the one error shape `parse_rule` reports, so every rejection
/// names the whole rule value it failed on.
fn bad_rule(rule : String, message : String) -> @text.ParseError {
  @text.ParseError::BadRule(rule~, message~)
}

///|
/// Drop a leading `RRULE:` property-name prefix, if present, so a whole
/// content line and a bare property value both parse.
fn without_rrule_prefix(rule : String) -> String {
  let v = rule.view()
  if v.length() >= 6 && v[:6].to_upper().to_owned() == "RRULE:" {
    v[6:].to_owned()
  } else {
    rule
  }
}

///|
/// Split on a one-character separator, keeping every part — including a
/// trailing empty one — so `a;;b` and `a;` stay visible to the caller as
/// breakage instead of being quietly folded away.
fn split_on(text : String, sep : UInt16) -> Array[String] {
  let parts : Array[String] = []
  let v = text.view()
  let n = v.length()
  let mut start = 0
  for i in 0.. (String, String)? {
  let v = part.view()
  match v.find("=".view()) {
    Some(i) => Some((v[:i].to_owned(), v[i + 1:].to_owned()))
    None => None
  }
}

///|
/// The `FREQ` value: one of the four supported calendar grades, or an
/// explicit error. The sub-daily grades are named in their own rejection
/// message so a feed author learns the boundary, not just the failure.
fn parse_freq(value : String, rule : String) -> Freq raise {
  match String::to_upper(value) {
    "YEARLY" => Yearly
    "MONTHLY" => Monthly
    "WEEKLY" => Weekly
    "DAILY" => Daily
    "HOURLY" | "MINUTELY" | "SECONDLY" =>
      raise bad_rule(rule, "unsupported FREQ: \{value}")
    _ => raise bad_rule(rule, "unknown FREQ: \{value}")
  }
}

///|
/// `INTERVAL` and `COUNT`: a positive integer, nothing else.
fn positive_clause(key : String, value : String, rule : String) -> Int raise {
  match parse_signed_int(value) {
    Some(n) =>
      if n >= 1 {
        n
      } else {
        raise bad_rule(rule, "\{key} must be a positive integer: \{value}")
      }
    None => raise bad_rule(rule, "malformed \{key} value: \{value}")
  }
}

///|
/// The `UNTIL` value: a DATE or date-time, read by the same four-state
/// parser the model layer uses. Its own error is re-wrapped as a rule
/// failure so every `parse_rule` caller sees one error shape.
fn parse_until(value : String, rule : String) -> @model.IcalDateTime raise {
  @model.parse_single_date_time(value, @model.ZoneTable::empty()) catch {
    _ => raise bad_rule(rule, "malformed UNTIL value: \{value}")
  }
}

///|
/// A comma-separated `BYDAY` list; every entry must be a weekday code with
/// an optional ordinal.
fn parse_byday_list(value : String, rule : String) -> Array[Byday] raise {
  let out : Array[Byday] = []
  for item in split_on(value, ',') {
    match parse_byday_item(item) {
      Some(entry) => out.push(entry)
      None => raise bad_rule(rule, "malformed BYDAY value: \{item}")
    }
  }
  out
}

///|
/// One `BYDAY` entry: an optional signed ordinal (±53, never 0) followed
/// by a two-letter weekday code.
fn parse_byday_item(item : String) -> Byday? {
  let text = String::to_upper(item)
  let v = text.view()
  let n = v.length()
  if n < 2 {
    None
  } else {
    match weekday_of(v[n - 2:].to_owned()) {
      None => None
      Some(weekday) => {
        let head = v[:n - 2].to_owned()
        if head == "" {
          Some({ ordinal: 0, weekday, })
        } else {
          match parse_signed_int(head) {
            Some(ordinal) =>
              if ordinal == 0 || ordinal.abs() > 53 {
                None
              } else {
                Some({ ordinal, weekday, })
              }
            None => None
          }
        }
      }
    }
  }
}

///|
/// The `WKST` value: one weekday code.
fn parse_weekday_code(value : String, rule : String) -> @time.Weekday raise {
  match weekday_of(String::to_upper(value)) {
    Some(weekday) => weekday
    None => raise bad_rule(rule, "malformed WKST value: \{value}")
  }
}

///|
/// A two-letter weekday code, as `BYDAY` and `WKST` spell them.
fn weekday_of(code : String) -> @time.Weekday? {
  match code {
    "MO" => Some(@time.Weekday::Monday)
    "TU" => Some(@time.Weekday::Tuesday)
    "WE" => Some(@time.Weekday::Wednesday)
    "TH" => Some(@time.Weekday::Thursday)
    "FR" => Some(@time.Weekday::Friday)
    "SA" => Some(@time.Weekday::Saturday)
    "SU" => Some(@time.Weekday::Sunday)
    _ => None
  }
}

///|
/// A comma-separated integer list for `BYMONTHDAY`, `BYMONTH`, and
/// `BYSETPOS`. Every entry must be non-zero and within `bound` of zero;
/// `signed` selects whether a leading sign is part of the grammar (`false`
/// for `BYMONTH`, whose RFC grammar is bare digits).
fn parse_int_list(
  value : String,
  key : String,
  rule : String,
  bound : Int,
  signed : Bool,
) -> Array[Int] raise {
  let out : Array[Int] = []
  for item in split_on(value, ',') {
    let v = item.view()
    if !signed && v.length() > 0 && (v[0] == '-' || v[0] == '+') {
      raise bad_rule(rule, "malformed \{key} value: \{item}")
    }
    match parse_signed_int(item) {
      Some(n) =>
        if n == 0 || n.abs() > bound {
          raise bad_rule(rule, "\{key} value out of range: \{item}")
        } else {
          out.push(n)
        }
      None => raise bad_rule(rule, "malformed \{key} value: \{item}")
    }
  }
  out
}

///|
/// A decimal integer with an optional sign. Values longer than nine digits
/// are refused rather than risked through silent wrap-around.
fn parse_signed_int(text : String) -> Int? {
  let v = text.view()
  let n = v.length()
  if n == 0 {
    return None
  }
  let mut i = 0
  let mut sign = 1
  if v[0] == '-' {
    sign = -1
    i = 1
  } else if v[0] == '+' {
    i = 1
  }
  if i >= n {
    return None
  }
  let mut acc = 0
  let mut digits = 0
  while i < n {
    let c = v[i]
    if !(c is ('0'..='9')) {
      return None
    }
    acc = acc * 10 + (c.to_int() - '0'.to_int())
    digits = digits + 1
    i = i + 1
  }
  if digits <= 9 {
    Some(sign * acc)
  } else {
    None
  }
}