///|
pub(all) struct PetProfile {
  name : String
  birthday : String
  arrival_day : String
  avatar : String
} derive(Eq, ToJson, FromJson)

///|
pub extend PetProfile with Eq::{not_equal, equal}

///|
pub extend PetProfile with ToJson::{to_json}

///|
pub extend PetProfile with @json.FromJson::{from_json}

///|
pub(all) struct DiaryEntry {
  id : Int32
  date : String
  text : String
  photo : String
} derive(Eq, ToJson, FromJson)

///|
pub extend DiaryEntry with Eq::{not_equal, equal}

///|
pub extend DiaryEntry with ToJson::{to_json}

///|
pub extend DiaryEntry with @json.FromJson::{from_json}

///|
pub(all) struct WeightEntry {
  id : Int32
  date : String
  grams : Int32
  photo : String
} derive(Eq, ToJson, FromJson)

///|
pub extend WeightEntry with Eq::{not_equal, equal}

///|
pub extend WeightEntry with ToJson::{to_json}

///|
pub extend WeightEntry with @json.FromJson::{from_json}

///|
pub(all) struct Journal {
  pet : PetProfile
  diaries : Array[DiaryEntry]
  weights : Array[WeightEntry]
  next_id : Int32
  reminders_enabled : Bool
} derive(Eq, ToJson, FromJson)

///|
pub extend Journal with Eq::{not_equal, equal}

///|
pub extend Journal with ToJson::{to_json}

///|
pub extend Journal with @json.FromJson::{from_json}

///|
pub fn empty_journal() -> Journal {
  {
    pet: { name: "", birthday: "", arrival_day: "", avatar: "", },
    diaries: [],
    weights: [],
    next_id: 1,
    reminders_enabled: false,
  }
}

///|
pub fn decode_journal(raw : String) -> Journal {
  match try_decode_journal(raw) {
    Some(value) => value
    None => empty_journal()
  }
}

///|
pub fn try_decode_journal(raw : String) -> Journal? {
  try {
    let value : Journal = @json.from_json(@json.parse(raw))
    if !valid_journal(value) {
      None
    } else {
      Some(value)
    }
  } catch {
    _ => None
  }
}

///|
/// Validate imported records before replacing any saved data.
fn valid_journal(journal : Journal) -> Bool {
  if journal.next_id < 1 || journal.next_id >= 2147483647 {
    return false
  }
  for date in [journal.pet.birthday, journal.pet.arrival_day] {
    if date != "" && day_number(date) is None {
      return false
    }
  }
  if journal.pet.birthday != "" &&
    journal.pet.arrival_day != "" &&
    journal.pet.birthday > journal.pet.arrival_day {
    return false
  }
  let ids : Map[Int32, Bool] = Map([])
  for entry in journal.diaries {
    if entry.id < 1 ||
      entry.id >= journal.next_id ||
      ids.contains(entry.id) ||
      day_number(entry.date) is None ||
      (entry.text.trim().length() == 0 && entry.photo.length() == 0) {
      return false
    }
    ids.set(entry.id, true)
  }
  for entry in journal.weights {
    if entry.id < 1 ||
      entry.id >= journal.next_id ||
      ids.contains(entry.id) ||
      day_number(entry.date) is None ||
      entry.grams <= 0 ||
      entry.grams > 200000 {
      return false
    }
    ids.set(entry.id, true)
  }
  true
}

///|
pub fn encode_journal(journal : Journal) -> String {
  journal.to_json().stringify()
}

///|
pub fn set_pet(
  journal : Journal,
  name : String,
  birthday : String,
  arrival_day : String,
) -> Journal {
  {
    ..journal,
    pet: { ..journal.pet, name: name.trim().to_owned(), birthday, arrival_day, },
  }
}

///|
/// Update a profile only when optional dates are valid and ordered.
pub fn try_set_pet(
  journal : Journal,
  name : String,
  birthday : String,
  arrival_day : String,
) -> Journal? {
  if name.trim().length() == 0 {
    return None
  }
  for date in [birthday, arrival_day] {
    if date != "" && day_number(date) is None {
      return None
    }
  }
  if birthday != "" && arrival_day != "" && birthday > arrival_day {
    return None
  }
  Some(set_pet(journal, name, birthday, arrival_day))
}

///|
pub fn set_avatar(journal : Journal, avatar : String) -> Journal {
  { ..journal, pet: { ..journal.pet, avatar, }, }
}

///|
pub fn set_reminders(journal : Journal, enabled : Bool) -> Journal {
  { ..journal, reminders_enabled: enabled, }
}

///|
pub fn add_diary(
  journal : Journal,
  date : String,
  text : String,
  photo : String,
) -> Journal? {
  if journal.next_id < 1 || journal.next_id >= 2147483646 {
    return None
  }
  let cleaned = text.trim().to_owned()
  if cleaned.length() == 0 && photo.length() == 0 {
    return None
  }
  if day_number(date) is None {
    return None
  }
  let diaries = journal.diaries.copy()
  diaries.push({ id: journal.next_id, date, text: cleaned, photo, })
  Some({ ..journal, diaries, next_id: journal.next_id + 1, })
}

///|
pub fn add_weight(
  journal : Journal,
  date : String,
  grams : Int32,
  photo : String,
) -> Journal? {
  if journal.next_id < 1 || journal.next_id >= 2147483646 {
    return None
  }
  if grams <= 0 || grams > 200000 || day_number(date) is None {
    return None
  }
  let weights = journal.weights.copy()
  weights.push({ id: journal.next_id, date, grams, photo, })
  Some({ ..journal, weights, next_id: journal.next_id + 1, })
}

///|
/// Parse kilograms as a positive decimal with at most three fractional digits.
/// Values outside 0.001–200 kg, signs and exponent notation are rejected.
pub fn parse_weight_kg(raw : String) -> Int32? {
  let text = raw.trim()
  let mut whole = 0
  let mut fraction = 0
  let mut whole_digits = 0
  let mut fraction_digits = 0
  let mut decimal = false
  for ch in text.iter() {
    if ch == '.' && !decimal && whole_digits > 0 {
      decimal = true
    } else if ch >= '0' && ch <= '9' {
      let digit = ch.to_int() - '0'.to_int()
      if decimal {
        fraction_digits += 1
        if fraction_digits > 3 {
          return None
        }
        fraction = fraction * 10 + digit
      } else {
        whole_digits += 1
        if whole_digits > 6 {
          return None
        }
        whole = whole * 10 + digit
        if whole > 200 {
          return None
        }
      }
    } else {
      return None
    }
  }
  if whole_digits == 0 || (decimal && fraction_digits == 0) {
    return None
  }
  let scale = match fraction_digits {
    1 => 100
    2 => 10
    _ => 1
  }
  let grams = whole * 1000 + fraction * scale
  if grams <= 0 || grams > 200000 {
    None
  } else {
    Some(grams)
  }
}

///|
pub fn format_grams_kg(grams : Int32) -> String {
  let whole = grams / 1000
  let fraction = grams % 1000
  let a = fraction / 100
  let b = fraction / 10 % 10
  let c = fraction % 10
  if fraction == 0 {
    "\{whole} kg"
  } else if c != 0 {
    "\{whole}.\{a}\{b}\{c} kg"
  } else if b != 0 {
    "\{whole}.\{a}\{b} kg"
  } else {
    "\{whole}.\{a} kg"
  }
}

///|
pub fn remove_diary(journal : Journal, id : Int32) -> Journal {
  { ..journal, diaries: journal.diaries.filter(entry => entry.id != id), }
}

///|
pub fn remove_weight(journal : Journal, id : Int32) -> Journal {
  { ..journal, weights: journal.weights.filter(entry => entry.id != id), }
}

///|
pub fn diaries_newest_first(journal : Journal) -> Array[DiaryEntry] {
  let result = journal.diaries.copy()
  result.sort_by((a, b) => {
    if a.date > b.date {
      -1
    } else if a.date < b.date {
      1
    } else {
      b.id - a.id
    }
  })
  result
}

///|
pub fn weights_oldest_first(journal : Journal) -> Array[WeightEntry] {
  let result = journal.weights.copy()
  result.sort_by((a, b) => {
    if a.date < b.date {
      -1
    } else if a.date > b.date {
      1
    } else if a.id < b.id {
      -1
    } else if a.id > b.id {
      1
    } else {
      0
    }
  })
  result
}

///|
pub fn latest_diary_gap(journal : Journal, today : String) -> Int32? {
  let today_number = match day_number(today) {
    Some(day) => day
    None => return None
  }
  let mut latest = -1
  for entry in journal.diaries {
    match day_number(entry.date) {
      Some(day) if day <= today_number && day > latest => latest = day
      _ => ()
    }
  }
  if latest == -1 {
    None
  } else {
    Some(today_number - latest)
  }
}

///|
pub fn reminder_text(
  journal : Journal,
  today : String,
  variant : Int32,
) -> String? {
  if !journal.reminders_enabled ||
    journal.pet.name.length() == 0 ||
    day_number(today) is None {
    return None
  }
  let days = latest_diary_gap(journal, today)
  match days {
    Some(0) => None
    None =>
      Some("\{journal.pet.name} 在等你写下第一篇成长日记 📷")
    Some(n) if n >= 3 =>
      if variant % 2 == 0 {
        Some(
          "\{journal.pet.name}:我们已经 \{n} 天没留下新回忆啦。",
        )
      } else {
        Some(
          "今天的我也很可爱,来给 \{journal.pet.name} 留个小记录吧。",
        )
      }
    Some(_) =>
      Some("\{journal.pet.name} 想把今天的小事也收进相册。")
  }
}

///|
pub fn day_number(iso : String) -> Int32? {
  if iso.length() != 10 || iso[4:5] != "-" || iso[7:8] != "-" {
    return None
  }
  for index, ch in iso.iter2() {
    if index != 4 && index != 7 && (ch < '0' || ch > '9') {
      return None
    }
  }
  let year = @string.parse_int(iso[0:4]) catch { _ => return None }
  let month = @string.parse_int(iso[5:7]) catch { _ => return None }
  let day = @string.parse_int(iso[8:10]) catch { _ => return None }
  if year < 1 || month < 1 || month > 12 || day < 1 {
    return None
  }
  let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
  let max_day = match month {
    2 => if leap { 29 } else { 28 }
    4 | 6 | 9 | 11 => 30
    _ => 31
  }
  if day > max_day {
    return None
  }
  let y = if month <= 2 { year - 1 } else { year }
  let era = y / 400
  let yoe = y - era * 400
  let mp = if month > 2 { month - 3 } else { month + 9 }
  let doy = (153 * mp + 2) / 5 + day - 1
  Some(era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy)
}