///|
/// A UTC instant at which a fixed-offset time zone changes its offset.
///
/// The transition instant is stored as exact UTC microseconds, independent of
/// the selected epoch. The seconds offset applies at and after that instant.
pub struct TimeZoneTransition {
  priv at_microseconds : Int64
  priv offset_seconds : Int32
  priv abbreviation : String?
}

///|
/// Construct a transition at the instant represented by `at`.
///
/// The local fields and offset on `at` are converted to an instant first, so
/// callers may pass either a UTC value or a value carrying another fixed
/// offset.
pub fn timezone_transition(
  at : DateTime,
  offset_minutes : Int32,
) -> TimeZoneTransition raise DateError {
  validate_offset(offset_minutes)
  timezone_transition_seconds(at, offset_minutes * 60)
}

///|
/// Return the UTC ordinal at which this transition takes effect.
pub fn TimeZoneTransition::at(self : TimeZoneTransition) -> Double {
  unix_microseconds_to_ordinal(self.at_microseconds)
}

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

///|
/// A transition-aware zone, optionally loaded from caller-supplied IANA TZif bytes.
///
/// The transition array must be strictly increasing in UTC time. Before the first
/// transition, `initial_offset_seconds` applies; each transition then replaces
/// the previous offset.
pub struct DateTimeZone {
  priv initial_offset_seconds : Int32
  priv initial_abbreviation : String?
  priv transitions : Array[TimeZoneTransition]
  priv footer : @tztypes.PosixTzRule?
  priv dateutil_footer : Bool
  priv footer_start_seconds : Int64?
}

///|
/// Construct a transition-aware date zone.
///
/// ```mbt check
/// test {
///   let zone = try! @date.timezone(-300, transitions=[
///     try! @date.timezone_transition(@date.datetime(2024, 3, 10, hour=7), -240),
///   ])
///   let before = try! zone.to_datetime(
///     @date.datetime(2024, 3, 10, hour=6, minute=59).to_ordinal(),
///   )
///   let after = try! zone.to_datetime(
///     @date.datetime(2024, 3, 10, hour=7).to_ordinal(),
///   )
///   assert_eq((before.hour(), before.offset_minutes()), (1, -300))
///   assert_eq((after.hour(), after.offset_minutes()), (3, -240))
/// }
/// ```
pub fn timezone(
  initial_offset_minutes : Int32,
  transitions? : Array[TimeZoneTransition],
) -> DateTimeZone raise DateError {
  validate_offset(initial_offset_minutes)
  timezone_seconds(initial_offset_minutes * 60, transitions?)
}

///|
/// Construct an exact seconds-offset transition zone.
///
/// ```mbt check
/// test {
///   let zone = @date.timezone_seconds(561, transitions=[
///     @date.timezone_transition_seconds(@date.datetime(1911, 3, 11), 0),
///   ])
///   assert_eq(
///     zone.offset_seconds_at(@date.datetime(1900, 1, 1).to_ordinal()),
///     561,
///   )
/// }
/// ```
pub fn timezone_seconds(
  initial_offset_seconds : Int32,
  transitions? : Array[TimeZoneTransition],
) -> DateTimeZone raise DateError {
  validate_offset_seconds(initial_offset_seconds)
  let copied = match transitions {
    Some(values) => values.copy()
    None => []
  }
  let mut previous : Int64? = None
  for transition in copied {
    validate_offset_seconds(transition.offset_seconds)
    if transition.at_microseconds < -62_135_596_800_000_000L ||
      transition.at_microseconds >= 253_402_300_800_000_000L {
      raise DateError::InvalidDate(
        message="timezone transition is outside years 1..9999",
      )
    }
    match previous {
      Some(value) if transition.at_microseconds <= value =>
        raise DateError::InvalidDate(
          message="timezone transitions must be strictly increasing",
        )
      _ => ()
    }
    previous = Some(transition.at_microseconds)
  }
  {
    initial_offset_seconds,
    initial_abbreviation: None,
    transitions: copied,
    footer: None,
    dateutil_footer: false,
    footer_start_seconds: None,
  }
}

///|
/// Construct a zone with one fixed UTC offset and no transitions.
pub fn fixed_timezone(offset_minutes : Int32) -> DateTimeZone raise DateError {
  timezone(offset_minutes)
}

///|
/// Return initial whole minutes, truncated toward zero; use `initial_offset_seconds` for precision.
pub fn DateTimeZone::initial_offset_minutes(self : DateTimeZone) -> Int32 {
  self.initial_offset_seconds / 60
}

///|
/// Return a copy of the explicit transition table within years 1..9999.
/// POSIX footer events are evaluated on demand and are not materialized here.
pub fn DateTimeZone::transitions(
  self : DateTimeZone,
) -> Array[TimeZoneTransition] {
  self.transitions.copy()
}

///|
/// Resolve whole offset minutes, truncated toward zero. Use `offset_seconds_at` for precision.
pub fn DateTimeZone::offset_at(
  self : DateTimeZone,
  value : Double,
) -> Int32 raise DateError {
  self.offset_seconds_at(value) / 60
}

///|
/// Resolve the exact seconds offset for a UTC ordinal.
pub fn DateTimeZone::offset_seconds_at(
  self : DateTimeZone,
  value : Double,
) -> Int32 raise DateError {
  let (offset, _) = self.offset_info_at(value)
  offset
}

///|
fn DateTimeZone::offset_info_at(
  self : DateTimeZone,
  value : Double,
) -> (Int32, String?) raise DateError {
  self.offset_info_at_microseconds(ordinal_to_unix_microseconds(value))
}

///|
/// Convert a UTC ordinal to local Gregorian fields using this zone.
pub fn DateTimeZone::to_datetime(
  self : DateTimeZone,
  value : Double,
) -> DateTime raise DateError {
  let (offset_seconds, abbreviation) = self.offset_info_at(value)
  let date = from_ordinal(value, offset_seconds~)
  self.from_utc_calendar(date, offset_seconds, abbreviation)
}

///|
// Calendar generators emit date2num coordinates, whose origin is whole seconds.
// They must not acquire the public num2date subsecond-epoch displacement.
fn DateTimeZone::generated_datetime(
  self : DateTimeZone,
  value : Double,
) -> DateTime raise DateError {
  let instant = ordinal_to_unix_microseconds(value, generated=true)
  let (offset, abbreviation) = self.offset_info_at_microseconds(instant)
  let date = decoded_ordinal(value, offset, true)
  self.from_utc_calendar(date, offset, abbreviation)
}

///|
/// Convert local fields to a UTC ordinal using this zone's transition rules.
///
/// The local value's offset selects one side of an ambiguous fall transition.
/// A local time inside a skipped spring transition raises `DateError`.
pub fn DateTimeZone::to_ordinal(
  self : DateTimeZone,
  value : DateTime,
) -> Double raise DateError {
  let candidates = self.local_ordinal_candidates(value)
  if candidates.is_empty() {
    raise DateError::InvalidDate(
      message="local time does not exist in timezone",
    )
  }
  let preferred = value.to_ordinal()
  if candidates.contains(preferred) {
    preferred
  } else {
    candidates[0]
  }
}

///|
// Return every UTC instant that has these local fields in this zone. The
// result is empty for a spring-forward gap and contains two values for a
// fall-back fold.
fn DateTimeZone::local_ordinal_candidates(
  self : DateTimeZone,
  value : DateTime,
) -> Array[Double] {
  let offsets = self.offsets()
  if !offsets.contains(value.offset_seconds) {
    offsets.push(value.offset_seconds)
  }
  let result : Array[Double] = []
  for offset in offsets {
    let instant = locator_microseconds(value) -
      Int64::from_int(offset) * 1_000_000L
    if instant < -62_135_596_800_000_000L || instant >= 253_402_300_800_000_000L {
      continue
    }
    let (active, _) = self.offset_info_at_microseconds(instant) catch {
      _ => continue
    }
    if active == offset {
      let ordinal = unix_microseconds_to_ordinal(instant)
      if !result.contains(ordinal) {
        result.push(ordinal)
      }
    }
  }
  result.sort()
  result
}

///|
/// Convert an instant represented by this value to a transition-aware zone.
pub fn DateTime::with_timezone(
  self : DateTime,
  zone : DateTimeZone,
) -> DateTime raise DateError {
  let instant = rruleset_instant(self)
  if instant < -62_135_596_800_000_000L || instant >= 253_402_300_800_000_000L {
    raise DateError::InvalidDate(message="instant is outside years 1..9999")
  }
  let (offset, name) = zone.offset_info_at_microseconds(instant)
  zone.from_utc_calendar(
    datetime_from_microseconds(
      instant + Int64::from_int(offset) * 1_000_000L,
      offset,
    ),
    offset,
    name,
  )
}

///|
fn validate_timezone_ordinal(value : Double) -> Unit raise DateError {
  ignore(ordinal_to_unix_microseconds(value))
}

///|
/// Construct a transition using an exact seconds offset east of UTC.
pub fn timezone_transition_seconds(
  at : DateTime,
  offset_seconds : Int32,
) -> TimeZoneTransition raise DateError {
  validate_offset_seconds(offset_seconds)
  { at_microseconds: rruleset_instant(at), offset_seconds, abbreviation: None, }
}

///|
/// Return the exact offset that takes effect at this transition.
pub fn TimeZoneTransition::offset_seconds(self : TimeZoneTransition) -> Int32 {
  self.offset_seconds
}

///|
/// Construct a fixed zone with an exact seconds offset east of UTC.
pub fn fixed_timezone_seconds(
  offset_seconds : Int32,
) -> DateTimeZone raise DateError {
  timezone_seconds(offset_seconds)
}

///|
/// Return the exact initial offset east of UTC, in seconds.
pub fn DateTimeZone::initial_offset_seconds(self : DateTimeZone) -> Int32 {
  self.initial_offset_seconds
}

///|
// All possible offsets supply local conversion candidates and a safe heap
// watermark, including historical and POSIX future-rule offsets.
fn DateTimeZone::offsets(self : DateTimeZone) -> Array[Int32] {
  let offsets = [self.initial_offset_seconds]
  for transition in self.transitions {
    if !offsets.contains(transition.offset_seconds) {
      offsets.push(transition.offset_seconds)
    }
  }
  match self.footer {
    Some(rule) => {
      if !offsets.contains(rule.std_offset) {
        offsets.push(rule.std_offset)
      }
      match rule.dst_offset {
        Some(offset) => if !offsets.contains(offset) { offsets.push(offset) }
        None => ()
      }
    }
    None => ()
  }
  offsets
}

///|
// Exact datetime arithmetic uses integer instants. Its sub-microsecond
// classification must not round through Matplotlib's floating day ordinals.
fn DateTimeZone::offset_info_at_microseconds(
  self : DateTimeZone,
  value : Int64,
) -> (Int32, String?) raise DateError {
  // Local years 1..9999 can temporarily map just outside the UTC calendar
  // during aware arithmetic. Keep lookup available there, as datetime does;
  // public numeric conversions retain their own supported-range validation.
  let seconds = locator_floor_div64(value, 1_000_000L)
  if self.dateutil_footer && self.footer is Some(rule) {
    return dateutil_posix_info(rule, seconds)
  }
  match self.footer {
    Some(rule) if self.footer_start_seconds is None ||
      seconds >= self.footer_start_seconds.unwrap() =>
      match @tzposix.apply_posix_rule(rule, seconds) {
        Ok((offset, _, name)) => return (offset, Some(name))
        Err(_) =>
          raise DateError::InvalidDate(
            message="cannot resolve TZif future rule",
          )
      }
    _ => ()
  }
  let mut low = 0
  let mut high = self.transitions.length()
  while low < high {
    let middle = low + (high - low) / 2
    if self.transitions[middle].at_microseconds <= value {
      low = middle + 1
    } else {
      high = middle
    }
  }
  if low == 0 {
    (self.initial_offset_seconds, self.initial_abbreviation)
  } else {
    let transition = self.transitions[low - 1]
    (transition.offset_seconds, transition.abbreviation)
  }
}

///|
/// Unit equality for retained rule-zone identities and fixed-offset snapshots.
/// Fixed offsets compare by seconds, including independently constructed values;
/// rule-based zones retain their object identity.
pub fn DateTimeZone::unit_equal(
  self : DateTimeZone,
  other : DateTimeZone,
) -> Bool {
  physical_equal(self, other) ||
  (
    self.footer is None &&
    other.footer is None &&
    self.transitions.is_empty() &&
    other.transitions.is_empty() &&
    self.initial_offset_seconds == other.initial_offset_seconds
  )
}