1mod convert;
2mod dto;
3pub mod v2;
4
5use std::collections::HashMap;
6
7use amlich_core::almanac::data::{get_ruleset, ruleset_registry};
8use amlich_core::almanac::recommendation::pack::recommendation_pack_descriptors;
9use amlich_core::holiday_data::{lunar_festivals, solar_holidays};
10use amlich_core::holidays::get_vietnamese_holidays;
11use amlich_core::insight_data::{
12 all_elements, find_can, find_chi, find_deity_classification_insight, find_deity_insight,
13 find_na_am_insight, find_ten_gods_insight, find_tiet_khi_insight, find_truc_insight,
14 get_day_guidance,
15};
16
17pub use dto::*;
18pub use dto::{NaAmErrorDto, NaAmLookupResultDto, NaAmResponseDto};
19
20fn snake_to_pascal(s: &str) -> String {
22 s.split('_')
23 .map(|part| {
24 let mut chars = part.chars();
25 match chars.next() {
26 None => String::new(),
27 Some(first) => {
28 let upper: String = first.to_uppercase().collect();
29 upper + chars.as_str()
30 }
31 }
32 })
33 .collect()
34}
35
36pub fn get_day_info(query: &DateQuery) -> Result<DayInfoDto, String> {
37 if !(1..=12).contains(&query.month) {
38 return Err("month must be 1-12".to_string());
39 }
40 if !(1..=31).contains(&query.day) {
41 return Err("day must be 1-31".to_string());
42 }
43
44 let normalized_ruleset_id = normalize_ruleset_id(query.ruleset_id.as_deref())?;
45 let normalized_event_kind = normalize_event_kind(query.event_kind.as_deref())?;
46 let enabled_pack_ids = normalize_enabled_pack_ids(&query.enabled_pack_ids)?;
47
48 let tz = query.timezone.unwrap_or(amlich_core::VIETNAM_TIMEZONE);
49 let snapshot = amlich_core::calculate_day_snapshot_with_recommendation_request(
50 query.day,
51 query.month,
52 query.year,
53 tz,
54 normalized_ruleset_id.as_deref(),
55 normalized_event_kind.as_deref(),
56 &enabled_pack_ids,
57 )?;
58 Ok(DayInfoDto::from(&snapshot))
59}
60
61fn normalize_ruleset_id(ruleset_id: Option<&str>) -> Result<Option<String>, String> {
62 let Some(ruleset_id) = ruleset_id.map(str::trim).filter(|id| !id.is_empty()) else {
63 return Ok(None);
64 };
65
66 let entry = get_ruleset(ruleset_id).map_err(|err| err.to_string())?;
67 Ok(Some(entry.descriptor.id.to_string()))
68}
69
70fn normalize_event_kind(event_kind: Option<&str>) -> Result<Option<String>, String> {
71 let Some(event_kind) = event_kind.map(str::trim).filter(|kind| !kind.is_empty()) else {
72 return Ok(None);
73 };
74
75 match event_kind {
76 "contract_signing" | "medical_checkup" | "travel" => {
77 Ok(Some(event_kind.to_string()))
78 }
79 other => Err(format!(
80 "unsupported recommendation event_kind: {other}. supported values: contract_signing, medical_checkup, travel"
81 )),
82 }
83}
84
85fn normalize_enabled_pack_ids(enabled_pack_ids: &[String]) -> Result<Vec<&str>, String> {
86 let mut normalized = Vec::with_capacity(enabled_pack_ids.len());
87 for pack_id in enabled_pack_ids {
88 let trimmed = pack_id.trim();
89 if trimmed.is_empty() {
90 return Err("recommendation pack id must not be empty".to_string());
91 }
92 normalized.push(trimmed);
93 }
94 Ok(normalized)
95}
96
97pub fn get_day_info_for_date(day: i32, month: i32, year: i32) -> Result<DayInfoDto, String> {
98 get_day_info(&DateQuery {
99 day,
100 month,
101 year,
102 timezone: None,
103 ruleset_id: None,
104 event_kind: None,
105 enabled_pack_ids: vec![],
106 })
107}
108
109pub fn get_ruleset_catalog() -> Vec<RulesetCatalogEntryDto> {
110 ruleset_registry()
111 .iter()
112 .map(RulesetCatalogEntryDto::from)
113 .collect()
114}
115
116pub fn get_recommendation_pack_catalog() -> Vec<RecommendationPackCatalogEntryDto> {
117 recommendation_pack_descriptors()
118 .iter()
119 .map(RecommendationPackCatalogEntryDto::from)
120 .collect()
121}
122
123pub fn get_holidays(year: i32, major_only: bool) -> Vec<HolidayDto> {
124 get_vietnamese_holidays(year)
125 .iter()
126 .filter(|h| !major_only || h.is_major)
127 .map(HolidayDto::from)
128 .collect()
129}
130
131pub fn get_day_insight(query: &DateQuery) -> Result<DayInsightDto, String> {
132 get_day_insight_with_profile(query, None, None, None, None)
133}
134
135pub fn get_day_insight_with_profile(
136 query: &DateQuery,
137 birth_year: Option<i32>,
138 birth_month: Option<i32>,
139 birth_day: Option<i32>,
140 gender: Option<amlich_core::almanac::tu_menh::Gender>,
141) -> Result<DayInsightDto, String> {
142 let day_info = get_day_info(query)?;
143
144 let festival = lunar_festivals()
145 .iter()
146 .find(|item| {
147 if item.is_solar {
148 item.solar_day == Some(day_info.solar.day)
149 && item.solar_month == Some(day_info.solar.month)
150 } else {
151 item.lunar_day == day_info.lunar.day && item.lunar_month == day_info.lunar.month
152 }
153 })
154 .map(FestivalInsightDto::from);
155
156 let holiday = solar_holidays()
157 .iter()
158 .find(|item| {
159 item.solar_day == day_info.solar.day && item.solar_month == day_info.solar.month
160 })
161 .map(HolidayInsightDto::from);
162
163 let can_info = find_can(&day_info.canchi.day.can);
164 let chi_info = find_chi(&day_info.canchi.day.chi);
165 let element_index: &HashMap<String, amlich_core::insight_data::ElementInfo> = all_elements();
166
167 let canchi = match (can_info, chi_info) {
168 (Some(can), Some(chi)) => {
169 let element = element_index
170 .get(&can.element)
171 .map(|el| ElementInsightDto::from((&can.element, el)));
172 Some(CanChiInsightDto {
173 can: CanInsightDto::from(can),
174 chi: ChiInsightDto::from(chi),
175 element,
176 })
177 }
178 _ => None,
179 };
180
181 let day_guidance = get_day_guidance(&day_info.canchi.day.chi).map(DayGuidanceDto::from);
182 let tiet_khi = find_tiet_khi_insight(&day_info.tiet_khi.name).map(TietKhiInsightDto::from);
183
184 let fortune = day_info.day_fortune.as_ref();
185
186 let na_am = fortune.and_then(|f| {
188 find_na_am_insight(&f.day_element.na_am).map(|n| NaAmInsightDto {
189 na_am: f.day_element.na_am.clone(),
190 element: f.day_element.element.clone(),
191 meaning: LocalizedTextDto::from(&n.meaning),
192 })
193 });
194
195 let truc = fortune.and_then(|f| {
197 find_truc_insight(&f.truc.name).map(|t| TrucInsightDto {
198 name: f.truc.name.clone(),
199 quality: f.truc.quality.clone(),
200 meaning: LocalizedTextDto::from(&t.meaning),
201 good_for: LocalizedListDto::from(&t.good_for),
202 avoid_for: LocalizedListDto::from(&t.avoid_for),
203 })
204 });
205
206 let day_deity = fortune.and_then(|f| {
208 f.day_deity.as_ref().map(|deity| {
209 let cls_id = match deity.classification.as_str() {
211 "hoang_dao" => "HoangDao",
212 "hac_dao" => "HacDao",
213 other => other,
214 };
215 let cls_meaning = find_deity_classification_insight(cls_id)
216 .map(|c| LocalizedTextDto::from(&c.meaning))
217 .unwrap_or_else(|| LocalizedTextDto {
218 vi: String::new(),
219 en: String::new(),
220 });
221 let deity_meaning =
222 find_deity_insight(&deity.name).map(|d| LocalizedTextDto::from(&d.meaning));
223 DayDeityInsightDto {
224 name: deity.name.clone(),
225 classification: deity.classification.clone(),
226 classification_meaning: cls_meaning,
227 deity_meaning,
228 }
229 })
230 });
231
232 let stars = fortune.map(|f| StarsInsightDto {
234 cat_tinh: f.stars.cat_tinh.clone(),
235 sat_tinh: f.stars.sat_tinh.clone(),
236 day_star: f.stars.day_star.as_ref().map(|s| s.name.clone()),
237 day_star_quality: f.stars.day_star.as_ref().map(|s| s.quality.clone()),
238 });
239
240 let taboos = fortune
242 .map(|f| {
243 f.taboos
244 .iter()
245 .map(|t| TabooInsightItemDto {
246 name: t.name.clone(),
247 severity: t.severity.clone(),
248 reason: t.reason.clone(),
249 })
250 .collect::<Vec<_>>()
251 })
252 .filter(|v| !v.is_empty());
253
254 let travel = fortune.map(|f| TravelInsightDto {
256 xuat_hanh_huong: f.travel.xuat_hanh_huong.clone(),
257 tai_than: f.travel.tai_than.clone(),
258 hy_than: f.travel.hy_than.clone(),
259 });
260
261 let xung_hop = fortune.map(|f| XungHopInsightDto {
263 luc_xung: f.xung_hop.luc_xung.clone(),
264 tam_hop: f.xung_hop.tam_hop.clone(),
265 liu_he: f.xung_hop.liu_he.clone(),
266 xiang_hai: f.xung_hop.xiang_hai.clone(),
267 });
268
269 let tang_can = fortune.and_then(|f| {
271 f.tang_can.as_ref().map(|tc| TangCanInsightDto {
272 main: tc.main.clone(),
273 central: tc.central.clone(),
274 residual: tc.residual.clone(),
275 strength: tc.strength,
276 })
277 });
278
279 let ten_gods = fortune.and_then(|f| {
282 f.ten_gods.as_ref().map(|tg| {
283 let map_entry = |r: &ThapThanResultDto| -> TenGodsEntryInsightDto {
284 let pascal_id = snake_to_pascal(&r.label);
285 let insight = find_ten_gods_insight(&pascal_id);
286 TenGodsEntryInsightDto {
287 label: r.label.clone(),
288 name: insight
289 .map(|i| LocalizedTextDto::from(&i.name))
290 .unwrap_or_else(|| LocalizedTextDto {
291 vi: String::new(),
292 en: String::new(),
293 }),
294 meaning: insight
295 .map(|i| LocalizedTextDto::from(&i.meaning))
296 .unwrap_or_else(|| LocalizedTextDto {
297 vi: String::new(),
298 en: String::new(),
299 }),
300 relation: r.relation.clone(),
301 same_polarity: r.same_polarity,
302 }
303 };
304 TenGodsInsightDto {
305 to_year_stem: tg.to_year_stem.as_ref().map(map_entry),
306 to_self: tg.to_self.as_ref().map(map_entry),
307 }
308 })
309 });
310
311 let hours = Some(HoursInsightDto {
313 good_hour_count: day_info.gio_hoang_dao.good_hours.len(),
314 good_hours: day_info
315 .gio_hoang_dao
316 .good_hours
317 .iter()
318 .map(|h| HourInsightEntryDto {
319 chi: h.hour_chi.clone(),
320 time_range: h.time_range.clone(),
321 star: h.star.clone(),
322 })
323 .collect(),
324 });
325
326 let tu_menh = match (birth_year, gender) {
328 (Some(by), Some(g)) => {
329 use amlich_core::almanac::tu_menh::compute_kua;
330 use amlich_core::insight_data::{find_kua_group_insight, find_kua_insight};
331 let kua_result = compute_kua(by, g);
332 let group_id = format!("{:?}", kua_result.group);
333 let kua_insight = find_kua_insight(kua_result.kua);
334 let group_insight = find_kua_group_insight(&group_id);
335 let empty_text = || LocalizedTextDto {
336 vi: String::new(),
337 en: String::new(),
338 };
339 Some(TuMenhInsightDto {
340 kua: kua_result.kua,
341 group: group_id,
342 trigram: kua_insight
343 .map(|k| LocalizedTextDto::from(&k.trigram))
344 .unwrap_or_else(empty_text),
345 direction: kua_insight
346 .map(|k| LocalizedTextDto::from(&k.direction))
347 .unwrap_or_else(empty_text),
348 meaning: kua_insight
349 .map(|k| LocalizedTextDto::from(&k.meaning))
350 .unwrap_or_else(empty_text),
351 group_meaning: group_insight
352 .map(|g| LocalizedTextDto::from(&g.meaning))
353 .unwrap_or_else(empty_text),
354 favorable_directions: kua_result
355 .favorable_directions
356 .iter()
357 .map(|d| format!("{:?}", d))
358 .collect(),
359 unfavorable_directions: kua_result
360 .unfavorable_directions
361 .iter()
362 .map(|d| format!("{:?}", d))
363 .collect(),
364 })
365 }
366 _ => None,
367 };
368
369 let dai_van = match (birth_day, birth_month, birth_year, gender) {
371 (Some(bd), Some(bm), Some(by), Some(g)) => {
372 use amlich_core::almanac::dai_van::calculate_dai_van;
373 use amlich_core::insight_data::{
374 dai_van_phases_insight, find_dai_van_direction_insight,
375 find_dai_van_element_insight,
376 };
377 let dv = calculate_dai_van(bd, bm, by, g);
378 let dir_id = format!("{:?}", dv.chieu_thu);
379 let dir_insight = find_dai_van_direction_insight(&dir_id);
380 let phases = dai_van_phases_insight();
381 let empty_text = || LocalizedTextDto {
382 vi: String::new(),
383 en: String::new(),
384 };
385 let pillars: Vec<DaiVanPillarInsightDto> = dv
386 .pillars
387 .iter()
388 .map(|p| {
389 let element = amlich_core::almanac::na_am::get_na_am_by_pair(
390 &p.can_chi.can,
391 &p.can_chi.chi,
392 )
393 .map(|e| e.element)
394 .unwrap_or_default();
395 let el_insight = find_dai_van_element_insight(&element);
396 DaiVanPillarInsightDto {
397 index: p.index,
398 can_chi: p.can_chi.full.clone(),
399 start_age: p.start_age,
400 end_age: p.end_age,
401 element,
402 element_meaning: el_insight
403 .map(|e| LocalizedTextDto::from(&e.meaning))
404 .unwrap_or_else(empty_text),
405 }
406 })
407 .collect();
408 Some(DaiVanInsightDto {
409 direction: dv.chieu_thu_label.clone(),
410 direction_meaning: dir_insight
411 .map(|d| LocalizedTextDto::from(&d.meaning))
412 .unwrap_or_else(empty_text),
413 start_age: dv.start_age_display.clone(),
414 current_pillar: None,
415 all_pillars: pillars,
416 phases_meaning: LocalizedTextDto::from(&phases.meaning),
417 })
418 }
419 _ => None,
420 };
421
422 Ok(DayInsightDto {
423 solar: day_info.solar,
424 lunar: day_info.lunar,
425 festival,
426 holiday,
427 canchi,
428 day_guidance,
429 tiet_khi,
430 na_am,
431 truc,
432 day_deity,
433 stars,
434 taboos,
435 travel,
436 xung_hop,
437 tang_can,
438 ten_gods,
439 hours,
440 tu_menh,
441 dai_van,
442 })
443}
444
445pub fn get_day_insight_for_date(day: i32, month: i32, year: i32) -> Result<DayInsightDto, String> {
446 get_day_insight(&DateQuery {
447 day,
448 month,
449 year,
450 timezone: None,
451 ruleset_id: None,
452 event_kind: None,
453 enabled_pack_ids: vec![],
454 })
455}
456
457pub fn get_day_insight_for_date_with_profile(
458 day: i32,
459 month: i32,
460 year: i32,
461 birth_year: Option<i32>,
462 birth_month: Option<i32>,
463 birth_day: Option<i32>,
464 gender: Option<amlich_core::almanac::tu_menh::Gender>,
465) -> Result<DayInsightDto, String> {
466 get_day_insight_with_profile(
467 &DateQuery {
468 day,
469 month,
470 year,
471 timezone: None,
472 ruleset_id: None,
473 event_kind: None,
474 enabled_pack_ids: vec![],
475 },
476 birth_year,
477 birth_month,
478 birth_day,
479 gender,
480 )
481}
482
483pub fn get_na_am_by_index(index: u8) -> NaAmResponseDto {
505 use amlich_core::almanac::na_am::get_na_am_by_index;
506
507 match get_na_am_by_index(index) {
508 Ok(entry) => NaAmResponseDto::Success(NaAmLookupResultDto::from(&entry)),
509 Err(error) => NaAmResponseDto::Error(NaAmErrorDto::from(error)),
510 }
511}
512
513pub fn get_na_am_by_pair(can: &str, chi: &str) -> NaAmResponseDto {
557 use amlich_core::almanac::na_am::get_na_am_by_pair;
558
559 match get_na_am_by_pair(can, chi) {
560 Ok(entry) => NaAmResponseDto::Success(NaAmLookupResultDto::from(&entry)),
561 Err(error) => NaAmResponseDto::Error(NaAmErrorDto::from(error)),
562 }
563}