///|
/// The number of days in a month, accounting for leap years.
fn days_in_month(year : Int, month : Int) -> Int {
  match month {
    2 =>
      if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) {
        29
      } else {
        28
      }
    4 | 6 | 9 | 11 => 30
    _ => 31
  }
}

///|
/// Whether a `DTPOSTED`/`DTUSER` value is a calendar date this importer accepts.
///
/// The accepted shapes are `YYYYMMDD`, `YYYYMMDDHHMMSS`,
/// `YYYYMMDDHHMMSS.sss` and `YYYYMMDDHHMMSS[gmt offset:tz name]` with any
/// combination of the fractional part and the zone bracket. The date itself is
/// checked against the real calendar, including leap years, because a bank that
/// writes `20260229` has produced a value no ledger can hold. A length between
/// nine and thirteen digits matches no documented shape and is rejected rather
/// than guessed at.
fn valid_date(value : String) -> Bool {
  let chars = value.iter().to_array()
  if chars.length() < 8 {
    return false
  }
  for index in 0..<8 {
    if chars[index] < '0' || chars[index] > '9' {
      return false
    }
  }
  let month = (chars[4].to_int() - 48) * 10 + chars[5].to_int() - 48
  let day = (chars[6].to_int() - 48) * 10 + chars[7].to_int() - 48
  let year = (chars[0].to_int() - 48) * 1000 +
    (chars[1].to_int() - 48) * 100 +
    (chars[2].to_int() - 48) * 10 +
    chars[3].to_int() -
    48
  if month < 1 || month > 12 || day < 1 || day > days_in_month(year, month) {
    return false
  }
  if chars.length() > 8 && chars.length() < 14 {
    return false
  }
  if chars.length() >= 14 {
    for index in 8..<14 {
      if chars[index] < '0' || chars[index] > '9' {
        return false
      }
    }
    let hour = (chars[8].to_int() - 48) * 10 + chars[9].to_int() - 48
    let minute = (chars[10].to_int() - 48) * 10 + chars[11].to_int() - 48
    let second = (chars[12].to_int() - 48) * 10 + chars[13].to_int() - 48
    if hour > 23 || minute > 59 || second > 59 {
      return false
    }
    if chars.length() > 14 {
      let mut offset = 14
      if chars[offset] == '.' {
        offset += 1
        let start = offset
        while offset < chars.length() &&
              chars[offset] >= '0' &&
              chars[offset] <= '9' {
          offset += 1
        }
        if offset == start {
          return false
        }
      }
      if offset < chars.length() {
        if chars[offset] != '[' ||
          chars[chars.length() - 1] != ']' ||
          offset + 2 >= chars.length() {
          return false
        }
      }
    }
  }
  true
}

///|
/// A parsed OFX posting date.
///
/// The fields are the ones the document states, in the zone it states them in:
/// `hour`/`minute`/`second` are local to `offset_minutes`, not UTC. Call
/// [`PostedDate::to_utc_string`] to compare or sort records that a bank wrote in
/// different zones.
pub struct PostedDate {
  year : Int
  month : Int
  day : Int
  hour : Int
  minute : Int
  second : Int
  offset_minutes : Int
}

///|
/// Parse an OFX date stamp, or return `None` when it is not one.
///
/// A date without a time is midnight; a date without a zone bracket is treated
/// as already being in UTC, which is what OFX means by omitting the offset. The
/// zone name after the colon is documentation for a human and is ignored: only
/// the numeric offset is used, because the abbreviations are not unique.
pub fn parse_posted_date(value : String) -> PostedDate? {
  if !valid_date(value) {
    return None
  }
  let chars = value.iter().to_array()
  let year = (chars[0].to_int() - 48) * 1000 +
    (chars[1].to_int() - 48) * 100 +
    (chars[2].to_int() - 48) * 10 +
    chars[3].to_int() -
    48
  let month = (chars[4].to_int() - 48) * 10 + chars[5].to_int() - 48
  let day = (chars[6].to_int() - 48) * 10 + chars[7].to_int() - 48
  let mut hour = 0
  let mut minute = 0
  let mut second = 0
  if chars.length() >= 14 {
    hour = (chars[8].to_int() - 48) * 10 + chars[9].to_int() - 48
    minute = (chars[10].to_int() - 48) * 10 + chars[11].to_int() - 48
    second = (chars[12].to_int() - 48) * 10 + chars[13].to_int() - 48
  }
  let mut offset_minutes = 0
  if chars.length() > 14 {
    let mut index = 14
    if chars[index] == '.' {
      index += 1
      while index < chars.length() && chars[index] >= '0' && chars[index] <= '9' {
        index += 1
      }
    }
    if index < chars.length() && chars[index] == '[' {
      let mut cursor = index + 1
      let mut negative = false
      if chars[cursor] == '-' || chars[cursor] == '+' {
        negative = chars[cursor] == '-'
        cursor += 1
      }
      let mut hours = 0
      while cursor < chars.length() &&
            chars[cursor] >= '0' &&
            chars[cursor] <= '9' {
        hours = hours * 10 + (chars[cursor].to_int() - 48)
        cursor += 1
      }
      let mut fraction = 0
      let mut fraction_digits = 0
      if cursor < chars.length() && chars[cursor] == '.' {
        cursor += 1
        while cursor < chars.length() &&
              chars[cursor] >= '0' &&
              chars[cursor] <= '9' &&
              fraction_digits < 3 {
          fraction = fraction * 10 + (chars[cursor].to_int() - 48)
          fraction_digits += 1
          cursor += 1
        }
      }
      while fraction_digits < 3 {
        fraction = fraction * 10
        fraction_digits += 1
      }
      let magnitude = hours * 60 + fraction * 60 / 1000
      offset_minutes = if negative { -magnitude } else { magnitude }
      if offset_minutes > 1440 || offset_minutes < -1440 {
        return None
      }
    }
  }
  Some({ year, month, day, hour, minute, second, offset_minutes, })
}

///|
/// Whether the stamp is already in UTC, i.e. declares no offset.
pub fn PostedDate::is_utc(self : PostedDate) -> Bool {
  self.offset_minutes == 0
}

///|
/// The date as `YYYY-MM-DD`, in the zone the document declared.
pub fn PostedDate::date_string(self : PostedDate) -> String {
  self.year.to_string() +
  "-" +
  self.month.to_string().pad_start(2, '0') +
  "-" +
  self.day.to_string().pad_start(2, '0')
}

///|
/// The instant as `YYYYMMDDHHMMSS` in UTC, with the day, month and year carried
/// where the offset crosses a boundary.
pub fn PostedDate::to_utc_string(self : PostedDate) -> String {
  let mut total = (self.hour * 60 + self.minute) * 60 +
    self.second -
    self.offset_minutes * 60
  let mut year = self.year
  let mut month = self.month
  let mut day = self.day
  if total < 0 {
    total += 86400
    let shifted = shift_days(year, month, day, -1)
    year = shifted.0
    month = shifted.1
    day = shifted.2
  } else if total >= 86400 {
    total -= 86400
    let shifted = shift_days(year, month, day, 1)
    year = shifted.0
    month = shifted.1
    day = shifted.2
  }
  year.to_string().pad_start(4, '0') +
  month.to_string().pad_start(2, '0') +
  day.to_string().pad_start(2, '0') +
  (total / 3600).to_string().pad_start(2, '0') +
  (total / 60 % 60).to_string().pad_start(2, '0') +
  (total % 60).to_string().pad_start(2, '0')
}

///|
/// The instant as `YYYY-MM-DDTHH:MM:SSZ` in UTC, for logs and JSON.
pub fn PostedDate::to_iso_string(self : PostedDate) -> String {
  let utc = self.to_utc_string()
  let chars = utc.iter().to_array()
  let out = StringBuilder()
  for index in 0.. (Int, Int, Int) {
  if delta > 0 {
    let last = days_in_month(year, month)
    if day < last {
      (year, month, day + 1)
    } else if month < 12 {
      (year, month + 1, 1)
    } else {
      (year + 1, 1, 1)
    }
  } else if day > 1 {
    (year, month, day - 1)
  } else if month > 1 {
    (year, month - 1, days_in_month(year, month - 1))
  } else {
    (year - 1, 12, 31)
  }
}

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

///|
/// The number of days in a month, or `None` when `month` is outside 1 to 12.
pub fn month_length(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
  }
}

///|
/// Whether a string is a date this importer accepts, without building one.
///
/// This is the same calendar check [`parse_posted_date`] applies, exposed for a
/// caller that only needs the yes-or-no answer, for example when validating a
/// `DTSTART`/`DTEND` pair before storing it.
pub fn is_valid_ofx_date(value : String) -> Bool {
  valid_date(value)
}

///|
/// Days since 1970-01-01 for a proleptic Gregorian date.
///
/// Howard Hinnant's `days_from_civil`: it turns a calendar date into a
/// continuous day number, which is what makes comparing two instants, counting
/// the days between them and shifting a date by a number of days all integer
/// arithmetic instead of a pile of month-length special cases.
fn days_from_civil(year : Int, month : Int, day : Int) -> Int {
  let y = if month <= 2 { year - 1 } else { year }
  let era = (if y >= 0 { y } else { y - 399 }) / 400
  let yoe = y - era * 400
  let mp = (month + 9) % 12
  let doy = (153 * mp + 2) / 5 + day - 1
  let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
  era * 146097 + doe - 719468
}

///|
/// The inverse of [`days_from_civil`]: the calendar date of a day number.
fn civil_from_days(days : Int) -> (Int, Int, Int) {
  let shifted = days + 719468
  let era = (if shifted >= 0 { shifted } else { shifted - 146096 }) / 146097
  let doe = shifted - era * 146097
  let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
  let y = yoe + era * 400
  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
  let mp = (5 * doy + 2) / 153
  let day = doy - (153 * mp + 2) / 5 + 1
  let month = if mp < 10 { mp + 3 } else { mp - 9 }
  (if month <= 2 { y + 1 } else { y }, month, day)
}

///|
/// The instant as seconds since the Unix epoch in UTC, ignoring the fractional
/// seconds an OFX stamp may carry.
pub fn PostedDate::to_epoch_seconds(self : PostedDate) -> Int64 {
  let days = days_from_civil(self.year, self.month, self.day)
  let seconds = days * 86400 +
    self.hour * 3600 +
    self.minute * 60 +
    self.second -
    self.offset_minutes * 60
  seconds.to_int64()
}

///|
/// The weekday of the date in the zone it declares, where `0` is Sunday.
pub fn PostedDate::weekday(self : PostedDate) -> Int {
  let days = days_from_civil(self.year, self.month, self.day)
  (days % 7 + 11) % 7
}

///|
/// The English name of [`PostedDate::weekday`], for logs and reports.
pub fn PostedDate::weekday_name(self : PostedDate) -> String {
  match self.weekday() {
    0 => "Sunday"
    1 => "Monday"
    2 => "Tuesday"
    3 => "Wednesday"
    4 => "Thursday"
    5 => "Friday"
    _ => "Saturday"
  }
}

///|
/// The date shifted by a whole number of days, carrying month and year.
///
/// `add_days(-1)` on the first of a month lands on the last day of the previous
/// one, including across a leap year.
pub fn PostedDate::add_days(self : PostedDate, days : Int) -> PostedDate {
  let shifted = civil_from_days(
    days_from_civil(self.year, self.month, self.day) + days,
  )
  {
    year: shifted.0,
    month: shifted.1,
    day: shifted.2,
    hour: self.hour,
    minute: self.minute,
    second: self.second,
    offset_minutes: self.offset_minutes,
  }
}

///|
/// Compare two instants in UTC: negative when `self` is earlier.
pub fn PostedDate::compare(self : PostedDate, other : PostedDate) -> Int {
  let left = self.to_epoch_seconds()
  let right = other.to_epoch_seconds()
  if left < right {
    -1
  } else if left > right {
    1
  } else {
    0
  }
}

///|
/// Whether the two instants are the same moment, wherever they were written.
///
/// `20260924120000[-5:EST]` and `20260924170000` are the same instant and this
/// returns `true`, which is what a reconciliation between two exports of the
/// same account needs.
pub fn PostedDate::same_instant(self : PostedDate, other : PostedDate) -> Bool {
  self.compare(other) == 0
}

///|
/// Whole days from `start` to `end`, comparing the instants in UTC.
///
/// A negative result means `end` is before `start`. The division floors, so a
/// difference of one hour still reports `0` days and a difference of
/// twenty-three hours reports `1` only when it crosses a UTC midnight.
pub fn days_between(start : PostedDate, end : PostedDate) -> Int {
  let seconds = end.to_epoch_seconds() - start.to_epoch_seconds()
  let days = seconds / 86400L
  let remainder = seconds % 86400L
  if remainder < 0L {
    (days - 1L).to_int()
  } else {
    days.to_int()
  }
}

///|
/// Build a date from its parts, or `None` when they are not a real calendar
/// moment.
///
/// This is the checked constructor the parser uses internally; a caller that
/// computes a date rather than reading one can use it to avoid inventing a
/// `PostedDate` that never existed.
pub fn PostedDate::of(
  year : Int,
  month : Int,
  day : Int,
  hour : Int,
  minute : Int,
  second : Int,
  offset_minutes : Int,
) -> PostedDate? {
  let length = match month_length(year, month) {
    Some(days) => days
    None => return None
  }
  if day < 1 || day > length {
    return None
  }
  let clock_valid = hour >= 0 &&
    hour <= 23 &&
    minute >= 0 &&
    minute <= 59 &&
    second >= 0 &&
    second <= 59
  if !clock_valid {
    return None
  }
  if offset_minutes > 1440 || offset_minutes < -1440 {
    return None
  }
  Some({ year, month, day, hour, minute, second, offset_minutes, })
}

///|
/// Midnight UTC on a date, or `None` when the date does not exist.
pub fn PostedDate::at_utc(year : Int, month : Int, day : Int) -> PostedDate? {
  PostedDate::of(year, month, day, 0, 0, 0, 0)
}

///|
/// The date as `YYYYMMDD`, the form OFX uses for a period boundary.
pub fn PostedDate::to_ofx_date(self : PostedDate) -> String {
  self.year.to_string().pad_start(4, '0') +
  self.month.to_string().pad_start(2, '0') +
  self.day.to_string().pad_start(2, '0')
}

///|
/// The instant as `YYYYMMDDHHMMSS` in the zone the document declared, which is
/// the inverse of [`parse_posted_date`] for a stamp without a zone bracket.
pub fn PostedDate::to_ofx_stamp(self : PostedDate) -> String {
  self.to_ofx_date() +
  self.hour.to_string().pad_start(2, '0') +
  self.minute.to_string().pad_start(2, '0') +
  self.second.to_string().pad_start(2, '0')
}

///|
/// Every date from `start` to `end` inclusive, or an empty array when `end` is
/// before `start`.
///
/// A statement period is a date range, and a caller that wants one row per day
/// (a cash-flow chart, a zero-activity check) should not have to write the
/// loop and the month-length rules again. The range is capped at 3660 days so a
/// reversed or absurd period cannot allocate without bound.
pub fn date_range(start : PostedDate, end : PostedDate) -> Array[PostedDate] {
  let dates : Array[PostedDate] = []
  let last = days_from_civil(end.year, end.month, end.day)
  let mut current = days_from_civil(start.year, start.month, start.day)
  let mut count = 0
  while current <= last && count < 3660 {
    let (year, month, day) = civil_from_days(current)
    match PostedDate::at_utc(year, month, day) {
      Some(date) => dates.push(date)
      None => ()
    }
    current += 1
    count += 1
  }
  dates
}

///|
/// Parse a period boundary such as `DTSTART`/`DTEND`.
///
/// OFX writes these as a date or as a full stamp; both are accepted, and the
/// result is the instant the document declares. `None` means the value is not a
/// date this importer accepts.
pub fn parse_period_date(value : String) -> PostedDate? {
  parse_posted_date(value)
}