///|
/// An exact instant paired with a time zone and a calendar, so that it has a
/// meaningful wall-clock reading.
///
/// This is the only Temporal type that is both an exact time and a calendar
/// date, which makes it the one where daylight-saving transitions are visible:
/// a day may be 23 or 25 hours long, and adding "one day" is not the same as
/// adding 24 hours.
///
/// Reference: 
pub struct ZonedDateTime {
  instant : Instant
  time_zone : TimeZone
  calendar : Calendar
  /// The offset in force at `instant`, cached so that reading the wall-clock
  /// fields does not need the provider again.
  offset : UtcOffset
} derive(Eq)

///|
pub impl Show for ZonedDateTime with fn output(self, logger) {
  logger.write_string(
    try! self.to_string_with_options(
      ToStringRoundingOptions::default(),
      DisplayOffset::default(),
      DisplayTimeZone::default(),
      DisplayCalendar::default(),
    ),
  )
}

///|
pub impl Debug for ZonedDateTime with fn to_repr(self) {
  Repr::Repr(self.to_string())
}

///|
/// Creates a zoned date-time from an instant and a time zone.
///
/// ```mbt check
/// test {
///   let zdt = @temporal.ZonedDateTime::try_new(
///     @int128.of_int64(1740827770000000000L),
///     @temporal.TimeZone::OffsetZone(@temporal.UtcOffset::from_minutes(-360)),
///     @temporal.utc_only_provider,
///   )
///   inspect(zdt.hour(), content="5")
/// }
/// ```
pub fn[P : TimeZoneProvider] ZonedDateTime::try_new(
  epoch_nanoseconds : @int128.Int128,
  time_zone : TimeZone,
  provider : P,
  calendar? : Calendar = Calendar::ISO,
) -> ZonedDateTime raise TemporalError {
  let instant = Instant::from_epoch_nanoseconds(epoch_nanoseconds)
  let offset = time_zone.utc_offset_for(epoch_nanoseconds, provider)
  { instant, time_zone, calendar, offset }
}

///|
/// Returns the underlying instant.
pub fn ZonedDateTime::to_instant(self : ZonedDateTime) -> Instant {
  self.instant
}

///|
/// Returns the nanoseconds since the epoch.
pub fn ZonedDateTime::epoch_nanoseconds(self : ZonedDateTime) -> @int128.Int128 {
  self.instant.epoch_nanoseconds
}

///|
/// Returns the milliseconds since the epoch.
pub fn ZonedDateTime::epoch_milliseconds(self : ZonedDateTime) -> Int64 {
  self.instant.epoch_milliseconds()
}

///|
/// Returns the time zone.
pub fn ZonedDateTime::time_zone(self : ZonedDateTime) -> TimeZone {
  self.time_zone
}

///|
/// Returns the calendar.
pub fn ZonedDateTime::calendar(self : ZonedDateTime) -> Calendar {
  self.calendar
}

///|
/// Returns the UTC offset in force at this instant.
pub fn ZonedDateTime::offset(self : ZonedDateTime) -> UtcOffset {
  self.offset
}

///|
/// Returns the local wall-clock date and time.
pub fn ZonedDateTime::to_plain_date_time(self : ZonedDateTime) -> PlainDateTime {
  PlainDateTime::from_iso(self.local_iso(), self.calendar)
}

///|
/// Returns the local date and time as an ISO record.
fn ZonedDateTime::local_iso(self : ZonedDateTime) -> IsoDateTime {
  IsoDateTime::from_epoch_nanoseconds(
    self.instant.epoch_nanoseconds,
    self.offset.nanoseconds(),
  )
}

///|
/// Returns the local date.
pub fn ZonedDateTime::to_plain_date(self : ZonedDateTime) -> PlainDate {
  PlainDate::from_iso(self.local_iso().date, self.calendar)
}

///|
/// Returns the local time.
pub fn ZonedDateTime::to_plain_time(self : ZonedDateTime) -> PlainTime {
  PlainTime::from_iso(self.local_iso().time)
}

///|
/// Returns the local year.
pub fn ZonedDateTime::year(self : ZonedDateTime) -> Int {
  self.local_iso().date.year
}

///|
/// Returns the local month, 1-based.
pub fn ZonedDateTime::month(self : ZonedDateTime) -> Int {
  self.local_iso().date.month
}

///|
/// Returns the local day of the month.
pub fn ZonedDateTime::day(self : ZonedDateTime) -> Int {
  self.local_iso().date.day
}

///|
/// Returns the local hour.
pub fn ZonedDateTime::hour(self : ZonedDateTime) -> Int {
  self.local_iso().time.hour
}

///|
/// Returns the local minute.
pub fn ZonedDateTime::minute(self : ZonedDateTime) -> Int {
  self.local_iso().time.minute
}

///|
/// Returns the local second.
pub fn ZonedDateTime::second(self : ZonedDateTime) -> Int {
  self.local_iso().time.second
}

///|
/// Returns the local millisecond.
pub fn ZonedDateTime::millisecond(self : ZonedDateTime) -> Int {
  self.local_iso().time.millisecond
}

///|
/// Returns the local microsecond within the millisecond.
pub fn ZonedDateTime::microsecond(self : ZonedDateTime) -> Int {
  self.local_iso().time.microsecond
}

///|
/// Returns the local nanosecond within the microsecond.
pub fn ZonedDateTime::nanosecond(self : ZonedDateTime) -> Int {
  self.local_iso().time.nanosecond
}

///|
/// Returns the local ISO day of the week, Monday = 1 through Sunday = 7.
pub fn ZonedDateTime::day_of_week(self : ZonedDateTime) -> Int {
  let date = self.local_iso().date
  iso_day_of_week(date.year, date.month, date.day)
}

///|
/// Returns the local 1-based day of the year.
pub fn ZonedDateTime::day_of_year(self : ZonedDateTime) -> Int {
  let date = self.local_iso().date
  iso_day_of_year(date.year, date.month, date.day)
}

///|
/// Compares two zoned date-times by the instant they name, ignoring their
/// time zones and calendars.
pub fn ZonedDateTime::compare_instant(
  self : ZonedDateTime,
  other : ZonedDateTime,
) -> Int {
  self.instant.compare(other.instant)
}

///|
/// Returns whether both name the same instant in the same zone and calendar.
pub fn ZonedDateTime::equals(
  self : ZonedDateTime,
  other : ZonedDateTime,
) -> Bool {
  self.instant == other.instant &&
  self.time_zone == other.time_zone &&
  self.calendar == other.calendar
}

///|
/// Returns a copy in a different time zone, naming the same instant.
pub fn[P : TimeZoneProvider] ZonedDateTime::with_time_zone(
  self : ZonedDateTime,
  time_zone : TimeZone,
  provider : P,
) -> ZonedDateTime raise TemporalError {
  ZonedDateTime::try_new(
    self.instant.epoch_nanoseconds,
    time_zone,
    provider,
    calendar=self.calendar,
  )
}

///|
/// Returns the number of hours in the local day, which is 23 or 25 across a
/// daylight-saving transition.
pub fn[P : TimeZoneProvider] ZonedDateTime::hours_in_day(
  self : ZonedDateTime,
  provider : P,
) -> Double raise TemporalError {
  let local_iso = self.local_iso()
  let today = IsoDateTime::new_unchecked(local_iso.date, iso_time_midnight)
  let tomorrow_date = IsoDate::balance(
    local_iso.date.year,
    local_iso.date.month,
    local_iso.date.day + 1,
  )
  let tomorrow = IsoDateTime::new_unchecked(tomorrow_date, iso_time_midnight)
  let start = self.time_zone.epoch_nanoseconds_for(today, Compatible, provider).nanoseconds
  let end = self.time_zone.epoch_nanoseconds_for(tomorrow, Compatible, provider).nanoseconds
  exact_ratio_to_double(end.sub(start), @int128.of_int64(NS_PER_HOUR))
}

///|
/// `AddDurationToZonedDateTime`: adds a duration.
///
/// The calendar part is applied to the local wall-clock date and re-resolved
/// through the time zone, so that "one day later" stays at the same local
/// time even across a transition. The time part is then added as exact
/// elapsed time.
pub fn[P : TimeZoneProvider] ZonedDateTime::add(
  self : ZonedDateTime,
  duration : Duration,
  provider : P,
  overflow? : Overflow = Constrain,
) -> ZonedDateTime raise TemporalError {
  let internal = duration.to_internal()
  let epoch = self.add_internal_duration(internal, provider, overflow)
  ZonedDateTime::try_new(
    epoch,
    self.time_zone,
    provider,
    calendar=self.calendar,
  )
}

///|
/// Subtracts a duration.
pub fn[P : TimeZoneProvider] ZonedDateTime::subtract(
  self : ZonedDateTime,
  duration : Duration,
  provider : P,
  overflow? : Overflow = Constrain,
) -> ZonedDateTime raise TemporalError {
  self.add(duration.negated(), provider, overflow~)
}

///|
/// `AddZonedDateTime`: returns the instant reached by adding an internal
/// duration record.
fn[P : TimeZoneProvider] ZonedDateTime::add_internal_duration(
  self : ZonedDateTime,
  duration : InternalDurationRecord,
  provider : P,
  overflow : Overflow,
) -> @int128.Int128 raise TemporalError {
  // With no calendar part there is nothing zone-dependent to resolve, so the
  // time can be added straight to the instant.
  if duration.date.sign() is Zero {
    return self.instant.epoch_nanoseconds.add(duration.time.nanoseconds())
  }
  let local_iso = self.local_iso()
  let added_date = local_iso.date.add_date_duration(duration.date, overflow)
  let intermediate = IsoDateTime::new_unchecked(added_date, local_iso.time)
  let intermediate_ns = self.time_zone.epoch_nanoseconds_for(
      intermediate,
      Compatible,
      provider,
    ).nanoseconds
  intermediate_ns.add(duration.time.nanoseconds())
}

///|
/// `DifferenceTemporalZonedDateTime`.
fn[P : TimeZoneProvider] ZonedDateTime::diff(
  self : ZonedDateTime,
  operation : DifferenceOperation,
  other : ZonedDateTime,
  settings : DifferenceSettings,
  provider : P,
) -> Duration raise TemporalError {
  if self.calendar != other.calendar {
    raise RangeError("cannot compare zoned date-times in different calendars")
  }
  let resolved = ResolvedRoundingOptions::from_diff_settings(
    settings,
    operation,
    UnitGroup::DateTime,
    Hour,
    Nanosecond,
  )
  // A difference measured purely in time units does not depend on the zone,
  // so it reduces to a difference between the two instants.
  if resolved.largest_unit.is_time_unit() {
    let internal = self.instant.diff_internal(other.instant, resolved)
    let result = Duration::from_internal(internal, resolved.largest_unit)
    return match operation {
      Until => result
      Since => result.negated()
    }
  }
  // Day lengths differ between zones, so a difference that reaches days or
  // larger is only meaningful within one zone.
  if self.time_zone != other.time_zone {
    raise RangeError(
      "time zones must match when the difference uses date units",
    )
  }
  if self.instant == other.instant {
    return duration_zero
  }
  let internal = self.diff_with_rounding(other.instant, resolved, provider)
  // The date part is already in calendar units; only the time part is
  // expanded, and it never exceeds a day here.
  let result = Duration::from_internal(internal, Hour)
  match operation {
    Until => result
    Since => result.negated()
  }
}

///|
/// `DifferenceZonedDateTimeWithRounding`.
fn[P : TimeZoneProvider] ZonedDateTime::diff_with_rounding(
  self : ZonedDateTime,
  other : Instant,
  options : ResolvedRoundingOptions,
  provider : P,
) -> InternalDurationRecord raise TemporalError {
  if options.largest_unit.is_time_unit() {
    return self.instant.diff_internal(other, options)
  }
  let diff = self.diff_zoned_date_time(other, options.largest_unit, provider)
  if options.smallest_unit is Nanosecond && options.increment.get() == 1 {
    return diff
  }
  diff.round_relative_duration(
    self.instant.epoch_nanoseconds,
    other.epoch_nanoseconds,
    self.to_plain_date_time(),
    Some(self.time_zone),
    provider,
    options,
  )
}

///|
/// `DifferenceZonedDateTime`: the unrounded difference between two instants in
/// this zone.
///
/// The calendar walk is done on local wall-clock dates, but a day boundary in
/// local time need not be a whole day of elapsed time. The loop below backs the
/// end date off by up to two days until the leftover time agrees in sign with
/// the overall difference, which is what keeps the two parts consistent across
/// a daylight-saving transition.
fn[P : TimeZoneProvider] ZonedDateTime::diff_zoned_date_time(
  self : ZonedDateTime,
  other : Instant,
  largest_unit : DateTimeUnit,
  provider : P,
) -> InternalDurationRecord raise TemporalError {
  if self.instant.epoch_nanoseconds == other.epoch_nanoseconds {
    return internal_duration_zero
  }
  let start = self.local_iso()
  let end = IsoDateTime::from_epoch_nanoseconds(
    other.epoch_nanoseconds,
    self.time_zone.offset_nanoseconds_for(other.epoch_nanoseconds, provider),
  )
  if start.date == end.date {
    let time_duration = TimeDuration::from_nanosecond_difference(
      other.epoch_nanoseconds,
      self.instant.epoch_nanoseconds,
    )
    return InternalDurationRecord::new(DateDuration::default(), time_duration)
  }
  let difference = other.epoch_nanoseconds.sub(self.instant.epoch_nanoseconds)
  let sign = if difference.is_negative() { -1 } else { 1 }
  // Going forwards may need two corrections, because the end time can land
  // before the start time in wall-clock terms and then again after the shift.
  let max_correction = if sign > 0 { 2 } else { 1 }
  let initial_time_duration = start.time.diff(end.time)
  let mut day_correction = if initial_time_duration.sign() ==
    Sign::of_int(sign).negate() {
    1
  } else {
    0
  }
  let mut intermediate_date = end.date
  let mut time_duration = time_duration_zero
  let mut success = false
  while day_correction <= max_correction && !success {
    intermediate_date = IsoDate::balance(
      end.date.year,
      end.date.month,
      end.date.day - day_correction * sign,
    )
    let intermediate = IsoDateTime::new_unchecked(intermediate_date, start.time)
    let intermediate_ns = self.time_zone.epoch_nanoseconds_for(
        intermediate,
        Compatible,
        provider,
      ).nanoseconds
    time_duration = TimeDuration::from_nanosecond_difference(
      other.epoch_nanoseconds,
      intermediate_ns,
    )
    if sign != -time_duration.sign().to_int() {
      success = true
    }
    day_correction = day_correction + 1
  }
  if !success {
    raise AssertError("no day correction reconciled the zoned difference")
  }
  let date_largest = largest_unit.larger(Day)
  let date_diff = start.date.diff_iso_date(intermediate_date, date_largest)
  InternalDurationRecord::new(date_diff, time_duration)
}

///|
/// Returns the duration from this zoned date-time until `other`.
pub fn[P : TimeZoneProvider] ZonedDateTime::until(
  self : ZonedDateTime,
  other : ZonedDateTime,
  provider : P,
  settings? : DifferenceSettings = DifferenceSettings::default(),
) -> Duration raise TemporalError {
  self.diff(Until, other, settings, provider)
}

///|
/// Returns the duration from `other` until this zoned date-time.
pub fn[P : TimeZoneProvider] ZonedDateTime::since(
  self : ZonedDateTime,
  other : ZonedDateTime,
  provider : P,
  settings? : DifferenceSettings = DifferenceSettings::default(),
) -> Duration raise TemporalError {
  self.diff(Since, other, settings, provider)
}

///|
/// Returns the instant at the start of the local day.
///
/// This is usually midnight, but on a day whose midnight does not exist it is
/// the first instant the day does reach.
pub fn[P : TimeZoneProvider] ZonedDateTime::start_of_day(
  self : ZonedDateTime,
  provider : P,
) -> ZonedDateTime raise TemporalError {
  let local_iso = self.local_iso()
  let midnight = IsoDateTime::new_unchecked(local_iso.date, iso_time_midnight)
  let epoch = self.time_zone.epoch_nanoseconds_for(
      midnight,
      Compatible,
      provider,
    ).nanoseconds
  ZonedDateTime::try_new(
    epoch,
    self.time_zone,
    provider,
    calendar=self.calendar,
  )
}

///|
/// Parses a zoned date-time from an RFC 9557 string.
///
/// The string must carry a time zone annotation, such as
/// `2025-03-01T11:16:10Z[UTC]`.
///
/// ```mbt check
/// test {
///   let zdt = @temporal.ZonedDateTime::of_string(
///     "2025-03-01T11:16:10Z[UTC]", @temporal.utc_only_provider,
///   )
///   inspect(zdt.hour(), content="11")
/// }
/// ```
pub fn[P : TimeZoneProvider] ZonedDateTime::of_string(
  source : String,
  provider : P,
  disambiguation? : Disambiguation = Compatible,
  offset_option? : OffsetDisambiguation = Reject,
) -> ZonedDateTime raise TemporalError {
  let record = parse_zoned_date_time_string(source)
  let date = temporal_unwrap(record.date, "date component")
  let time_zone = temporal_unwrap(record.time_zone, "time zone annotation")
  let calendar = calendar_of(record)
  let iso_date = IsoDate::new_with_overflow(
    date.year,
    date.month,
    date.day,
    Reject,
  )
  let iso = IsoDateTime::new(iso_date, time_or_midnight(record))
  let epoch = match record.offset {
    // With no offset in the string, the local time is resolved through the
    // zone's own rules.
    None =>
      time_zone.epoch_nanoseconds_for(iso, disambiguation, provider).nanoseconds
    Some(ZDesignator) =>
      // `Z` names an exact instant directly.
      iso.as_nanoseconds()
    Some(NumericOffset(offset)) =>
      resolve_offset(
        iso, offset, time_zone, disambiguation, offset_option, provider,
      )
  }
  ZonedDateTime::try_new(epoch, time_zone, provider, calendar~)
}

///|
/// Reconciles an explicit offset in a string against the zone's own rules.
fn[P : TimeZoneProvider] resolve_offset(
  iso : IsoDateTime,
  offset : UtcOffset,
  time_zone : TimeZone,
  disambiguation : Disambiguation,
  offset_option : OffsetDisambiguation,
  provider : P,
) -> @int128.Int128 raise TemporalError {
  match offset_option {
    // `use` trusts the string's offset outright.
    Use => iso.as_nanoseconds().sub(@int128.of_int64(offset.nanoseconds()))
    // `ignore` discards it and resolves through the zone.
    Ignore =>
      time_zone.epoch_nanoseconds_for(iso, disambiguation, provider).nanoseconds
    Prefer | Reject => {
      let candidates = time_zone.possible_epoch_nanoseconds_for(iso, provider)
      let target = iso
        .as_nanoseconds()
        .sub(@int128.of_int64(offset.nanoseconds()))
      let matched = match candidates {
        One(ns, _) => if ns == target { Some(ns) } else { None }
        Two(first, _, second, _) =>
          if first == target {
            Some(first)
          } else if second == target {
            Some(second)
          } else {
            None
          }
        Zero(_, _) => None
      }
      match matched {
        Some(ns) => ns
        None =>
          if offset_option is Reject {
            raise RangeError(
              "the UTC offset in the string does not match the time zone",
            )
          } else {
            time_zone.epoch_nanoseconds_for(iso, disambiguation, provider).nanoseconds
          }
      }
    }
  }
}

///|
/// Renders the zoned date-time in RFC 9557 form.
pub fn ZonedDateTime::to_string_with_options(
  self : ZonedDateTime,
  options : ToStringRoundingOptions,
  display_offset : DisplayOffset,
  display_time_zone : DisplayTimeZone,
  display_calendar : DisplayCalendar,
) -> String raise TemporalError {
  let resolved = options.resolve()
  let rounding = ResolvedRoundingOptions::from_to_string_options(resolved)
  let length = temporal_unwrap(
    rounding.smallest_unit.as_nanoseconds(),
    "time unit length",
  )
  let increment = @int128.of_int64(length).mul(
    @int128.of_int(rounding.increment.get()),
  )
  let rounded = IncrementRounder::from_signed_num(
    self.instant.epoch_nanoseconds,
    increment,
  ).round_as_if_positive(rounding.rounding_mode)
  let iso = IsoDateTime::from_epoch_nanoseconds(
    rounded,
    self.offset.nanoseconds(),
  )
  let buf = StringBuilder::new()
  write_date(buf, iso.date.year, iso.date.month, iso.date.day)
  buf.write_string("T")
  write_time(
    buf,
    iso.time.hour,
    iso.time.minute,
    iso.time.second,
    iso.time.subsecond_nanoseconds(),
    resolved.precision,
    true,
  )
  if display_offset is Auto {
    buf.write_string(self.offset.to_string())
  }
  match display_time_zone {
    Never => ()
    Auto => buf.write_string("[\{self.time_zone.identifier()}]")
    Critical => buf.write_string("[!\{self.time_zone.identifier()}]")
  }
  write_calendar_annotation(buf, self.calendar, display_calendar)
  buf.to_string()
}

///|
/// Renders the zoned date-time in RFC 9557 form with default options.
pub fn ZonedDateTime::to_string(self : ZonedDateTime) -> String {
  try! self.to_string_with_options(
    ToStringRoundingOptions::default(),
    DisplayOffset::default(),
    DisplayTimeZone::default(),
    DisplayCalendar::default(),
  )
}

///|
/// Rounds the zoned date-time to a multiple of the given unit.
///
/// Rounding to whole days uses the actual length of the local day, which may
/// be 23 or 25 hours across a daylight-saving transition.
pub fn[P : TimeZoneProvider] ZonedDateTime::round(
  self : ZonedDateTime,
  options : RoundingOptions,
  provider : P,
) -> ZonedDateTime raise TemporalError {
  let resolved = ResolvedRoundingOptions::from_datetime_options(options)
  if resolved.is_noop() {
    return self
  }
  let this_ns = self.instant.epoch_nanoseconds
  let local_iso = self.local_iso()
  if resolved.smallest_unit is Day {
    let start_date = local_iso.date
    let end_date = IsoDate::balance(
      start_date.year,
      start_date.month,
      start_date.day + 1,
    )
    let start = self.start_of_day_for(start_date, provider)
    let end = self.start_of_day_for(end_date, provider)
    if this_ns.compare(start.nanoseconds) < 0 {
      raise RangeError("the instant is outside the bounds of its local day")
    }
    // Clamp to just before the next day so that an instant exactly on the
    // boundary rounds within this day rather than the next.
    let clamped = this_ns.min(end.nanoseconds.sub(@int128.one))
    let day_length = end.nanoseconds.sub(start.nanoseconds)
    let progress = clamped.sub(start.nanoseconds)
    let rounded = if day_length.is_zero() {
      @int128.zero
    } else {
      IncrementRounder::from_signed_num(progress, day_length.abs()).round(
        resolved.rounding_mode,
      )
    }
    return ZonedDateTime::try_new(
      start.nanoseconds.add(rounded),
      self.time_zone,
      provider,
      calendar=self.calendar,
    )
  }
  let rounded = local_iso.round(resolved)
  // The rounded wall-clock time is re-anchored using the offset that was in
  // force, so that rounding does not silently jump across a transition.
  let epoch = resolve_offset(
    rounded,
    self.offset,
    self.time_zone,
    Compatible,
    Prefer,
    provider,
  )
  ZonedDateTime::try_new(
    epoch,
    self.time_zone,
    provider,
    calendar=self.calendar,
  )
}

///|
/// Returns the first instant of the given local date in this time zone.
fn[P : TimeZoneProvider] ZonedDateTime::start_of_day_for(
  self : ZonedDateTime,
  date : IsoDate,
  provider : P,
) -> EpochNanosecondsAndOffset raise TemporalError {
  self.time_zone.epoch_nanoseconds_for(
    IsoDateTime::new_unchecked(date, iso_time_midnight),
    Compatible,
    provider,
  )
}