///|
/// A structured date/time value used for feed timestamps.
/// Supports RFC 822 (for RSS ``) and RFC 3339 (for Atom ``/``).
pub struct FeedDate {
  year : Int
  month : Int // 1-12
  day : Int // 1-31
  hour : Int // 0-23
  minute : Int // 0-59
  second : Int // 0-59
  offset_minutes : Int // UTC offset in minutes; 0 = UTC, 540 = +09:00, -300 = -05:00
} derive(Debug, Eq, Compare)

///|
/// Create a `FeedDate` at UTC (offset = 0).
pub fn FeedDate::utc(
  year : Int,
  month : Int,
  day : Int,
  hour? : Int = 0,
  minute? : Int = 0,
  second? : Int = 0,
) -> FeedDate {
  { year, month, day, hour, minute, second, offset_minutes: 0 }
}

///|
/// Create a `FeedDate` with an explicit UTC offset in minutes.
pub fn FeedDate::new(
  year : Int,
  month : Int,
  day : Int,
  hour? : Int = 0,
  minute? : Int = 0,
  second? : Int = 0,
  offset_minutes? : Int = 0,
) -> FeedDate {
  { year, month, day, hour, minute, second, offset_minutes }
}

// Day-of-week names and month abbreviations for RFC 822.

///|
let dow_names : Array[String] = [
  "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat",
]

///|
let month_names : Array[String] = [
  "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]

///|
fn is_leap_year(year : Int) -> Bool {
  year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}

///|
fn days_in_month(year : Int, month : Int) -> Int {
  match month {
    1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
    4 | 6 | 9 | 11 => 30
    2 => if is_leap_year(year) { 29 } else { 28 }
    _ => 0
  }
}

///|
fn date_validation_error(
  year : Int,
  month : Int,
  day : Int,
  hour : Int,
  minute : Int,
  second : Int,
  offset_minutes : Int,
) -> String? {
  if year < 1 || year > 9999 {
    return Some("year out of range: \{year}")
  }
  if month < 1 || month > 12 {
    return Some("month out of range: \{month}")
  }
  let max_day = days_in_month(year, month)
  if day < 1 || day > max_day {
    return Some("day out of range: \{day}")
  }
  if hour < 0 || hour > 23 {
    return Some("hour out of range: \{hour}")
  }
  if minute < 0 || minute > 59 {
    return Some("minute out of range: \{minute}")
  }
  if second < 0 || second > 59 {
    return Some("second out of range: \{second}")
  }
  if offset_minutes < -23 * 60 - 59 || offset_minutes > 23 * 60 + 59 {
    return Some("UTC offset out of range: \{offset_minutes}")
  }
  None
}

///|
fn FeedDate::validation_error(self : FeedDate, field : String) -> FeedError? {
  match
    date_validation_error(
      self.year,
      self.month,
      self.day,
      self.hour,
      self.minute,
      self.second,
      self.offset_minutes,
    ) {
    Some(msg) => Some(InvalidDate("\{field}: \{msg}"))
    None => None
  }
}

///|
fn FeedDate::is_valid(self : FeedDate) -> Bool {
  self.validation_error("date") is None
}

///|
/// Zero-pad an integer to exactly 2 decimal digits.
fn pad2(n : Int) -> String {
  if n < 10 {
    "0" + n.to_string()
  } else {
    n.to_string()
  }
}

///|
/// Compute the day-of-week using Sakamoto's algorithm.
/// Returns 0 = Sunday, 1 = Monday, ..., 6 = Saturday.
/// Uses the LOCAL date (as written, regardless of offset).
fn day_of_week(year : Int, month : Int, day : Int) -> Int {
  let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
  let y = if month < 3 { year - 1 } else { year }
  (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7
}

///|
/// Render as RFC 822 date string, e.g. `"Wed, 02 Oct 2002 13:00:00 GMT"`.
/// When `offset_minutes` is non-zero, uses numeric zone like `"+0900"`.
fn FeedDate::to_rfc822(self : FeedDate) -> String {
  let dow = dow_names[day_of_week(self.year, self.month, self.day)]
  let mon = month_names[self.month - 1]
  let zone = if self.offset_minutes == 0 {
    "GMT"
  } else {
    let abs_min = if self.offset_minutes < 0 {
      -self.offset_minutes
    } else {
      self.offset_minutes
    }
    let sign = if self.offset_minutes < 0 { "-" } else { "+" }
    let h = abs_min / 60
    let m = abs_min % 60
    sign + pad2(h) + pad2(m)
  }
  "\{dow}, \{pad2(self.day)} \{mon} \{self.year} \{pad2(self.hour)}:\{pad2(self.minute)}:\{pad2(self.second)} \{zone}"
}

///|
/// Render as RFC 3339 date string, e.g. `"2002-10-02T13:00:00Z"`.
/// When `offset_minutes` is non-zero, uses `"+HH:MM"` suffix.
fn FeedDate::to_rfc3339(self : FeedDate) -> String {
  let date = "\{self.year}-\{pad2(self.month)}-\{pad2(self.day)}T\{pad2(self.hour)}:\{pad2(self.minute)}:\{pad2(self.second)}"
  if self.offset_minutes == 0 {
    date + "Z"
  } else {
    let abs_min = if self.offset_minutes < 0 {
      -self.offset_minutes
    } else {
      self.offset_minutes
    }
    let sign = if self.offset_minutes < 0 { "-" } else { "+" }
    let h = abs_min / 60
    let m = abs_min % 60
    date + sign + pad2(h) + ":" + pad2(m)
  }
}

///|
fn FeedDate::to_rfc822_checked(self : FeedDate) -> String? {
  if self.is_valid() {
    Some(self.to_rfc822())
  } else {
    None
  }
}

///|
fn FeedDate::to_rfc3339_checked(self : FeedDate) -> String? {
  if self.is_valid() {
    Some(self.to_rfc3339())
  } else {
    None
  }
}

///|
fn optional_date_to_rfc822(date : FeedDate?) -> String? {
  match date {
    Some(d) => d.to_rfc822_checked()
    None => None
  }
}

///|
fn optional_date_to_rfc3339(date : FeedDate?) -> String? {
  match date {
    Some(d) => d.to_rfc3339_checked()
    None => None
  }
}