///|
/// Three-byte CP24Time2a value used by short time-tagged ASDUs.
pub struct Cp24Time {
  millisecond : Int
  minute : Int
} derive(Eq, Debug)

///|
pub fn Cp24Time::new(
  millisecond : Int,
  minute : Int,
) -> Result[Cp24Time, String] {
  if millisecond < 0 || millisecond > 59999 {
    Err("CP24 millisecond field must be between 0 and 59999")
  } else if minute < 0 || minute > 59 {
    Err("CP24 minute field must be between 0 and 59")
  } else {
    Ok({ millisecond, minute })
  }
}

///|
pub fn Cp24Time::millisecond(self : Cp24Time) -> Int {
  self.millisecond
}

///|
pub fn Cp24Time::minute(self : Cp24Time) -> Int {
  self.minute
}

///|
pub fn Cp24Time::second(self : Cp24Time) -> Int {
  self.millisecond / 1000
}

///|
pub fn Cp24Time::remainder_millisecond(self : Cp24Time) -> Int {
  self.millisecond % 1000
}

///|
pub fn Cp24Time::to_array(self : Cp24Time) -> Array[Byte] {
  [
    (self.millisecond & 0xff).to_byte(),
    ((self.millisecond >> 8) & 0xff).to_byte(),
    self.minute.to_byte(),
  ]
}

///|
pub fn Cp24Time::from_bytes(
  data : Bytes,
  offset? : Int = 0,
) -> Result[Cp24Time, String] {
  if offset < 0 || data.length() < offset + 3 {
    Err("CP24 value is truncated")
  } else {
    let millisecond = data[offset].to_int() | (data[offset + 1].to_int() << 8)
    Cp24Time::new(millisecond, data[offset + 2].to_int())
  }
}

///|
/// Whether a year is a Gregorian leap year.
pub fn is_leap_year(year : Int) -> Bool {
  year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}

///|
/// Return the number of days in a Gregorian month.
pub fn days_in_month(year : Int, month : Int) -> Int? {
  match month {
    1 | 3 | 5 | 7 | 8 | 10 | 12 => Some(31)
    4 | 6 | 9 | 11 => Some(30)
    2 => Some(if is_leap_year(year) { 29 } else { 28 })
    _ => None
  }
}

///|
/// Seven-byte CP56Time2a timestamp.
pub struct Cp56Time {
  year : Int
  month : Int
  day : Int
  weekday : Int
  hour : Int
  minute : Int
  millisecond : Int
} derive(Eq, Debug)

///|
/// Construct a CP56 timestamp. The year is the two-digit protocol year.
pub fn Cp56Time::new(
  year : Int,
  month : Int,
  day : Int,
  hour : Int,
  minute : Int,
  millisecond : Int,
  weekday? : Int = 0,
) -> Result[Cp56Time, String] {
  if year < 0 || year > 99 {
    Err("CP56 year must be between 0 and 99")
  } else if month < 1 || month > 12 {
    Err("CP56 month must be between 1 and 12")
  } else if day < 1 || day > days_in_month(2000 + year, month).unwrap() {
    Err("CP56 day is outside the selected month")
  } else if weekday < 0 || weekday > 7 {
    Err("CP56 weekday must be between 0 and 7")
  } else if hour < 0 || hour > 23 {
    Err("CP56 hour must be between 0 and 23")
  } else if minute < 0 || minute > 59 {
    Err("CP56 minute must be between 0 and 59")
  } else if millisecond < 0 || millisecond > 59999 {
    Err("CP56 millisecond field must be between 0 and 59999")
  } else {
    Ok({ year, month, day, weekday, hour, minute, millisecond })
  }
}

///|
pub fn Cp56Time::year(self : Cp56Time) -> Int {
  self.year
}

///|
pub fn Cp56Time::month(self : Cp56Time) -> Int {
  self.month
}

///|
pub fn Cp56Time::day(self : Cp56Time) -> Int {
  self.day
}

///|
pub fn Cp56Time::weekday(self : Cp56Time) -> Int {
  self.weekday
}

///|
pub fn Cp56Time::hour(self : Cp56Time) -> Int {
  self.hour
}

///|
pub fn Cp56Time::minute(self : Cp56Time) -> Int {
  self.minute
}

///|
pub fn Cp56Time::millisecond(self : Cp56Time) -> Int {
  self.millisecond
}

///|
pub fn Cp56Time::second(self : Cp56Time) -> Int {
  self.millisecond / 1000
}

///|
pub fn Cp56Time::remainder_millisecond(self : Cp56Time) -> Int {
  self.millisecond % 1000
}

///|
/// Return the four-digit calendar year represented by the protocol year.
pub fn Cp56Time::calendar_year(self : Cp56Time, century? : Int = 2000) -> Int {
  century + self.year
}

///|
pub fn Cp56Time::to_array(self : Cp56Time) -> Array[Byte] {
  [
    (self.millisecond & 0xff).to_byte(),
    ((self.millisecond >> 8) & 0xff).to_byte(),
    self.minute.to_byte(),
    self.hour.to_byte(),
    (self.day | (self.weekday << 5)).to_byte(),
    self.month.to_byte(),
    self.year.to_byte(),
  ]
}

///|
pub fn Cp56Time::from_bytes(
  data : Bytes,
  offset? : Int = 0,
) -> Result[Cp56Time, String] {
  if offset < 0 || data.length() < offset + 7 {
    Err("CP56 value is truncated")
  } else {
    let millisecond = data[offset].to_int() | (data[offset + 1].to_int() << 8)
    let minute = data[offset + 2].to_int()
    let hour = data[offset + 3].to_int()
    let day_field = data[offset + 4].to_int()
    Cp56Time::new(
      data[offset + 6].to_int(),
      data[offset + 5].to_int(),
      day_field & 0x1f,
      hour,
      minute,
      millisecond,
      weekday=day_field >> 5,
    )
  }
}

///|
/// Calculate ISO-like weekday for a Gregorian date: Monday is 1, Sunday is 7.
pub fn calendar_weekday(
  year : Int,
  month : Int,
  day : Int,
) -> Result[Int, String] {
  if month < 1 || month > 12 {
    Err("month must be between 1 and 12")
  } else if day < 1 || day > days_in_month(year, month).unwrap() {
    Err("day is outside the selected month")
  } else {
    let adjusted_year = if month < 3 { year - 1 } else { year }
    let adjusted_month = if month < 3 { month + 12 } else { month }
    let k = adjusted_year % 100
    let j = adjusted_year / 100
    let h = (day + 13 * (adjusted_month + 1) / 5 + k + k / 4 + j / 4 + 5 * j) %
      7
    Ok(if h == 0 { 6 } else { h - 1 })
  }
}

///|
/// Tag precision carried by a time-tagged application object.
pub enum TimeTagKind {
  NoTimeTag
  Cp24TimeTag
  Cp56TimeTag
} derive(Eq, Debug)

///|
pub fn TimeTagKind::width(self : TimeTagKind) -> Int {
  match self {
    NoTimeTag => 0
    Cp24TimeTag => 3
    Cp56TimeTag => 7
  }
}

///|
pub fn time_tag_kind_for_type(type_id : ApplicationType) -> TimeTagKind {
  match type_id {
    MSpTa | MDpTa | MStTa | MBoTa | MMeTa | MMeTb | MMeTc | MItTa => Cp24TimeTag
    MSpTb | MDpTb | MStTb | MBoTb | MMeTd | MMeTe | MMeTf | MItTb => Cp56TimeTag
    MEpTa => Cp24TimeTag
    MEpTb | MEpTc | MEpTd | MEpTe | MEpTf => Cp56TimeTag
    _ => NoTimeTag
  }
}

///|
/// Sum milliseconds since midnight, used by deterministic simulations.
pub fn milliseconds_since_midnight(
  hour : Int,
  minute : Int,
  second : Int,
  millisecond : Int,
) -> Result[Int, String] {
  if hour < 0 ||
    hour > 23 ||
    minute < 0 ||
    minute > 59 ||
    second < 0 ||
    second > 59 ||
    millisecond < 0 ||
    millisecond > 999 {
    Err("time of day component is outside its range")
  } else {
    Ok(((hour * 60 + minute) * 60 + second) * 1000 + millisecond)
  }
}

///|
/// Split milliseconds since midnight into hour, minute, second and remainder.
pub fn split_milliseconds_since_midnight(
  value : Int,
) -> Result[(Int, Int, Int, Int), String] {
  if value < 0 || value >= 24 * 60 * 60 * 1000 {
    Err("time of day must fit a single day")
  } else {
    let hour = value / 3600000
    let minute = value % 3600000 / 60000
    let second = value % 60000 / 1000
    let millisecond = value % 1000
    Ok((hour, minute, second, millisecond))
  }
}

///|
/// A tagged union used by event stores and application services.
pub enum TimeTag {
  Short(Cp24Time)
  Long(Cp56Time)
} derive(Eq, Debug)

///|
pub fn TimeTag::kind(self : TimeTag) -> TimeTagKind {
  match self {
    Short(_) => Cp24TimeTag
    Long(_) => Cp56TimeTag
  }
}

///|
pub fn TimeTag::width(self : TimeTag) -> Int {
  self.kind().width()
}

///|
pub fn TimeTag::to_array(self : TimeTag) -> Array[Byte] {
  match self {
    Short(value) => value.to_array()
    Long(value) => value.to_array()
  }
}

///|
/// Decode the selected tag precision from a byte sequence.
pub fn decode_time_tag(
  kind : TimeTagKind,
  data : Bytes,
  offset? : Int = 0,
) -> Result[TimeTag, String] {
  match kind {
    NoTimeTag => Err("a time tag is not present")
    Cp24TimeTag =>
      Cp24Time::from_bytes(data, offset~).map(value => Short(value))
    Cp56TimeTag => Cp56Time::from_bytes(data, offset~).map(value => Long(value))
  }
}

///|
pub fn encode_time_tag(tag : TimeTag) -> Bytes {
  Bytes::from_array(tag.to_array())
}

///|
/// Return the number of days in a year.
pub fn days_in_year(year : Int) -> Int {
  if is_leap_year(year) {
    366
  } else {
    365
  }
}

///|
/// Convert a month/day pair to a one-based ordinal day.
pub fn ordinal_day(year : Int, month : Int, day : Int) -> Result[Int, String] {
  match days_in_month(year, month) {
    None => Err("month must be between 1 and 12")
    Some(max_day) =>
      if day < 1 || day > max_day {
        Err("day is outside the selected month")
      } else {
        let mut total = 0
        for current in 1.. Int {
  let day = ordinal_day(self.calendar_year(), self.month, self.day).unwrap()
  day * 86400000 + self.hour * 3600000 + self.minute * 60000 + self.millisecond
}

///|
pub fn time_tag_examples() -> Array[TimeTag] {
  [
    Short(Cp24Time::new(0, 0).unwrap()),
    Long(Cp56Time::new(26, 8, 19, 12, 30, 45000, weekday=3).unwrap()),
  ]
}