// tempo — UTC-only date/time library for MoonBit
// Types: Date, Time, DateTime, FixedOffsetDateTime, Duration
// Features: Unix timestamps, RFC 3339 parsing/formatting, arithmetic

///|
/// Error type for all tempo operations.
pub(all) suberror TempoError {
  TempoError(String)
}

///|
pub impl Show for TempoError with fn output(self, logger) {
  match self {
    TempoError(msg) => logger.write_string("TempoError: " + msg)
  }
}

// ─── Core types ──────────────────────────────────────────────────────────────

///|
/// A calendar date in the proleptic Gregorian calendar (UTC).
pub struct Date {
  year : Int
  month : Int // 1–12
  day : Int // 1–31
} derive(Eq, Compare, Hash, Debug)

///|
/// A calendar-aware date period stored as exact year, month, and day fields.
///
/// This is a calendar-field vector, not an elapsed-time scalar. It is never
/// auto-canonicalized: fifteen months stays fifteen months, and thirty days
/// never becomes one month.
pub(all) struct Period {
  years : Int
  months : Int
  days : Int
} derive(Eq, Hash, Debug)

///|
/// A calendar year and month in the proleptic Gregorian calendar.
pub(all) struct YearMonth {
  year : Int
  month : Int // 1–12
} derive(Eq, Compare, Hash, Debug)

///|
/// A calendar date interval with an inclusive end date: `[start, end]`.
///
/// This mirrors NodaTime's DateInterval-vs-Interval convention split:
/// DateInterval is inclusive-end for whole calendar dates, while `Interval` is
/// half-open for instants. Assumes `start <= end`; methods do not validate
/// inverted intervals.
pub(all) struct DateInterval {
  start : Date
  end : Date
} derive(Eq, Debug)

///|
/// ISO 8601 weekday, ordered Monday through Sunday.
pub(all) enum Weekday {
  Monday
  Tuesday
  Wednesday
  Thursday
  Friday
  Saturday
  Sunday
} derive(Eq, Compare, Hash, Debug)

///|
/// ISO 8601 weekday number: Monday = 1 through Sunday = 7.
pub fn Weekday::to_int(self : Weekday) -> Int {
  match self {
    Monday => 1
    Tuesday => 2
    Wednesday => 3
    Thursday => 4
    Friday => 5
    Saturday => 6
    Sunday => 7
  }
}

///|
/// Convert an ISO weekday number (1..7) to a `Weekday`.
pub fn Weekday::from_int(n : Int) -> Weekday? {
  match n {
    1 => Some(Monday)
    2 => Some(Tuesday)
    3 => Some(Wednesday)
    4 => Some(Thursday)
    5 => Some(Friday)
    6 => Some(Saturday)
    7 => Some(Sunday)
    _ => None
  }
}

///|
/// ISO 8601 weekday number: Monday = 1 through Sunday = 7.
pub fn Weekday::number_from_monday(self : Weekday) -> Int {
  self.to_int()
}

///|
/// Sunday-based weekday number: Sunday = 1 through Saturday = 7.
pub fn Weekday::number_from_sunday(self : Weekday) -> Int {
  match self {
    Sunday => 1
    Monday => 2
    Tuesday => 3
    Wednesday => 4
    Thursday => 5
    Friday => 6
    Saturday => 7
  }
}

///|
/// Next weekday, wrapping Sunday to Monday.
pub fn Weekday::next(self : Weekday) -> Weekday {
  match self {
    Monday => Tuesday
    Tuesday => Wednesday
    Wednesday => Thursday
    Thursday => Friday
    Friday => Saturday
    Saturday => Sunday
    Sunday => Monday
  }
}

///|
/// Previous weekday, wrapping Monday to Sunday.
pub fn Weekday::previous(self : Weekday) -> Weekday {
  match self {
    Monday => Sunday
    Tuesday => Monday
    Wednesday => Tuesday
    Thursday => Wednesday
    Friday => Thursday
    Saturday => Friday
    Sunday => Saturday
  }
}

///|
pub impl Show for Weekday with fn output(self, logger) {
  match self {
    Monday => logger.write_string("Monday")
    Tuesday => logger.write_string("Tuesday")
    Wednesday => logger.write_string("Wednesday")
    Thursday => logger.write_string("Thursday")
    Friday => logger.write_string("Friday")
    Saturday => logger.write_string("Saturday")
    Sunday => logger.write_string("Sunday")
  }
}

///|
/// Calendar month, ordered January through December.
pub(all) enum Month {
  January
  February
  March
  April
  May
  June
  July
  August
  September
  October
  November
  December
} derive(Eq, Compare, Hash, Debug)

///|
/// Calendar month number: January = 1 through December = 12.
pub fn Month::to_int(self : Month) -> Int {
  match self {
    January => 1
    February => 2
    March => 3
    April => 4
    May => 5
    June => 6
    July => 7
    August => 8
    September => 9
    October => 10
    November => 11
    December => 12
  }
}

///|
/// Convert a calendar month number (1..12) to a `Month`.
pub fn Month::from_int(n : Int) -> Month? {
  match n {
    1 => Some(January)
    2 => Some(February)
    3 => Some(March)
    4 => Some(April)
    5 => Some(May)
    6 => Some(June)
    7 => Some(July)
    8 => Some(August)
    9 => Some(September)
    10 => Some(October)
    11 => Some(November)
    12 => Some(December)
    _ => None
  }
}

///|
/// Next month, wrapping December to January.
pub fn Month::next(self : Month) -> Month {
  match self {
    January => February
    February => March
    March => April
    April => May
    May => June
    June => July
    July => August
    August => September
    September => October
    October => November
    November => December
    December => January
  }
}

///|
/// Previous month, wrapping January to December.
pub fn Month::previous(self : Month) -> Month {
  match self {
    January => December
    February => January
    March => February
    April => March
    May => April
    June => May
    July => June
    August => July
    September => August
    October => September
    November => October
    December => November
  }
}

///|
/// Number of days in this month for `year`.
pub fn Month::days_in(self : Month, year : Int) -> Int {
  days_in_month(year, self.to_int())
}

///|
pub impl Show for Month with fn output(self, logger) {
  match self {
    January => logger.write_string("January")
    February => logger.write_string("February")
    March => logger.write_string("March")
    April => logger.write_string("April")
    May => logger.write_string("May")
    June => logger.write_string("June")
    July => logger.write_string("July")
    August => logger.write_string("August")
    September => logger.write_string("September")
    October => logger.write_string("October")
    November => logger.write_string("November")
    December => logger.write_string("December")
  }
}

///|
/// A time of day with nanosecond precision (UTC).
pub struct Time {
  hour : Int // 0–23
  minute : Int // 0–59
  second : Int // 0–59
  nanosecond : Int // 0–999_999_999
} derive(Eq, Compare, Hash, Debug)

///|
/// Units supported by `DateTime::truncate_to` and `DateTime::round_to`, ordered
/// smallest to largest.
pub(all) enum TimeUnit {
  Second
  Minute
  Hour
  Day
} derive(Eq, Compare, Hash, Debug)

///|
pub impl Show for TimeUnit with fn output(self, logger) {
  match self {
    Second => logger.write_string("Second")
    Minute => logger.write_string("Minute")
    Hour => logger.write_string("Hour")
    Day => logger.write_string("Day")
  }
}

///|
/// Rounding modes for `DateTime::round_to`, ordered from earlier-boundary
/// preference to nearest-boundary behavior.
pub(all) enum RoundMode {
  Floor
  Ceil
  HalfExpand
} derive(Eq, Compare, Hash, Debug)

///|
pub impl Show for RoundMode with fn output(self, logger) {
  match self {
    Floor => logger.write_string("Floor")
    Ceil => logger.write_string("Ceil")
    HalfExpand => logger.write_string("HalfExpand")
  }
}

///|
/// A combined UTC date and time.
pub struct DateTime {
  date : Date
  time : Time
} derive(Eq, Compare, Hash, Debug)

///|
/// A UTC instant paired with a fixed numeric display offset.
///
/// `DateTime` is tempo's primary timestamp type. Use `FixedOffsetDateTime` only
/// for wire formats and interop surfaces where a timestamp carries an explicit
/// numeric offset but no IANA time zone. The stored instant is normalized UTC;
/// the offset is retained only as a display hint.
pub struct FixedOffsetDateTime {
  priv utc : DateTime
  priv offset_seconds : Int
} derive(Eq, Compare, Hash, Debug)

///|
/// A DateTime interval with a half-open end instant: `[start, end)`.
///
/// This mirrors NodaTime's DateInterval-vs-Interval convention split:
/// `DateInterval` is inclusive-end for calendar dates, while Interval is
/// half-open for instants. Assumes `start <= end`; methods do not validate
/// inverted intervals.
pub(all) struct Interval {
  start : DateTime
  end : DateTime
} derive(Eq, Debug)

///|
/// A signed duration stored as total nanoseconds.
pub struct Duration {
  nanoseconds : Int64
} derive(Eq, Compare, Hash, Debug)

// ─── Calendar helpers ─────────────────────────────────────────────────────────

///|
/// `true` if `year` is a leap year in the proleptic Gregorian calendar.
pub fn is_leap_year(year : Int) -> Bool {
  (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}

///|
/// Number of days in the given month (1–12). Returns `0` for invalid month
/// values outside 1–12.
pub 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
  }
}

// Floor division for Int64 (rounds toward negative infinity).

///|
fn floor_div64(a : Int64, b : Int64) -> Int64 {
  let q = a / b
  if (a < 0L) != (b < 0L) && q * b != a {
    q - 1L
  } else {
    q
  }
}

// Howard Hinnant's civil_from_days: days-since-epoch -> (year, month, day)

///|
fn civil_from_shifted_days(z : Int64) -> (Int64, Int, Int) {
  let era = floor_div64(z, 146097L)
  let doe = (z - era * 146097L).to_int() // 0..146096
  let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 // 0..399
  let y = yoe.to_int64() + era * 400L
  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100) // 0..365
  let mp = (5 * doy + 2) / 153 // 0..11
  let d = doy - (153 * mp + 2) / 5 + 1 // 1..31
  let m = if mp < 10 { mp + 3 } else { mp - 9 } // 1..12
  let y = if m <= 2 { y + 1 } else { y }
  (y, m, d)
}

///|
fn civil_from_days64(z : Int64) -> (Int64, Int, Int) {
  civil_from_shifted_days(z + 719468L)
}

///|
fn civil_from_days(z : Int64) -> (Int, Int, Int) {
  let (y64, m, d) = civil_from_days64(z)
  (y64.to_int(), m, d)
}

///|
fn civil_from_days_checked(z : Int64) -> (Int, Int, Int) raise TempoError {
  let shifted = match checked_i64_add(z, 719468L) {
    Some(shifted) => shifted
    None => raise TempoError("date epoch day overflows Int64")
  }
  let (y64, m, d) = civil_from_shifted_days(shifted)
  let y = int64_to_int_checked(y64, "date year")
  (y, m, d)
}

// Howard Hinnant's days_from_civil: (year, month, day) -> days-since-epoch

///|
fn days_from_civil64(year : Int64, month : Int, day : Int) -> Int64 {
  let y = if month <= 2 { year - 1L } else { year }
  let era = floor_div64(y, 400L)
  let yoe = y - era * 400L // 0..399
  let m_adj = if month > 2 { month - 3 } else { month + 9 }
  let doy = (153 * m_adj + 2) / 5 + day - 1 // 0..365
  let doe = yoe * 365L + yoe / 4L - yoe / 100L + doy.to_int64() // 0..146096
  era * 146097L + doe - 719468L
}

///|
fn days_from_civil(year : Int, month : Int, day : Int) -> Int64 {
  days_from_civil64(year.to_int64(), month, day)
}

// ─── Constructors ─────────────────────────────────────────────────────────────

///|
/// Create a `Date`, validating that `month` is 1–12 and `day` fits the
/// calendar month (accounting for leap years in February).
pub fn Date::new(year : Int, month : Int, day : Int) -> Date raise TempoError {
  if month < 1 || month > 12 {
    raise TempoError("month \{month} out of range 1–12")
  }
  let max_day = days_in_month(year, month)
  if day < 1 || day > max_day {
    raise TempoError("day \{day} out of range for \{year}-\{month}")
  }
  { year, month, day }
}

// ─── Period constructors and arithmetic ──────────────────────────────────────

///|
fn int64_to_int_checked(
  value : Int64,
  context : String,
) -> Int raise TempoError {
  if value < @int.MIN_VALUE.to_int64() || value > @int.MAX_VALUE.to_int64() {
    raise TempoError("\{context} overflows Int")
  }
  value.to_int()
}

///|
fn checked_int_add(a : Int, b : Int, context : String) -> Int raise TempoError {
  int64_to_int_checked(a.to_int64() + b.to_int64(), context)
}

///|
fn checked_int_sub(a : Int, b : Int, context : String) -> Int raise TempoError {
  int64_to_int_checked(a.to_int64() - b.to_int64(), context)
}

///|
fn checked_int_neg(a : Int, context : String) -> Int raise TempoError {
  if a == @int.MIN_VALUE {
    raise TempoError("\{context} overflows Int")
  }
  -a
}

///|
/// Create a `Period`, storing the year, month, and day fields exactly.
pub fn Period::of(years : Int, months : Int, days : Int) -> Period {
  { years, months, days }
}

///|
/// Create a `Period` with only a year field.
pub fn Period::of_years(years : Int) -> Period {
  { years, months: 0, days: 0 }
}

///|
/// Create a `Period` with only a month field.
pub fn Period::of_months(months : Int) -> Period {
  { years: 0, months, days: 0 }
}

///|
/// Create a `Period` with only a day field.
pub fn Period::of_days(days : Int) -> Period {
  { years: 0, months: 0, days }
}

///|
/// Create a `Period` from whole weeks, stored as days.
pub fn Period::of_weeks(weeks : Int) -> Period raise TempoError {
  let days = int64_to_int_checked(weeks.to_int64() * 7L, "period weeks")
  { years: 0, months: 0, days }
}

///|
/// The zero period.
pub fn Period::zero() -> Period {
  { years: 0, months: 0, days: 0 }
}

///|
/// Year field accessor.
pub fn Period::years(self : Period) -> Int {
  self.years
}

///|
/// Month field accessor.
pub fn Period::months(self : Period) -> Int {
  self.months
}

///|
/// Day field accessor.
pub fn Period::days(self : Period) -> Int {
  self.days
}

///|
/// Normalize the month field into years. Days are left untouched.
pub fn Period::normalized(self : Period) -> Period raise TempoError {
  let total_months = self.to_total_months()
  {
    years: int64_to_int_checked(total_months / 12L, "period years"),
    months: int64_to_int_checked(total_months % 12L, "period months"),
    days: self.days,
  }
}

///|
/// Total months represented by the year and month fields. Days are ignored.
pub fn Period::to_total_months(self : Period) -> Int64 {
  self.years.to_int64() * 12L + self.months.to_int64()
}

///|
/// Add two periods field-wise, without normalization.
pub fn Period::plus(self : Period, other : Period) -> Period raise TempoError {
  {
    years: checked_int_add(self.years, other.years, "period years"),
    months: checked_int_add(self.months, other.months, "period months"),
    days: checked_int_add(self.days, other.days, "period days"),
  }
}

///|
/// Subtract two periods field-wise, without normalization.
pub fn Period::minus(self : Period, other : Period) -> Period raise TempoError {
  {
    years: checked_int_sub(self.years, other.years, "period years"),
    months: checked_int_sub(self.months, other.months, "period months"),
    days: checked_int_sub(self.days, other.days, "period days"),
  }
}

///|
/// Negate each period field, without normalization.
pub fn Period::negated(self : Period) -> Period raise TempoError {
  {
    years: checked_int_neg(self.years, "period years"),
    months: checked_int_neg(self.months, "period months"),
    days: checked_int_neg(self.days, "period days"),
  }
}

///|
/// `true` when all three fields are zero.
pub fn Period::is_zero(self : Period) -> Bool {
  self.years == 0 && self.months == 0 && self.days == 0
}

// ─── Additional calendar constructors ────────────────────────────────────────

///|
/// Create a `Date` from `year` and day-of-year in 1..365, or 1..366 for a
/// leap year.
pub fn Date::from_ordinal(
  year : Int,
  day_of_year : Int,
) -> Date raise TempoError {
  let days_in_year = if is_leap_year(year) { 366 } else { 365 }
  if day_of_year < 1 || day_of_year > days_in_year {
    raise TempoError("day_of_year \{day_of_year} out of range for year \{year}")
  }
  Date::new(year, 1, 1).add_days(day_of_year - 1)
}

///|
/// Create a `YearMonth`, validating that `month` is 1–12.
pub fn YearMonth::new(year : Int, month : Int) -> YearMonth raise TempoError {
  if month < 1 || month > 12 {
    raise TempoError("month \{month} out of range 1–12")
  }
  { year, month }
}

// ─── Date arithmetic ──────────────────────────────────────────────────────────

///|
fn shift_year_month(year : Int, month : Int, months : Int64) -> (Int, Int) {
  let total = year.to_int64() * 12L + (month - 1).to_int64() + months
  let new_year = floor_div64(total, 12L).to_int()
  let new_month = floor_mod64(total, 12L).to_int() + 1
  (new_year, new_month)
}

///|
fn Date::add_months64(self : Date, months : Int64) -> Date {
  let (year, month) = shift_year_month(self.year, self.month, months)
  let max_day = days_in_month(year, month)
  let day = if self.day > max_day { max_day } else { self.day }
  { year, month, day }
}

///|
fn checked_shift_year_month(
  year : Int,
  month : Int,
  months : Int64,
) -> (Int, Int) raise TempoError {
  let base = year.to_int64() * 12L + (month - 1).to_int64()
  let total = match checked_i64_add(base, months) {
    Some(total) => total
    None => raise TempoError("date month adjustment overflows Int64")
  }
  let new_year = int64_to_int_checked(
    floor_div64(total, 12L),
    "date month adjustment year",
  )
  let new_month = floor_mod64(total, 12L).to_int() + 1
  (new_year, new_month)
}

///|
fn Date::add_months64_checked(
  self : Date,
  months : Int64,
) -> Date raise TempoError {
  let (year, month) = checked_shift_year_month(self.year, self.month, months)
  let max_day = days_in_month(year, month)
  let day = if self.day > max_day { max_day } else { self.day }
  Date::new(year, month, day)
}

///|
/// Add a signed number of calendar months to this date.
///
/// If the original day does not exist in the target month, the result is
/// clamped to that month's last day. Clamping is non-sticky: 2024-02-28 plus
/// one month is 2024-03-28, not 2024-03-31.
///
/// This infallible operation wraps if the resulting year falls outside the
/// `Int` year envelope. Use `Date::add_months_checked` to get `None` instead.
pub fn Date::add_months(self : Date, months : Int) -> Date {
  self.add_months64(months.to_int64())
}

///|
/// Add a signed number of calendar months, returning `None` if the resulting
/// year cannot be represented by `Date`'s `Int` year field.
pub fn Date::add_months_checked(self : Date, months : Int) -> Date? {
  Some(self.add_months64_checked(months.to_int64())) catch {
    TempoError(_) => None
  }
}

///|
/// Add a signed number of calendar years to this date.
///
/// If the original day does not exist in the target year/month, the result is
/// clamped to that month's last day, so leap day moves to February 28 in
/// non-leap target years.
///
/// This infallible operation wraps if the resulting year falls outside the
/// `Int` year envelope. Use `Date::add_years_checked` to get `None` instead.
pub fn Date::add_years(self : Date, years : Int) -> Date {
  self.add_months64(years.to_int64() * 12L)
}

///|
/// Add a signed number of calendar years, returning `None` if the resulting
/// year cannot be represented by `Date`'s `Int` year field.
pub fn Date::add_years_checked(self : Date, years : Int) -> Date? {
  Some(self.add_months64_checked(years.to_int64() * 12L)) catch {
    TempoError(_) => None
  }
}

///|
/// Add a calendar period to this date.
///
/// Years and months are combined into one month adjustment first, using the
/// same end-of-month clamping as `Date::add_months`; days are added afterward.
pub fn Date::add_period(self : Date, period : Period) -> Date raise TempoError {
  self
  .add_months64_checked(period.to_total_months())
  .add_days_checked_or_raise(period.days)
}

///|
fn YearMonth::plus_months64(self : YearMonth, months : Int64) -> YearMonth {
  let (year, month) = shift_year_month(self.year, self.month, months)
  { year, month }
}

///|
/// Add a signed number of calendar months to this year-month.
pub fn YearMonth::plus_months(self : YearMonth, months : Int) -> YearMonth {
  self.plus_months64(months.to_int64())
}

///|
/// Add a signed number of calendar years to this year-month.
pub fn YearMonth::plus_years(self : YearMonth, years : Int) -> YearMonth {
  self.plus_months64(years.to_int64() * 12L)
}

///|
/// Return a copy of this date with `year` replaced, validating the result.
pub fn Date::with_year(self : Date, year : Int) -> Date raise TempoError {
  Date::new(year, self.month, self.day)
}

///|
/// Return a copy of this date with `month` replaced, validating the result.
///
/// This does not clamp. Chaining with `with_day` can raise on an intermediate
/// invalid date before a later update would make it valid.
pub fn Date::with_month(self : Date, month : Int) -> Date raise TempoError {
  Date::new(self.year, month, self.day)
}

///|
/// Return a copy of this date with `day` replaced, validating the result.
///
/// This does not clamp. Chaining with `with_month` can raise on an intermediate
/// invalid date before a later update would make it valid.
pub fn Date::with_day(self : Date, day : Int) -> Date raise TempoError {
  Date::new(self.year, self.month, day)
}

///|
/// Return the first day of this date's calendar month.
pub fn Date::start_of_month(self : Date) -> Date {
  { ..self, day: 1 }
}

///|
/// Return the last day of this date's calendar month.
pub fn Date::end_of_month(self : Date) -> Date {
  { ..self, day: days_in_month(self.year, self.month) }
}

///|
/// Return the first day of this date's calendar year.
pub fn Date::start_of_year(self : Date) -> Date {
  { ..self, month: 1, day: 1 }
}

///|
/// Return the last day of this date's calendar year.
pub fn Date::end_of_year(self : Date) -> Date {
  { ..self, month: 12, day: 31 }
}

///|
/// Calendar quarter number in 1..4.
pub fn Date::quarter(self : Date) -> Int {
  match self.month {
    1 | 2 | 3 => 1
    4 | 5 | 6 => 2
    7 | 8 | 9 => 3
    10 | 11 | 12 => 4
    _ => abort("Date month out of range")
  }
}

///|
/// `true` if this date's year is a leap year.
pub fn Date::is_leap(self : Date) -> Bool {
  is_leap_year(self.year)
}

///|
/// Number of days in this date's calendar year.
pub fn Date::days_in_year(self : Date) -> Int {
  if self.is_leap() {
    366
  } else {
    365
  }
}

///|
/// Return the first day of this date's calendar quarter.
pub fn Date::start_of_quarter(self : Date) -> Date {
  let month = match self.quarter() {
    1 => 1
    2 => 4
    3 => 7
    4 => 10
    _ => abort("Date::quarter returned out-of-range quarter")
  }
  { ..self, month, day: 1 }
}

///|
/// Return the last day of this date's calendar quarter.
pub fn Date::end_of_quarter(self : Date) -> Date {
  let month = self.start_of_quarter().month + 2
  { ..self, month, day: days_in_month(self.year, month) }
}

///|
/// Add a signed number of calendar days to this date (proleptic Gregorian).
///
/// This infallible operation wraps if the resulting year falls outside the
/// `Int` year envelope. Use `Date::add_days_checked` to get `None` instead.
pub fn Date::add_days(self : Date, days : Int) -> Date {
  let base = days_from_civil(self.year, self.month, self.day)
  let (y, m, d) = civil_from_days(base + days.to_int64())
  { year: y, month: m, day: d }
}

///|
fn Date::add_days_checked_or_raise(
  self : Date,
  days : Int,
) -> Date raise TempoError {
  let base = days_from_civil(self.year, self.month, self.day)
  let target = match checked_i64_add(base, days.to_int64()) {
    Some(target) => target
    None => raise TempoError("date day adjustment overflows Int64")
  }
  let (y, m, d) = civil_from_days_checked(target)
  Date::new(y, m, d)
}

///|
/// Add a signed number of calendar days, returning `None` if the resulting year
/// cannot be represented by `Date`'s `Int` year field.
pub fn Date::add_days_checked(self : Date, days : Int) -> Date? {
  Some(self.add_days_checked_or_raise(days)) catch {
    TempoError(_) => None
  }
}

///|
fn day_of_week_from_epoch_day(d : Int64) -> Int {
  // Align so Monday (1970-01-05, etc.) is remainder 0.
  floor_mod64(d - 4L, 7L).to_int() + 1
}

///|
/// ISO 8601 weekday: Monday = 1 through Sunday = 7.
pub fn Date::day_of_week(self : Date) -> Int {
  let d = days_from_civil(self.year, self.month, self.day)
  day_of_week_from_epoch_day(d)
}

///|
/// ISO 8601 weekday for this date.
pub fn Date::weekday(self : Date) -> Weekday {
  match self.day_of_week() {
    1 => Monday
    2 => Tuesday
    3 => Wednesday
    4 => Thursday
    5 => Friday
    6 => Saturday
    7 => Sunday
    _ => abort("Date::day_of_week returned out-of-range weekday")
  }
}

///|
fn iso_week1_monday_days64(week_year : Int64) -> Int64 {
  let jan4 = days_from_civil64(week_year, 1, 4)
  jan4 + (1 - day_of_week_from_epoch_day(jan4)).to_int64()
}

///|
fn iso_weeks_in_year(week_year : Int) -> Int {
  let jan1 = days_from_civil(week_year, 1, 1)
  let jan1_weekday = day_of_week_from_epoch_day(jan1)
  if jan1_weekday == 4 || (jan1_weekday == 3 && is_leap_year(week_year)) {
    53
  } else {
    52
  }
}

///|
fn Date::iso_week_year64(self : Date) -> Int64 {
  let epoch_day = self.epoch_day()
  let target = epoch_day +
    (4 - day_of_week_from_epoch_day(epoch_day)).to_int64()
  let (year, _, _) = civil_from_days64(target)
  year
}

///|
/// ISO 8601 week-numbering year for this date.
///
/// This can differ from the calendar year near New Year; for example,
/// 2021-01-01 belongs to ISO week year 2020.
///
/// At the absolute `Int` year boundary, the true ISO week-year may be one year
/// outside the representable `Int` range. This infallible accessor wraps in that
/// residual case; `Date::format_iso_week` still formats the true expanded ISO
/// week-year text.
pub fn Date::iso_week_year(self : Date) -> Int {
  self.iso_week_year64().to_int()
}

///|
/// ISO 8601 week number in 1..53 for this date.
pub fn Date::iso_week(self : Date) -> Int {
  let week_year = self.iso_week_year64()
  let week1 = iso_week1_monday_days64(week_year)
  ((self.epoch_day() - week1) / 7L).to_int() + 1
}

///|
/// Create a date from an ISO 8601 week date.
///
/// `week_year` is the ISO week-numbering year, `week` is 1..52 or 1..53
/// depending on that year, and `weekday` is Monday = 1 through Sunday = 7.
/// Raises `TempoError` if the ISO week fields are invalid or if the computed
/// calendar date falls outside `Date`'s representable `Int` year envelope.
pub fn Date::from_iso_week(
  week_year : Int,
  week : Int,
  weekday : Int,
) -> Date raise TempoError {
  let max_week = iso_weeks_in_year(week_year)
  if week < 1 || week > max_week {
    raise TempoError("ISO week \{week} out of range for \{week_year}")
  }
  let wd = match Weekday::from_int(weekday) {
    Some(wd) => wd
    None => raise TempoError("ISO weekday \{weekday} out of range 1–7")
  }
  let days = (week - 1).to_int64() * 7L +
    (wd.number_from_monday() - 1).to_int64()
  let target = match
    checked_i64_add(iso_week1_monday_days64(week_year.to_int64()), days) {
    Some(target) => target
    None => raise TempoError("ISO week date overflows Int64")
  }
  let (year, month, day) = civil_from_days_checked(target)
  Date::new(year, month, day)
}

///|
/// Format this date as an ISO 8601 week date: `YYYY-Www-D`.
///
/// The ISO week-year is computed with `Int64` intermediates, so at the absolute
/// `Int` year boundary the formatted week-year may be the expanded year just
/// outside `Date`'s representable calendar envelope.
pub fn Date::format_iso_week(self : Date) -> String {
  "\{pad4_year64(self.iso_week_year64())}-W\{pad2(self.iso_week())}-\{self.weekday().number_from_monday()}"
}

///|
fn positive_mod7(n : Int) -> Int {
  (n % 7 + 7) % 7
}

///|
/// Nearest date strictly after this date that falls on `wd`.
pub fn Date::next(self : Date, wd : Weekday) -> Date {
  let raw = positive_mod7(wd.to_int() - self.weekday().to_int())
  let delta = if raw == 0 { 7 } else { raw }
  self.add_days(delta)
}

///|
/// Nearest date strictly before this date that falls on `wd`.
pub fn Date::previous(self : Date, wd : Weekday) -> Date {
  let raw = positive_mod7(self.weekday().to_int() - wd.to_int())
  let delta = if raw == 0 { 7 } else { raw }
  self.add_days(-delta)
}

///|
/// This date if it falls on `wd`, otherwise the next such date.
pub fn Date::next_or_same(self : Date, wd : Weekday) -> Date {
  let delta = positive_mod7(wd.to_int() - self.weekday().to_int())
  self.add_days(delta)
}

///|
/// This date if it falls on `wd`, otherwise the previous such date.
pub fn Date::previous_or_same(self : Date, wd : Weekday) -> Date {
  let delta = positive_mod7(self.weekday().to_int() - wd.to_int())
  self.add_days(-delta)
}

///|
/// The nth occurrence of `wd` in `year`/`month`.
///
/// Positive `n` counts from the start of the month; negative `n` counts from
/// the end, so `n == -1` is the last occurrence.
pub fn Date::nth_weekday_of_month(
  year : Int,
  month : Int,
  wd : Weekday,
  n : Int,
) -> Date raise TempoError {
  if n == 0 {
    raise TempoError("weekday occurrence must not be zero")
  }
  if n > 0 {
    let first = Date::new(year, month, 1)
    let offset = positive_mod7(wd.to_int() - first.weekday().to_int())
    let first_match_day = 1 + offset
    let day = first_match_day.to_int64() + 7L * (n.to_int64() - 1L)
    let max_day = days_in_month(year, month).to_int64()
    if day < 1L || day > max_day {
      raise TempoError("weekday occurrence does not exist in month")
    } else {
      Date::new(year, month, day.to_int())
    }
  } else {
    let last_day = days_in_month(year, month)
    let last = Date::new(year, month, last_day)
    let back = positive_mod7(last.weekday().to_int() - wd.to_int())
    let last_match_day = last_day - back
    let day = last_match_day.to_int64() + 7L * (n.to_int64() + 1L)
    let max_day = last_day.to_int64()
    if day < 1L || day > max_day {
      raise TempoError("weekday occurrence does not exist in month")
    } else {
      Date::new(year, month, day.to_int())
    }
  }
}

///|
/// Calendar month for this date.
pub fn Date::month_enum(self : Date) -> Month {
  match Month::from_int(self.month) {
    Some(month) => month
    None => abort("Date month out of range")
  }
}

///|
/// Calendar month for this year-month.
pub fn YearMonth::month_enum(self : YearMonth) -> Month {
  match Month::from_int(self.month) {
    Some(month) => month
    None => abort("YearMonth month out of range")
  }
}

///|
/// Number of days in this year-month.
pub fn YearMonth::length(self : YearMonth) -> Int {
  self.month_enum().days_in(self.year)
}

///|
/// Return the date in this year-month at `day`, validating the day.
pub fn YearMonth::at_day(self : YearMonth, day : Int) -> Date raise TempoError {
  Date::new(self.year, self.month, day)
}

///|
/// Return the last date in this year-month.
pub fn YearMonth::at_end_of_month(self : YearMonth) -> Date {
  { year: self.year, month: self.month, day: self.length() }
}

///|
/// Day of year in 1..366 (1 = January 1).
pub fn Date::day_of_year(self : Date) -> Int {
  let y = self.year
  let start = days_from_civil(y, 1, 1)
  let today = days_from_civil(self.year, self.month, self.day)
  (today - start).to_int() + 1
}

///|
fn Date::epoch_day(self : Date) -> Int64 {
  days_from_civil(self.year, self.month, self.day)
}

///|
fn Date::proleptic_month(self : Date) -> Int64 {
  self.year.to_int64() * 12L + (self.month - 1).to_int64()
}

///|
/// Calendar days from `self` to `other` (`other` minus `self`). Negative if `other` is earlier.
pub fn Date::days_until(self : Date, other : Date) -> Int {
  let a = self.epoch_day()
  let b = other.epoch_day()
  (b - a).to_int()
}

///|
/// Calendar period from this date until `other`.
///
/// The result round-trips through `Date::add_period`, including month-end
/// clamping and the day-borrow behavior used by `java.time.LocalDate.until`.
pub fn Date::until(self : Date, other : Date) -> Period raise TempoError {
  let mut total_months = other.proleptic_month() - self.proleptic_month()
  let provisional_days = other.day - self.day
  if total_months > 0L && provisional_days < 0 {
    total_months -= 1L
  } else if total_months < 0L && provisional_days > 0 {
    total_months += 1L
  }
  let anchor = self.add_months64_checked(total_months)
  let days = int64_to_int_checked(
    other.epoch_day() - anchor.epoch_day(),
    "period days",
  )
  {
    years: int64_to_int_checked(total_months / 12L, "period years"),
    months: int64_to_int_checked(total_months % 12L, "period months"),
    days,
  }
}

///|
/// `true` if this date is earlier than `other`.
pub fn Date::is_before(self : Date, other : Date) -> Bool {
  self.compare(other) < 0
}

///|
/// `true` if this date is later than `other`.
pub fn Date::is_after(self : Date, other : Date) -> Bool {
  self.compare(other) > 0
}

///|
/// Return the earlier of this date and `other`.
pub fn Date::min(self : Date, other : Date) -> Date {
  if self.compare(other) <= 0 {
    self
  } else {
    other
  }
}

///|
/// Return the later of this date and `other`.
pub fn Date::max(self : Date, other : Date) -> Date {
  if self.compare(other) >= 0 {
    self
  } else {
    other
  }
}

///|
/// Clamp this date to the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
pub fn Date::clamp(self : Date, lo : Date, hi : Date) -> Date {
  if self.compare(lo) < 0 {
    lo
  } else if self.compare(hi) > 0 {
    hi
  } else {
    self
  }
}

// ─── DateInterval helpers ────────────────────────────────────────────────────

///|
/// `true` if `d` is in this inclusive-end date interval `[start, end]`.
pub fn DateInterval::contains(self : DateInterval, d : Date) -> Bool {
  self.start <= d && d <= self.end
}

///|
/// `true` if this inclusive-end date interval shares at least one date with
/// `other`.
pub fn DateInterval::overlaps(
  self : DateInterval,
  other : DateInterval,
) -> Bool {
  self.start <= other.end && other.start <= self.end
}

///|
/// Return the inclusive-end overlap between this date interval and `other`.
pub fn DateInterval::intersection(
  self : DateInterval,
  other : DateInterval,
) -> DateInterval? {
  let s = self.start.max(other.start)
  let e = self.end.min(other.end)
  if s <= e {
    Some({ start: s, end: e })
  } else {
    None
  }
}

///|
/// Inclusive day count for this date interval.
pub fn DateInterval::length_in_days(self : DateInterval) -> Int {
  self.start.days_until(self.end) + 1
}

///|
fn floor_mod64(a : Int64, b : Int64) -> Int64 {
  a - floor_div64(a, b) * b
}

///|
/// Parse a calendar date in 'YYYY-MM-DD' format.
pub fn Date::parse(s : String) -> Date raise TempoError {
  let v = s.view()
  let (year, v) = parse_digits(v, 4)
  let v = consume(v, '-')
  let (month, v) = parse_digits(v, 2)
  let v = consume(v, '-')
  let (day, v) = parse_digits(v, 2)
  match v {
    [] => ()
    _ => raise TempoError("unexpected trailing characters")
  }
  Date::new(year, month, day)
}

///|
/// Parse an ISO 8601 ordinal date in 'YYYY-DDD' format.
///
/// This parser is strict: it accepts only an unsigned 4-digit year, a hyphen,
/// exactly 3 digits for the day-of-year, and end-of-input. Expanded-year output
/// from `Date::format_ordinal` is not accepted.
pub fn Date::parse_ordinal(s : String) -> Date raise TempoError {
  let v = s.view()
  let (year, v) = parse_digits(v, 4)
  let v = consume(v, '-')
  let (day_of_year, v) = parse_digits(v, 3)
  match v {
    [] => ()
    _ => raise TempoError("unexpected trailing characters")
  }
  Date::from_ordinal(year, day_of_year)
}

///|
/// Parse an ISO 8601 week date in 'YYYY-Www-D' format.
///
/// The leading year is the ISO week-numbering year, which can differ from the
/// resulting calendar year near New Year. This parser is strict: it accepts
/// only an unsigned 4-digit week-year, a literal `W`, a 2-digit week, a 1-digit
/// weekday, and end-of-input. Expanded-year output from `Date::format_iso_week`
/// is not accepted.
pub fn Date::parse_iso_week(s : String) -> Date raise TempoError {
  let v = s.view()
  let (week_year, v) = parse_digits(v, 4)
  let v = consume(v, '-')
  let v = consume(v, 'W')
  let (week, v) = parse_digits(v, 2)
  let v = consume(v, '-')
  let (weekday, v) = parse_digits(v, 1)
  match v {
    [] => ()
    _ => raise TempoError("unexpected trailing characters")
  }
  Date::from_iso_week(week_year, week, weekday)
}

///|
/// Parse a calendar year-month in 'YYYY-MM' format.
pub fn YearMonth::parse(s : String) -> YearMonth raise TempoError {
  let v = s.view()
  let (year, v) = parse_digits(v, 4)
  let v = consume(v, '-')
  let (month, v) = parse_digits(v, 2)
  match v {
    [] => ()
    _ => raise TempoError("unexpected trailing characters")
  }
  YearMonth::new(year, month)
}

///|
/// Format this date as 'YYYY-MM-DD'.
pub fn Date::format(self : Date) -> String {
  "\{pad4_year(self.year)}-\{pad2(self.month)}-\{pad2(self.day)}"
}

///|
/// Format this date as an ordinal date using ISO 8601 expanded-year output
/// (see `pad4_year`), followed by day-of-year in 001..366.
pub fn Date::format_ordinal(self : Date) -> String {
  "\{pad4_year(self.year)}-\{pad3(self.day_of_year())}"
}

///|
/// Format this year-month using the same year text as `Date::format`, followed
/// by `-` and a zero-padded month. Years in 0..9999 produce `YYYY-MM`; negative
/// years and years >= 10000 produce expanded-year strings that cannot be
/// round-tripped through `YearMonth::parse`.
pub fn YearMonth::format(self : YearMonth) -> String {
  "\{pad4_year(self.year)}-\{pad2(self.month)}"
}

///|
/// Create a `Time`, validating that each field is within its canonical range:
/// `hour` 0–23, `minute` 0–59, `second` 0–59, `nanosecond` 0–999_999_999.
pub fn Time::new(
  hour : Int,
  minute : Int,
  second : Int,
  nanosecond : Int,
) -> Time raise TempoError {
  if hour < 0 || hour > 23 {
    raise TempoError("hour \{hour} out of range 0–23")
  }
  if minute < 0 || minute > 59 {
    raise TempoError("minute \{minute} out of range 0–59")
  }
  if second < 0 || second > 59 {
    raise TempoError("second \{second} out of range 0–59")
  }
  if nanosecond < 0 || nanosecond > 999_999_999 {
    raise TempoError("nanosecond \{nanosecond} out of range 0–999_999_999")
  }
  { hour, minute, second, nanosecond }
}

///|
/// Return a copy of this time with `hour` replaced, validating the result.
pub fn Time::with_hour(self : Time, hour : Int) -> Time raise TempoError {
  Time::new(hour, self.minute, self.second, self.nanosecond)
}

///|
/// Return a copy of this time with `minute` replaced, validating the result.
pub fn Time::with_minute(self : Time, minute : Int) -> Time raise TempoError {
  Time::new(self.hour, minute, self.second, self.nanosecond)
}

///|
/// Return a copy of this time with `second` replaced, validating the result.
pub fn Time::with_second(self : Time, second : Int) -> Time raise TempoError {
  Time::new(self.hour, self.minute, second, self.nanosecond)
}

///|
/// Return a copy of this time with `nanosecond` replaced, validating the result.
pub fn Time::with_nanosecond(
  self : Time,
  nanosecond : Int,
) -> Time raise TempoError {
  Time::new(self.hour, self.minute, self.second, nanosecond)
}

///|
/// `true` if this time is earlier than `other`.
pub fn Time::is_before(self : Time, other : Time) -> Bool {
  self.compare(other) < 0
}

///|
/// `true` if this time is later than `other`.
pub fn Time::is_after(self : Time, other : Time) -> Bool {
  self.compare(other) > 0
}

///|
/// Return the earlier of this time and `other`.
pub fn Time::min(self : Time, other : Time) -> Time {
  if self.compare(other) <= 0 {
    self
  } else {
    other
  }
}

///|
/// Return the later of this time and `other`.
pub fn Time::max(self : Time, other : Time) -> Time {
  if self.compare(other) >= 0 {
    self
  } else {
    other
  }
}

///|
/// Clamp this time to the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
pub fn Time::clamp(self : Time, lo : Time, hi : Time) -> Time {
  if self.compare(lo) < 0 {
    lo
  } else if self.compare(hi) > 0 {
    hi
  } else {
    self
  }
}

///|
/// Create a `DateTime` from a validated `Date` and `Time`.
/// Both arguments should be constructed via `Date::new` / `Time::new` (which
/// validate). This constructor performs no additional checks.
pub fn DateTime::new(date : Date, time : Time) -> DateTime {
  { date, time }
}

///|
/// Return the date component of this `DateTime`.
pub fn DateTime::to_date(self : DateTime) -> Date {
  self.date
}

///|
/// Return the time component of this `DateTime`.
pub fn DateTime::to_time(self : DateTime) -> Time {
  self.time
}

///|
/// Bundle a UTC instant with a fixed numeric display offset in seconds.
///
/// The `utc` argument is the UTC instant and is stored as-is. `DateTime`
/// remains tempo's primary timestamp type; this wrapper is for wire-format
/// interop when a timestamp carries an explicit numeric offset but no IANA
/// time zone.
///
/// Raises `TempoError` when `offset_seconds` is not whole-minute granularity or
/// is outside the RFC 3339 fixed-offset range ±23:59.
pub fn FixedOffsetDateTime::from_datetime_and_offset(
  utc : DateTime,
  offset_seconds : Int,
) -> FixedOffsetDateTime raise TempoError {
  validate_fixed_offset_seconds(offset_seconds)
  { utc, offset_seconds }
}

///|
fn validate_fixed_offset_seconds(offset_seconds : Int) -> Unit raise TempoError {
  if offset_seconds % 60 != 0 {
    raise TempoError("fixed offset must be whole-minute granularity")
  }
  let max_offset_seconds = 23 * 3600 + 59 * 60
  if offset_seconds < -max_offset_seconds || offset_seconds > max_offset_seconds {
    raise TempoError("fixed offset out of RFC 3339 range ±23:59")
  }
}

///|
/// Return the stored UTC instant.
pub fn FixedOffsetDateTime::to_utc(self : FixedOffsetDateTime) -> DateTime {
  self.utc
}

///|
/// Return the retained fixed display offset in seconds.
pub fn FixedOffsetDateTime::offset_seconds(self : FixedOffsetDateTime) -> Int {
  self.offset_seconds
}

///|
/// Return a copy of this `DateTime` with `date` replaced.
pub fn DateTime::with_date(self : DateTime, date : Date) -> DateTime {
  { ..self, date, }
}

///|
/// Return a copy of this `DateTime` with `time` replaced.
pub fn DateTime::with_time(self : DateTime, time : Time) -> DateTime {
  { ..self, time, }
}

///|
/// Return a copy of this `DateTime` with its date year replaced.
pub fn DateTime::with_year(
  self : DateTime,
  year : Int,
) -> DateTime raise TempoError {
  { ..self, date: self.date.with_year(year) }
}

///|
/// Return a copy of this `DateTime` with its date month replaced.
pub fn DateTime::with_month(
  self : DateTime,
  month : Int,
) -> DateTime raise TempoError {
  { ..self, date: self.date.with_month(month) }
}

///|
/// Return a copy of this `DateTime` with its date day replaced.
pub fn DateTime::with_day(
  self : DateTime,
  day : Int,
) -> DateTime raise TempoError {
  { ..self, date: self.date.with_day(day) }
}

///|
/// Return a copy of this `DateTime` with its time hour replaced.
pub fn DateTime::with_hour(
  self : DateTime,
  hour : Int,
) -> DateTime raise TempoError {
  { ..self, time: self.time.with_hour(hour) }
}

///|
/// Return a copy of this `DateTime` with its time minute replaced.
pub fn DateTime::with_minute(
  self : DateTime,
  minute : Int,
) -> DateTime raise TempoError {
  { ..self, time: self.time.with_minute(minute) }
}

///|
/// Return a copy of this `DateTime` with its time second replaced.
pub fn DateTime::with_second(
  self : DateTime,
  second : Int,
) -> DateTime raise TempoError {
  { ..self, time: self.time.with_second(second) }
}

///|
/// Return a copy of this `DateTime` with its time nanosecond replaced.
pub fn DateTime::with_nanosecond(
  self : DateTime,
  nanosecond : Int,
) -> DateTime raise TempoError {
  { ..self, time: self.time.with_nanosecond(nanosecond) }
}

///|
/// The Unix epoch: 1970-01-01T00:00:00Z.
pub fn DateTime::epoch() -> DateTime {
  {
    date: { year: 1970, month: 1, day: 1 },
    time: { hour: 0, minute: 0, second: 0, nanosecond: 0 },
  }
}

///|
/// Return this DateTime at the start of its calendar month.
/// Preserves the time-of-day; rebuild with `DateTime::new` and a zero `Time` if
/// midnight is desired.
pub fn DateTime::start_of_month(self : DateTime) -> DateTime {
  DateTime::new(self.date.start_of_month(), self.time)
}

///|
/// Return this DateTime at the end of its calendar month.
/// Preserves the time-of-day; rebuild with `DateTime::new` and a zero `Time` if
/// midnight is desired.
pub fn DateTime::end_of_month(self : DateTime) -> DateTime {
  DateTime::new(self.date.end_of_month(), self.time)
}

///|
/// Return this DateTime at the start of its calendar year.
/// Preserves the time-of-day; rebuild with `DateTime::new` and a zero `Time` if
/// midnight is desired.
pub fn DateTime::start_of_year(self : DateTime) -> DateTime {
  DateTime::new(self.date.start_of_year(), self.time)
}

///|
/// Return this DateTime at the end of its calendar year.
/// Preserves the time-of-day; rebuild with `DateTime::new` and a zero `Time` if
/// midnight is desired.
pub fn DateTime::end_of_year(self : DateTime) -> DateTime {
  DateTime::new(self.date.end_of_year(), self.time)
}

///|
/// Return this DateTime at the start of its calendar day.
pub fn DateTime::start_of_day(self : DateTime) -> DateTime {
  { ..self, time: { hour: 0, minute: 0, second: 0, nanosecond: 0 } }
}

///|
/// Return this DateTime at the end of its calendar day.
pub fn DateTime::end_of_day(self : DateTime) -> DateTime {
  {
    ..self,
    time: { hour: 23, minute: 59, second: 59, nanosecond: 999_999_999 },
  }
}

///|
/// Floor this DateTime to the requested unit boundary.
///
/// Day truncation is anchored to the calendar day: it returns midnight UTC on
/// the same date, rather than truncating a duration since the Unix epoch.
pub fn DateTime::truncate_to(self : DateTime, unit : TimeUnit) -> DateTime {
  match unit {
    Second => { ..self, time: { ..self.time, nanosecond: 0 } }
    Minute => { ..self, time: { ..self.time, second: 0, nanosecond: 0 } }
    Hour =>
      { ..self, time: { ..self.time, minute: 0, second: 0, nanosecond: 0 } }
    Day => self.start_of_day()
  }
}

///|
/// Round this DateTime to the requested unit boundary.
///
/// `Floor` is equivalent to `truncate_to(unit)`. `Ceil` rounds to the next
/// boundary only when there is a remainder. `HalfExpand` rounds to the nearest
/// boundary, with exact half-unit ties rounded up toward the later boundary.
///
/// Rounding is anchored to the calendar day and carries with `Date::add_days`,
/// avoiding Unix-nanosecond conversion for dates outside the Int64 nanosecond
/// timestamp range.
pub fn DateTime::round_to(
  self : DateTime,
  unit : TimeUnit,
  mode : RoundMode,
) -> DateTime {
  let floored = self.truncate_to(unit)
  let t = self.time
  let unit_ns = match unit {
    Second => ns_per_sec
    Minute => ns_per_min
    Hour => ns_per_hour
    Day => 86_400L * ns_per_sec
  }
  let rem_ns = match unit {
    Second => t.nanosecond.to_int64()
    Minute => t.second.to_int64() * ns_per_sec + t.nanosecond.to_int64()
    Hour =>
      (t.minute.to_int64() * 60L + t.second.to_int64()) * ns_per_sec +
      t.nanosecond.to_int64()
    Day =>
      (
        t.hour.to_int64() * 3600L +
        t.minute.to_int64() * 60L +
        t.second.to_int64()
      ) *
      ns_per_sec +
      t.nanosecond.to_int64()
  }
  let round_up = match mode {
    Floor => false
    Ceil => rem_ns > 0L
    HalfExpand => rem_ns * 2L >= unit_ns
  }
  if !round_up {
    floored
  } else {
    let unit_secs = match unit {
      Second => 1
      Minute => 60
      Hour => 3600
      Day => 86_400
    }
    let ft = floored.time
    let sod = ft.hour * 3600 + ft.minute * 60 + ft.second
    let total = sod + unit_secs
    let day_carry = total / 86_400
    let r = total % 86_400
    {
      date: floored.date.add_days(day_carry),
      time: {
        hour: r / 3600,
        minute: r % 3600 / 60,
        second: r % 60,
        nanosecond: 0,
      },
    }
  }
}

// ─── Unix timestamp conversion ────────────────────────────────────────────────

///|
/// Convert a Unix timestamp (seconds since 1970-01-01T00:00:00Z) to a `DateTime`.
/// Negative values represent dates before the Unix epoch. The year range is
/// limited by what `Int64` can represent as days (~±100 billion years).
pub fn DateTime::from_unix_seconds(ts : Int64) -> DateTime {
  let secs_per_day = 86400L
  let days = floor_div64(ts, secs_per_day)
  let sod = (ts - days * secs_per_day).to_int() // 0..86399
  let (year, month, day) = civil_from_days(days)
  let hour = sod / 3600
  let minute = sod % 3600 / 60
  let second = sod % 60
  { date: { year, month, day }, time: { hour, minute, second, nanosecond: 0 } }
}

///|
/// Convert a Unix timestamp in nanoseconds to a `DateTime`.
/// Nanosecond resolution is preserved. Negative values represent dates before
/// the Unix epoch.
pub fn DateTime::from_unix_nanos(ns : Int64) -> DateTime {
  let ts_sec = floor_div64(ns, ns_per_sec)
  let nanosecond = (ns - ts_sec * ns_per_sec).to_int()
  let dt = DateTime::from_unix_seconds(ts_sec)
  { ..dt, time: { ..dt.time, nanosecond, } }
}

///|
/// Convert a Unix timestamp in milliseconds to a `DateTime`.
/// Negative values represent dates before the Unix epoch.
pub fn DateTime::from_unix_millis(ms : Int64) -> DateTime {
  let ts_sec = floor_div64(ms, 1000L)
  let nanosecond = ((ms - ts_sec * 1000L) * ns_per_ms).to_int()
  let dt = DateTime::from_unix_seconds(ts_sec)
  { ..dt, time: { ..dt.time, nanosecond, } }
}

///|
/// Convert a Unix timestamp in microseconds to a `DateTime`.
/// Negative values represent dates before the Unix epoch.
pub fn DateTime::from_unix_micros(us : Int64) -> DateTime {
  let ts_sec = floor_div64(us, 1_000_000L)
  let nanosecond = ((us - ts_sec * 1_000_000L) * ns_per_us).to_int()
  let dt = DateTime::from_unix_seconds(ts_sec)
  { ..dt, time: { ..dt.time, nanosecond, } }
}

///|
/// Convert this DateTime to a Unix timestamp in seconds (nanoseconds truncated).
pub fn DateTime::to_unix_seconds(self : DateTime) -> Int64 {
  let days = days_from_civil(self.date.year, self.date.month, self.date.day)
  let t = self.time
  let sod = (t.hour * 3600 + t.minute * 60 + t.second).to_int64()
  days * 86400L + sod
}

///|
/// Convert this `DateTime` to a Unix timestamp in nanoseconds.
///
/// **Overflow note:** this method silently wraps on `Int64` overflow. Use
/// `to_unix_nanos_checked` when dates outside the representable nanosecond
/// range should return `None`; use `to_unix_seconds` for wider ranges.
pub fn DateTime::to_unix_nanos(self : DateTime) -> Int64 {
  self.to_unix_seconds() * 1_000_000_000L + self.time.nanosecond.to_int64()
}

///|
/// Minimum Unix timestamp in nanoseconds representable by an `Int64`.
pub fn DateTime::min_unix_nanos() -> Int64 {
  int64_min_value
}

///|
/// Maximum Unix timestamp in nanoseconds representable by an `Int64`.
pub fn DateTime::max_unix_nanos() -> Int64 {
  int64_max_value
}

///|
/// Convert this `DateTime` to a Unix timestamp in nanoseconds.
///
/// Returns `None` when the instant is outside the representable `Int64`
/// nanosecond range.
pub fn DateTime::to_unix_nanos_checked(self : DateTime) -> Int64? {
  let sec = self.to_unix_seconds()
  let ns = self.time.nanosecond.to_int64()
  let min_sec = floor_div64(int64_min_value, ns_per_sec)
  let min_ns_remainder = int64_min_value % ns_per_sec
  let min_ns = if min_ns_remainder < 0L {
    min_ns_remainder + ns_per_sec
  } else {
    min_ns_remainder
  }
  let max_sec = int64_max_value / ns_per_sec
  let max_ns = int64_max_value % ns_per_sec
  if sec < min_sec ||
    sec > max_sec ||
    (sec == min_sec && ns < min_ns) ||
    (sec == max_sec && ns > max_ns) {
    None
  } else if sec == min_sec {
    Some(int64_min_value + (ns - min_ns))
  } else {
    let prod = sec * ns_per_sec
    if sec != 0L && prod / ns_per_sec != sec {
      None
    } else if prod > int64_max_value - ns {
      None
    } else {
      Some(prod + ns)
    }
  }
}

///|
/// Convert this `DateTime` to a Unix timestamp in milliseconds.
pub fn DateTime::to_unix_millis(self : DateTime) -> Int64 {
  self.to_unix_seconds() * 1000L + (self.time.nanosecond / 1_000_000).to_int64()
}

///|
/// Convert this `DateTime` to a Unix timestamp in microseconds.
pub fn DateTime::to_unix_micros(self : DateTime) -> Int64 {
  self.to_unix_seconds() * 1_000_000L +
  (self.time.nanosecond / 1_000).to_int64()
}

// ─── Duration constructors ────────────────────────────────────────────────────

///|
let ns_per_us : Int64 = 1_000L

///|
let ns_per_ms : Int64 = 1_000_000L

///|
let ns_per_sec : Int64 = 1_000_000_000L

///|
let ns_per_min : Int64 = 60_000_000_000L

///|
let ns_per_hour : Int64 = 3_600_000_000_000L

///|
let ns_per_day : Int64 = 86_400_000_000_000L

///|
let int64_min_value : Int64 = -9_223_372_036_854_775_808L

///|
let int64_max_value : Int64 = 9_223_372_036_854_775_807L

///|
fn checked_i64_add(a : Int64, b : Int64) -> Int64? {
  if (b > 0L && a > int64_max_value - b) || (b < 0L && a < int64_min_value - b) {
    None
  } else {
    Some(a + b)
  }
}

///|
fn checked_i64_sub(a : Int64, b : Int64) -> Int64? {
  if (b < 0L && a > int64_max_value + b) || (b > 0L && a < int64_min_value + b) {
    None
  } else {
    Some(a - b)
  }
}

///|
/// Create a Duration from a number of nanoseconds.
pub fn Duration::nanoseconds(ns : Int64) -> Duration {
  { nanoseconds: ns }
}

///|
/// Create a Duration representing the given number of days (86 400 seconds each).
pub fn Duration::days(d : Int64) -> Duration {
  { nanoseconds: d * 86_400L * ns_per_sec }
}

///|
/// Create a Duration representing the given number of weeks (7 × 86 400 seconds).
pub fn Duration::weeks(w : Int64) -> Duration {
  { nanoseconds: w * 7L * 86_400L * ns_per_sec }
}

///|
/// Create a Duration representing the given number of microseconds.
pub fn Duration::microseconds(us : Int64) -> Duration {
  { nanoseconds: us * ns_per_us }
}

///|
/// Create a Duration representing the given number of milliseconds.
pub fn Duration::milliseconds(ms : Int64) -> Duration {
  { nanoseconds: ms * ns_per_ms }
}

///|
/// Create a Duration representing the given number of seconds.
pub fn Duration::seconds(s : Int64) -> Duration {
  { nanoseconds: s * ns_per_sec }
}

///|
/// Create a Duration representing the given number of minutes.
pub fn Duration::minutes(m : Int64) -> Duration {
  { nanoseconds: m * ns_per_min }
}

///|
/// Create a Duration representing the given number of hours.
pub fn Duration::hours(h : Int64) -> Duration {
  { nanoseconds: h * ns_per_hour }
}

// ─── Duration accessors ───────────────────────────────────────────────────────

///|
/// Total nanoseconds in this duration.
pub fn Duration::as_nanoseconds(self : Duration) -> Int64 {
  self.nanoseconds
}

///|
/// Total whole microseconds in this duration (truncated toward zero).
pub fn Duration::as_microseconds(self : Duration) -> Int64 {
  self.nanoseconds / ns_per_us
}

///|
/// Total whole milliseconds in this duration (truncated toward zero).
pub fn Duration::as_milliseconds(self : Duration) -> Int64 {
  self.nanoseconds / ns_per_ms
}

///|
/// Total whole seconds in this duration (truncated toward zero).
pub fn Duration::as_seconds(self : Duration) -> Int64 {
  self.nanoseconds / ns_per_sec
}

///|
/// Total seconds in this duration as a `Double`.
///
/// `Double` has a ~53-bit mantissa, so magnitudes beyond ~2^53 nanoseconds
/// (~104 days) lose sub-unit precision.
pub fn Duration::as_seconds_f64(self : Duration) -> Double {
  self.nanoseconds.to_double() / ns_per_sec.to_double()
}

///|
/// Total whole minutes in this duration (truncated toward zero).
pub fn Duration::as_minutes(self : Duration) -> Int64 {
  self.nanoseconds / ns_per_min
}

///|
/// Total minutes in this duration as a `Double`.
///
/// `Double` has a ~53-bit mantissa, so magnitudes beyond ~2^53 nanoseconds
/// (~104 days) lose sub-unit precision.
pub fn Duration::as_minutes_f64(self : Duration) -> Double {
  self.nanoseconds.to_double() / ns_per_min.to_double()
}

///|
/// Total whole hours in this duration (truncated toward zero).
pub fn Duration::as_hours(self : Duration) -> Int64 {
  self.nanoseconds / ns_per_hour
}

///|
/// Total hours in this duration as a `Double`.
///
/// `Double` has a ~53-bit mantissa, so magnitudes beyond ~2^53 nanoseconds
/// (~104 days) lose sub-unit precision.
pub fn Duration::as_hours_f64(self : Duration) -> Double {
  self.nanoseconds.to_double() / ns_per_hour.to_double()
}

///|
/// Total whole days in this duration (truncated toward zero).
pub fn Duration::as_days(self : Duration) -> Int64 {
  self.nanoseconds / (86_400L * ns_per_sec)
}

///|
pub fn Duration::as_weeks(self : Duration) -> Int64 {
  self.nanoseconds / (7L * 86_400L * ns_per_sec)
}

///|
/// `true` when this duration is exactly zero.
pub fn Duration::is_zero(self : Duration) -> Bool {
  self.nanoseconds == 0L
}

///|
/// `true` when this duration is negative.
pub fn Duration::is_negative(self : Duration) -> Bool {
  self.nanoseconds < 0L
}

///|
/// Absolute value of this duration.
///
/// `Int64::min_value` cannot be negated as an `Int64`, so that edge saturates
/// to `Int64::max_value` instead of raising or wrapping.
pub fn Duration::abs(self : Duration) -> Duration {
  if self.nanoseconds == int64_min_value {
    { nanoseconds: int64_max_value }
  } else if self.nanoseconds < 0L {
    { nanoseconds: -self.nanoseconds }
  } else {
    self
  }
}

///|
/// `true` when this duration is greater than zero.
pub fn Duration::is_positive(self : Duration) -> Bool {
  self.nanoseconds > 0L
}

///|
/// Sign of this duration: `-1` for negative, `0` for zero, `1` for positive.
pub fn Duration::signum(self : Duration) -> Int {
  if self.nanoseconds < 0L {
    -1
  } else if self.nanoseconds > 0L {
    1
  } else {
    0
  }
}

// ─── Duration arithmetic ──────────────────────────────────────────────────────

///|
/// Multiply this duration by an `Int64` scalar.
///
/// This wraps on `Int64` overflow. Use `checked_multiply` when overflow should
/// return `None`.
pub fn Duration::multiply(self : Duration, n : Int64) -> Duration {
  { nanoseconds: self.nanoseconds * n }
}

///|
/// Multiply this duration by an `Int64` scalar, returning `None` on overflow.
pub fn Duration::checked_multiply(self : Duration, n : Int64) -> Duration? {
  if n == 0L {
    Some({ nanoseconds: 0L })
  } else {
    let product = self.nanoseconds * n
    if product == int64_min_value && n == -1L {
      None
    } else if product / n == self.nanoseconds {
      Some({ nanoseconds: product })
    } else {
      None
    }
  }
}

///|
/// Divide this duration by an `Int64` scalar, truncating toward zero.
///
/// Division by zero raises `TempoError`. The `Int64::min_value / -1` overflow
/// edge also raises `TempoError` instead of trapping on backends where signed
/// division overflow is a runtime error.
pub fn Duration::divide(
  self : Duration,
  n : Int64,
) -> Duration raise TempoError {
  if n == 0L {
    raise TempoError("duration division by zero")
  }
  if self.nanoseconds == int64_min_value && n == -1L {
    raise TempoError("duration division overflow")
  }
  { nanoseconds: self.nanoseconds / n }
}

///|
/// Add two durations.
///
/// This wraps on `Int64` overflow. Use `checked_add` when overflow should
/// return `None`.
pub impl Add for Duration with fn add(self, other : Duration) -> Duration {
  { nanoseconds: self.nanoseconds + other.nanoseconds }
}

///|
/// Subtract one duration from another.
///
/// This wraps on `Int64` overflow. Use `checked_sub` when overflow should
/// return `None`.
pub impl Sub for Duration with fn sub(self, other : Duration) -> Duration {
  { nanoseconds: self.nanoseconds - other.nanoseconds }
}

///|
/// Add two durations, returning `None` on `Int64` overflow.
pub fn Duration::checked_add(self : Duration, other : Duration) -> Duration? {
  match checked_i64_add(self.nanoseconds, other.nanoseconds) {
    Some(ns) => Some(Duration::nanoseconds(ns))
    None => None
  }
}

///|
/// Subtract one duration from another, returning `None` on `Int64` overflow.
pub fn Duration::checked_sub(self : Duration, other : Duration) -> Duration? {
  match checked_i64_sub(self.nanoseconds, other.nanoseconds) {
    Some(ns) => Some(Duration::nanoseconds(ns))
    None => None
  }
}

///|
pub impl Neg for Duration with fn neg(self) -> Duration {
  { nanoseconds: -self.nanoseconds }
}

// ─── DateTime arithmetic ──────────────────────────────────────────────────────

///|
/// Add a Duration to this DateTime.
///
/// This wraps on `Int64` overflow. Use `checked_add` when overflow or a
/// DateTime outside the representable Unix-nanoseconds range should return
/// `None`.
pub fn DateTime::add(self : DateTime, d : Duration) -> DateTime {
  DateTime::from_unix_nanos(self.to_unix_nanos() + d.nanoseconds)
}

///|
/// Subtract a Duration from this DateTime.
///
/// This wraps on `Int64` overflow. Use `checked_sub` when overflow or a
/// DateTime outside the representable Unix-nanoseconds range should return
/// `None`.
pub fn DateTime::sub(self : DateTime, d : Duration) -> DateTime {
  DateTime::from_unix_nanos(self.to_unix_nanos() - d.nanoseconds)
}

///|
/// Compute `self - other` as a Duration (may be negative).
///
/// This wraps on `Int64` overflow. Use `checked_diff` when overflow or a
/// DateTime outside the representable Unix-nanoseconds range should return
/// `None`.
pub fn DateTime::diff(self : DateTime, other : DateTime) -> Duration {
  { nanoseconds: self.to_unix_nanos() - other.to_unix_nanos() }
}

///|
/// Add a Duration to this DateTime, returning `None` on `Int64` overflow or
/// when this DateTime is outside the representable Unix-nanoseconds range.
pub fn DateTime::checked_add(self : DateTime, d : Duration) -> DateTime? {
  match self.to_unix_nanos_checked() {
    Some(ns) =>
      match checked_i64_add(ns, d.nanoseconds) {
        Some(result) => Some(DateTime::from_unix_nanos(result))
        None => None
      }
    None => None
  }
}

///|
/// Subtract a Duration from this DateTime, returning `None` on `Int64` overflow
/// or when this DateTime is outside the representable Unix-nanoseconds range.
pub fn DateTime::checked_sub(self : DateTime, d : Duration) -> DateTime? {
  match self.to_unix_nanos_checked() {
    Some(ns) =>
      match checked_i64_sub(ns, d.nanoseconds) {
        Some(result) => Some(DateTime::from_unix_nanos(result))
        None => None
      }
    None => None
  }
}

///|
/// Compute `self - other` as a Duration, returning `None` on `Int64` overflow
/// or when either DateTime is outside the representable Unix-nanoseconds range.
pub fn DateTime::checked_diff(self : DateTime, other : DateTime) -> Duration? {
  match (self.to_unix_nanos_checked(), other.to_unix_nanos_checked()) {
    (Some(a), Some(b)) =>
      match checked_i64_sub(a, b) {
        Some(ns) => Some(Duration::nanoseconds(ns))
        None => None
      }
    _ => None
  }
}

///|
/// `true` if this DateTime is earlier than `other`.
pub fn DateTime::is_before(self : DateTime, other : DateTime) -> Bool {
  self.compare(other) < 0
}

///|
/// `true` if this DateTime is later than `other`.
pub fn DateTime::is_after(self : DateTime, other : DateTime) -> Bool {
  self.compare(other) > 0
}

///|
/// Return the earlier of this DateTime and `other`.
pub fn DateTime::min(self : DateTime, other : DateTime) -> DateTime {
  if self.compare(other) <= 0 {
    self
  } else {
    other
  }
}

///|
/// Return the later of this DateTime and `other`.
pub fn DateTime::max(self : DateTime, other : DateTime) -> DateTime {
  if self.compare(other) >= 0 {
    self
  } else {
    other
  }
}

///|
/// Clamp this DateTime to the inclusive range `[lo, hi]`. Assumes `lo <= hi`.
pub fn DateTime::clamp(
  self : DateTime,
  lo : DateTime,
  hi : DateTime,
) -> DateTime {
  if self.compare(lo) < 0 {
    lo
  } else if self.compare(hi) > 0 {
    hi
  } else {
    self
  }
}

// ─── Interval helpers ────────────────────────────────────────────────────────

///|
/// `true` if `dt` is in this half-open DateTime interval `[start, end)`.
pub fn Interval::contains(self : Interval, dt : DateTime) -> Bool {
  self.start <= dt && dt < self.end
}

///|
/// `true` if this half-open DateTime interval shares at least one instant with
/// `other`.
pub fn Interval::overlaps(self : Interval, other : Interval) -> Bool {
  self.start < other.end && other.start < self.end
}

///|
/// Return the half-open overlap between this DateTime interval and `other`.
pub fn Interval::intersection(self : Interval, other : Interval) -> Interval? {
  let s = self.start.max(other.start)
  let e = self.end.min(other.end)
  if s < e {
    Some({ start: s, end: e })
  } else {
    None
  }
}

///|
/// Duration of this half-open DateTime interval (`end - start`).
pub fn Interval::to_duration(self : Interval) -> Duration {
  self.end.diff(self.start)
}

///|
/// Wall-clock duration elapsed since `earlier` (`self - earlier`).
pub fn DateTime::since(self : DateTime, earlier : DateTime) -> Duration {
  self.diff(earlier)
}

///|
/// Wall-clock duration until `later` (`later - self`).
pub fn DateTime::until(self : DateTime, later : DateTime) -> Duration {
  later.diff(self)
}

// ─── Formatting ───────────────────────────────────────────────────────────────

// Zero-pad an integer to 2 digits.

///|
fn pad2(n : Int) -> String {
  if n < 10 {
    "0\{n}"
  } else {
    "\{n}"
  }
}

// Zero-pad an integer to 3 digits.

///|
fn pad3(n : Int) -> String {
  if n < 10 {
    "00\{n}"
  } else if n < 100 {
    "0\{n}"
  } else {
    "\{n}"
  }
}

// Proleptic Gregorian year for text output: optional leading `-` for years
// before 1 CE, then the absolute year number zero-padded to at least four
// decimal digits. Years with |year| >= 10000 use more
// than four digits. RFC 3339 `date-time` only allows four-digit positive years;
// this formatting follows ISO 8601-style expanded years for arbitrary `Date`
// values.

///|
fn pad4_i64(n : Int64) -> String {
  if n < 10L {
    "000\{n}"
  } else if n < 100L {
    "00\{n}"
  } else if n < 1000L {
    "0\{n}"
  } else {
    "\{n}"
  }
}

///|
fn pad4_year64(year : Int64) -> String {
  if year < 0L {
    "-" + pad4_i64(-year)
  } else {
    pad4_i64(year)
  }
}

///|
fn pad4_year(year : Int) -> String {
  pad4_year64(year.to_int64())
}

///|
fn pad9(n : Int) -> String {
  if n < 10 {
    "00000000\{n}"
  } else if n < 100 {
    "0000000\{n}"
  } else if n < 1000 {
    "000000\{n}"
  } else if n < 10000 {
    "00000\{n}"
  } else if n < 100000 {
    "0000\{n}"
  } else if n < 1000000 {
    "000\{n}"
  } else if n < 10000000 {
    "00\{n}"
  } else if n < 100000000 {
    "0\{n}"
  } else {
    "\{n}"
  }
}

// Format nanoseconds as a fractional-seconds string, trimming trailing zeros.

///|
fn fmt_frac(ns : Int) -> String {
  let s = pad9(ns)
  let mut end = s.length()
  while end > 1 && s[end - 1] == '0' {
    end -= 1
  }
  s[:end].to_owned()
}

///|
/// Format this DateTime as an RFC 3339-style string (UTC, `Z` suffix) when the
/// year is in the usual four-digit range; negative years and years ≥ 10000 use
/// ISO 8601 expanded-year conventions (see `pad4_year`). Sub-second precision
/// is included only when nanoseconds ≠ 0.
/// Note: expanded-year output (years outside 0000–9999) cannot be round-tripped
/// through `DateTime::parse`, which accepts only 4-digit positive years (RFC 3339).
pub fn DateTime::format(self : DateTime) -> String {
  format_datetime_without_zone(self) + "Z"
}

///|
/// Format this DateTime with a brace-token pattern.
///
/// Supported tokens are `{YYYY}`, `{MM}`, `{DD}`, `{HH}`, `{mm}`, `{ss}`,
/// `{fff}`, and `{nnnnnnnnn}`. Non-brace text outside tokens is copied
/// literally. Use `{{` and `}}` to emit literal braces. Unknown tokens and
/// unmatched braces raise `TempoError`.
pub fn DateTime::format_with(
  self : DateTime,
  pattern : String,
) -> String raise TempoError {
  let buf = StringBuilder()
  let len = pattern.length()
  let mut i = 0
  while i < len {
    if pattern[i] == '{' {
      if i + 1 < len && pattern[i + 1] == '{' {
        buf.write_string("{")
        i += 2
      } else {
        let start = i + 1
        let mut end = start
        while end < len && pattern[end] != '}' {
          end += 1
        }
        if end >= len {
          raise TempoError("unterminated datetime format token")
        }
        let token = pattern[start:end].to_owned()
        buf.write_string(format_datetime_pattern_token(self, token))
        i = end + 1
      }
    } else if pattern[i] == '}' {
      if i + 1 < len && pattern[i + 1] == '}' {
        buf.write_string("}")
        i += 2
      } else {
        raise TempoError("unmatched closing brace in datetime format pattern")
      }
    } else {
      buf.write_string(pattern[i:i + 1].to_owned())
      i += 1
    }
  }
  buf.to_string()
}

///|
fn format_datetime_pattern_token(
  dt : DateTime,
  token : String,
) -> String raise TempoError {
  let d = dt.date
  let t = dt.time
  match token {
    "YYYY" => pad4_year(d.year)
    "MM" => pad2(d.month)
    "DD" => pad2(d.day)
    "HH" => pad2(t.hour)
    "mm" => pad2(t.minute)
    "ss" => pad2(t.second)
    "fff" => pad3(t.nanosecond / 1_000_000)
    "nnnnnnnnn" => pad9(t.nanosecond)
    _ => raise TempoError("unknown datetime format token {\{token}}")
  }
}

///|
/// Format this DateTime as a UTC timestamp with a fixed-width fractional
/// second; for years 0..9999, the whole output is fixed-width and
/// lexicographically sortable: `YYYY-MM-DDTHH:MM:SS.nnnnnnnnnZ`.
///
/// The fractional-second field is always present with exactly 9 nanosecond
/// digits. For years 0..9999, byte-for-byte string comparison of two
/// `format_fixed` outputs matches chronological order. Outside that year range,
/// expanded or negative year text (especially a leading `-`) does not preserve
/// that lexicographic ordering guarantee.
pub fn DateTime::format_fixed(self : DateTime) -> String {
  let d = self.date
  let t = self.time
  "\{pad4_year(d.year)}-\{pad2(d.month)}-\{pad2(d.day)}T\{pad2(t.hour)}:\{pad2(t.minute)}:\{pad2(t.second)}.\{pad9(t.nanosecond)}Z"
}

///|
fn format_datetime_without_zone(dt : DateTime) -> String {
  let d = dt.date
  let t = dt.time
  let base = "\{pad4_year(d.year)}-\{pad2(d.month)}-\{pad2(d.day)}T\{pad2(t.hour)}:\{pad2(t.minute)}:\{pad2(t.second)}"
  if t.nanosecond == 0 {
    base
  } else {
    base + "." + fmt_frac(t.nanosecond)
  }
}

///|
fn shift_datetime_by_seconds(dt : DateTime, offset_seconds : Int) -> DateTime {
  let t = dt.time
  let seconds_of_day = t.hour * 3600 + t.minute * 60 + t.second
  let total_seconds = seconds_of_day.to_int64() + offset_seconds.to_int64()
  let day_delta = floor_div64(total_seconds, 86400L).to_int()
  let local_second = floor_mod64(total_seconds, 86400L).to_int()
  {
    date: dt.date.add_days(day_delta),
    time: {
      hour: local_second / 3600,
      minute: local_second % 3600 / 60,
      second: local_second % 60,
      nanosecond: t.nanosecond,
    },
  }
}

///|
fn format_offset_suffix(offset_seconds : Int) -> String {
  if offset_seconds == 0 {
    "Z"
  } else {
    let offset = offset_seconds.to_int64()
    let sign = if offset < 0L { "-" } else { "+" }
    let abs_seconds = if offset < 0L { -offset } else { offset }
    let total_minutes = abs_seconds / 60L
    let hours = (total_minutes / 60L).to_int()
    let minutes = (total_minutes % 60L).to_int()
    "\{sign}\{pad2(hours)}:\{pad2(minutes)}"
  }
}

///|
/// Format the local wall-clock time with the retained fixed offset.
///
/// The local wall-clock is computed as the stored UTC instant plus
/// `offset_seconds`; the fixed offset suffix is `Z` for zero and `+HH:MM` /
/// `-HH:MM` otherwise. This is for RFC 3339-style interop only; `DateTime`
/// remains the primary UTC timestamp type.
///
/// Note: formatting uses the same infallible day arithmetic as `Date`; if the
/// local-time projection carries past the absolute `Int` year envelope, the
/// year wraps. `pad4_year` itself handles the full `Int` range and does not
/// abort.
pub fn FixedOffsetDateTime::format(self : FixedOffsetDateTime) -> String {
  let wall_clock = shift_datetime_by_seconds(self.utc, self.offset_seconds)
  format_datetime_without_zone(wall_clock) +
  format_offset_suffix(self.offset_seconds)
}

///|
/// Format this time as `HH:MM:SS` or `HH:MM:SS.fraction` (nanoseconds trimmed,
/// consistent with `DateTime::format`).
pub fn Time::format(self : Time) -> String {
  let base = "\{pad2(self.hour)}:\{pad2(self.minute)}:\{pad2(self.second)}"
  if self.nanosecond == 0 {
    base
  } else {
    base + "." + fmt_frac(self.nanosecond)
  }
}

///|
/// Format this calendar period as ISO 8601 date-period components.
pub fn Period::format(self : Period) -> String {
  if self.is_zero() {
    "P0D"
  } else {
    let buf = StringBuilder()
    buf.write_string("P")
    if self.years != 0 {
      buf.write_string("\{self.years}Y")
    }
    if self.months != 0 {
      buf.write_string("\{self.months}M")
    }
    if self.days != 0 {
      buf.write_string("\{self.days}D")
    }
    buf.to_string()
  }
}

///|
/// Format this fixed-length duration as a canonical ISO 8601 duration string.
///
/// Calendar units (years and months) are not representable as a fixed
/// nanosecond duration and are therefore never emitted.
pub fn Duration::format_iso(self : Duration) -> String {
  let ns = self.nanoseconds
  if ns == 0L {
    "PT0S"
  } else {
    let sign = if ns < 0L { "-" } else { "" }
    // Divide before taking absolute values so Int64::min_value formats without
    // negating the whole duration.
    let days = ns / ns_per_day
    let rem = ns - days * ns_per_day
    let hours = rem / ns_per_hour
    let rem = rem - hours * ns_per_hour
    let minutes = rem / ns_per_min
    let rem = rem - minutes * ns_per_min
    let seconds = rem / ns_per_sec
    let frac_ns = rem - seconds * ns_per_sec
    let days = if days < 0L { -days } else { days }
    let hours = if hours < 0L { -hours } else { hours }
    let minutes = if minutes < 0L { -minutes } else { minutes }
    let seconds = if seconds < 0L { -seconds } else { seconds }
    let frac_ns = (if frac_ns < 0L { -frac_ns } else { frac_ns }).to_int()
    let has_time = hours > 0L || minutes > 0L || seconds > 0L || frac_ns > 0
    let buf = StringBuilder()
    buf.write_string(sign)
    buf.write_string("P")
    if days > 0L {
      buf.write_string("\{days}D")
    }
    if has_time {
      buf.write_string("T")
      if hours > 0L {
        buf.write_string("\{hours}H")
      }
      if minutes > 0L {
        buf.write_string("\{minutes}M")
      }
      if frac_ns > 0 {
        buf.write_string("\{seconds}.\{fmt_frac(frac_ns)}S")
      } else if seconds > 0L {
        buf.write_string("\{seconds}S")
      }
    }
    buf.to_string()
  }
}

///|
/// Format this duration as English elapsed time using day/hour/minute/second
/// units. Sub-second remainder is dropped.
pub fn Duration::humanize(self : Duration) -> String {
  let total_seconds = self.abs().as_seconds()
  if total_seconds == 0L {
    "0 seconds"
  } else {
    let days = total_seconds / 86_400L
    let rem = total_seconds - days * 86_400L
    let hours = rem / 3_600L
    let rem = rem - hours * 3_600L
    let minutes = rem / 60L
    let seconds = rem - minutes * 60L
    let buf = StringBuilder()
    if self.is_negative() {
      buf.write_string("-")
    }
    let wrote = write_humanized_duration_component(buf, false, days, "day")
    let wrote = write_humanized_duration_component(buf, wrote, hours, "hour")
    let wrote = write_humanized_duration_component(
      buf, wrote, minutes, "minute",
    )
    let _ = write_humanized_duration_component(buf, wrote, seconds, "second")
    buf.to_string()
  }
}

///|
fn write_humanized_duration_component(
  buf : StringBuilder,
  wrote : Bool,
  value : Int64,
  unit : String,
) -> Bool {
  if value == 0L {
    wrote
  } else {
    if wrote {
      buf.write_string(" ")
    }
    buf.write_string("\{value} ")
    buf.write_string(unit)
    if value != 1L {
      buf.write_string("s")
    }
    true
  }
}

///|
fn[T] json_decode_error(
  path : @json.JsonPath,
  msg : String,
) -> T raise @json.JsonDecodeError {
  raise @json.JsonDecodeError((path, msg))
}

///|
pub impl ToJson for Date with fn to_json(self : Date) -> Json {
  Json::string(self.format())
}

///|
pub impl @json.FromJson for Date with fn from_json(json, path) {
  guard json is String(s) else {
    json_decode_error(path, "Date::from_json: expected string")
  }
  Date::parse(s) catch {
    TempoError(msg) => json_decode_error(path, "Date::from_json: " + msg)
  }
}

///|
pub impl ToJson for Time with fn to_json(self : Time) -> Json {
  Json::string(self.format())
}

///|
pub impl @json.FromJson for Time with fn from_json(json, path) {
  guard json is String(s) else {
    json_decode_error(path, "Time::from_json: expected string")
  }
  Time::parse(s) catch {
    TempoError(msg) => json_decode_error(path, "Time::from_json: " + msg)
  }
}

///|
pub impl ToJson for DateTime with fn to_json(self : DateTime) -> Json {
  Json::string(self.format())
}

///|
pub impl @json.FromJson for DateTime with fn from_json(json, path) {
  guard json is String(s) else {
    json_decode_error(path, "DateTime::from_json: expected string")
  }
  DateTime::parse(s) catch {
    TempoError(msg) => json_decode_error(path, "DateTime::from_json: " + msg)
  }
}

///|
pub impl ToJson for Duration with fn to_json(self : Duration) -> Json {
  Json::string(self.format_iso())
}

///|
pub impl @json.FromJson for Duration with fn from_json(json, path) {
  guard json is String(s) else {
    json_decode_error(path, "Duration::from_json: expected string")
  }
  Duration::parse_iso(s) catch {
    TempoError(msg) => json_decode_error(path, "Duration::from_json: " + msg)
  }
}

///|
pub impl Show for Date with fn output(self, logger) {
  logger.write_string(
    "\{pad4_year(self.year)}-\{pad2(self.month)}-\{pad2(self.day)}",
  )
}

///|
pub impl Show for YearMonth with fn output(self, logger) {
  logger.write_string(self.format())
}

///|
pub impl Show for Time with fn output(self, logger) {
  logger.write_string(self.format())
}

///|
pub impl Show for DateTime with fn output(self, logger) {
  logger.write_string(self.format())
}

///|
pub impl Show for FixedOffsetDateTime with fn output(self, logger) {
  logger.write_string(self.format())
}

///|
pub impl Show for Period with fn output(self, logger) {
  logger.write_string(self.format())
}

///|
pub impl Show for Duration with fn output(self, logger) {
  logger.write_string(self.to_string_repr())
}

///|
fn Duration::to_string_repr(self : Duration) -> String {
  let ns = self.nanoseconds
  if ns == 0L {
    "0s"
  } else {
    let sign = if ns < 0L { "-" } else { "" }
    // Decompose with signed arithmetic first, then abs each component
    // individually. Each component's magnitude is bounded (|h| ≤ 2_562_047,
    // |m| < 60, etc.), so the per-component negation is always safe and avoids
    // the Int64::min_value overflow that `-ns` would trigger.
    let h = ns / ns_per_hour
    let rem = ns - h * ns_per_hour
    let m = rem / ns_per_min
    let rem = rem - m * ns_per_min
    let s = rem / ns_per_sec
    let rem = rem - s * ns_per_sec
    let ms = rem / ns_per_ms
    let rem = rem - ms * ns_per_ms
    let us = rem / ns_per_us
    let sub_ns = rem - us * ns_per_us
    let h = if h < 0L { -h } else { h }
    let m = if m < 0L { -m } else { m }
    let s = if s < 0L { -s } else { s }
    let ms = if ms < 0L { -ms } else { ms }
    let us = if us < 0L { -us } else { us }
    let sub_ns = (if sub_ns < 0L { -sub_ns } else { sub_ns }).to_int()
    let buf = StringBuilder()
    buf.write_string(sign)
    if h > 0L {
      buf.write_string("\{h}h")
    }
    if m > 0L {
      buf.write_string("\{m}m")
    }
    if s > 0L || (h == 0L && m == 0L) {
      buf.write_string("\{s}s")
    }
    if ms > 0L {
      buf.write_string("\{ms}ms")
    }
    if us > 0L {
      buf.write_string("\{us}µs")
    }
    if sub_ns > 0 {
      buf.write_string("\{sub_ns}ns")
    }
    buf.to_string()
  }
}

// ─── RFC 3339 parsing ─────────────────────────────────────────────────────────

// Parse exactly `n` ASCII decimal digits from a StringView,
// returning (value, rest).

///|
fn parse_digits(
  view : StringView,
  n : Int,
) -> (Int, StringView) raise TempoError {
  for v = view, remaining = n, acc = 0 {
    if remaining == 0 {
      break (acc, v)
    }
    match v {
      ['0'..='9' as c, .. rest] =>
        continue rest, remaining - 1, acc * 10 + c.to_int() - '0'
      [c, ..] => raise TempoError("expected digit, got '\{c}'")
      [] => raise TempoError("unexpected end of input, expected digit")
    }
  }
}

// Consume a literal character, returning the rest.

///|
fn consume(view : StringView, expected : Char) -> StringView raise TempoError {
  match view {
    [c, .. rest] if c == expected => rest
    [c, ..] => raise TempoError("expected '\{expected}', got '\{c}'")
    [] => raise TempoError("unexpected end of input, expected '\{expected}'")
  }
}

// Parse the fractional-seconds part (digits after '.'), normalize to nanoseconds.
// Collects up to 9 significant digits (truncating, not rounding), then skips
// any remaining digits to avoid Int overflow.

///|
fn parse_frac_ns(view : StringView) -> (Int, StringView) raise TempoError {
  for v = view, count = 0, acc = 0 {
    match v {
      ['0'..='9' as c, .. rest] =>
        if count < 9 {
          // Still within 9 significant digits — accumulate normally.
          continue rest, count + 1, acc * 10 + c.to_int() - '0'
        } else {
          // Beyond 9 digits: skip without touching acc to avoid overflow.
          continue rest, count + 1, acc
        }
      _ => {
        if count == 0 {
          raise TempoError("expected digits after '.'")
        }
        // Pad up to 9 digits if fewer were provided (e.g. ".1" → 100_000_000).
        let ns = for a = acc, r = 9 - count.clamp(min=0, max=9) {
          if r == 0 {
            break a
          }
          continue a * 10, r - 1
        }
        break (ns, v)
      }
    }
  }
}

///|
fn parse_period_number(view : StringView) -> (Int, StringView) raise TempoError {
  let (negative, v) = match view {
    ['-', .. rest] => (true, rest)
    ['+', .. rest] => (false, rest)
    _ => (false, view)
  }
  let limit = if negative {
    @int.MAX_VALUE.to_int64() + 1L
  } else {
    @int.MAX_VALUE.to_int64()
  }
  for v = v, count = 0, acc = 0L {
    match v {
      ['0'..='9' as c, .. rest] => {
        let digit = (c.to_int() - '0').to_int64()
        if acc > (limit - digit) / 10L {
          raise TempoError("period component overflows Int")
        }
        continue rest, count + 1, acc * 10L + digit
      }
      _ => {
        if count == 0 {
          raise TempoError("expected period component digits")
        }
        let value = if negative {
          if acc == limit {
            @int.MIN_VALUE
          } else {
            (0L - acc).to_int()
          }
        } else {
          acc.to_int()
        }
        break (value, v)
      }
    }
  }
}

///|
fn reject_period_order() -> Unit raise TempoError {
  raise TempoError("period components are repeated or out of order")
}

///|
/// Parse an ISO 8601 date-period string in the `PnYnMnD` subset.
///
/// Week notation (`PnW`) is accepted and stored as days. Time components and
/// any `T` separator are rejected.
pub fn Period::parse(s : String) -> Period raise TempoError {
  let v = match s.view() {
    ['P', .. rest] => rest
    [c, ..] => raise TempoError("expected 'P', got '\{c}'")
    [] => raise TempoError("unexpected end of input, expected 'P'")
  }
  for v = v, years = 0, months = 0, days = 0, saw_component = false, rank = 0 {
    match v {
      [] =>
        if saw_component {
          break { years, months, days }
        } else {
          raise TempoError("expected period component")
        }
      ['T', ..] => raise TempoError("period time components are not supported")
      ['+' | '-' | '0'..='9', ..] => {
        let (amount, rest) = parse_period_number(v)
        match rest {
          ['Y', .. rest] => {
            if rank >= 1 {
              reject_period_order()
            }
            continue rest, amount, months, days, true, 1
          }
          ['M', .. rest] => {
            if rank >= 2 {
              reject_period_order()
            }
            continue rest, years, amount, days, true, 2
          }
          ['D', .. rest] => {
            if rank >= 3 {
              reject_period_order()
            }
            continue rest, years, months, amount, true, 3
          }
          ['W', .. rest] => {
            if saw_component {
              raise TempoError("period week component cannot be combined")
            }
            match rest {
              [] => break Period::of_weeks(amount)
              _ => raise TempoError("period week component cannot be combined")
            }
          }
          [] =>
            raise TempoError("unexpected end of input, expected period unit")
          [c, ..] => raise TempoError("expected period unit, got '\{c}'")
        }
      }
      [c, ..] => raise TempoError("expected period component, got '\{c}'")
    }
  }
}

///|
let duration_calendar_units_error : String = "calendar units (years/months) are not representable as a fixed Duration; use a calendar-period representation instead"

///|
fn parse_duration_number(
  view : StringView,
) -> (Int64, StringView) raise TempoError {
  for v = view, count = 0, acc = 0L {
    match v {
      ['0'..='9' as c, .. rest] => {
        let digit = (c.to_int() - '0').to_int64()
        if acc > (int64_max_value - digit) / 10L {
          raise TempoError("duration component overflows Int64")
        }
        continue rest, count + 1, acc * 10L + digit
      }
      _ => {
        if count == 0 {
          raise TempoError("expected duration component digits")
        }
        break (acc, v)
      }
    }
  }
}

///|
fn duration_component_ns(
  amount : Int64,
  unit : Int64,
) -> Int64 raise TempoError {
  match Duration::nanoseconds(unit).checked_multiply(amount) {
    Some(d) => d.nanoseconds
    None => raise TempoError("duration component overflows Int64 nanoseconds")
  }
}

///|
fn duration_accumulate(
  total : Int64,
  component : Int64,
  negative : Bool,
) -> Int64 raise TempoError {
  let next = if negative {
    checked_i64_sub(total, component)
  } else {
    checked_i64_add(total, component)
  }
  match next {
    Some(ns) => ns
    None => raise TempoError("duration overflows Int64 nanoseconds")
  }
}

///|
fn duration_reject_time_component_without_t(
  unit : Char,
) -> Unit raise TempoError {
  raise TempoError("time component '\{unit}' requires 'T'")
}

///|
fn duration_reject_time_order() -> Unit raise TempoError {
  raise TempoError("duration time components are repeated or out of order")
}

///|
/// Parse an ISO 8601 duration string in the fixed-nanosecond subset:
/// `PnDTnHnMnS`, with optional fractional seconds and leading sign.
///
/// Calendar units (`Y` years or date-part `M` months) raise `TempoError`; use a
/// calendar-period representation for those units.
pub fn Duration::parse_iso(s : String) -> Duration raise TempoError {
  let (negative, v) = match s.view() {
    ['-', .. rest] => (true, rest)
    ['+', .. rest] => (false, rest)
    v => (false, v)
  }
  let v = match v {
    ['P', .. rest] => rest
    [c, ..] => raise TempoError("expected 'P', got '\{c}'")
    [] => raise TempoError("unexpected end of input, expected 'P'")
  }
  let total = for v = v, total = 0L, saw_component = false, in_time = false, saw_day = false, time_rank = 0 {
    match v {
      [] =>
        if saw_component {
          break total
        } else {
          raise TempoError("expected duration component")
        }
      ['T', .. rest] => {
        if in_time {
          raise TempoError("duration contains repeated 'T'")
        }
        match rest {
          [] => raise TempoError("expected time component after 'T'")
          _ => continue rest, total, saw_component, true, saw_day, time_rank
        }
      }
      ['0'..='9', ..] => {
        let (amount, rest) = parse_duration_number(v)
        match rest {
          ['Y', ..] => raise TempoError(duration_calendar_units_error)
          ['D', .. rest] => {
            if in_time {
              raise TempoError("date component 'D' is not allowed after 'T'")
            }
            if saw_day {
              raise TempoError("duration day component is repeated")
            }
            let component = duration_component_ns(amount, ns_per_day)
            let total = duration_accumulate(total, component, negative)
            continue rest, total, true, in_time, true, time_rank
          }
          ['H', .. rest] => {
            if !in_time {
              duration_reject_time_component_without_t('H')
            }
            if time_rank >= 1 {
              duration_reject_time_order()
            }
            let component = duration_component_ns(amount, ns_per_hour)
            let total = duration_accumulate(total, component, negative)
            continue rest, total, true, in_time, saw_day, 1
          }
          ['M', .. rest] =>
            if in_time {
              if time_rank >= 2 {
                duration_reject_time_order()
              }
              let component = duration_component_ns(amount, ns_per_min)
              let total = duration_accumulate(total, component, negative)
              continue rest, total, true, in_time, saw_day, 2
            } else {
              raise TempoError(duration_calendar_units_error)
            }
          ['S', .. rest] => {
            if !in_time {
              duration_reject_time_component_without_t('S')
            }
            if time_rank >= 3 {
              duration_reject_time_order()
            }
            let component = duration_component_ns(amount, ns_per_sec)
            let total = duration_accumulate(total, component, negative)
            continue rest, total, true, in_time, saw_day, 3
          }
          ['.', .. frac_view] => {
            if !in_time {
              raise TempoError(
                "fractional components are only supported for seconds in the time part after 'T'",
              )
            }
            if time_rank >= 3 {
              duration_reject_time_order()
            }
            let seconds = duration_component_ns(amount, ns_per_sec)
            let total = duration_accumulate(total, seconds, negative)
            let (frac_ns, rest) = parse_frac_ns(frac_view)
            let rest = match rest {
              ['S', .. rest] => rest
              [c, ..] => raise TempoError("expected 'S', got '\{c}'")
              [] => raise TempoError("unexpected end of input, expected 'S'")
            }
            let total = duration_accumulate(total, frac_ns.to_int64(), negative)
            continue rest, total, true, in_time, saw_day, 3
          }
          [] =>
            raise TempoError("unexpected end of input, expected duration unit")
          [c, ..] => raise TempoError("expected duration unit, got '\{c}'")
        }
      }
      ['Y', ..] => raise TempoError(duration_calendar_units_error)
      [c, ..] => raise TempoError("expected duration component, got '\{c}'")
    }
  }
  Duration::nanoseconds(total)
}

///|
/// Parse an RFC 3339 / ISO 8601 datetime string.
/// Fixed numeric offsets are accepted and normalized to UTC.
pub fn DateTime::parse(s : String) -> DateTime raise TempoError {
  let (utc, _) = parse_rfc3339_parts(s)
  utc
}

///|
/// Parse an RFC 3339 datetime string, retaining the explicit fixed offset.
///
/// The stored instant is normalized UTC. The parsed numeric offset is retained
/// only for formatting/interoperability; it is not an IANA time zone.
pub fn FixedOffsetDateTime::parse(
  s : String,
) -> FixedOffsetDateTime raise TempoError {
  let (utc, offset_seconds) = parse_rfc3339_parts(s)
  FixedOffsetDateTime::from_datetime_and_offset(utc, offset_seconds)
}

///|
fn parse_rfc3339_parts(s : String) -> (DateTime, Int) raise TempoError {
  let v = s.view()
  let (year, v) = parse_digits(v, 4)
  let v = consume(v, '-')
  let (month, v) = parse_digits(v, 2)
  let v = consume(v, '-')
  let (day, v) = parse_digits(v, 2)
  // Date/time separator: T or t
  let v = match v {
    ['T' | 't', .. rest] => rest
    [c, ..] => raise TempoError("expected 'T', got '\{c}'")
    [] => raise TempoError("unexpected end of input, expected 'T'")
  }
  let (hour, v) = parse_digits(v, 2)
  let v = consume(v, ':')
  let (minute, v) = parse_digits(v, 2)
  let v = consume(v, ':')
  let (second, v) = parse_digits(v, 2)
  // Optional fractional seconds
  let (nanosecond, v) = match v {
    ['.', .. rest] => parse_frac_ns(rest)
    _ => (0, v)
  }
  // Timezone: Z or ±HH:MM.
  let (v, offset_minutes) = match v {
    ['Z' | 'z', .. rest] => (rest, 0)
    ['+' | '-' as sign, .. rest] => {
      let (tz_h, rest) = parse_digits(rest, 2)
      let rest = consume(rest, ':')
      let (tz_m, rest) = parse_digits(rest, 2)
      if tz_h > 23 || tz_m > 59 {
        raise TempoError(
          "offset '\{sign}\{pad2(tz_h)}:\{pad2(tz_m)}' out of range",
        )
      }
      let minutes = tz_h * 60 + tz_m
      let minutes = if sign == '-' { -minutes } else { minutes }
      (rest, minutes)
    }
    [c, ..] => raise TempoError("expected timezone, got '\{c}'")
    [] => raise TempoError("unexpected end of input, expected timezone")
  }
  // Must have consumed the entire string
  match v {
    [] => ()
    _ => raise TempoError("unexpected trailing characters")
  }
  let date = Date::new(year, month, day)
  let time = Time::new(hour, minute, second, nanosecond)
  let base = { date, time }
  let utc = if offset_minutes == 0 {
    base
  } else {
    let total_min = (hour * 60 + minute - offset_minutes).to_int64()
    let day_delta = floor_div64(total_min, 1440L).to_int()
    let minute_of_day = floor_mod64(total_min, 1440L).to_int()
    {
      date: date.add_days(day_delta),
      time: Time::new(
        minute_of_day / 60,
        minute_of_day % 60,
        second,
        nanosecond,
      ),
    }
  }
  (utc, offset_minutes * 60)
}

///|
/// Parse a time-of-day string: `HH:MM:SS` or `HH:MM:SS.fraction`.
pub fn Time::parse(s : String) -> Time raise TempoError {
  let v = s.view()
  let (hour, v) = parse_digits(v, 2)
  let v = consume(v, ':')
  let (minute, v) = parse_digits(v, 2)
  let v = consume(v, ':')
  let (second, v) = parse_digits(v, 2)
  let (nanosecond, v) = match v {
    ['.', .. rest] => parse_frac_ns(rest)
    _ => (0, v)
  }
  match v {
    [] => ()
    _ => raise TempoError("unexpected trailing characters")
  }
  Time::new(hour, minute, second, nanosecond)
}