///|
/// A calendar date and time as carried by `UTCTime` and `GeneralizedTime`.
///
/// `utc_offset_minutes` is the offset from UTC. DER requires the UTC designator
/// (`Z`), so every encoding produced by this package uses an offset of 0; the
/// decoder still accepts the numeric offsets that BER permits.
pub struct Asn1Time {
  year : Int
  month : Int
  day : Int
  hour : Int
  minute : Int
  second : Int
  utc_offset_minutes : Int
}

///|
/// Creates a time value. Use `Asn1Time::validate` to check the field ranges.
pub fn Asn1Time::new(
  year : Int,
  month : Int,
  day : Int,
  hour : Int,
  minute : Int,
  second : Int,
  utc_offset_minutes? : Int = 0,
) -> Asn1Time {
  { year, month, day, hour, minute, second, utc_offset_minutes, }
}

///|
/// Whether the 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 a month, or 0 when the month is out of range.
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
  }
}

///|
/// Whether every field is inside its valid range.
pub fn Asn1Time::is_valid(self : Asn1Time) -> Bool {
  self.month >= 1 &&
  self.month <= 12 &&
  self.day >= 1 &&
  self.day <= days_in_month(self.year, self.month) &&
  self.hour >= 0 &&
  self.hour <= 23 &&
  self.minute >= 0 &&
  self.minute <= 59 &&
  self.second >= 0 &&
  self.second <= 59 &&
  self.utc_offset_minutes >= -12 * 60 &&
  self.utc_offset_minutes <= 14 * 60
}

///|
/// Renders the value as `2024-05-06T07:08:09Z`, or with a numeric offset when
/// one is present.
pub fn Asn1Time::to_string(self : Asn1Time) -> String {
  let buffer = StringBuilder()
  buffer.write_string(pad_digits(self.year, 4))
  buffer.write_string("-")
  buffer.write_string(pad_digits(self.month, 2))
  buffer.write_string("-")
  buffer.write_string(pad_digits(self.day, 2))
  buffer.write_string("T")
  buffer.write_string(pad_digits(self.hour, 2))
  buffer.write_string(":")
  buffer.write_string(pad_digits(self.minute, 2))
  buffer.write_string(":")
  buffer.write_string(pad_digits(self.second, 2))
  if self.utc_offset_minutes == 0 {
    buffer.write_string("Z")
  } else {
    let offset = self.utc_offset_minutes
    buffer.write_string(if offset < 0 { "-" } else { "+" })
    let magnitude = if offset < 0 { -offset } else { offset }
    buffer.write_string(pad_digits(magnitude / 60, 2))
    buffer.write_string(":")
    buffer.write_string(pad_digits(magnitude % 60, 2))
  }
  buffer.to_string()
}

///|
/// Formats a non-negative integer with exactly `width` decimal digits.
fn pad_digits(value : Int, width : Int) -> String {
  let digits : Array[Byte] = []
  let mut divisor = 1
  for _ in 0..<(width - 1) {
    divisor = divisor * 10
  }
  let mut remaining = value
  for _ in 0.. Int raise DerError {
  let mut value = 0
  for i in start..<(start + count) {
    let code = text.code_unit_at(i).to_int()
    if code < 0x30 || code > 0x39 {
      raise InvalidValue("time value must contain ASCII digits")
    }
    value = value * 10 + (code - 0x30)
  }
  value
}

///|
/// Parses the trailing zone designator, returning the offset in minutes.
fn parse_zone(text : String, start : Int) -> Int raise DerError {
  let length = text.length()
  if start + 1 == length {
    if text.code_unit_at(start).to_int() == 0x5A {
      return 0
    }
    raise InvalidValue("time value must end with 'Z' or a numeric offset")
  }
  if start + 5 == length {
    let sign = text.code_unit_at(start).to_int()
    let hours = parse_digits(text, start + 1, 2)
    let minutes = parse_digits(text, start + 3, 2)
    if hours > 14 || minutes > 59 {
      raise InvalidValue("time zone offset is out of range")
    }
    let total = hours * 60 + minutes
    if sign == 0x2B {
      total
    } else if sign == 0x2D {
      -total
    } else {
      raise InvalidValue("invalid time zone sign")
    }
  } else {
    raise InvalidValue("invalid time zone designator")
  }
}

///|
/// Decodes a `UTCTime`. Two-digit years are mapped to 1950-2049, as required by
/// RFC 5280.
pub fn decode_utc_time(content : BytesView) -> Asn1Time raise DerError {
  let text = decode_ascii(content, "UTCTime")
  let length = text.length()
  if length != 13 && length != 17 {
    raise InvalidValue("UTCTime must be YYMMDDHHMMSSZ or YYMMDDHHMMSS+hhmm")
  }
  let short_year = parse_digits(text, 0, 2)
  let time = {
    year: if short_year >= 50 {
      1900 + short_year
    } else {
      2000 + short_year
    },
    month: parse_digits(text, 2, 2),
    day: parse_digits(text, 4, 2),
    hour: parse_digits(text, 6, 2),
    minute: parse_digits(text, 8, 2),
    second: parse_digits(text, 10, 2),
    utc_offset_minutes: parse_zone(text, 12),
  }
  if !time.is_valid() {
    raise InvalidValue("UTCTime fields are out of range")
  }
  time
}

///|
/// Decodes a `GeneralizedTime` with seconds present and no fractional seconds,
/// which is the only form DER and RFC 5280 allow.
pub fn decode_generalized_time(content : BytesView) -> Asn1Time raise DerError {
  let text = decode_ascii(content, "GeneralizedTime")
  let length = text.length()
  if length != 15 && length != 19 {
    raise InvalidValue(
      "GeneralizedTime must be YYYYMMDDHHMMSSZ or YYYYMMDDHHMMSS+hhmm",
    )
  }
  let time = {
    year: parse_digits(text, 0, 4),
    month: parse_digits(text, 4, 2),
    day: parse_digits(text, 6, 2),
    hour: parse_digits(text, 8, 2),
    minute: parse_digits(text, 10, 2),
    second: parse_digits(text, 12, 2),
    utc_offset_minutes: parse_zone(text, 14),
  }
  if !time.is_valid() {
    raise InvalidValue("GeneralizedTime fields are out of range")
  }
  time
}

///|
/// Encodes a `UTCTime`. DER requires years in 1950-2049 and the `Z` designator.
pub fn encode_utc_time(value : Asn1Time) -> Bytes raise DerError {
  if !value.is_valid() {
    raise InvalidValue("cannot encode an invalid time")
  }
  if value.year < 1950 || value.year > 2049 {
    raise InvalidValue("UTCTime can only represent years 1950-2049")
  }
  if value.utc_offset_minutes != 0 {
    raise InvalidValue("DER UTCTime must use the 'Z' designator")
  }
  let text = pad_digits(value.year % 100, 2) +
    pad_digits(value.month, 2) +
    pad_digits(value.day, 2) +
    pad_digits(value.hour, 2) +
    pad_digits(value.minute, 2) +
    pad_digits(value.second, 2) +
    "Z"
  encode_universal(TAG_UTC_TIME, @utf8.encode(text)[:])
}

///|
/// Encodes a `GeneralizedTime`. DER requires the `Z` designator.
pub fn encode_generalized_time(value : Asn1Time) -> Bytes raise DerError {
  if !value.is_valid() {
    raise InvalidValue("cannot encode an invalid time")
  }
  if value.utc_offset_minutes != 0 {
    raise InvalidValue("DER GeneralizedTime must use the 'Z' designator")
  }
  let text = pad_digits(value.year, 4) +
    pad_digits(value.month, 2) +
    pad_digits(value.day, 2) +
    pad_digits(value.hour, 2) +
    pad_digits(value.minute, 2) +
    pad_digits(value.second, 2) +
    "Z"
  encode_universal(TAG_GENERALIZED_TIME, @utf8.encode(text)[:])
}

///|
/// Encodes a time as `UTCTime` when the year is in 1950-2049 and as
/// `GeneralizedTime` otherwise, which is what X.509 profile requires.
pub fn encode_time(value : Asn1Time) -> Bytes raise DerError {
  if value.year >= 1950 && value.year <= 2049 {
    encode_utc_time(value)
  } else {
    encode_generalized_time(value)
  }
}