///|
/// One local time type entry from a TZif file: a UTC offset, whether it
/// observes daylight saving, and its abbreviation (e.g. `"EST"`, `"EDT"`).
pub struct LocalTimeType {
  priv utc_offset : Int
  priv is_dst : Bool
  priv abbreviation : String
} derive(Eq, Hash, @debug.Debug)

///|
pub extend LocalTimeType with Eq::{equal}

///|
pub extend LocalTimeType with Eq::{not_equal}

///|
pub extend LocalTimeType with Hash::{hash, hash_combine}

///|
pub extend LocalTimeType with @debug.Debug::{to_repr}

///|
/// Constructs a local time type from its components, or `None` if
/// `utc_offset` is outside `±86399` seconds (`±23:59:59`), the range a
/// `FixedOffset` can hold; so every `LocalTimeType` has an offset the
/// time-zone lookups can report.
pub fn LocalTimeType::new(
  utc_offset : Int,
  is_dst : Bool,
  abbreviation : String,
) -> LocalTimeType? {
  if offset_in_range(utc_offset) {
    Some(LocalTimeType::{ utc_offset, is_dst, abbreviation, })
  } else {
    None
  }
}

///|
/// The signed number of seconds added to UTC to obtain local time under
/// this local time type.
pub fn LocalTimeType::utc_offset(self : LocalTimeType) -> Int {
  self.utc_offset
}

///|
/// Whether this local time type observes daylight saving.
pub fn LocalTimeType::is_dst(self : LocalTimeType) -> Bool {
  self.is_dst
}

///|
/// The abbreviation for this local time type (e.g. `"EST"`, `"EDT"`).
pub fn LocalTimeType::abbreviation(self : LocalTimeType) -> String {
  self.abbreviation
}

///|
/// The parsed contents of a TZif (RFC 8536) timezone-data file.
///
/// `transitions[i]` (a Unix instant) switches to the local time type
/// `local_time_types[transition_types[i]]`; `transitions` is sorted
/// ascending. `posix_tz` is the POSIX TZ footer, parsed, present only in
/// version 2 and later files, used to extrapolate offsets past the last
/// recorded transition.
pub struct TzifData {
  priv transitions : Array[Int64]
  priv transition_types : Array[Int]
  priv local_time_types : Array[LocalTimeType]
  priv leap_seconds : Array[(Int64, Int)]
  priv posix_tz : PosixTz?
} derive(Eq, Hash, @debug.Debug)

///|
pub extend TzifData with Eq::{equal}

///|
pub extend TzifData with Eq::{not_equal}

///|
pub extend TzifData with Hash::{hash, hash_combine}

///|
pub extend TzifData with @debug.Debug::{to_repr}

///|
/// A copy of the transitions array; changing it does not affect
/// this value.
pub fn TzifData::transitions(self : TzifData) -> Array[Int64] {
  self.transitions.copy()
}

///|
/// A copy of the transition types array; changing it does not affect
/// this value.
pub fn TzifData::transition_types(self : TzifData) -> Array[Int] {
  self.transition_types.copy()
}

///|
/// A copy of the local time types array; changing it does not affect
/// this value.
pub fn TzifData::local_time_types(self : TzifData) -> Array[LocalTimeType] {
  self.local_time_types.copy()
}

///|
/// Leap-second records `(transition, correction)`, parsed for fidelity but
/// not applied to civil-time arithmetic. Returns a copy.
pub fn TzifData::leap_seconds(self : TzifData) -> Array[(Int64, Int)] {
  self.leap_seconds.copy()
}

///|
/// The parsed POSIX TZ footer (e.g. `"EST5EDT,M3.2.0,M11.1.0"`), present
/// only in version 2 and later files and `None` when the footer is empty or
/// absent; `parse_tzif` has already rejected a malformed one.
pub fn TzifData::posix_tz(self : TzifData) -> PosixTz? {
  self.posix_tz
}

///|
fn read_i32_be(data : Bytes, offset : Int) -> Int {
  (data.at(offset).to_int() << 24) |
  (data.at(offset + 1).to_int() << 16) |
  (data.at(offset + 2).to_int() << 8) |
  data.at(offset + 3).to_int()
}

///|
fn read_i64_be(data : Bytes, offset : Int) -> Int64 {
  (data.at(offset).to_int64() << 56) |
  (data.at(offset + 1).to_int64() << 48) |
  (data.at(offset + 2).to_int64() << 40) |
  (data.at(offset + 3).to_int64() << 32) |
  (data.at(offset + 4).to_int64() << 24) |
  (data.at(offset + 5).to_int64() << 16) |
  (data.at(offset + 6).to_int64() << 8) |
  data.at(offset + 7).to_int64()
}

///|
/// The length of the NUL-terminated string starting at `offset`, or `None`
/// if no NUL occurs before `limit`.
fn cstring_length_within(data : Bytes, offset : Int, limit : Int) -> Int? {
  for i = offset {
    if i >= limit {
      break None
    }
    if data.at(i) == b'\x00' {
      break Some(i - offset)
    }
    continue i + 1
  }
}

///|
/// One parsed TZif data block (the portion following a 44-byte header),
/// using `time_size`-byte transition/leap times (`4` for a version-1
/// block, `8` for the version-2-and-later block that follows it).
priv struct RawBlock {
  transitions : Array[Int64]
  transition_types : Array[Int]
  local_time_types : Array[LocalTimeType]
  leap_seconds : Array[(Int64, Int)]
  /// The offset in `data` immediately following this block.
  next_offset : Int
}

///|
fn parse_block(data : Bytes, header_offset : Int, time_size : Int) -> RawBlock? {
  if data.length() < header_offset + 44 {
    return None
  }
  if data.at(header_offset).to_int() != b'T'.to_int() ||
    data.at(header_offset + 1).to_int() != b'Z'.to_int() ||
    data.at(header_offset + 2).to_int() != b'i'.to_int() ||
    data.at(header_offset + 3).to_int() != b'f'.to_int() {
    return None
  }
  let isutcnt = read_i32_be(data, header_offset + 20)
  let isstdcnt = read_i32_be(data, header_offset + 24)
  let leapcnt = read_i32_be(data, header_offset + 28)
  let timecnt = read_i32_be(data, header_offset + 32)
  let typecnt = read_i32_be(data, header_offset + 36)
  let charcnt = read_i32_be(data, header_offset + 40)
  if isutcnt < 0 ||
    isstdcnt < 0 ||
    leapcnt < 0 ||
    timecnt < 0 ||
    typecnt < 1 ||
    charcnt < 0 {
    return None
  }
  let data_size = timecnt.to_int64() * (time_size + 1).to_int64() +
    typecnt.to_int64() * 6L +
    charcnt.to_int64() +
    leapcnt.to_int64() * (time_size + 4).to_int64() +
    isstdcnt.to_int64() +
    isutcnt.to_int64()
  if data.length().to_int64() < header_offset.to_int64() + 44L + data_size {
    return None
  }
  let mut p = header_offset + 44
  let transitions = []
  for i = 0; i < timecnt; i = i + 1 {
    let transition = if time_size == 8 {
      read_i64_be(data, p)
    } else {
      read_i32_be(data, p).to_int64()
    }
    if i > 0 && transition <= transitions[i - 1] {
      return None
    }
    transitions.push(transition)
    p = p + time_size
  }
  let transition_types = []
  for i = 0; i < timecnt; i = i + 1 {
    let index = data.at(p).to_int()
    if index >= typecnt {
      return None
    }
    transition_types.push(index)
    p = p + 1
  }
  let local_time_types = []
  let designations_offset = p + typecnt * 6
  for i = 0; i < typecnt; i = i + 1 {
    let utc_offset = read_i32_be(data, p)
    let is_dst = data.at(p + 4).to_int() != 0
    let desigidx = data.at(p + 5).to_int()
    let name_offset = designations_offset + desigidx
    let name_len = match
      cstring_length_within(data, name_offset, designations_offset + charcnt) {
      None => return None
      Some(len) => len
    }
    let abbreviation = @utf8.decode_lossy(
      data.exact_view(start=name_offset, end=name_offset + name_len),
    )
    match LocalTimeType::new(utc_offset, is_dst, abbreviation) {
      None => return None
      Some(local_time_type) => local_time_types.push(local_time_type)
    }
    p = p + 6
  }
  p = designations_offset + charcnt
  let leap_seconds = []
  for i = 0; i < leapcnt; i = i + 1 {
    let t = if time_size == 8 {
      read_i64_be(data, p)
    } else {
      read_i32_be(data, p).to_int64()
    }
    let correction = read_i32_be(data, p + time_size)
    leap_seconds.push((t, correction))
    p = p + time_size + 4
  }
  p = p + isstdcnt + isutcnt
  Some(RawBlock::{
    transitions,
    transition_types,
    local_time_types,
    leap_seconds,
    next_offset: p,
  })
}

///|
/// Parses a TZif (RFC 8536) timezone-data byte buffer, or `None` if it is
/// malformed or truncated, or holds a UTC offset beyond `±23:59:59`. Also
/// rejected are structures that would make lookups misbehave: no local time
/// type, a transition naming a missing type, transitions that are not
/// strictly increasing, an abbreviation outside the designation block, and a
/// non-empty POSIX TZ footer that `parse_posix_tz` rejects.
///
/// For a version 2/3/4 file, only the 64-bit second data block (and the
/// POSIX TZ footer that follows it) is returned: the initial 32-bit block
/// exists solely for backward compatibility with readers that predate
/// 64-bit `time_t`.
pub fn parse_tzif(data : Bytes) -> TzifData? {
  match parse_block(data, 0, 4) {
    None => None
    Some(v1) =>
      if data.at(4).to_int() == 0 {
        Some(TzifData::{
          transitions: v1.transitions,
          transition_types: v1.transition_types,
          local_time_types: v1.local_time_types,
          leap_seconds: v1.leap_seconds,
          posix_tz: None,
        })
      } else {
        match parse_block(data, v1.next_offset, 8) {
          None => None
          Some(v2) =>
            match parse_posix_footer(data, v2.next_offset) {
              None => None
              Some(footer) => {
                let posix_tz = if footer == "" {
                  Some(None)
                } else {
                  parse_posix_tz(footer).map(rule => Some(rule))
                }
                posix_tz.map(posix_tz => TzifData::{
                  transitions: v2.transitions,
                  transition_types: v2.transition_types,
                  local_time_types: v2.local_time_types,
                  leap_seconds: v2.leap_seconds,
                  posix_tz,
                })
              }
            }
        }
      }
  }
}

///|
/// Parses the `\n`-delimited POSIX TZ string footer starting at `offset`,
/// or `None` if it is malformed.
fn parse_posix_footer(data : Bytes, offset : Int) -> String? {
  if data.length() < offset + 2 || data.at(offset).to_int() != b'\n'.to_int() {
    None
  } else {
    let nl2 = offset + 1 + cstring_length_until_newline(data, offset + 1)
    if nl2 >= data.length() || data.at(nl2).to_int() != b'\n'.to_int() {
      None
    } else {
      Some(@utf8.decode_lossy(data.exact_view(start=offset + 1, end=nl2)))
    }
  }
}

///|
/// The number of bytes from `offset` up to (not including) the next `\n`.
fn cstring_length_until_newline(data : Bytes, offset : Int) -> Int {
  for i = offset {
    if i >= data.length() || data.at(i) == b'\n' {
      break i - offset
    }
    continue i + 1
  }
}