///|
/// A fixed offset from UTC, stored in nanoseconds.
///
/// Offsets used as time zone identifiers are restricted to minute precision;
/// sub-minute offsets only arise when parsing an RFC 9557 string that carries
/// one, and are rejected where the specification requires minute precision.
pub struct UtcOffset(Int64) derive(Eq, Compare, Debug)

///|
pub impl Show for UtcOffset with fn output(self, logger) {
  logger.write_string(self.to_string())
}

///|
/// UTC itself, an offset of zero.
pub let utc_offset_zero : UtcOffset = UtcOffset(0L)

///|
/// Creates an offset from whole minutes.
///
/// ```mbt check
/// test {
///   inspect(@temporal.UtcOffset::from_minutes(-330), content="-05:30")
/// }
/// ```
pub fn UtcOffset::from_minutes(minutes : Int) -> UtcOffset {
  UtcOffset(minutes.to_int64() * NS_PER_MINUTE)
}

///|
/// Creates an offset from whole seconds.
pub fn UtcOffset::from_seconds(seconds : Int64) -> UtcOffset {
  UtcOffset(seconds * NS_PER_SECOND)
}

///|
/// Creates an offset from nanoseconds.
pub fn UtcOffset::from_nanoseconds(nanoseconds : Int64) -> UtcOffset {
  UtcOffset(nanoseconds)
}

///|
/// Returns the offset in nanoseconds.
pub fn UtcOffset::nanoseconds(self : UtcOffset) -> Int64 {
  self.0
}

///|
/// Returns the offset in whole seconds, truncated toward zero.
pub fn UtcOffset::seconds(self : UtcOffset) -> Int64 {
  self.0 / NS_PER_SECOND
}

///|
/// Returns the offset in whole minutes, truncated toward zero.
pub fn UtcOffset::minutes(self : UtcOffset) -> Int {
  (self.0 / NS_PER_MINUTE).to_int()
}

///|
/// Returns whether the offset carries finer detail than whole minutes.
pub fn UtcOffset::is_sub_minute(self : UtcOffset) -> Bool {
  self.0 % NS_PER_MINUTE != 0L
}

///|
/// Renders the offset as `±HH:MM`, extending to seconds and fractions only
/// when the offset needs them.
///
/// ```mbt check
/// test {
///   inspect(@temporal.UtcOffset::from_minutes(0), content="+00:00")
///   inspect(@temporal.UtcOffset::from_seconds(3661), content="+01:01:01")
/// }
/// ```
pub fn UtcOffset::to_string(self : UtcOffset) -> String {
  let buf = StringBuilder::new()
  buf.write_string(if self.0 < 0L { "-" } else { "+" })
  let total = self.0.abs()
  let nanosecond = (total % NS_PER_SECOND).to_int()
  let seconds_left = total / NS_PER_SECOND
  let second = (seconds_left % 60L).to_int()
  let minutes_left = seconds_left / 60L
  let minute = (minutes_left % 60L).to_int()
  let hour = (minutes_left / 60L).to_int()
  let precision : Precision = if nanosecond == 0 && second == 0 {
    Minute
  } else {
    Auto
  }
  write_time(buf, hour, minute, second, nanosecond, precision, true)
  buf.to_string()
}

///|
/// A time zone: either a fixed offset from UTC or a named IANA zone.
///
/// Named zones are resolved through a [`TimeZoneProvider`], because the
/// transition data they need is far larger than a date library should embed.
/// The built-in [`utc_only_provider`] handles `UTC` and nothing else.
pub(all) enum TimeZone {
  /// A fixed offset from UTC, such as `+05:30`.
  OffsetZone(UtcOffset)
  /// A named IANA zone, such as `America/Chicago`.
  IanaZone(String)
} derive(Eq, Debug)

///|
pub impl Show for TimeZone with fn output(self, logger) {
  logger.write_string(self.identifier())
}

///|
/// The UTC time zone.
pub let time_zone_utc : TimeZone = IanaZone("UTC")

///|
/// Returns the time zone identifier, as `toString` would render it.
pub fn TimeZone::identifier(self : TimeZone) -> String {
  match self {
    OffsetZone(offset) => offset.to_string()
    IanaZone(name) => name
  }
}

///|
/// The possible instants a wall-clock time maps to in a time zone.
///
/// A time in a spring-forward gap has none, a time in a fall-back overlap has
/// two, and every other time has exactly one.
pub(all) enum CandidateEpochNanoseconds {
  /// The time falls in a gap. The two offsets are those in force immediately
  /// before and after the transition.
  Zero(UtcOffset, UtcOffset)
  /// The usual case: exactly one instant.
  One(@int128.Int128, UtcOffset)
  /// The time is ambiguous, given earliest first.
  Two(@int128.Int128, UtcOffset, @int128.Int128, UtcOffset)
} derive(Debug)

///|
/// An instant paired with the offset in force at that instant.
pub struct EpochNanosecondsAndOffset {
  /// Nanoseconds since the epoch.
  nanoseconds : @int128.Int128
  /// The UTC offset in force.
  offset : UtcOffset
} derive(Eq, Debug)

///|
/// Supplies the transition data for named IANA time zones.
///
/// Implement this to plug in a TZif database or any other source of zone
/// rules; the library itself embeds none.
pub trait TimeZoneProvider {
  /// Returns the offset in force in `identifier` at the given instant.
  fn offset_nanoseconds_for(Self, String, @int128.Int128) -> Int64 raise TemporalError
  /// Returns the instants that the local date-time maps to in `identifier`.
  fn candidates_for_local_datetime(Self, String, IsoDateTime) -> CandidateEpochNanoseconds raise TemporalError
}

///|
/// A provider that knows only `UTC`.
///
/// It is enough for fixed-offset work and for the `UTC` zone itself; any other
/// named zone raises a `RangeError`.
pub struct UtcOnlyProvider {
  /// Unused; the provider carries no state.
  priv marker : Unit
} derive(Debug)

///|
/// The shared [`UtcOnlyProvider`] instance.
pub let utc_only_provider : UtcOnlyProvider = { marker: () }

///|
pub impl TimeZoneProvider for UtcOnlyProvider with fn offset_nanoseconds_for(
  _self,
  identifier,
  _epoch,
) {
  if is_utc_identifier(identifier) {
    0L
  } else {
    raise RangeError(
      "time zone '\{identifier}' needs a TimeZoneProvider with IANA data",
    )
  }
}

///|
pub impl TimeZoneProvider for UtcOnlyProvider with fn candidates_for_local_datetime(
  _self,
  identifier,
  local_iso,
) {
  if is_utc_identifier(identifier) {
    One(local_iso.as_nanoseconds(), utc_offset_zero)
  } else {
    raise RangeError(
      "time zone '\{identifier}' needs a TimeZoneProvider with IANA data",
    )
  }
}

///|
/// Returns whether the identifier names UTC under one of its accepted
/// spellings.
fn is_utc_identifier(identifier : String) -> Bool {
  ascii_lowercase(identifier) is ("utc" | "etc/utc" | "etc/gmt" | "gmt")
}

///|
/// `GetOffsetNanosecondsFor`: the offset in force at the given instant.
pub fn[P : TimeZoneProvider] TimeZone::offset_nanoseconds_for(
  self : TimeZone,
  epoch_nanoseconds : @int128.Int128,
  provider : P,
) -> Int64 raise TemporalError {
  match self {
    OffsetZone(offset) => offset.nanoseconds()
    IanaZone(name) => provider.offset_nanoseconds_for(name, epoch_nanoseconds)
  }
}

///|
/// Returns the offset in force at the given instant.
pub fn[P : TimeZoneProvider] TimeZone::utc_offset_for(
  self : TimeZone,
  epoch_nanoseconds : @int128.Int128,
  provider : P,
) -> UtcOffset raise TemporalError {
  UtcOffset(self.offset_nanoseconds_for(epoch_nanoseconds, provider))
}

///|
/// `GetPossibleEpochNanoseconds`: the instants a local time maps to.
fn[P : TimeZoneProvider] TimeZone::possible_epoch_nanoseconds_for(
  self : TimeZone,
  local_iso : IsoDateTime,
  provider : P,
) -> CandidateEpochNanoseconds raise TemporalError {
  match self {
    OffsetZone(offset) => {
      // A fixed offset never has gaps or overlaps, so subtracting it gives the
      // one and only instant.
      let balanced = IsoDateTime::balance(
        local_iso.date.year,
        local_iso.date.month,
        local_iso.date.day.to_int64(),
        local_iso.time.hour.to_int64(),
        local_iso.time.minute.to_int64() - offset.minutes().to_int64(),
        local_iso.time.second.to_int64(),
        local_iso.time.millisecond.to_int64(),
        @int128.of_int(local_iso.time.microsecond),
        @int128.of_int(local_iso.time.nanosecond),
      )
      check_iso_days_range(balanced.date)
      One(balanced.as_nanoseconds(), offset)
    }
    IanaZone(name) => provider.candidates_for_local_datetime(name, local_iso)
  }
}

///|
/// `CheckISODaysRange`.
fn check_iso_days_range(date : IsoDate) -> Unit raise TemporalError {
  if date.to_epoch_days().abs() > 100_000_000L {
    raise RangeError("date is not within a valid ISO day range")
  }
}

///|
/// `GetEpochNanosecondsFor`: resolves a local time to a single instant.
pub fn[P : TimeZoneProvider] TimeZone::epoch_nanoseconds_for(
  self : TimeZone,
  local_iso : IsoDateTime,
  disambiguation : Disambiguation,
  provider : P,
) -> EpochNanosecondsAndOffset raise TemporalError {
  let candidates = self.possible_epoch_nanoseconds_for(local_iso, provider)
  self.disambiguate(candidates, local_iso, disambiguation, provider)
}

///|
/// `DisambiguatePossibleEpochNanoseconds`: picks one instant from the
/// candidates a local time maps to.
fn[P : TimeZoneProvider] TimeZone::disambiguate(
  self : TimeZone,
  candidates : CandidateEpochNanoseconds,
  local_iso : IsoDateTime,
  disambiguation : Disambiguation,
  provider : P,
) -> EpochNanosecondsAndOffset raise TemporalError {
  match candidates {
    One(ns, offset) => { nanoseconds: ns, offset }
    Two(first, first_offset, second, second_offset) =>
      match disambiguation {
        Compatible | Earlier => { nanoseconds: first, offset: first_offset }
        Later => { nanoseconds: second, offset: second_offset }
        Reject => raise RangeError("the local time is ambiguous")
      }
    Zero(before, after) => {
      if disambiguation is Reject {
        raise RangeError("the local time does not exist in this time zone")
      }
      // The local time falls in a gap. Shift it by the width of the gap and
      // resolve again: `earlier` steps back to just before the transition and
      // `later` steps forward to just after it.
      let gap_nanoseconds = after.nanoseconds() - before.nanoseconds()
      let shift = match disambiguation {
        Earlier => -gap_nanoseconds
        Compatible | Later => gap_nanoseconds
        Reject => raise AssertError("reject was handled above")
      }
      let shifted = IsoDateTime::balance(
        local_iso.date.year,
        local_iso.date.month,
        local_iso.date.day.to_int64(),
        local_iso.time.hour.to_int64(),
        local_iso.time.minute.to_int64(),
        local_iso.time.second.to_int64(),
        local_iso.time.millisecond.to_int64(),
        @int128.of_int(local_iso.time.microsecond),
        @int128.of_int(local_iso.time.nanosecond + shift.to_int()),
      )
      let candidates = self.possible_epoch_nanoseconds_for(shifted, provider)
      match candidates {
        One(ns, offset) => { nanoseconds: ns, offset }
        Two(first, first_offset, second, second_offset) =>
          match disambiguation {
            Earlier => { nanoseconds: first, offset: first_offset }
            Compatible | Later => { nanoseconds: second, offset: second_offset }
            Reject => raise AssertError("reject was handled above")
          }
        Zero(_, _) =>
          raise AssertError(
            "shifting out of a gap must not land in another gap",
          )
      }
    }
  }
}