use crate::duration::Duration;
use crate::naive::NaiveDate;
const DAYS_IN_800_YEARS: i64 = 292_207;
const TIME_UNITS_PER_DAY: i64 = 800;
const EPOCH_OFFSET: i64 = 373;
const CS_JULIAN_DAY_OFFSET: i64 = 1_954_167;
const JDN_MINUS_NUM_DAYS_FROM_CE: i64 = 1_721_425;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum CalType {
Normal,
ExtraDay,
ExtraMonth,
Conflict,
}
#[derive(Clone, Copy)]
struct YearFacts {
horakhun: i64,
tithi: i64,
nyd: i64,
next_nyd: i64,
offset: bool,
cal_type: CalType,
first_month_is_5: bool,
first_day: i64,
}
fn raw_year_facts(cs_year: i64) -> YearFacts {
let horakhun = (cs_year * DAYS_IN_800_YEARS + EPOCH_OFFSET) / TIME_UNITS_PER_DAY + 1;
let kammacapon = TIME_UNITS_PER_DAY - (cs_year * DAYS_IN_800_YEARS + EPOCH_OFFSET) % TIME_UNITS_PER_DAY;
let avo_quot = (horakhun * 11 + 650) / 692;
let mut avoman = (horakhun * 11 + 650) % 692;
if avoman == 0 {
avoman = 692;
}
let mut tithi = (avo_quot + horakhun) % 30;
if avoman == 692 {
tithi -= 1;
}
let horakhun1 = ((cs_year + 1) * DAYS_IN_800_YEARS + EPOCH_OFFSET) / TIME_UNITS_PER_DAY + 1;
let avo_quot1 = (horakhun1 * 11 + 650) / 692;
let tithi1 = (avo_quot1 + horakhun1) % 30;
let weekday = horakhun % 7;
let langsak = tithi.max(1);
let mut nyd_helper = langsak;
if nyd_helper < 6 {
nyd_helper += 29;
}
let nyd = ((weekday - nyd_helper + 1 + 35) % 7 + 7) % 7;
let leapday = kammacapon <= 207;
let mut cal_type = CalType::Normal;
if tithi > 24 || tithi < 6 {
cal_type = CalType::ExtraMonth;
}
if tithi == 25 && tithi1 == 5 {
cal_type = CalType::Normal;
}
if (leapday && avoman <= 126) || (!leapday && avoman <= 137) {
cal_type = if cal_type == CalType::ExtraMonth { CalType::Conflict } else { CalType::ExtraDay };
}
let next_nyd_delta = match cal_type {
CalType::Normal => 4,
CalType::ExtraDay => 5,
CalType::ExtraMonth | CalType::Conflict => 6,
};
let next_nyd = (nyd + next_nyd_delta) % 7;
YearFacts { horakhun, tithi, nyd, next_nyd, offset: false, cal_type, first_month_is_5: false, first_day: 0 }
}
fn resolve_year(cs_year: i64) -> YearFacts {
let mut y: [YearFacts; 5] = core::array::from_fn(|i| raw_year_facts(cs_year - 2 + i as i64));
if y[2].tithi == 24 && y[3].tithi == 6 {
for i in 0..5 {
y[i].cal_type = CalType::ExtraMonth;
y[i].next_nyd = (y[i].next_nyd + 2) % 7;
}
}
for i in [1, 2, 3] {
if y[i].cal_type == CalType::Conflict {
let j = if y[i].nyd == y[i - 1].next_nyd { 1i64 } else { -1i64 };
let k = (i as i64 + j) as usize;
y[k].cal_type = CalType::ExtraDay;
y[k].next_nyd = (y[k].next_nyd + 1) % 7;
}
}
for i in [1, 2, 3] {
if y[i - 1].next_nyd != y[i].nyd && y[i].next_nyd != y[i + 1].nyd {
y[i].offset = true;
y[i].nyd = (y[i].nyd + 6) % 7;
y[i].next_nyd = (y[i].next_nyd + 6) % 7;
}
}
let mut mid = y[2];
if mid.cal_type == CalType::Conflict {
mid.cal_type = CalType::ExtraMonth;
}
finalize_new_year(mid)
}
fn finalize_new_year(mut facts: YearFacts) -> YearFacts {
let langsak = facts.tithi.max(1) + i64::from(facts.offset);
let mut first_month_is_5 = true;
let mut first_day = langsak;
let mut offset_days = langsak;
let threshold = 6 + i64::from(facts.offset);
if offset_days < threshold {
first_month_is_5 = false;
first_day = offset_days;
offset_days += 29;
}
facts.first_month_is_5 = first_month_is_5;
facts.first_day = first_day;
let _ = offset_days; facts
}
fn month_length(month: i64, cal_type: CalType) -> i64 {
if month == 88 {
return 30;
}
let base = if month % 2 == 0 { 30 } else { 29 };
if month == 7 && cal_type == CalType::ExtraDay {
base + 1
} else {
base
}
}
const MONTH_SEQUENCE_NORMAL: [i64; 12] = [5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4];
const MONTH_SEQUENCE_EXTRA_MONTH: [i64; 13] = [5, 6, 7, 8, 88, 9, 10, 11, 12, 1, 2, 3, 4];
fn month_start_offset(facts: &YearFacts, target_month: i64) -> Option<i64> {
let (sequence, start_month): (&[i64], i64) = if facts.cal_type == CalType::ExtraMonth {
(&MONTH_SEQUENCE_EXTRA_MONTH, if facts.first_month_is_5 { 5 } else { 6 })
} else {
(&MONTH_SEQUENCE_NORMAL, if facts.first_month_is_5 { 5 } else { 6 })
};
let start_index = sequence.iter().position(|&m| m == start_month)?;
let mut offset = -(facts.first_day - 1);
if sequence[start_index] == target_month {
return Some(offset);
}
for i in start_index..sequence.len() - 1 {
offset += month_length(sequence[i], facts.cal_type);
if sequence[i + 1] == target_month {
return Some(offset);
}
}
None
}
fn full_moon_jdn(cs_year: i64, month: i64) -> Option<i64> {
let facts = resolve_year(cs_year);
let day_offset = month_start_offset(&facts, month)?;
Some(facts.horakhun + day_offset + 14 + CS_JULIAN_DAY_OFFSET)
}
fn jdn_to_naive_date(jdn: i64) -> Option<NaiveDate> {
let days = jdn - JDN_MINUS_NUM_DAYS_FROM_CE;
let days = i32::try_from(days).ok()?;
NaiveDate::from_num_days_from_ce_opt(days)
}
fn vesak_governing_cs_year(gregorian_year: i32) -> i64 {
i64::from(gregorian_year) - 638
}
fn magha_governing_cs_year(gregorian_year: i32) -> i64 {
vesak_governing_cs_year(gregorian_year) - 1
}
fn is_extra_month_year(cs_year: i64) -> bool {
resolve_year(cs_year).cal_type == CalType::ExtraMonth
}
pub(crate) fn magha_bucha(year: i32) -> Option<NaiveDate> {
let leap = is_extra_month_year(vesak_governing_cs_year(year));
let jdn = full_moon_jdn(magha_governing_cs_year(year), if leap { 4 } else { 3 })?;
jdn_to_naive_date(jdn)
}
pub(crate) fn visakha_bucha(year: i32) -> Option<NaiveDate> {
let cs_year = vesak_governing_cs_year(year);
let leap = is_extra_month_year(cs_year);
let jdn = full_moon_jdn(cs_year, if leap { 7 } else { 6 })?;
jdn_to_naive_date(jdn)
}
pub(crate) fn asalha_bucha(year: i32) -> Option<NaiveDate> {
let cs_year = vesak_governing_cs_year(year);
let leap = is_extra_month_year(cs_year);
let jdn = full_moon_jdn(cs_year, if leap { 88 } else { 8 })?;
jdn_to_naive_date(jdn)
}
pub(crate) fn khao_phansa(year: i32) -> Option<NaiveDate> {
asalha_bucha(year)?.checked_add_signed(Duration::days(1))
}
pub(crate) fn awk_phansa(year: i32) -> Option<NaiveDate> {
let cs_year = vesak_governing_cs_year(year);
let jdn = full_moon_jdn(cs_year, 11)?;
jdn_to_naive_date(jdn)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_within_2_days(actual: Option<NaiveDate>, expected: NaiveDate, label: &str) {
let actual = actual.unwrap_or_else(|| panic!("{label}: got None, expected close to {expected:?}"));
let diff = (actual.num_days_from_ce() - expected.num_days_from_ce()).abs();
assert!(diff <= 2, "{label}: got {actual:?}, expected {expected:?} (diff {diff} days)");
}
#[test]
fn magha_bucha_matches_published_reference_dates() {
let cases = [
(2008, 2, 21),
(2009, 2, 9),
(2011, 2, 18),
(2013, 2, 25),
(2014, 2, 14),
(2017, 2, 11),
(2019, 2, 19),
(2020, 2, 8),
(2022, 2, 16),
(2024, 2, 24),
(2025, 2, 12),
];
for (y, m, d) in cases {
assert_eq!(
magha_bucha(y),
NaiveDate::from_ymd_opt(y, m, d),
"magha_bucha({y})"
);
}
}
#[test]
fn magha_bucha_matches_within_2_days_in_known_fuzzy_years() {
assert_within_2_days(magha_bucha(2015), NaiveDate::from_ymd_opt(2015, 3, 4).unwrap(), "magha_bucha(2015)");
assert_within_2_days(magha_bucha(2016), NaiveDate::from_ymd_opt(2016, 2, 22).unwrap(), "magha_bucha(2016)");
}
#[test]
fn visakha_bucha_matches_published_reference_dates_including_intercalary_years() {
let cases = [
(2006, 5, 12),
(2007, 5, 31), (2008, 5, 19),
(2009, 5, 8),
(2010, 5, 28), (2011, 5, 17),
(2012, 6, 4), (2013, 5, 24),
(2017, 5, 10),
(2018, 5, 29), (2019, 5, 18),
(2020, 5, 6),
(2021, 5, 26), (2022, 5, 15),
(2023, 6, 3), (2024, 5, 22),
(2025, 5, 11),
(2026, 5, 31), ];
for (y, m, d) in cases {
assert_eq!(
visakha_bucha(y),
NaiveDate::from_ymd_opt(y, m, d),
"visakha_bucha({y})"
);
}
}
#[test]
fn asalha_bucha_matches_published_reference_dates() {
let cases = [
(2009, 7, 7),
(2011, 7, 15),
(2012, 8, 2), (2013, 7, 22),
(2016, 7, 19),
(2018, 7, 27), (2019, 7, 16),
(2021, 7, 24), (2022, 7, 13),
(2023, 8, 1), (2024, 7, 20),
(2025, 7, 10),
];
for (y, m, d) in cases {
assert_eq!(
asalha_bucha(y),
NaiveDate::from_ymd_opt(y, m, d),
"asalha_bucha({y})"
);
}
}
#[test]
fn khao_phansa_is_always_the_day_after_asalha_bucha() {
for y in 1950..2060 {
let asalha = asalha_bucha(y).unwrap_or_else(|| panic!("asalha_bucha({y}) returned None"));
let khao = khao_phansa(y).unwrap_or_else(|| panic!("khao_phansa({y}) returned None"));
assert_eq!(khao, asalha.checked_add_signed(Duration::days(1)).unwrap(), "year {y}");
}
}
#[test]
fn awk_phansa_matches_published_reference_dates() {
assert_eq!(awk_phansa(2024), NaiveDate::from_ymd_opt(2024, 10, 17));
assert_eq!(awk_phansa(2025), NaiveDate::from_ymd_opt(2025, 10, 7));
}
#[test]
fn resolve_year_matches_1900_to_2100_invariants() {
for cs_year in 1250i64..1470 {
let facts = resolve_year(cs_year);
let days = match facts.cal_type {
CalType::Normal => 354,
CalType::ExtraDay => 355,
CalType::ExtraMonth => 384,
CalType::Conflict => panic!("cs_year {cs_year}: unresolved Conflict cal_type"),
};
assert!(days == 354 || days == 355 || days == 384, "cs_year {cs_year}: cal_type {:?}", facts.cal_type);
}
for y in 1900..2101 {
assert!(magha_bucha(y).is_some(), "magha_bucha({y}) returned None");
assert!(visakha_bucha(y).is_some(), "visakha_bucha({y}) returned None");
assert!(asalha_bucha(y).is_some(), "asalha_bucha({y}) returned None");
assert!(khao_phansa(y).is_some(), "khao_phansa({y}) returned None");
assert!(awk_phansa(y).is_some(), "awk_phansa({y}) returned None");
}
}
#[test]
fn festivals_occur_in_the_expected_month_order_every_year() {
for y in 1950..2060 {
let magha = magha_bucha(y).unwrap();
let visakha = visakha_bucha(y).unwrap();
let asalha = asalha_bucha(y).unwrap();
let awk = awk_phansa(y).unwrap();
assert!(magha < visakha, "year {y}: magha {magha:?} should precede visakha {visakha:?}");
assert!(visakha < asalha, "year {y}: visakha {visakha:?} should precede asalha {asalha:?}");
assert!(asalha < awk, "year {y}: asalha {asalha:?} should precede awk_phansa {awk:?}");
}
}
}