///|
/// The precision of a temporal value determines how much calendar detail can
/// safely be retained after de-identification.
pub(all) enum TemporalPrecision {
  Day
  Month
  Year
  Age
  Unknown
} derive(Debug, Eq)

///|
/// A calendar date represented without a timezone.  Clinical notes often
/// contain dates without a time zone, so shifting is deliberately calendar
/// based rather than timestamp based.
pub(all) struct CalendarDate {
  year : Int
  month : Int
  day : Int
} derive(Debug, Eq)

///|
/// A deterministic date transformation configuration.
pub(all) struct TemporalPolicy {
  salt : String
  day_delta : Int
  keep_year : Bool
  keep_month : Bool
  age_cutoff : Int
  shift_ages : Bool
} derive(Debug, Eq)

///|
/// A date candidate with its original precision and transformed value.
pub(all) struct TemporalValue {
  original : String
  shifted : String
  precision : TemporalPrecision
  date : CalendarDate?
  start : Int
  end : Int
} derive(Debug, Eq)

///|
pub fn TemporalPolicy::default() -> TemporalPolicy {
  {
    salt: "moonbit-temporal",
    day_delta: 0,
    keep_year: true,
    keep_month: true,
    age_cutoff: 89,
    shift_ages: true,
  }
}

///|
pub fn temporal_policy(salt : String, day_delta : Int) -> TemporalPolicy {
  { ..TemporalPolicy::default(), salt, day_delta }
}

///|
pub fn temporal_precision_name(value : TemporalPrecision) -> String {
  match value {
    Day => "day"
    Month => "month"
    Year => "year"
    Age => "age"
    Unknown => "unknown"
  }
}

///|
pub fn calendar_date(year~ : Int, month~ : Int, day~ : Int) -> CalendarDate {
  { year, month, day }
}

///|
pub fn CalendarDate::is_valid(self : CalendarDate) -> Bool {
  self.year >= 1 &&
  self.month >= 1 &&
  self.month <= 12 &&
  self.day >= 1 &&
  self.day <= days_in_month(self.year, self.month)
}

///|
pub fn CalendarDate::is_leap(self : CalendarDate) -> Bool {
  is_leap_year(self.year)
}

///|
pub fn CalendarDate::month_days(self : CalendarDate) -> Int {
  days_in_month(self.year, self.month)
}

///|
pub fn CalendarDate::day_of_year(self : CalendarDate) -> Int {
  if !self.is_valid() {
    0
  } else {
    let mut total = self.day
    for month in 1.. Int {
  if self.month <= 0 {
    0
  } else {
    (self.month - 1) / 3 + 1
  }
}

///|
pub fn CalendarDate::weekday(self : CalendarDate) -> Int {
  if !self.is_valid() {
    -1
  } else {
    // Sakamoto's algorithm, with Sunday represented by zero.
    let table = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
    let y = if self.month < 3 { self.year - 1 } else { self.year }
    (y + y / 4 - y / 100 + y / 400 + table[self.month - 1] + self.day) % 7
  }
}

///|
pub fn CalendarDate::weekday_name(self : CalendarDate) -> String {
  match self.weekday() {
    0 => "Sunday"
    1 => "Monday"
    2 => "Tuesday"
    3 => "Wednesday"
    4 => "Thursday"
    5 => "Friday"
    6 => "Saturday"
    _ => "Unknown"
  }
}

///|
pub fn CalendarDate::to_iso(self : CalendarDate) -> String {
  if !self.is_valid() {
    ""
  } else {
    pad_left("\{self.year}", 4, '0') +
    "-" +
    pad_left("\{self.month}", 2, '0') +
    "-" +
    pad_left("\{self.day}", 2, '0')
  }
}

///|
pub fn CalendarDate::to_compact(self : CalendarDate) -> String {
  if !self.is_valid() {
    ""
  } else {
    "\{self.year}\{pad_left("\{self.month}", 2, '0')}\{pad_left("\{self.day}", 2, '0')}"
  }
}

///|
pub fn CalendarDate::to_chinese(self : CalendarDate) -> String {
  if !self.is_valid() {
    ""
  } else {
    "\{self.year}年\{self.month}月\{self.day}日"
  }
}

///|
pub fn CalendarDate::to_month(self : CalendarDate) -> String {
  if !self.is_valid() {
    ""
  } else {
    "\{self.year}-\{pad_left("\{self.month}", 2, '0')}"
  }
}

///|
pub fn calendar_date_order(left : CalendarDate, right : CalendarDate) -> Int {
  if left.year != right.year {
    left.year - right.year
  } else if left.month != right.month {
    left.month - right.month
  } else {
    left.day - right.day
  }
}

///|
pub fn calendar_date_equal(left : CalendarDate, right : CalendarDate) -> Bool {
  calendar_date_order(left, right) == 0
}

///|
pub fn calendar_date_before(left : CalendarDate, right : CalendarDate) -> Bool {
  calendar_date_order(left, right) < 0
}

///|
pub fn calendar_date_after(left : CalendarDate, right : CalendarDate) -> Bool {
  calendar_date_order(left, right) > 0
}

///|
fn date_separatorized(text : String) -> String {
  let builder = StringBuilder()
  for c in text {
    if c.is_ascii_digit() {
      builder.write_char(c)
    } else if c == '-' ||
      c == '/' ||
      c == '.' ||
      c == '年' ||
      c == '月' ||
      c == '日' ||
      c == ' ' {
      if !builder.is_empty() && !builder.to_string().has_suffix("-") {
        builder.write_char('-')
      }
    }
  }
  builder.to_string().trim(chars="-").to_owned()
}

///|
pub fn parse_calendar_date(text : String) -> CalendarDate? {
  let normalized = date_separatorized(text)
  let parts = normalized.split("-").to_array()
  if parts.length() < 3 {
    None
  } else {
    let year = decimal_value(parts[0].to_owned())
    let month = decimal_value(parts[1].to_owned())
    let day = decimal_value(parts[2].to_owned())
    let value = { year, month, day }
    if value.is_valid() {
      Some(value)
    } else {
      None
    }
  }
}

///|
pub fn parse_year_month(text : String) -> (Int, Int)? {
  let normalized = date_separatorized(text)
  let parts = normalized.split("-").to_array()
  if parts.length() < 2 {
    None
  } else {
    let year = decimal_value(parts[0].to_owned())
    let month = decimal_value(parts[1].to_owned())
    if year >= 1 && month >= 1 && month <= 12 {
      Some((year, month))
    } else {
      None
    }
  }
}

///|
pub fn parse_age(text : String) -> Int? {
  let normalized = text.trim().to_owned().to_lower()
  let digits = numeric_prefix(normalized)
  if digits.is_empty() {
    None
  } else {
    let value = decimal_value(digits)
    if value >= 0 && value <= 150 {
      Some(value)
    } else {
      None
    }
  }
}

///|
pub fn date_like_precision(text : String) -> TemporalPrecision {
  if parse_calendar_date(text) is Some(_) {
    Day
  } else if parse_year_month(text) is Some(_) {
    Month
  } else if text.to_array().all(fn(c) { c.is_ascii_digit() }) &&
    text.length() == 4 {
    Year
  } else if parse_age(text) is Some(_) {
    Age
  } else {
    Unknown
  }
}

///|
pub fn date_distance(left : CalendarDate, right : CalendarDate) -> Int {
  let mut distance = 0
  let mut cursor = left
  if calendar_date_equal(cursor, right) {
    0
  } else if calendar_date_before(cursor, right) {
    while calendar_date_before(cursor, right) {
      cursor = add_calendar_days(cursor, 1)
      distance += 1
      if distance > 2000000 {
        break
      }
    }
    distance
  } else {
    while calendar_date_after(cursor, right) {
      cursor = add_calendar_days(cursor, -1)
      distance -= 1
      if distance < -2000000 {
        break
      }
    }
    distance
  }
}

///|
pub fn add_calendar_days(date : CalendarDate, amount : Int) -> CalendarDate {
  if !date.is_valid() || amount == 0 {
    date
  } else if amount > 0 {
    let mut result = date
    for _ in 0.. 1 {
        result = { ..result, day: result.day - 1 }
      } else if result.month > 1 {
        let month = result.month - 1
        result = {
          year: result.year,
          month,
          day: days_in_month(result.year, month),
        }
      } else {
        let year = result.year - 1
        result = { year, month: 12, day: 31 }
      }
    }
    result
  }
}

///|
pub fn add_calendar_months(date : CalendarDate, amount : Int) -> CalendarDate {
  if !date.is_valid() {
    date
  } else {
    let total = date.year * 12 + date.month - 1 + amount
    let year = total / 12
    let month = total % 12 + 1
    let day = if date.day > days_in_month(year, month) {
      days_in_month(year, month)
    } else {
      date.day
    }
    { year, month, day }
  }
}

///|
pub fn add_calendar_years(date : CalendarDate, amount : Int) -> CalendarDate {
  let year = date.year + amount
  let day = if date.month == 2 && date.day == 29 && !is_leap_year(year) {
    28
  } else {
    date.day
  }
  { year, month: date.month, day }
}

///|
pub fn age_on_date(birth : CalendarDate, at : CalendarDate) -> Int {
  if !birth.is_valid() || !at.is_valid() || calendar_date_before(at, birth) {
    0
  } else {
    let mut age = at.year - birth.year
    if at.month < birth.month || (at.month == birth.month && at.day < birth.day) {
      age -= 1
    }
    age
  }
}

///|
pub fn bucket_age(age : Int, cutoff : Int) -> String {
  if age < 0 {
    "unknown"
  } else if age >= cutoff {
    "90+"
  } else if age < 1 {
    "under-1"
  } else if age < 5 {
    "1-4"
  } else if age < 13 {
    "5-12"
  } else if age < 18 {
    "13-17"
  } else if age < 30 {
    "18-29"
  } else if age < 45 {
    "30-44"
  } else if age < 65 {
    "45-64"
  } else {
    "65-89"
  }
}

///|
pub fn bucket_age_default(age : Int) -> String {
  bucket_age(age, TemporalPolicy::default().age_cutoff)
}

///|
fn deterministic_day_delta(value : String, policy : TemporalPolicy) -> Int {
  let token = stable_hash(policy.salt + "\u{1f}" + value)
  let digits = token
    .to_array()
    .fold(init=0, (sum, c) => {
      let v = if c.is_ascii_digit() { c.to_int() - '0'.to_int() } else { 0 }
      sum + v
    })
  let span = if policy.day_delta < 0 {
    -policy.day_delta
  } else {
    policy.day_delta
  }
  if span == 0 {
    0
  } else {
    digits % (span * 2 + 1) - span
  }
}

///|
pub fn shift_date_deterministic(
  date : CalendarDate,
  original : String,
  policy : TemporalPolicy,
) -> CalendarDate {
  let delta = if policy.day_delta == 0 {
    0
  } else {
    deterministic_day_delta(original, policy)
  }
  add_calendar_days(date, delta)
}

///|
pub fn shift_date_with_delta(
  date : CalendarDate,
  delta : Int,
  precision : TemporalPrecision,
) -> CalendarDate {
  match precision {
    Day => add_calendar_days(date, delta)
    Month => add_calendar_months(date, delta / 30)
    Year => add_calendar_years(date, delta / 365)
    Age | Unknown => date
  }
}

///|
pub fn shift_temporal_text(
  original : String,
  date : CalendarDate,
  precision : TemporalPrecision,
  policy : TemporalPolicy,
) -> String {
  let shifted = shift_date_deterministic(date, original, policy)
  match precision {
    Day => shifted.to_iso()
    Month => shifted.to_month()
    Year => "\{shifted.year}"
    Age => bucket_age_default(age_on_date(date, shifted))
    Unknown => original
  }
}

///|
pub fn temporal_value(
  original : String,
  start : Int,
  end : Int,
  policy : TemporalPolicy,
) -> TemporalValue {
  let precision = date_like_precision(original)
  let date = parse_calendar_date(original)
  let shifted = match date {
    Some(value) => shift_temporal_text(original, value, precision, policy)
    None => original
  }
  { original, shifted, precision, date, start, end }
}

///|
pub fn temporal_value_is_valid(
  value : TemporalValue,
  text_length : Int,
) -> Bool {
  value.start >= 0 &&
  value.end > value.start &&
  value.end <= text_length &&
  value.original.length() > 0 &&
  value.shifted.length() > 0
}

///|
pub fn temporal_value_summary(value : TemporalValue) -> String {
  [
    "original=\{quote_for_log(value.original)}",
    "shifted=\{quote_for_log(value.shifted)}",
    "precision=\{temporal_precision_name(value.precision)}",
    "span=\{value.start}..\{value.end}",
  ].join("\n")
}

///|
pub fn temporal_values_for_findings(
  input : String,
  findings : Array[Finding],
  policy : TemporalPolicy,
) -> Array[TemporalValue] {
  findings
  .filter(fn(item) { item.kind == Date })
  .map(fn(item) { temporal_value(item.text, item.start, item.end, policy) })
  .filter(fn(item) { temporal_value_is_valid(item, input.length()) })
}

///|
pub fn temporal_findings(
  input : String,
  policy : TemporalPolicy,
) -> Array[Finding] raise DeidError {
  let findings = scan(input).filter(fn(item) { item.kind == Date })
  findings.map(fn(item) {
    let value = temporal_value(item.text, item.start, item.end, policy)
    { ..item, replacement: value.shifted }
  })
}

///|
pub fn shift_dates_in_text(
  input : String,
  policy : TemporalPolicy,
) -> String raise DeidError {
  let findings = temporal_findings(input, policy)
  let (text, _) = apply_findings(input, findings)
  text
}

///|
pub fn shift_dates_with_audit(
  input : String,
  policy : TemporalPolicy,
) -> DeidResult raise DeidError {
  let findings = temporal_findings(input, policy)
  let (text, offsets) = apply_findings(input, findings)
  {
    text,
    findings,
    offsets,
    audit: build_audit(input, text, findings, offsets),
  }
}

///|
pub fn temporal_histogram(
  input : String,
  policy : TemporalPolicy,
) -> Map[String, Int] raise DeidError {
  let result : Map[String, Int] = Map([])
  for value in temporal_values_for_findings(input, scan(input), policy) {
    let key = temporal_precision_name(value.precision)
    result[key] = result.get_or_default(key, 0) + 1
  }
  result
}

///|
pub fn temporal_years(input : String) -> Array[Int] raise DeidError {
  let years = []
  for finding in scan(input).filter(fn(item) { item.kind == Date }) {
    match parse_calendar_date(finding.text) {
      Some(date) => if !years.contains(date.year) { years.push(date.year) }
      None => ()
    }
  }
  years.sort()
  years
}

///|
pub fn temporal_date_spans(input : String) -> Array[Span] raise DeidError {
  scan(input)
  .filter(fn(item) { item.kind == Date })
  .map(fn(item) { { start: item.start, end: item.end } })
}

///|
pub fn temporal_policy_summary(policy : TemporalPolicy) -> String {
  [
    "salt_checksum=\{stable_hash(policy.salt)}",
    "day_delta=\{policy.day_delta}",
    "keep_year=\{policy.keep_year}",
    "keep_month=\{policy.keep_month}",
    "age_cutoff=\{policy.age_cutoff}",
    "shift_ages=\{policy.shift_ages}",
  ].join("\n")
}

///|
pub fn temporal_shift_is_deterministic(
  input : String,
  policy : TemporalPolicy,
) -> Bool raise DeidError {
  shift_dates_in_text(input, policy) == shift_dates_in_text(input, policy)
}

///|
pub fn temporal_shift_preserves_non_dates(
  input : String,
  policy : TemporalPolicy,
) -> Bool raise DeidError {
  let shifted = shift_dates_in_text(input, policy)
  let findings = temporal_findings(input, policy)
  let mut left = 0
  for finding in findings {
    if finding.start >= left {
      if input[left:finding.start] != shifted[left:finding.start] {
        return false
      }
      left = finding.end
    }
  }
  input[left:] == shifted[left:]
}

///|
pub fn temporal_date_sequence(
  start : CalendarDate,
  count : Int,
  step : Int,
) -> Array[CalendarDate] {
  let result = []
  if count > 0 && start.is_valid() {
    let mut current = start
    for _ in 0.. Bool {
  if values.length() < 2 {
    true
  } else {
    let mut result = true
    for i in 1.. CalendarDate? {
  if values.is_empty() {
    None
  } else {
    Some(
      values.fold(init=values[0], (best, item) => {
        if calendar_date_before(item, best) {
          item
        } else {
          best
        }
      }),
    )
  }
}

///|
pub fn temporal_max_date(values : Array[CalendarDate]) -> CalendarDate? {
  if values.is_empty() {
    None
  } else {
    Some(
      values.fold(init=values[0], (best, item) => {
        if calendar_date_after(item, best) {
          item
        } else {
          best
        }
      }),
    )
  }
}

///|
pub fn temporal_range_text(values : Array[CalendarDate]) -> String {
  match (temporal_min_date(values), temporal_max_date(values)) {
    (Some(left), Some(right)) => "\{left.to_iso()}..\{right.to_iso()}"
    _ => "empty"
  }
}

///|
pub fn temporal_policy_for_tenant(tenant : String) -> TemporalPolicy {
  let normalized = normalize_for_matching(tenant)
  let digest = stable_hash(normalized)
  let mut delta = 0
  for c in digest {
    if c.is_ascii_hexdigit() {
      delta += c.to_int()
    }
  }
  {
    ..TemporalPolicy::default(),
    salt: "tenant:" + tenant,
    day_delta: delta % 731 - 365,
  }
}

///|
pub fn temporal_age_replacement(
  age_text : String,
  policy : TemporalPolicy,
) -> String {
  match parse_age(age_text) {
    Some(age) =>
      if policy.shift_ages {
        bucket_age(age, policy.age_cutoff)
      } else {
        age_text
      }
    None => age_text
  }
}

///|
pub fn temporal_value_json(value : TemporalValue) -> String {
  "{" +
  "\"original\":\{json_escape(value.original)}," +
  "\"shifted\":\{json_escape(value.shifted)}," +
  "\"precision\":\{json_escape(temporal_precision_name(value.precision))}," +
  "\"start\":\{value.start},\"end\":\{value.end}" +
  "}"
}

///|
pub fn temporal_values_json(values : Array[TemporalValue]) -> String {
  "[" + values.map(temporal_value_json).join(",") + "]"
}

///|
pub fn temporal_compliance_summary(
  input : String,
  policy : TemporalPolicy,
) -> String raise DeidError {
  let values = temporal_values_for_findings(input, scan(input), policy)
  [
    "candidates=\{values.length()}",
    "histogram=\{map_to_json(temporal_histogram(input, policy))}",
    "deterministic=\{temporal_shift_is_deterministic(input, policy)}",
    "non_date_stable=\{temporal_shift_preserves_non_dates(input, policy)}",
  ].join("\n")
}