///|
/// Half-open time range [start, finish). This representation makes adjacent
/// windows composable without double-counting their shared endpoint.
pub struct TimeRange {
  start : DateTime
  finish : DateTime
} derive(Eq, Debug)

///|
pub suberror TimeRangeError {
  EmptyOrReversedRange(DateTime, DateTime)
} derive(Eq, Debug)

///|
pub fn TimeRange::new(
  start : DateTime,
  finish : DateTime,
) -> TimeRange raise TimeRangeError {
  if start.to_epoch_second() >= finish.to_epoch_second() {
    raise EmptyOrReversedRange(start, finish)
  }
  { start, finish }
}

///|
pub fn TimeRange::duration_seconds(self : TimeRange) -> Int64 {
  self.finish.to_epoch_second() - self.start.to_epoch_second()
}

///|
pub fn TimeRange::contains(self : TimeRange, value : DateTime) -> Bool {
  self.start.to_epoch_second() <= value.to_epoch_second() &&
  value.to_epoch_second() < self.finish.to_epoch_second()
}

///|
pub fn TimeRange::overlaps(self : TimeRange, other : TimeRange) -> Bool {
  self.start.to_epoch_second() < other.finish.to_epoch_second() &&
  other.start.to_epoch_second() < self.finish.to_epoch_second()
}

///|
pub fn TimeRange::is_adjacent(self : TimeRange, other : TimeRange) -> Bool {
  self.finish == other.start || other.finish == self.start
}

///|
pub fn TimeRange::intersection(
  self : TimeRange,
  other : TimeRange,
) -> TimeRange? {
  if !self.overlaps(other) {
    return None
  }
  let start = if self.start.to_epoch_second() >= other.start.to_epoch_second() {
    self.start
  } else {
    other.start
  }
  let finish = if self.finish.to_epoch_second() <=
    other.finish.to_epoch_second() {
    self.finish
  } else {
    other.finish
  }
  Some({ start, finish })
}

///|
pub fn TimeRange::merge(self : TimeRange, other : TimeRange) -> TimeRange? {
  if !self.overlaps(other) && !self.is_adjacent(other) {
    return None
  }
  let start = if self.start.to_epoch_second() <= other.start.to_epoch_second() {
    self.start
  } else {
    other.start
  }
  let finish = if self.finish.to_epoch_second() >=
    other.finish.to_epoch_second() {
    self.finish
  } else {
    other.finish
  }
  Some({ start, finish })
}

///|
fn sort_ranges(values : Array[TimeRange]) -> Array[TimeRange] {
  let result = values.copy()
  result.sort_by((left, right) => left.start.compare(right.start))
  result
}

///|
/// Merge overlapping ranges and, by default, directly adjacent ranges.
pub fn merge_time_ranges(
  values : Array[TimeRange],
  merge_adjacent? : Bool = true,
) -> Array[TimeRange] {
  let result : Array[TimeRange] = []
  for value in sort_ranges(values) {
    if result.length() == 0 {
      result.push(value)
      continue
    }
    let previous = result[result.length() - 1]
    let can_merge = previous.overlaps(value) ||
      (merge_adjacent && previous.is_adjacent(value))
    if can_merge {
      result[result.length() - 1] = previous.merge(value).unwrap()
    } else {
      result.push(value)
    }
  }
  result
}

///|
/// Find free half-open windows inside bounds after normalizing busy ranges.
pub fn free_time_ranges(
  bounds : TimeRange,
  busy : Array[TimeRange],
) -> Array[TimeRange] {
  let clipped : Array[TimeRange] = []
  for value in busy {
    match bounds.intersection(value) {
      Some(overlap) => clipped.push(overlap)
      None => ()
    }
  }
  let occupied = merge_time_ranges(clipped)
  let free : Array[TimeRange] = []
  let mut cursor = bounds.start
  for value in occupied {
    if cursor.to_epoch_second() < value.start.to_epoch_second() {
      free.push({ start: cursor, finish: value.start })
    }
    if cursor.to_epoch_second() < value.finish.to_epoch_second() {
      cursor = value.finish
    }
  }
  if cursor.to_epoch_second() < bounds.finish.to_epoch_second() {
    free.push({ start: cursor, finish: bounds.finish })
  }
  free
}

///|
pub fn TimeRange::to_text(self : TimeRange) -> String {
  self.start.to_iso_string() +
  " / " +
  self.finish.to_iso_string() +
  " (" +
  self.duration_seconds().to_string() +
  "s)"
}

///|
pub fn ranges_total_seconds(values : Array[TimeRange]) -> Int64 {
  let mut total = 0L
  for value in merge_time_ranges(values) {
    total += value.duration_seconds()
  }
  total
}