///|
/// A calendar-aware ISO 8601 duration. Years and months are retained as
/// calendar units; weeks, days and clock fields can also be reduced to seconds.
pub struct Duration {
  years : Int
  months : Int
  weeks : Int
  days : Int
  hours : Int
  minutes : Int
  seconds : Int
  negative : Bool
} derive(Eq, Debug)

///|
pub suberror DurationError {
  EmptyDuration
  MissingDesignator
  MissingNumber(Int)
  InvalidCharacter(Int, Int)
  DuplicateUnit(String)
  UnitOutOfOrder(String)
  DateUnitInTimePart(String)
  TimeUnitInDatePart(String)
  FractionUnsupported
  NonFixedCalendarUnit
} derive(Eq, Debug)

///|
pub fn Duration::zero() -> Duration {
  {
    years: 0,
    months: 0,
    weeks: 0,
    days: 0,
    hours: 0,
    minutes: 0,
    seconds: 0,
    negative: false,
  }
}

///|
pub fn Duration::new(
  years? : Int = 0,
  months? : Int = 0,
  weeks? : Int = 0,
  days? : Int = 0,
  hours? : Int = 0,
  minutes? : Int = 0,
  seconds? : Int = 0,
  negative? : Bool = false,
) -> Duration {
  { years, months, weeks, days, hours, minutes, seconds, negative }
}

///|
pub fn Duration::is_zero(self : Duration) -> Bool {
  self.years == 0 &&
  self.months == 0 &&
  self.weeks == 0 &&
  self.days == 0 &&
  self.hours == 0 &&
  self.minutes == 0 &&
  self.seconds == 0
}

///|
pub fn Duration::has_calendar_units(self : Duration) -> Bool {
  self.years != 0 || self.months != 0
}

///|
pub fn Duration::sign(self : Duration) -> Int {
  if self.negative && !self.is_zero() {
    -1
  } else {
    1
  }
}

///|
pub fn Duration::fixed_seconds(self : Duration) -> Int64 raise DurationError {
  if self.has_calendar_units() {
    raise NonFixedCalendarUnit
  }
  let magnitude = self.weeks.to_int64() * 604800L +
    self.days.to_int64() * 86400L +
    self.hours.to_int64() * 3600L +
    self.minutes.to_int64() * 60L +
    self.seconds.to_int64()
  if self.negative {
    -magnitude
  } else {
    magnitude
  }
}

///|
fn duration_unit_name(code : Int, in_time : Bool) -> String {
  match code {
    'Y' => "years"
    'M' => if in_time { "minutes" } else { "months" }
    'W' => "weeks"
    'D' => "days"
    'H' => "hours"
    'S' => "seconds"
    _ => "unknown"
  }
}

///|
/// Parse the integer subset of ISO 8601 durations, including leading minus.
/// Supported examples include P2W, P1Y2M3DT4H5M6S and -PT90S.
pub fn parse_duration(source : String) -> Duration raise DurationError {
  let text = source.trim().to_upper()
  if text.length() == 0 {
    raise EmptyDuration
  }
  let mut cursor = 0
  let mut negative = false
  if text.code_unit_at(cursor) == '-' {
    negative = true
    cursor += 1
  } else if text.code_unit_at(cursor) == '+' {
    cursor += 1
  }
  if cursor >= text.length() || text.code_unit_at(cursor) != 'P' {
    raise MissingDesignator
  }
  cursor += 1
  let mut in_time = false
  let mut saw_value = false
  let mut pending = 0
  let mut has_pending = false
  let mut last_date_order = 0
  let mut last_time_order = 0
  let mut years = 0
  let mut months = 0
  let mut weeks = 0
  let mut days = 0
  let mut hours = 0
  let mut minutes = 0
  let mut seconds = 0
  while cursor < text.length() {
    let code = text.code_unit_at(cursor).to_int()
    if code >= '0' && code <= '9' {
      pending = pending * 10 + code - '0'
      has_pending = true
      cursor += 1
      continue
    }
    if code == '.' || code == ',' {
      raise FractionUnsupported
    }
    if code == 'T' {
      if in_time || has_pending {
        raise InvalidCharacter(cursor, code)
      }
      in_time = true
      cursor += 1
      continue
    }
    if !has_pending {
      raise MissingNumber(cursor)
    }
    let name = duration_unit_name(code, in_time)
    if name == "unknown" {
      raise InvalidCharacter(cursor, code)
    }
    if in_time {
      let order = match code {
        'H' => 1
        'M' => 2
        'S' => 3
        'Y' | 'W' | 'D' => raise DateUnitInTimePart(name)
        _ => raise InvalidCharacter(cursor, code)
      }
      if order <= last_time_order {
        if order == last_time_order {
          raise DuplicateUnit(name)
        }
        raise UnitOutOfOrder(name)
      }
      last_time_order = order
      match code {
        'H' => hours = pending
        'M' => minutes = pending
        'S' => seconds = pending
        _ => ()
      }
    } else {
      let order = match code {
        'Y' => 1
        'M' => 2
        'W' => 3
        'D' => 4
        'H' | 'S' => raise TimeUnitInDatePart(name)
        _ => raise InvalidCharacter(cursor, code)
      }
      if order <= last_date_order {
        if order == last_date_order {
          raise DuplicateUnit(name)
        }
        raise UnitOutOfOrder(name)
      }
      last_date_order = order
      match code {
        'Y' => years = pending
        'M' => months = pending
        'W' => weeks = pending
        'D' => days = pending
        _ => ()
      }
    }
    pending = 0
    has_pending = false
    saw_value = true
    cursor += 1
  }
  if has_pending {
    raise MissingDesignator
  }
  if !saw_value {
    raise EmptyDuration
  }
  { years, months, weeks, days, hours, minutes, seconds, negative }
}

///|
fn append_duration_part(
  parts : Array[String],
  value : Int,
  suffix : String,
) -> Unit {
  if value != 0 {
    parts.push(value.to_string() + suffix)
  }
}

///|
pub fn Duration::to_iso_string(self : Duration) -> String {
  if self.is_zero() {
    return "PT0S"
  }
  let date_parts : Array[String] = []
  append_duration_part(date_parts, self.years, "Y")
  append_duration_part(date_parts, self.months, "M")
  append_duration_part(date_parts, self.weeks, "W")
  append_duration_part(date_parts, self.days, "D")
  let time_parts : Array[String] = []
  append_duration_part(time_parts, self.hours, "H")
  append_duration_part(time_parts, self.minutes, "M")
  append_duration_part(time_parts, self.seconds, "S")
  let prefix = if self.negative { "-P" } else { "P" }
  prefix +
  date_parts.join("") +
  (if time_parts.length() > 0 { "T" + time_parts.join("") } else { "" })
}

///|
pub fn DateTime::add_duration(self : DateTime, duration : Duration) -> DateTime {
  let factor = duration.sign()
  let calendar_shifted = self
    .add_years(factor * duration.years)
    .add_months(factor * duration.months)
    .add_days(factor * (duration.weeks * 7 + duration.days))
  let clock_seconds = duration.hours.to_int64() * 3600L +
    duration.minutes.to_int64() * 60L +
    duration.seconds.to_int64()
  calendar_shifted.add_seconds(factor.to_int64() * clock_seconds)
}

///|
pub fn duration_between(start : DateTime, finish : DateTime) -> Duration {
  let delta = finish.to_epoch_second() - start.to_epoch_second()
  let negative = delta < 0L
  let mut remaining = if negative { -delta } else { delta }
  let weeks = (remaining / 604800L).to_int()
  remaining = remaining % 604800L
  let days = (remaining / 86400L).to_int()
  remaining = remaining % 86400L
  let hours = (remaining / 3600L).to_int()
  remaining = remaining % 3600L
  let minutes = (remaining / 60L).to_int()
  let seconds = (remaining % 60L).to_int()
  { years: 0, months: 0, weeks, days, hours, minutes, seconds, negative }
}

///|
/// Produce a compact English explanation suitable for reports and CLI output.
pub fn Duration::explain_en(self : Duration) -> String {
  if self.is_zero() {
    return "zero seconds"
  }
  let parts : Array[String] = []
  let append = fn(value : Int, singular : String) {
    if value > 0 {
      parts.push(
        value.to_string() + " " + singular + (if value == 1 { "" } else { "s" }),
      )
    }
  }
  append(self.years, "year")
  append(self.months, "month")
  append(self.weeks, "week")
  append(self.days, "day")
  append(self.hours, "hour")
  append(self.minutes, "minute")
  append(self.seconds, "second")
  (if self.negative { "minus " } else { "" }) + parts.join(", ")
}

///|
pub fn Duration::explain_zh(self : Duration) -> String {
  if self.is_zero() {
    return "0 秒"
  }
  let parts : Array[String] = []
  let append = fn(value : Int, unit : String) {
    if value > 0 {
      parts.push(value.to_string() + " " + unit)
    }
  }
  append(self.years, "年")
  append(self.months, "个月")
  append(self.weeks, "周")
  append(self.days, "天")
  append(self.hours, "小时")
  append(self.minutes, "分钟")
  append(self.seconds, "秒")
  (if self.negative { "负 " } else { "" }) + parts.join(" ")
}