///|
/// Errors raised while constructing or parsing a date value.
pub(all) suberror DateError {
  InvalidDate(message~ : String)
  InvalidFormat(message~ : String)
}

///|
/// A Gregorian date and time used by the date ticker.
///
/// `offset_seconds` stores an exact offset east of UTC, including historical seconds. The numeric
/// representation follows Matplotlib: days since 1970-01-01 UTC, with the
/// local time converted back to its instant before encoding.
pub struct DateTime {
  year : Int32
  month : Int32
  day : Int32
  hour : Int32
  minute : Int32
  second : Int32
  microsecond : Int32
  offset_minutes : Int32
  priv offset_seconds : Int32
  priv abbreviation : String?
  priv timezone : DateTimeZone?
}

///|
fn validate_date(
  year : Int32,
  month : Int32,
  day : Int32,
  hour : Int32,
  minute : Int32,
  second : Int32,
  microsecond : Int32,
  offset_seconds : Int32,
) -> Unit raise DateError {
  if year < 1 || year > 9999 {
    raise DateError::InvalidDate(message="year must be in 1..9999")
  }
  if month < 1 || month > 12 {
    raise DateError::InvalidDate(message="month must be in 1..12")
  }
  if day < 1 || day > days_in_month(year, month) {
    raise DateError::InvalidDate(message="day is outside the month")
  }
  if hour < 0 || hour > 23 {
    raise DateError::InvalidDate(message="hour must be in 0..23")
  }
  if minute < 0 || minute > 59 {
    raise DateError::InvalidDate(message="minute must be in 0..59")
  }
  if second < 0 || second > 59 {
    raise DateError::InvalidDate(message="second must be in 0..59")
  }
  if microsecond < 0 || microsecond > 999999 {
    raise DateError::InvalidDate(message="microsecond must be in 0..999999")
  }
  validate_offset_seconds(offset_seconds)
}

///|
/// Construct a validated Gregorian date and time.
/// `offset_seconds` selects exact historical offsets. Supply either seconds
/// or a nonzero `offset_minutes`, never both.
///
/// ```mbt check
/// test {
///   let value = @date.datetime(1970, 1, 1, hour=2, offset_minutes=120)
///   assert_eq(value.offset_minutes(), 120)
///   assert_eq(value.to_ordinal(), 0.0)
/// }
/// ```
pub fn datetime(
  year : Int32,
  month : Int32,
  day : Int32,
  hour~ : Int32 = 0,
  minute~ : Int32 = 0,
  second~ : Int32 = 0,
  microsecond~ : Int32 = 0,
  offset_minutes~ : Int32 = 0,
  offset_seconds? : Int32,
) -> DateTime raise DateError {
  let offset_seconds = resolve_offset_seconds(offset_minutes, offset_seconds)
  validate_date(
    year, month, day, hour, minute, second, microsecond, offset_seconds,
  )
  {
    year,
    month,
    day,
    hour,
    minute,
    second,
    microsecond,
    offset_minutes: offset_seconds / 60,
    offset_seconds,
    abbreviation: None,
    timezone: None,
  }
}

///|
pub fn DateTime::year(self : DateTime) -> Int32 {
  self.year
}

///|
pub fn DateTime::month(self : DateTime) -> Int32 {
  self.month
}

///|
pub fn DateTime::day(self : DateTime) -> Int32 {
  self.day
}

///|
pub fn DateTime::hour(self : DateTime) -> Int32 {
  self.hour
}

///|
pub fn DateTime::minute(self : DateTime) -> Int32 {
  self.minute
}

///|
pub fn DateTime::second(self : DateTime) -> Int32 {
  self.second
}

///|
pub fn DateTime::microsecond(self : DateTime) -> Int32 {
  self.microsecond
}

///|
/// Return whole offset minutes, truncated toward zero for historical seconds.
/// Use `offset_seconds()` for exact arithmetic.
pub fn DateTime::offset_minutes(self : DateTime) -> Int32 {
  self.offset_seconds / 60
}

///|
/// Convert this instant to a different fixed UTC offset.
/// The conversion preserves all six microsecond digits even far from epoch.
///
/// ```mbt check
/// test {
///   let value = @date.datetime(2243, 10, 17, microsecond=123456)
///   let shifted = value.with_offset(-480)
///   assert_eq(shifted.day(), 16)
///   assert_eq(shifted.hour(), 16)
///   assert_eq(shifted.microsecond(), 123456)
/// }
/// ```
pub fn DateTime::with_offset(
  self : DateTime,
  offset_minutes : Int32,
) -> DateTime raise DateError {
  validate_offset(offset_minutes)
  self.with_offset_seconds(offset_minutes * 60)
}

///|
/// Return the exact fixed offset east of UTC, in seconds.
///
/// ```mbt check
/// test {
///   let value = @date.datetime(1900, 1, 1, offset_seconds=561)
///   assert_eq(value.offset_seconds(), 561)
///   assert_eq(@date.format_date(value.with_offset(0), "%H:%M:%S"), "23:50:39")
/// }
/// ```
pub fn DateTime::offset_seconds(self : DateTime) -> Int32 {
  self.offset_seconds
}

///|
/// Return the TZif designation, if this value was converted through a TZif zone.
pub fn DateTime::timezone_abbreviation(self : DateTime) -> String? {
  self.abbreviation
}

///|
/// Convert this instant to an exact offset, preserving all microsecond digits.
pub fn DateTime::with_offset_seconds(
  self : DateTime,
  offset_seconds : Int32,
) -> DateTime raise DateError {
  validate_offset_seconds(offset_seconds)
  let utc_microseconds = locator_microseconds(self) -
    Int64::from_int(self.offset_seconds) * 1_000_000L
  datetime_from_microseconds(
    utc_microseconds + Int64::from_int(offset_seconds) * 1_000_000L,
    offset_seconds,
  )
}

///|
/// Return the weekday using Monday=0 through Sunday=6, like Python datetime.
pub fn DateTime::weekday(self : DateTime) -> Int32 {
  weekday_from_civil(self.year, self.month, self.day)
}

///|
fn validate_offset(offset_minutes : Int32) -> Unit raise DateError {
  if offset_minutes <= -1440 || offset_minutes >= 1440 {
    raise DateError::InvalidDate(
      message="offset_minutes must be in -1439..1439",
    )
  }
}

///|
fn validate_offset_seconds(offset_seconds : Int32) -> Unit raise DateError {
  if offset_seconds <= -86400 || offset_seconds >= 86400 {
    raise DateError::InvalidDate(
      message="offset_seconds must be in -86399..86399",
    )
  }
}

///|
fn resolve_offset_seconds(
  minutes : Int32,
  seconds : Int32?,
) -> Int32 raise DateError {
  validate_offset(minutes)
  match seconds {
    Some(value) => {
      if minutes != 0 {
        raise DateError::InvalidDate(
          message="specify offset_minutes or offset_seconds, not both",
        )
      }
      validate_offset_seconds(value)
      value
    }
    None => minutes * 60
  }
}

///|
/// Return the retained rule zone, if converted using with_timezone/to_datetime.
/// Explicit fixed-offset conversions clear this reference.
pub fn DateTime::timezone(self : DateTime) -> DateTimeZone? {
  self.timezone
}