///|
/// Convention used when an occurrence lands on a non-working date.
pub(all) enum BusinessDayConvention {
  Unadjusted
  Following
  ModifiedFollowing
  Preceding
  ModifiedPreceding
} derive(Eq, Compare, Debug)

///|
/// Named workweek and holiday set. Dates are local calendar dates; no timezone
/// assumptions are introduced by business-day adjustment.
pub struct BusinessCalendar {
  name : String
  working_weekdays : Array[Weekday]
  holidays : Array[Date]
} derive(Eq, Debug)

///|
pub suberror BusinessCalendarError {
  EmptyWorkingWeek
  SearchLimitExceeded(Date, Int)
} derive(Eq, Debug)

///|
fn unique_weekdays(values : Array[Weekday]) -> Array[Weekday] {
  let result : Array[Weekday] = []
  for value in values {
    if !result.contains(value) {
      result.push(value)
    }
  }
  result.sort()
  result
}

///|
fn unique_dates(values : Array[Date]) -> Array[Date] {
  let result : Array[Date] = []
  for value in values {
    if !result.contains(value) {
      result.push(value)
    }
  }
  result.sort()
  result
}

///|
pub fn BusinessCalendar::new(
  name : String,
  working_weekdays? : Array[Weekday] = [
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
  ],
  holidays? : Array[Date] = [],
) -> BusinessCalendar raise BusinessCalendarError {
  let week = unique_weekdays(working_weekdays)
  if week.length() == 0 {
    raise EmptyWorkingWeek
  }
  { name, working_weekdays: week, holidays: unique_dates(holidays) }
}

///|
pub fn BusinessCalendar::is_holiday(
  self : BusinessCalendar,
  date : Date,
) -> Bool {
  self.holidays.contains(date)
}

///|
pub fn BusinessCalendar::is_working_day(
  self : BusinessCalendar,
  date : Date,
) -> Bool {
  self.working_weekdays.contains(date.weekday()) && !self.is_holiday(date)
}

///|
pub fn BusinessCalendar::add_holiday(
  self : BusinessCalendar,
  date : Date,
) -> BusinessCalendar {
  let holidays = self.holidays.copy()
  if !holidays.contains(date) {
    holidays.push(date)
    holidays.sort()
  }
  { ..self, holidays, }
}

///|
pub fn BusinessCalendar::remove_holiday(
  self : BusinessCalendar,
  date : Date,
) -> BusinessCalendar {
  let holidays : Array[Date] = []
  for value in self.holidays {
    if value != date {
      holidays.push(value)
    }
  }
  { ..self, holidays, }
}

///|
pub fn BusinessCalendar::with_working_weekdays(
  self : BusinessCalendar,
  working_weekdays : Array[Weekday],
) -> BusinessCalendar raise BusinessCalendarError {
  let week = unique_weekdays(working_weekdays)
  if week.length() == 0 {
    raise EmptyWorkingWeek
  }
  { ..self, working_weekdays: week }
}

///|
pub fn BusinessCalendar::next_working_date(
  self : BusinessCalendar,
  date : Date,
  include_current? : Bool = false,
  search_limit? : Int = 370,
) -> Date raise BusinessCalendarError {
  let mut candidate = if include_current { date } else { date.add_days(1) }
  let mut inspected = 0
  while inspected < search_limit {
    if self.is_working_day(candidate) {
      return candidate
    }
    candidate = candidate.add_days(1)
    inspected += 1
  }
  raise SearchLimitExceeded(date, inspected)
}

///|
pub fn BusinessCalendar::previous_working_date(
  self : BusinessCalendar,
  date : Date,
  include_current? : Bool = false,
  search_limit? : Int = 370,
) -> Date raise BusinessCalendarError {
  let mut candidate = if include_current { date } else { date.add_days(-1) }
  let mut inspected = 0
  while inspected < search_limit {
    if self.is_working_day(candidate) {
      return candidate
    }
    candidate = candidate.add_days(-1)
    inspected += 1
  }
  raise SearchLimitExceeded(date, inspected)
}

///|
fn same_month(left : Date, right : Date) -> Bool {
  left.year == right.year && left.month == right.month
}

///|
pub fn BusinessCalendar::adjust(
  self : BusinessCalendar,
  date : Date,
  convention : BusinessDayConvention,
) -> Date raise BusinessCalendarError {
  if convention == Unadjusted || self.is_working_day(date) {
    return date
  }
  match convention {
    Unadjusted => date
    Following => self.next_working_date(date)
    Preceding => self.previous_working_date(date)
    ModifiedFollowing => {
      let following = self.next_working_date(date)
      if same_month(date, following) {
        following
      } else {
        self.previous_working_date(date)
      }
    }
    ModifiedPreceding => {
      let preceding = self.previous_working_date(date)
      if same_month(date, preceding) {
        preceding
      } else {
        self.next_working_date(date)
      }
    }
  }
}

///|
pub fn BusinessCalendar::add_business_days(
  self : BusinessCalendar,
  date : Date,
  amount : Int,
) -> Date raise BusinessCalendarError {
  if amount == 0 {
    return date
  }
  let direction = if amount > 0 { 1 } else { -1 }
  let target = amount.abs()
  let mut traversed = 0
  let mut candidate = date
  let mut inspected = 0
  while traversed < target {
    candidate = candidate.add_days(direction)
    inspected += 1
    if inspected > target * 8 + 370 {
      raise SearchLimitExceeded(date, inspected)
    }
    if self.is_working_day(candidate) {
      traversed += 1
    }
  }
  candidate
}

///|
/// Count working dates in the half-open interval [start, finish).
pub fn BusinessCalendar::count_business_days(
  self : BusinessCalendar,
  start : Date,
  finish : Date,
) -> Int {
  if start == finish {
    return 0
  }
  let direction = if start.to_epoch_day() < finish.to_epoch_day() {
    1
  } else {
    -1
  }
  let mut count = 0
  let mut cursor = start
  while cursor != finish {
    if self.is_working_day(cursor) {
      count += direction
    }
    cursor = cursor.add_days(direction)
  }
  count
}

///|
pub fn BusinessCalendar::working_dates_between(
  self : BusinessCalendar,
  start : Date,
  finish : Date,
) -> Array[Date] {
  let result : Array[Date] = []
  if start.to_epoch_day() >= finish.to_epoch_day() {
    return result
  }
  let mut cursor = start
  while cursor != finish {
    if self.is_working_day(cursor) {
      result.push(cursor)
    }
    cursor = cursor.add_days(1)
  }
  result
}

///|
pub struct BusinessDayAdjustment {
  original : DateTime
  adjusted : DateTime
  changed : Bool
  convention : BusinessDayConvention
} derive(Eq, Debug)

///|
pub fn BusinessCalendar::adjust_datetime(
  self : BusinessCalendar,
  value : DateTime,
  convention : BusinessDayConvention,
) -> BusinessDayAdjustment raise BusinessCalendarError {
  let date = self.adjust(value.date, convention)
  {
    original: value,
    adjusted: { date, time: value.time },
    changed: date != value.date,
    convention,
  }
}

///|
pub fn BusinessCalendar::adjust_occurrences(
  self : BusinessCalendar,
  values : Array[DateTime],
  convention : BusinessDayConvention,
  deduplicate? : Bool = true,
) -> Array[BusinessDayAdjustment] raise BusinessCalendarError {
  let result : Array[BusinessDayAdjustment] = []
  let adjusted_values : Array[DateTime] = []
  for value in values {
    let adjustment = self.adjust_datetime(value, convention)
    if !deduplicate || !adjusted_values.contains(adjustment.adjusted) {
      result.push(adjustment)
      adjusted_values.push(adjustment.adjusted)
    }
  }
  result
}

///|
pub fn BusinessDayConvention::label(self : BusinessDayConvention) -> String {
  match self {
    Unadjusted => "unadjusted"
    Following => "following"
    ModifiedFollowing => "modified-following"
    Preceding => "preceding"
    ModifiedPreceding => "modified-preceding"
  }
}

///|
pub fn business_day_convention_from_string(
  text : String,
) -> BusinessDayConvention? {
  match text.trim().to_lower() {
    "unadjusted" | "none" => Some(Unadjusted)
    "following" | "next" => Some(Following)
    "modified-following" | "mod-following" => Some(ModifiedFollowing)
    "preceding" | "previous" => Some(Preceding)
    "modified-preceding" | "mod-preceding" => Some(ModifiedPreceding)
    _ => None
  }
}

///|
/// Describe the calendar in a stable, diff-friendly text format.
pub fn BusinessCalendar::to_text(self : BusinessCalendar) -> String {
  let weekdays = self.working_weekdays.map(value => value.code()).join(",")
  let holidays = self.holidays.map(value => value.to_iso_string()).join(",")
  "calendar: " +
  self.name +
  "\nworking-weekdays: " +
  weekdays +
  "\nholidays: " +
  (if holidays.length() == 0 { "none" } else { holidays })
}