///|
/// The point a duration's calendar units are measured against.
///
/// Years, months, weeks and days have no fixed length, so rounding or totalling
/// a duration that uses them needs a starting point to resolve them from.
pub(all) enum RelativeTo {
  /// Measure from a calendar date, treating every day as 24 hours.
  DateRelative(PlainDate)
  /// Measure from an exact instant in a time zone, so that daylight-saving
  /// transitions make some days longer or shorter than 24 hours.
  ZonedRelative(ZonedDateTime)
} derive(Debug)

///|
/// Parses a `relativeTo` value, preferring a zoned reading when the string
/// carries a time zone annotation.
///
/// ```mbt check
/// test {
///   let relative = @temporal.RelativeTo::of_string(
///     "2020-01-01", @temporal.utc_only_provider,
///   )
///   inspect(relative is DateRelative(_), content="true")
/// }
/// ```
pub fn[P : TimeZoneProvider] RelativeTo::of_string(
  source : String,
  provider : P,
) -> RelativeTo raise TemporalError {
  let record = parse_date_time_string(source) catch {
    _ => parse_zoned_date_time_string(source)
  }
  match record.time_zone {
    None => {
      let date = temporal_unwrap(record.date, "date component")
      DateRelative(
        PlainDate::new_with_overflow(
          date.year,
          date.month,
          date.day,
          Reject,
          calendar_of(record),
        ),
      )
    }
    Some(_) =>
      ZonedRelative(
        ZonedDateTime::of_string(source, provider, offset_option=Reject),
      )
  }
}

///|
/// `GetTemporalRoundingOptions` for `Duration.round`.
///
/// Rounding a duration is unusual among the round methods in that both
/// `largestUnit` and `smallestUnit` may be given, and at least one must be.
fn resolve_duration_rounding_options(
  duration : Duration,
  options : RoundingOptions,
) -> ResolvedRoundingOptions raise TemporalError {
  let increment = options.increment.unwrap_or(rounding_increment_one)
  let rounding_mode = options.rounding_mode.unwrap_or(HalfExpand)
  UnitGroup::DateTime.validate_unit(options.smallest_unit, None) |> ignore
  let smallest_unit = options.smallest_unit.unwrap_or(Nanosecond)
  let existing_largest_unit = duration.default_largest_unit()
  let default_largest_unit = existing_largest_unit.larger(smallest_unit)
  let largest_unit = match options.largest_unit {
    None | Some(Auto) => default_largest_unit
    Some(unit) => unit
  }
  if options.largest_unit is None && options.smallest_unit is None {
    raise RangeError("at least one of smallestUnit and largestUnit is required")
  }
  if largest_unit.larger(smallest_unit) != largest_unit {
    raise RangeError("smallestUnit must not be larger than largestUnit")
  }
  if smallest_unit.to_maximum_rounding_increment() is Some(maximum) {
    increment.validate(maximum.to_int64(), false)
  }
  // An increment above one only makes sense for the smallest unit in play; a
  // date unit that is not also the largest would leave an ambiguous remainder.
  if increment > rounding_increment_one &&
    largest_unit != smallest_unit &&
    smallest_unit.is_date_unit() {
    raise RangeError(
      "a roundingIncrement above 1 requires smallestUnit to equal largestUnit for date units",
    )
  }
  { largest_unit, smallest_unit, increment, rounding_mode }
}

///|
/// Rounds the duration.
///
/// `relative_to` is required whenever the duration uses calendar units, or
/// when rounding to one.
///
/// ```mbt check
/// test {
///   let d = @temporal.Duration::of(hours=1, minutes=45)
///   let options = @temporal.RoundingOptions::new(smallest_unit=Hour)
///   inspect(d.round(options, @temporal.utc_only_provider), content="PT2H")
/// }
/// ```
pub fn[P : TimeZoneProvider] Duration::round(
  self : Duration,
  options : RoundingOptions,
  provider : P,
  relative_to? : RelativeTo,
) -> Duration raise TemporalError {
  let resolved = resolve_duration_rounding_options(self, options)
  match relative_to {
    Some(ZonedRelative(zoned)) => {
      let internal = self.to_internal()
      let target = zoned.add_internal_duration(internal, provider, Constrain)
      let target_zoned = ZonedDateTime::try_new(
        target,
        zoned.time_zone,
        provider,
        calendar=zoned.calendar,
      )
      let rounded = zoned.diff_with_rounding(
        target_zoned.to_instant(),
        resolved,
        provider,
      )
      // The result is reported in exact time units, because a date unit would
      // need re-resolving against the zone to mean anything.
      let largest_unit = if resolved.largest_unit.is_date_unit() {
        Hour
      } else {
        resolved.largest_unit
      }
      Duration::from_internal(rounded, largest_unit)
    }
    Some(DateRelative(date)) => {
      let internal = InternalDurationRecord::from_duration_with_24_hour_days(
        self,
      )
      let (target_days, target_time) = iso_time_midnight.add(internal.time)
      let date_duration = internal.date.adjust(target_days)
      let target_date = date.iso.add_date_duration(date_duration, Constrain)
      let start = IsoDateTime::new_unchecked(date.iso, iso_time_midnight)
      let end = IsoDateTime::new_unchecked(target_date, target_time)
      let rounded = PlainDateTime::new_unchecked(start, date.calendar).diff_with_rounding(
        PlainDateTime::new_unchecked(end, date.calendar),
        resolved,
      )
      Duration::from_internal(rounded, resolved.largest_unit)
    }
    None => {
      if self.default_largest_unit().is_calendar_unit() ||
        resolved.largest_unit.is_calendar_unit() {
        raise RangeError(
          "rounding a duration with calendar units requires a relativeTo reference",
        )
      }
      let internal = InternalDurationRecord::from_duration_with_24_hour_days(
        self,
      )
      let rounded = if resolved.smallest_unit is Day {
        let days = internal.time.round_to_fractional_days(
          resolved.increment,
          resolved.rounding_mode,
        )
        InternalDurationRecord::new(
          DateDuration::new(0L, 0L, 0L, days),
          time_duration_zero,
        )
      } else {
        InternalDurationRecord::new(
          DateDuration::default(),
          internal.time.round(resolved),
        )
      }
      Duration::from_internal(rounded, resolved.largest_unit)
    }
  }
}

///|
/// Returns the duration expressed as a fractional count of `unit`.
///
/// `relative_to` is required whenever the duration uses calendar units, or
/// when totalling into one.
///
/// ```mbt check
/// test {
///   let d = @temporal.Duration::of(hours=1, minutes=30)
///   inspect(d.total(Minute, @temporal.utc_only_provider), content="90")
/// }
/// ```
pub fn[P : TimeZoneProvider] Duration::total(
  self : Duration,
  unit : DateTimeUnit,
  provider : P,
  relative_to? : RelativeTo,
) -> Double raise TemporalError {
  match relative_to {
    Some(ZonedRelative(zoned)) => {
      let internal = self.to_internal()
      let target = zoned.add_internal_duration(internal, provider, Constrain)
      let target_zoned = ZonedDateTime::try_new(
        target,
        zoned.time_zone,
        provider,
        calendar=zoned.calendar,
      )
      zoned.diff_with_total(target_zoned.to_instant(), unit, provider)
    }
    Some(DateRelative(date)) => {
      // Only the sub-day components are balanced into a time-of-day here; the
      // duration's own days stay in the date part so the calendar walk sees
      // them.
      let (target_days, target_time) = iso_time_midnight.add(
        TimeDuration::from_duration(self),
      )
      let date_duration = DateDuration::new(
        self.years,
        self.months,
        self.weeks,
        self.days + target_days,
      )
      let target_date = date.iso.add_date_duration(date_duration, Constrain)
      let start = IsoDateTime::new_unchecked(date.iso, iso_time_midnight)
      let end = IsoDateTime::new_unchecked(target_date, target_time)
      PlainDateTime::new_unchecked(start, date.calendar).diff_with_total(
        PlainDateTime::new_unchecked(end, date.calendar),
        unit,
      )
    }
    None => {
      let largest_unit = self.default_largest_unit()
      if largest_unit.is_calendar_unit() || unit.is_calendar_unit() {
        raise RangeError(
          "totalling a duration with calendar units requires a relativeTo reference",
        )
      }
      InternalDurationRecord::from_duration_with_24_hour_days(self).time.total(
        unit,
      )
    }
  }
}

///|
/// `DifferenceZonedDateTimeWithTotal`.
fn[P : TimeZoneProvider] ZonedDateTime::diff_with_total(
  self : ZonedDateTime,
  other : Instant,
  unit : DateTimeUnit,
  provider : P,
) -> Double raise TemporalError {
  // A time unit has a fixed length, so the zone does not enter into it.
  if unit.is_time_unit() {
    return TimeDuration::from_nanosecond_difference(
      other.epoch_nanoseconds,
      self.instant.epoch_nanoseconds,
    ).total(unit)
  }
  let diff = self.diff_zoned_date_time(other, unit, provider)
  diff.total_relative_duration(
    self.instant.epoch_nanoseconds,
    other.epoch_nanoseconds,
    self.to_plain_date_time(),
    Some(self.time_zone),
    provider,
    unit,
  )
}