///|
/// Rounding a duration against a reference point.
///
/// Calendar units have no fixed length, so rounding a duration that contains
/// them means asking where the reference point lands. The algorithm brackets
/// the true result between two candidate durations, `r1` and `r2`, measures how
/// far along that bracket the destination falls, and applies the rounding mode
/// to that fraction. The spec calls this "nudging".
///
/// Every fraction is kept as an exact numerator and denominator in 128-bit
/// integers, so no intermediate step loses precision.
///
/// Spec: 

///|
/// The outcome of one nudge step.
priv struct NudgeRecord {
  duration : InternalDurationRecord
  nudged_epoch_nanoseconds : @int128.Int128
  /// Whether rounding pushed the calendar unit up to the next value.
  expanded : Bool
}

///|
/// The bracket a nudge works within.
priv struct NudgeWindow {
  /// The candidate count of the smallest unit at the start of the bracket.
  r1 : @int128.Int128
  /// The candidate count one increment further along.
  r2 : @int128.Int128
  start_epoch_nanoseconds : @int128.Int128
  end_epoch_nanoseconds : @int128.Int128
  start_duration : DateDuration
  end_duration : DateDuration
}

///|
/// The reference point a relative rounding is measured against: a date-time
/// and, when the rounding is time-zone aware, the zone it sits in.
priv struct RelativeContext[P] {
  date_time : PlainDateTime
  time_zone : TimeZone?
  provider : P
}

///|
/// Resolves a local date-time to an instant, honouring the time zone when the
/// context has one.
fn[P : TimeZoneProvider] RelativeContext::epoch_nanoseconds_for(
  self : RelativeContext[P],
  local_iso : IsoDateTime,
) -> @int128.Int128 raise TemporalError {
  match self.time_zone {
    None => local_iso.as_nanoseconds()
    Some(zone) =>
      zone.epoch_nanoseconds_for(local_iso, Compatible, self.provider).nanoseconds
  }
}

///|
/// `ComputeNudgeWindow`: brackets the rounded value between two candidate
/// durations and finds the instants they map to.
fn[P : TimeZoneProvider] InternalDurationRecord::compute_nudge_window(
  self : InternalDurationRecord,
  sign : Int,
  origin_epoch_nanoseconds : @int128.Int128,
  context : RelativeContext[P],
  options : ResolvedRoundingOptions,
  additional_shift : Bool,
) -> NudgeWindow raise TemporalError {
  let increment = @int128.of_int(options.increment.get())
  let increment_x_sign = increment.mul(@int128.of_int(sign))
  let date = self.date
  let dt = context.date_time
  let (r1, r2, start_duration, end_duration) = match options.smallest_unit {
    Year => {
      let years = truncate_to_increment(@int128.of_int64(date.years), increment)
      let r1 = if additional_shift {
        years.add(increment_x_sign)
      } else {
        years
      }
      let r2 = r1.add(increment_x_sign)
      (
        r1,
        r2,
        DateDuration::new(int64_of(r1), 0L, 0L, 0L),
        DateDuration::new(int64_of(r2), 0L, 0L, 0L),
      )
    }
    Month => {
      let months = truncate_to_increment(
        @int128.of_int64(date.months),
        increment,
      )
      let r1 = if additional_shift {
        months.add(increment_x_sign)
      } else {
        months
      }
      let r2 = r1.add(increment_x_sign)
      (
        r1,
        r2,
        date.adjust(0L, months=int64_of(r1)),
        date.adjust(0L, months=int64_of(r2)),
      )
    }
    Week => {
      // Weeks are counted against the calendar walk from the start of the
      // duration's year-and-month span, so both ends have to be materialized
      // as dates first.
      let start = IsoDate::try_balance(
        dt.iso.date.year + date.years.to_int(),
        dt.iso.date.month + date.months.to_int(),
        dt.iso.date.day.to_int64(),
      )
      let end = IsoDate::try_balance(
        dt.iso.date.year + date.years.to_int(),
        dt.iso.date.month + date.months.to_int(),
        dt.iso.date.day.to_int64() + date.days,
      )
      let weeks_start = PlainDate::new_unchecked(start, dt.calendar)
      let weeks_end = PlainDate::new_unchecked(end, dt.calendar)
      let until = weeks_start.internal_diff_date(weeks_end, Week)
      let weeks = truncate_to_increment(
        @int128.of_int64(date.weeks + until.weeks),
        increment,
      )
      let r1 = weeks
      let r2 = weeks.add(increment_x_sign)
      (
        r1,
        r2,
        DateDuration::new(date.years, date.months, int64_of(r1), 0L),
        DateDuration::new(date.years, date.months, int64_of(r2), 0L),
      )
    }
    Day => {
      let days = truncate_to_increment(@int128.of_int64(date.days), increment)
      let r1 = days
      let r2 = days.add(increment_x_sign)
      (
        r1,
        r2,
        DateDuration::new(date.years, date.months, date.weeks, int64_of(r1)),
        DateDuration::new(date.years, date.months, date.weeks, int64_of(r2)),
      )
    }
    _ =>
      raise AssertError(
        "NudgeToCalendarUnit was invoked with unit '\{options.smallest_unit}'",
      )
  }
  // A zero start duration lands exactly on the origin, so the calendar walk
  // can be skipped.
  let start_epoch_nanoseconds = if start_duration.sign() is Zero {
    origin_epoch_nanoseconds
  } else {
    let start = dt.iso.date.add_date_duration(start_duration, Constrain)
    context.epoch_nanoseconds_for(
      IsoDateTime::new_unchecked(start, dt.iso.time),
    )
  }
  let end = dt.iso.date.add_date_duration(end_duration, Constrain)
  let end_epoch_nanoseconds = context.epoch_nanoseconds_for(
    IsoDateTime::new_unchecked(end, dt.iso.time),
  )
  {
    r1,
    r2,
    start_epoch_nanoseconds,
    end_epoch_nanoseconds,
    start_duration,
    end_duration,
  }
}

///|
/// Computes the nudge window, widening it by one increment when the
/// destination turns out to lie outside the first bracket.
///
/// Returns the window along with whether the widening happened, which the
/// caller reports as a calendar-unit expansion.
fn[P : TimeZoneProvider] InternalDurationRecord::compute_and_adjust_nudge_window(
  self : InternalDurationRecord,
  sign : Int,
  origin_epoch_nanoseconds : @int128.Int128,
  dest_epoch_nanoseconds : @int128.Int128,
  context : RelativeContext[P],
  options : ResolvedRoundingOptions,
) -> (NudgeWindow, Bool) raise TemporalError {
  let window = self.compute_nudge_window(
    sign, origin_epoch_nanoseconds, context, options, false,
  )
  let contains = if sign >= 0 {
    window.start_epoch_nanoseconds.compare(dest_epoch_nanoseconds) <= 0 &&
    dest_epoch_nanoseconds.compare(window.end_epoch_nanoseconds) <= 0
  } else {
    window.end_epoch_nanoseconds.compare(dest_epoch_nanoseconds) <= 0 &&
    dest_epoch_nanoseconds.compare(window.start_epoch_nanoseconds) <= 0
  }
  if contains {
    (window, false)
  } else {
    (
      self.compute_nudge_window(
        sign, origin_epoch_nanoseconds, context, options, true,
      ),
      true,
    )
  }
}

///|
/// `NudgeToCalendarUnit`: rounds the duration to a whole number of years,
/// months, weeks or days.
fn[P : TimeZoneProvider] InternalDurationRecord::nudge_calendar_unit(
  self : InternalDurationRecord,
  sign : Int,
  origin_epoch_nanoseconds : @int128.Int128,
  dest_epoch_nanoseconds : @int128.Int128,
  context : RelativeContext[P],
  options : ResolvedRoundingOptions,
) -> NudgeRecord raise TemporalError {
  let (window, did_expand) = self.compute_and_adjust_nudge_window(
    sign, origin_epoch_nanoseconds, dest_epoch_nanoseconds, context, options,
  )
  // The spec computes `total = r1 + progress × increment × sign` where
  // `progress` is a fraction. Multiplying through by the denominator keeps
  // everything in exact integer arithmetic:
  //   total × divisor = r1 × divisor + dividend × increment × sign
  let dividend = dest_epoch_nanoseconds.sub(window.start_epoch_nanoseconds)
  let divisor = window.end_epoch_nanoseconds.sub(window.start_epoch_nanoseconds)
  if divisor.is_zero() {
    raise AssertError("nudge window collapsed to a single instant")
  }
  let total_times_divisor = window.r1
    .mul(divisor)
    .add(
      dividend.mul(
        @int128.of_int(options.increment.get() * sign_multiplier(sign)),
      ),
    )
  let unsigned_mode = options.rounding_mode.to_unsigned(sign >= 0)
  // Detect `progress = 1` exactly, which the rounding modes must not be asked
  // to decide because the value sits on the far endpoint rather than between
  // the two.
  let (quotient, remainder) = total_times_divisor.div_rem_euclid(divisor)
  let total_is_r2 = quotient == window.r2 && remainder.is_zero()
  let rounded_unit = if total_is_r2 {
    window.r2.abs()
  } else {
    unsigned_mode.apply(
      total_times_divisor.abs(),
      divisor.abs(),
      window.r1.abs(),
      window.r2.abs(),
    )
  }
  if rounded_unit == window.r2.abs() {
    {
      duration: InternalDurationRecord::new(
        window.end_duration,
        time_duration_zero,
      ),
      nudged_epoch_nanoseconds: window.end_epoch_nanoseconds,
      expanded: true,
    }
  } else {
    {
      duration: InternalDurationRecord::new(
        window.start_duration,
        time_duration_zero,
      ),
      nudged_epoch_nanoseconds: window.start_epoch_nanoseconds,
      expanded: did_expand,
    }
  }
}

///|
/// `NudgeToCalendarUnit` in its "total" form, which reports the exact
/// fractional count rather than rounding.
fn[P : TimeZoneProvider] InternalDurationRecord::nudge_calendar_unit_total(
  self : InternalDurationRecord,
  sign : Int,
  origin_epoch_nanoseconds : @int128.Int128,
  dest_epoch_nanoseconds : @int128.Int128,
  context : RelativeContext[P],
  options : ResolvedRoundingOptions,
) -> Double raise TemporalError {
  let (window, _) = self.compute_and_adjust_nudge_window(
    sign, origin_epoch_nanoseconds, dest_epoch_nanoseconds, context, options,
  )
  let dividend = dest_epoch_nanoseconds.sub(window.start_epoch_nanoseconds)
  let divisor = window.end_epoch_nanoseconds.sub(window.start_epoch_nanoseconds)
  if divisor.is_zero() {
    raise AssertError("nudge window collapsed to a single instant")
  }
  let numerator = window.r1
    .mul(divisor)
    .add(
      dividend.mul(
        @int128.of_int(options.increment.get() * sign_multiplier(sign)),
      ),
    )
  exact_ratio_to_double(numerator, divisor)
}

///|
/// `NudgeToZonedTime`: rounds a time unit against a day whose length is set by
/// the time zone, so that a DST day is 23 or 25 hours long.
fn[P : TimeZoneProvider] InternalDurationRecord::nudge_to_zoned_time(
  self : InternalDurationRecord,
  sign : Int,
  context : RelativeContext[P],
  time_zone : TimeZone,
  options : ResolvedRoundingOptions,
) -> NudgeRecord raise TemporalError {
  let dt = context.date_time
  let start = dt.iso.date.add_date_duration(self.date, Constrain)
  let start_dt = IsoDateTime::new_unchecked(start, dt.iso.time)
  let end_date = IsoDate::balance(
    start.year,
    start.month,
    start.day + sign_multiplier(sign),
  )
  let end_dt = IsoDateTime::new_unchecked(end_date, dt.iso.time)
  let start_ns = time_zone.epoch_nanoseconds_for(
      start_dt,
      Compatible,
      context.provider,
    ).nanoseconds
  let end_ns = time_zone.epoch_nanoseconds_for(
      end_dt,
      Compatible,
      context.provider,
    ).nanoseconds
  let day_span = TimeDuration::from_nanosecond_difference(end_ns, start_ns)
  let unit_length = temporal_unwrap(
    options.smallest_unit.as_nanoseconds(),
    "time unit length",
  )
  let increment = @int128.of_int64(unit_length).mul(
    @int128.of_int(options.increment.get()),
  )
  let rounded_time = self.time.round_to_increment(
    increment,
    options.rounding_mode,
  )
  let beyond_day_span = rounded_time.add(day_span.negated())
  // Rounding may push past the end of the day; when it does, the result is
  // re-rounded relative to the following day.
  let (expanded, day_delta, rounded_time, nudged) = if beyond_day_span.sign() !=
    Sign::of_int(sign).negate() {
    let rounded = beyond_day_span.round_to_increment(
      increment,
      options.rounding_mode,
    )
    (
      true,
      sign_multiplier(sign).to_int64(),
      rounded,
      rounded.nanoseconds().add(end_ns),
    )
  } else {
    (false, 0L, rounded_time, rounded_time.nanoseconds().add(start_ns))
  }
  let date = DateDuration::new(
    self.date.years,
    self.date.months,
    self.date.weeks,
    self.date.days + day_delta,
  )
  {
    duration: InternalDurationRecord::new(date, rounded_time),
    nudged_epoch_nanoseconds: nudged,
    expanded,
  }
}

///|
/// `NudgeToDayOrTime`: rounds when every unit involved has a fixed length.
fn InternalDurationRecord::nudge_to_day_or_time(
  self : InternalDurationRecord,
  dest_epoch_nanoseconds : @int128.Int128,
  options : ResolvedRoundingOptions,
) -> NudgeRecord raise TemporalError {
  let time_duration = self.time.add_days(self.date.days)
  let unit_length = temporal_unwrap(
    options.smallest_unit.as_nanoseconds(),
    "time unit length",
  )
  let increment = @int128.of_int64(unit_length).mul(
    @int128.of_int(options.increment.get()),
  )
  let rounded_time = time_duration.round_to_increment(
    increment,
    options.rounding_mode,
  )
  let diff_time = rounded_time.sub(time_duration)
  let whole_days = time_duration.truncated_divide(NS_PER_DAY)
  let rounded_whole_days = rounded_time.truncated_divide(NS_PER_DAY)
  let delta = rounded_whole_days - whole_days
  // Rounding "expanded" the day count only if it moved away from zero.
  let did_expand_days = Sign::of_int64(delta) == time_duration.sign()
  let nudged = diff_time.nanoseconds().add(dest_epoch_nanoseconds)
  // With a date largest unit the whole days belong in the date part; with a
  // time largest unit they stay in the time part as hours.
  let (days, remainder) = if options.largest_unit.is_date_unit() {
    (
      rounded_whole_days,
      rounded_time.add(
        TimeDuration::from_components(
          -rounded_whole_days * 24L,
          0L,
          0L,
          0L,
          @int128.zero,
          @int128.zero,
        ),
      ),
    )
  } else {
    (0L, rounded_time)
  }
  {
    duration: InternalDurationRecord::combine(self.date.adjust(days), remainder),
    nudged_epoch_nanoseconds: nudged,
    expanded: did_expand_days,
  }
}

///|
/// `BubbleRelativeDuration`: after an expansion, carries the result up into
/// larger units where it now reaches them exactly.
///
/// Rounding 11 months up to 12 should report one year, not twelve months, and
/// this is the pass that notices.
fn[P : TimeZoneProvider] InternalDurationRecord::bubble_relative_duration(
  self : InternalDurationRecord,
  sign : Int,
  nudged_epoch_nanoseconds : @int128.Int128,
  context : RelativeContext[P],
  largest_unit : DateTimeUnit,
  smallest_unit : DateTimeUnit,
) -> InternalDurationRecord raise TemporalError {
  let mut duration = self
  if smallest_unit == largest_unit {
    return duration
  }
  let iso_date_time = context.date_time.iso
  let largest_index = largest_unit.table_index()
  let smallest_index = smallest_unit.table_index()
  // The caller may pass a smallest unit below the largest one; clamping keeps
  // the walk from running backwards.
  let upper_bound = @cmp.maximum(smallest_index, largest_index)
  for index = upper_bound - 1; index >= largest_index; index = index - 1 {
    let unit = unit_from_table_index(index)
    // Weeks only participate when the caller actually asked for weeks.
    if unit is Week && !(largest_unit is Week) {
      continue
    }
    let end_duration = match unit {
      Year =>
        DateDuration::new(
          duration.date.years + sign_multiplier(sign).to_int64(),
          0L,
          0L,
          0L,
        )
      Month =>
        duration.date.adjust(
          0L,
          weeks=0L,
          months=duration.date.months + sign_multiplier(sign).to_int64(),
        )
      _ =>
        duration.date.adjust(
          0L,
          weeks=duration.date.weeks + sign_multiplier(sign).to_int64(),
        )
    }
    let end = iso_date_time.date.add_date_duration(end_duration, Constrain)
    let end_date_time = IsoDateTime::new_unchecked(end, iso_date_time.time)
    let end_epoch_nanoseconds = context.epoch_nanoseconds_for(end_date_time)
    let beyond_end = nudged_epoch_nanoseconds.sub(end_epoch_nanoseconds)
    // Stop as soon as stepping one more unit would overshoot.
    if beyond_end.signum() != -sign_multiplier(sign) {
      duration = InternalDurationRecord::from_date_duration(end_duration)
    } else {
      break
    }
  }
  duration
}

///|
/// `RoundRelativeDuration`: rounds a duration against a reference point.
fn[P : TimeZoneProvider] InternalDurationRecord::round_relative_duration(
  self : InternalDurationRecord,
  origin_epoch_nanoseconds : @int128.Int128,
  dest_epoch_nanoseconds : @int128.Int128,
  date_time : PlainDateTime,
  time_zone : TimeZone?,
  provider : P,
  options : ResolvedRoundingOptions,
) -> InternalDurationRecord raise TemporalError {
  let context = { date_time, time_zone, provider }
  // A unit is "irregular" when its length depends on the reference point:
  // calendar units always, and days too once a time zone is involved.
  let irregular_length_unit = options.smallest_unit.is_calendar_unit() ||
    (time_zone is Some(_) && options.smallest_unit is Day)
  let sign = self.sign().to_multiplier()
  let nudge_result = if irregular_length_unit {
    self.nudge_calendar_unit(
      sign, origin_epoch_nanoseconds, dest_epoch_nanoseconds, context, options,
    )
  } else if time_zone is Some(zone) {
    self.nudge_to_zoned_time(sign, context, zone, options)
  } else {
    self.nudge_to_day_or_time(dest_epoch_nanoseconds, options)
  }
  let duration = nudge_result.duration
  if nudge_result.expanded && !(options.smallest_unit is Week) {
    let start_unit = options.smallest_unit.larger(Day)
    duration.bubble_relative_duration(
      sign,
      nudge_result.nudged_epoch_nanoseconds,
      context,
      options.largest_unit,
      start_unit,
    )
  } else {
    duration
  }
}

///|
/// `TotalRelativeDuration`: the duration as an exact fractional count of
/// `unit`, measured against a reference point.
fn[P : TimeZoneProvider] InternalDurationRecord::total_relative_duration(
  self : InternalDurationRecord,
  origin_epoch_nanoseconds : @int128.Int128,
  dest_epoch_nanoseconds : @int128.Int128,
  date_time : PlainDateTime,
  time_zone : TimeZone?,
  provider : P,
  unit : DateTimeUnit,
) -> Double raise TemporalError {
  if unit.is_calendar_unit() || (time_zone is Some(_) && unit is Day) {
    let context = { date_time, time_zone, provider }
    return self.nudge_calendar_unit_total(
      self.sign().to_multiplier(),
      origin_epoch_nanoseconds,
      dest_epoch_nanoseconds,
      context,
      {
        largest_unit: unit,
        smallest_unit: unit,
        increment: rounding_increment_one,
        rounding_mode: Trunc,
      },
    )
  }
  self.time.add_days(self.date.days).total(unit)
}

///|
/// Truncates a value toward zero to a multiple of `increment`.
fn truncate_to_increment(
  value : @int128.Int128,
  increment : @int128.Int128,
) -> @int128.Int128 {
  value.div(increment).mul(increment)
}

///|
/// Narrows a 128-bit count to `Int64`, raising when it does not fit.
///
/// A duration whose component exceeds this range cannot be valid, so the
/// failure surfaces as a range error rather than a silent wrap.
fn int64_of(value : @int128.Int128) -> Int64 raise TemporalError {
  match value.to_int64() {
    Some(v) => v
    None => raise RangeError("duration component exceeds its valid range")
  }
}

///|
/// Coerces a sign to `-1` or `1`, mapping zero to `1`.
fn sign_multiplier(sign : Int) -> Int {
  if sign < 0 {
    -1
  } else {
    1
  }
}