use crate::core::calculation::NominalAgeBoundary;
use crate::core::calendar::{LunarDateInfo, lunar_facts};
use crate::core::error::ChartError;
use crate::core::model::calendar::{CalendarKind, SolarDay, SolarMonth};
use crate::core::model::chart::{
Chart, DecadalFrame, DecadalPeriod, PALACE_COUNT, PalaceName, TemporalPalaceLayout,
TemporalPalaceName,
};
use crate::core::model::ganzhi::{EarthlyBranch, StemBranch};
use crate::core::model::star::mutagen::Scope;
pub(super) fn build_life_branch_palace_layout(
scope: Scope,
life_branch: EarthlyBranch,
) -> Result<TemporalPalaceLayout, ChartError> {
let life_index = yin_first_branch_index(life_branch);
let names = (0..PALACE_COUNT)
.map(|index| {
TemporalPalaceName::new(
EarthlyBranch::Yin.offset(index as isize),
PalaceName::Life.offset(life_index as isize - index as isize),
)
})
.collect();
TemporalPalaceLayout::try_new(scope, names)
}
pub(super) fn yin_first_branch_index(branch: EarthlyBranch) -> usize {
(branch.index() + PALACE_COUNT - EarthlyBranch::Yin.index()) % PALACE_COUNT
}
pub(super) fn monthly_palace_index(
natal: &Chart,
target_lunar_year: i32,
target_lunar_month: u8,
target_lunar_day: u8,
target_is_leap_month: bool,
) -> usize {
let birth = natal.birth_context();
let birth_date = birth.date();
debug_assert_eq!(birth_date.kind(), CalendarKind::Lunar);
let yearly_index =
yin_first_branch_index(StemBranch::from_lunar_year(target_lunar_year).branch());
let birth_month = birth_date.month() as isize;
let birth_hour = birth.birth_time_variant().branch().index() as isize;
let target_leap_addition = isize::from(target_is_leap_month && target_lunar_day > 15);
let target_month = target_lunar_month as isize + target_leap_addition;
(yearly_index as isize - birth_month + birth_hour + target_month)
.rem_euclid(PALACE_COUNT as isize) as usize
}
pub(super) fn daily_palace_index(
natal: &Chart,
target_lunar_year: i32,
target_lunar_month: u8,
target_lunar_day: u8,
target_is_leap_month: bool,
) -> usize {
let monthly_index = monthly_palace_index(
natal,
target_lunar_year,
target_lunar_month,
target_lunar_day,
target_is_leap_month,
);
(monthly_index as isize + target_lunar_day as isize - 1).rem_euclid(PALACE_COUNT as isize)
as usize
}
pub(crate) fn target_lunar_date(
year: i32,
month: SolarMonth,
day: SolarDay,
) -> Result<LunarDateInfo, ChartError> {
lunar_facts(year, month, day)
}
pub(crate) fn nominal_age_for_target_year(
natal: &Chart,
target_lunar_year: i32,
) -> Result<u8, ChartError> {
let birth = natal.birth_context().date();
resolve_nominal_age(
birth.year(),
birth.month(),
birth.day(),
target_lunar_year,
1,
1,
NominalAgeBoundary::NaturalYear,
)
}
pub(crate) fn nominal_age_for_target(
natal: &Chart,
target_lunar_year: i32,
target_lunar_month: u8,
target_lunar_day: u8,
policy: NominalAgeBoundary,
) -> Result<u8, ChartError> {
let birth = natal.birth_context().date();
resolve_nominal_age(
birth.year(),
birth.month(),
birth.day(),
target_lunar_year,
target_lunar_month,
target_lunar_day,
policy,
)
}
pub(crate) fn resolve_nominal_age(
birth_lunar_year: i32,
birth_lunar_month: u8,
birth_lunar_day: u8,
target_lunar_year: i32,
target_lunar_month: u8,
target_lunar_day: u8,
policy: NominalAgeBoundary,
) -> Result<u8, ChartError> {
let base = target_lunar_year - birth_lunar_year;
let increment = match policy {
NominalAgeBoundary::NaturalYear => 1,
NominalAgeBoundary::Birthday => {
let past_birthday = (target_lunar_year == birth_lunar_year
&& target_lunar_month == birth_lunar_month
&& target_lunar_day > birth_lunar_day)
|| target_lunar_month > birth_lunar_month;
i32::from(past_birthday)
}
};
let raw = base + increment;
u8::try_from(raw)
.ok()
.filter(|age| (1..=120).contains(age))
.ok_or(ChartError::InvalidNominalAge {
value: raw.clamp(0, u8::MAX as i32) as u8,
})
}
pub(crate) fn select_decadal_period_by_age(
frame: &DecadalFrame,
nominal_age: u8,
) -> Result<&DecadalPeriod, ChartError> {
frame
.periods()
.iter()
.find(|period| (period.start_age()..=period.end_age()).contains(&nominal_age))
.ok_or(ChartError::NominalAgeOutsideDecadalFrame { nominal_age })
}
#[cfg(test)]
mod tests {
use super::*;
const BIRTH: (i32, u8, u8) = (2020, 9, 17);
fn age(target: (i32, u8, u8), policy: NominalAgeBoundary) -> u8 {
resolve_nominal_age(
BIRTH.0, BIRTH.1, BIRTH.2, target.0, target.1, target.2, policy,
)
.expect("nominal age should resolve")
}
#[test]
fn nominal_age_natural_year_changes_at_expected_boundary() {
assert_eq!(age((2020, 12, 29), NominalAgeBoundary::NaturalYear), 1);
assert_eq!(age((2021, 1, 1), NominalAgeBoundary::NaturalYear), 2);
}
#[test]
fn nominal_age_birthday_same_month_before_birthday() {
assert_eq!(age((2024, 9, 16), NominalAgeBoundary::Birthday), 4);
}
#[test]
fn nominal_age_birthday_same_month_on_birthday() {
assert_eq!(age((2024, 9, 17), NominalAgeBoundary::Birthday), 4);
}
#[test]
fn nominal_age_birthday_same_month_after_birthday() {
assert_eq!(age((2024, 9, 18), NominalAgeBoundary::Birthday), 4);
}
#[test]
fn nominal_age_birthday_later_year_same_month_after_birthday() {
assert_eq!(age((2025, 9, 18), NominalAgeBoundary::Birthday), 5);
}
#[test]
fn nominal_age_birthday_later_month_uses_next_nominal_age() {
assert_eq!(age((2024, 10, 1), NominalAgeBoundary::Birthday), 5);
}
#[test]
fn nominal_age_birthday_birth_year_later_day_reaches_age_one() {
assert_eq!(age((2020, 9, 18), NominalAgeBoundary::Birthday), 1);
}
#[test]
fn nominal_age_birthday_previous_month_uses_previous_nominal_age() {
assert_eq!(age((2024, 8, 20), NominalAgeBoundary::Birthday), 4);
}
#[test]
fn natural_year_and_birthday_agree_after_birthday() {
assert_eq!(
age((2024, 10, 1), NominalAgeBoundary::Birthday),
age((2024, 10, 1), NominalAgeBoundary::NaturalYear),
);
}
#[test]
fn nominal_age_rejects_out_of_range() {
let err = resolve_nominal_age(2020, 9, 1, 1800, 1, 1, NominalAgeBoundary::NaturalYear)
.expect_err("negative age is out of range");
assert!(matches!(err, ChartError::InvalidNominalAge { .. }));
}
}