// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// Why an expression was refused.
#derive(Eq, Debug)
pub(all) suberror Refused {
  Fields(got~ : Int32)
  Range(field~ : String, got~ : Int32, low~ : Int32, high~ : Int32)
  Malformed(String)
}

///|
pub impl Show for Refused with fn output(self, logger) {
  match self {
    Fields(got~) => logger.write_string("\{got} fields, expected five or six")
    Range(field~, got~, low~, high~) =>
      logger.write_string("\{field} is \{got}, outside \{low}..\{high}")
    Malformed(m) => logger.write_string("malformed: " + m)
  }
}

///|
pub extend Refused with Debug::{to_repr}

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

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

///|
/// A parsed expression: which values of each field it fires on.
///
/// Each field is a bit set, so asking whether a moment matches is six bit tests
/// rather than a search. `star_day` and `star_week` are kept because the two fields
/// interact — see [`Cron::matches`].
#derive(Eq, Debug)
pub(all) struct Cron {
  seconds : Int64
  minutes : Int64
  hours : Int32
  days : Int32
  months : Int32
  weekdays : Int32
  star_day : Bool
  star_week : Bool
}

///|
pub extend Cron with Debug::{to_repr}

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

///|
/// An expression, in the five-field form crontab(5) defines or the six-field form
/// that puts seconds in front.
///
/// ```text
/// ┌───────────── second (0-59, six-field form only)
/// │ ┌───────────── minute (0-59)
/// │ │ ┌───────────── hour (0-23)
/// │ │ │ ┌───────────── day of month (1-31)
/// │ │ │ │ ┌───────────── month (1-12 or JAN-DEC)
/// │ │ │ │ │ ┌───────────── day of week (0-7 or SUN-SAT, both 0 and 7 being Sunday)
/// * * * * * *
/// ```
///
/// Each field takes `*`, a number, a range `a-b`, a step `*/n` or `a-b/n`, and a
/// comma-separated list of those. `?` reads as `*`, which is how a scheduler
/// spells the day field it is not using.
///
/// The shorthands `@yearly`, `@annually`, `@monthly`, `@weekly`, `@daily`,
/// `@midnight` and `@hourly` are the ones crontab(5) lists. `@reboot` is not here:
/// it names an event rather than a time, and this package answers when something
/// fires, not why.
pub fn parse(expr : StringView) -> Cron raise Refused {
  let text = expr.to_owned()
  let short = match trim(text).to_lower() {
    "@yearly" | "@annually" => Some("0 0 1 1 *")
    "@monthly" => Some("0 0 1 * *")
    "@weekly" => Some("0 0 * * 0")
    "@daily" | "@midnight" => Some("0 0 * * *")
    "@hourly" => Some("0 * * * *")
    _ => None
  }
  let fields = split(
    match short {
      Some(s) => s
      None => text
    },
  )
  let (sec, rest) = match fields.length() {
    5 => (bit64(0), 0)
    6 => (field64(fields[0], 0, 59, "second"), 1)
    n => raise Fields(got=n)
  }
  let days = field(fields[rest + 2], 1, 31, "day of month")
  let weekdays = week(fields[rest + 3 + 1])
  {
    seconds: sec,
    minutes: field64(fields[rest], 0, 59, "minute"),
    hours: field(fields[rest + 1], 0, 23, "hour"),
    days,
    months: month(fields[rest + 3]),
    weekdays,
    star_day: is_star(fields[rest + 2]),
    star_week: is_star(fields[rest + 3 + 1]),
  }
}

///|
/// Whether a field is unrestricted, which `?` is too (a scheduler writes it in
/// the day field it is not using).
fn is_star(s : String) -> Bool {
  s == "*" || s == "?"
}

///|
/// Whether `m` is a moment this expression fires on.
///
/// The day-of-month and day-of-week fields are **or**-ed when both are restricted,
/// which is Vixie cron's rule and surprises everyone once: `0 0 13 * 5` is the
/// thirteenth *or* any Friday, not Friday the thirteenth. When one of them is `*`
/// they are and-ed, which is why `0 0 13 * *` is only the thirteenth.
pub fn Cron::matches(self : Cron, m : @moondate.Moment) -> Bool {
  let (date, time) = match m {
    Zoned(date~, time~, ..) | Plain(date~, time~) => (date, time)
    Day(date~) => (date, { hour: 0, minute: 0, second: 0, nano: 0, })
    Clock(_) => return false
  }
  if not64(self.seconds, time.second) ||
    not64(self.minutes, time.minute) ||
    !has(self.hours, time.hour) ||
    !has(self.months, date.month) {
    return false
  }
  let by_day = has(self.days, date.day)
  let by_week = has(self.weekdays, date.weekday())
  if self.star_day && self.star_week {
    true
  } else if self.star_day {
    by_week
  } else if self.star_week {
    by_day
  } else {
    by_day || by_week
  }
}

///|
/// The first moment strictly after `m` that this expression fires on.
///
/// `None` when it never does. An expression can name a day that does not exist —
/// `0 0 30 2 *` is the thirtieth of February — so the search is bounded at four
/// years, which is past any leap-year cycle a real schedule turns on.
pub fn Cron::next(self : Cron, m : @moondate.Moment) -> @moondate.Moment? {
  let mut at = m.plus(@moondate.second)
  // Start on a whole second: a schedule fires on the clock, not between ticks.
  at = floor(at)
  let limit = 4 * 366 * 24 * 60 * 60
  for i = 0; i < limit; i = i + 1 {
    if self.matches(at) {
      return Some(at)
    }
    at = at.plus(@moondate.second)
  }
  None
}

///|
/// The moment with its fraction of a second dropped.
fn floor(m : @moondate.Moment) -> @moondate.Moment {
  match m {
    Zoned(date~, time~, zone~) => Zoned(date~, time={ ..time, nano: 0, }, zone~)
    Plain(date~, time~) => Plain(date~, time={ ..time, nano: 0, })
    other => other
  }
}

// -- fields -------------------------------------------------------------------

///|
/// A field as a bit set over `low..=high`.
fn field(
  s : String,
  low : Int32,
  high : Int32,
  name : String,
) -> Int32 raise Refused {
  let mut out = 0
  for part in split_on(s, ',') {
    let (from, to, step) = span(part, low, high, name)
    for v = from; v <= to; v = v + step {
      out = out | (1 << v)
    }
  }
  out
}

///|
/// The same over a range that needs sixty-four bits.
fn field64(
  s : String,
  low : Int32,
  high : Int32,
  name : String,
) -> Int64 raise Refused {
  let mut out = 0L
  for part in split_on(s, ',') {
    let (from, to, step) = span(part, low, high, name)
    for v = from; v <= to; v = v + step {
      out = out | (1L << v)
    }
  }
  out
}

///|
/// One comma-separated piece as `(from, to, step)`.
fn span(
  s : String,
  low : Int32,
  high : Int32,
  name : String,
) -> (Int32, Int32, Int32) raise Refused {
  let slash = find(s, '/')
  let head = if slash < 0 { s } else { s[0:slash].to_owned() }
  let step = if slash < 0 {
    1
  } else {
    let n = number(s[slash + 1:].to_owned(), name)
    if n < 1 {
      raise Range(field=name + " step", got=n, low=1, high~)
    }
    n
  }
  if is_star(head) {
    return (low, high, step)
  }
  let dash = find_from(head, '-', 1)
  if dash < 0 {
    let v = value(head, low, high, name)
    // `n/step` means from n to the end, which is what every implementation does
    // even though crontab(5) only documents `a-b/n`.
    return if slash < 0 { (v, v, 1) } else { (v, high, step) }
  }
  let from = value(head[0:dash].to_owned(), low, high, name)
  let to = value(head[dash + 1:].to_owned(), low, high, name)
  if to < from {
    raise Malformed("\{name} range runs backwards: \{head}")
  }
  (from, to, step)
}

///|
/// One value, checked against the field's range.
fn value(
  s : String,
  low : Int32,
  high : Int32,
  name : String,
) -> Int32 raise Refused {
  let v = number(s, name)
  if v < low || v > high {
    raise Range(field=name, got=v, low~, high~)
  }
  v
}

///|
/// The month field, which also takes the three-letter names.
fn month(s : String) -> Int32 raise Refused {
  field(names(s, month_names, 1), 1, 12, "month")
}

///|
/// The day-of-week field, which also takes the three-letter names, and where both
/// 0 and 7 are Sunday (crontab(5)).
fn week(s : String) -> Int32 raise Refused {
  let bits = field(names(s, day_names, 0), 0, 7, "day of week")
  if has(bits, 7) {
    (bits | 1) & 0x7f
  } else {
    bits
  }
}

///|
/// Three-letter names replaced by their numbers, so the rest of the parser sees
/// only digits. `base` is the number the first name stands for: months count from
/// one, days of the week from zero.
fn names(s : String, table : Array[String], base : Int32) -> String {
  let out = StringBuilder()
  let upper = s.to_upper()
  let mut i = 0
  while i < upper.length() {
    let mut hit = -1
    if i + 3 <= upper.length() {
      let three = upper[i:i + 3].to_owned()
      for k = 0; k < table.length(); k = k + 1 {
        if table[k] == three {
          hit = k
          break
        }
      }
    }
    if hit >= 0 {
      out.write_string((hit + base).to_string())
      i = i + 3
    } else {
      out.write_char(upper[i].unsafe_to_char())
      i = i + 1
    }
  }
  out.to_string()
}

///|
let month_names : Array[String] = [
  "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
]

///|
let day_names : Array[String] = [
  "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT",
]

// -- text ---------------------------------------------------------------------

///|
/// The expression's fields, split on runs of whitespace.
fn split(s : String) -> Array[String] {
  let out : Array[String] = []
  let sb = StringBuilder()
  for i = 0; i < s.length(); i = i + 1 {
    let c = s[i].to_int()
    if c == 0x20 || c == 0x09 {
      let piece = sb.to_string()
      if piece.length() > 0 {
        out.push(piece)
      }
      sb.reset()
    } else {
      sb.write_char(s[i].unsafe_to_char())
    }
  }
  let piece = sb.to_string()
  if piece.length() > 0 {
    out.push(piece)
  }
  out
}

///|
fn split_on(s : String, c : Char) -> Array[String] {
  let out : Array[String] = []
  let mut start = 0
  for i = 0; i <= s.length(); i = i + 1 {
    if i == s.length() || s[i].to_int() == c.to_int() {
      out.push(s[start:i].to_owned())
      start = i + 1
    }
  }
  out
}

///|
fn find(s : String, c : Char) -> Int32 {
  find_from(s, c, 0)
}

///|
fn find_from(s : String, c : Char, from : Int32) -> Int32 {
  for i = from; i < s.length(); i = i + 1 {
    if s[i].to_int() == c.to_int() {
      return i
    }
  }
  -1
}

///|
fn number(s : String, name : String) -> Int32 raise Refused {
  if s.length() == 0 {
    raise Malformed(name + " has an empty value")
  }
  let mut out = 0
  for i = 0; i < s.length(); i = i + 1 {
    let d = s[i].to_int()
    if d < 0x30 || d > 0x39 {
      raise Malformed(name + " is not a number: " + s)
    }
    out = out * 10 + (d - 0x30)
  }
  out
}

///|
fn trim(s : String) -> String {
  let mut a = 0
  let mut b = s.length()
  while a < b && (s[a].to_int() == 0x20 || s[a].to_int() == 0x09) {
    a = a + 1
  }
  while b > a && (s[b - 1].to_int() == 0x20 || s[b - 1].to_int() == 0x09) {
    b = b - 1
  }
  s[a:b].to_owned()
}

///|
/// A bit set holding only `v`.
fn bit64(v : Int32) -> Int64 {
  1L << v
}

///|
/// Whether `v` is absent from a sixty-four bit set.
fn not64(set : Int64, v : Int32) -> Bool {
  ((set >> v) & 1L) == 0L
}

///|
/// Whether `v` is in a thirty-two bit set.
fn has(set : Int32, v : Int32) -> Bool {
  ((set >> v) & 1) == 1
}