1use std::collections::BTreeMap;
2
3use chrono::{Datelike, NaiveDate};
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7use crate::dto::{
8 CanChiInfoDto, DailyRecommendationsDto, DateQuery, DayFortuneDto, DayInsightDto,
9 GioHoangDaoDto, KuaResultDto, LunarDto, NaAmResponseDto, SolarDto, ThapThanResultDto,
10 TietKhiDto, UpcomingEventDto,
11};
12
13const SCHEMA_VERSION: &str = "amlich.engine/v1";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Include {
18 Base,
19 CanChi,
20 TietKhi,
21 Hours,
22 Fortune,
23 Insight,
24 Evidence,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ApiMetaDto {
29 pub schema_version: String,
30 pub ruleset_id: String,
31 pub ruleset_version: String,
32 pub profile: String,
33 pub generated_at: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DayBundleDto {
38 pub schema_version: String,
39 pub ruleset_id: String,
40 pub ruleset_version: String,
41 pub profile: String,
42 pub generated_at: String,
43 pub solar: SolarDto,
44 pub lunar: LunarDto,
45 pub jd: i32,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub canchi: Option<CanChiInfoDto>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub tiet_khi: Option<TietKhiDto>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub gio_hoang_dao: Option<GioHoangDaoDto>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub day_fortune: Option<DayFortuneDto>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub daily_recommendations: Option<DailyRecommendationsDto>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub contextual_recommendations: Option<DailyRecommendationsDto>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub insight: Option<DayInsightDto>,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub upcoming_events: Vec<UpcomingEventDto>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DayRangeDto {
66 pub schema_version: String,
67 pub ruleset_id: String,
68 pub ruleset_version: String,
69 pub profile: String,
70 pub generated_at: String,
71 pub start: String,
72 pub end: String,
73 pub days: Vec<DayBundleDto>,
74}
75
76impl From<&crate::dto::DayInfoDto> for ApiMetaDto {
77 fn from(info: &crate::dto::DayInfoDto) -> Self {
78 Self {
79 schema_version: SCHEMA_VERSION.to_string(),
80 ruleset_id: info.ruleset_id.clone(),
81 ruleset_version: info.ruleset_version.clone(),
82 profile: info.profile.clone(),
83 generated_at: chrono::Utc::now().to_rfc3339(),
84 }
85 }
86}
87
88impl DayBundleDto {
89 fn from_parts(
90 info: crate::dto::DayInfoDto,
91 insight: Option<DayInsightDto>,
92 includes: &[Include],
93 ) -> Result<Self, String> {
94 let meta = ApiMetaDto::from(&info);
95
96 let upcoming_events =
97 amlich_core::holidays::get_upcoming_events(info.jd, info.solar.year, 14)
98 .into_iter()
99 .map(|e| UpcomingEventDto {
100 name: e.name,
101 days_left: e.days_left,
102 is_lunar: e.is_lunar,
103 })
104 .collect();
105
106 Ok(Self {
107 schema_version: meta.schema_version,
108 ruleset_id: meta.ruleset_id,
109 ruleset_version: meta.ruleset_version,
110 profile: meta.profile,
111 generated_at: meta.generated_at,
112 solar: info.solar,
113 lunar: info.lunar,
114 jd: info.jd,
115 canchi: includes.contains(&Include::CanChi).then_some(info.canchi),
116 tiet_khi: includes
117 .contains(&Include::TietKhi)
118 .then_some(info.tiet_khi),
119 gio_hoang_dao: includes
120 .contains(&Include::Hours)
121 .then_some(info.gio_hoang_dao),
122 day_fortune: includes.contains(&Include::Fortune).then_some(
123 info.day_fortune
124 .ok_or_else(|| "missing day_fortune in day info".to_string())?,
125 ),
126 daily_recommendations: includes
127 .contains(&Include::Fortune)
128 .then_some(info.daily_recommendations),
129 contextual_recommendations: includes
130 .contains(&Include::Fortune)
131 .then_some(info.contextual_recommendations)
132 .flatten(),
133 insight,
134 upcoming_events,
135 })
136 }
137
138 pub fn meta(&self) -> ApiMetaDto {
139 ApiMetaDto {
140 schema_version: self.schema_version.clone(),
141 ruleset_id: self.ruleset_id.clone(),
142 ruleset_version: self.ruleset_version.clone(),
143 profile: self.profile.clone(),
144 generated_at: self.generated_at.clone(),
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct TietKhiTransitionDto {
151 pub date: String,
152 pub term: TietKhiDto,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct TietKhiYearDto {
157 pub year: i32,
158 pub transitions: Vec<TietKhiTransitionDto>,
159}
160
161fn include_set(includes: &[Include]) -> Vec<Include> {
162 if includes.is_empty() {
163 vec![
164 Include::Base,
165 Include::CanChi,
166 Include::TietKhi,
167 Include::Hours,
168 Include::Fortune,
169 ]
170 } else {
171 includes.to_vec()
172 }
173}
174
175fn validate_includes(includes: &[Include]) -> Result<(), String> {
176 if includes.contains(&Include::Evidence) && !includes.contains(&Include::Fortune) {
177 return Err("include=evidence requires include=fortune".to_string());
178 }
179 Ok(())
180}
181
182pub fn get_day_bundle(query: &DateQuery, includes: &[Include]) -> Result<DayBundleDto, String> {
183 let includes = include_set(includes);
184 validate_includes(&includes)?;
185
186 let info = crate::get_day_info(query)?;
187 let insight = if includes.contains(&Include::Insight) {
188 Some(crate::get_day_insight(query)?)
189 } else {
190 None
191 };
192
193 DayBundleDto::from_parts(info, insight, &includes)
194}
195
196pub fn get_day_bundle_for_date(
197 day: i32,
198 month: i32,
199 year: i32,
200 includes: &[Include],
201 timezone: Option<f64>,
202) -> Result<DayBundleDto, String> {
203 let query = DateQuery {
204 day,
205 month,
206 year,
207 timezone,
208 ruleset_id: None,
209 event_kind: None,
210 enabled_pack_ids: vec![],
211 };
212 get_day_bundle(&query, includes)
213}
214
215fn get_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
216 let mut current = value;
217 for segment in path {
218 current = current.get(*segment)?;
219 }
220 Some(current)
221}
222
223fn set_path(root: &mut Map<String, Value>, path: &[&str], leaf: Value) {
224 if path.is_empty() {
225 return;
226 }
227 if path.len() == 1 {
228 root.insert(path[0].to_string(), leaf);
229 return;
230 }
231
232 let entry = root
233 .entry(path[0].to_string())
234 .or_insert_with(|| Value::Object(Map::new()));
235 if let Value::Object(obj) = entry {
236 set_path(obj, &path[1..], leaf);
237 }
238}
239
240pub fn project_fields(value: &Value, fields: &[String]) -> Result<Value, String> {
241 if fields.is_empty() {
242 return Ok(value.clone());
243 }
244
245 let mut projected = Map::new();
246 for field in fields {
247 let segments: Vec<&str> = field.split('.').collect();
248 let leaf = get_path(value, &segments)
249 .ok_or_else(|| format!("unknown field path: {field}"))?
250 .clone();
251 set_path(&mut projected, &segments, leaf);
252 }
253
254 Ok(Value::Object(projected))
255}
256
257pub fn get_day_bundle_projected(
258 query: &DateQuery,
259 includes: &[Include],
260 fields: &[String],
261) -> Result<Value, String> {
262 let bundle = get_day_bundle(query, includes)?;
263 let value = serde_json::to_value(bundle).map_err(|e| format!("serialize failed: {e}"))?;
264 project_fields(&value, fields)
265}
266
267pub fn get_day_range(
268 start: DateQuery,
269 end: DateQuery,
270 includes: &[Include],
271) -> Result<DayRangeDto, String> {
272 let start_date = NaiveDate::from_ymd_opt(start.year, start.month as u32, start.day as u32)
273 .ok_or_else(|| "invalid start date".to_string())?;
274 let end_date = NaiveDate::from_ymd_opt(end.year, end.month as u32, end.day as u32)
275 .ok_or_else(|| "invalid end date".to_string())?;
276
277 if end_date < start_date {
278 return Err("end date must be greater than or equal to start date".to_string());
279 }
280
281 if (end_date - start_date).num_days() > 366 {
282 return Err("date range is too large (max 366 days)".to_string());
283 }
284
285 let mut days = Vec::new();
286 let mut cursor = start_date;
287 while cursor <= end_date {
288 let query = DateQuery {
289 day: cursor.day() as i32,
290 month: cursor.month() as i32,
291 year: cursor.year(),
292 timezone: start.timezone,
293 ruleset_id: start.ruleset_id.clone(),
294 event_kind: start.event_kind.clone(),
295 enabled_pack_ids: start.enabled_pack_ids.clone(),
296 };
297 days.push(get_day_bundle(&query, includes)?);
298 cursor = cursor
299 .succ_opt()
300 .ok_or_else(|| "failed to iterate date range".to_string())?;
301 }
302
303 let first = days
304 .first()
305 .ok_or_else(|| "empty range after processing".to_string())?;
306 Ok(DayRangeDto {
307 schema_version: first.schema_version.clone(),
308 ruleset_id: first.ruleset_id.clone(),
309 ruleset_version: first.ruleset_version.clone(),
310 profile: first.profile.clone(),
311 generated_at: first.generated_at.clone(),
312 start: format!("{}-{:02}-{:02}", start.year, start.month, start.day),
313 end: format!("{}-{:02}-{:02}", end.year, end.month, end.day),
314 days,
315 })
316}
317
318pub fn get_almanac(query: &DateQuery) -> Result<DayFortuneDto, String> {
319 let info = crate::get_day_info(query)?;
320 info.day_fortune
321 .ok_or_else(|| "missing day_fortune for date".to_string())
322}
323
324pub fn get_insight(query: &DateQuery) -> Result<DayInsightDto, String> {
325 crate::get_day_insight(query)
326}
327
328pub fn get_insight_with_profile(
329 query: &DateQuery,
330 birth_year: Option<i32>,
331 birth_month: Option<i32>,
332 birth_day: Option<i32>,
333 gender: Option<amlich_core::almanac::tu_menh::Gender>,
334) -> Result<DayInsightDto, String> {
335 crate::get_day_insight_with_profile(query, birth_year, birth_month, birth_day, gender)
336}
337
338pub fn get_tiet_khi_for_year(year: i32, timezone: Option<f64>) -> Result<TietKhiYearDto, String> {
339 let tz = timezone.or(Some(amlich_core::VIETNAM_TIMEZONE));
340 let mut by_date: BTreeMap<String, TietKhiDto> = BTreeMap::new();
341
342 let mut cursor =
343 NaiveDate::from_ymd_opt(year, 1, 1).ok_or_else(|| "invalid year".to_string())?;
344 let end = NaiveDate::from_ymd_opt(year, 12, 31).ok_or_else(|| "invalid year".to_string())?;
345
346 while cursor <= end {
347 let query = DateQuery {
348 day: cursor.day() as i32,
349 month: cursor.month() as i32,
350 year: cursor.year(),
351 timezone: tz,
352 ruleset_id: None,
353 event_kind: None,
354 enabled_pack_ids: vec![],
355 };
356 let day = crate::get_day_info(&query)?;
357 by_date.insert(cursor.to_string(), day.tiet_khi);
358 cursor = cursor
359 .succ_opt()
360 .ok_or_else(|| "failed to iterate year".to_string())?;
361 }
362
363 let mut transitions = Vec::new();
364 let mut last_name: Option<String> = None;
365 for (date, term) in by_date {
366 let changed = last_name
367 .as_ref()
368 .map(|name| name != &term.name)
369 .unwrap_or(true);
370 if changed {
371 last_name = Some(term.name.clone());
372 transitions.push(TietKhiTransitionDto { date, term });
373 }
374 }
375
376 Ok(TietKhiYearDto { year, transitions })
377}
378
379pub fn convert_solar_to_lunar(query: &DateQuery) -> Result<LunarDto, String> {
380 let info = crate::get_day_info(query)?;
381 Ok(info.lunar)
382}
383
384pub fn convert_lunar_to_solar(
385 day: i32,
386 month: i32,
387 year: i32,
388 leap: bool,
389 timezone: Option<f64>,
390) -> Result<SolarDto, String> {
391 let tz = timezone.unwrap_or(amlich_core::VIETNAM_TIMEZONE);
392 let (solar_day, solar_month, solar_year) =
393 amlich_core::lunar::convert_lunar_to_solar(day, month, year, leap, tz);
394 if (solar_day, solar_month, solar_year) == (0, 0, 0) {
395 return Err("invalid lunar date conversion".to_string());
396 }
397 Ok(crate::get_day_info_for_date(solar_day, solar_month, solar_year)?.solar)
398}
399
400pub fn lookup_na_am_by_index(index: u8) -> NaAmResponseDto {
401 crate::get_na_am_by_index(index)
402}
403
404pub fn lookup_na_am_by_pair(can: &str, chi: &str) -> NaAmResponseDto {
405 crate::get_na_am_by_pair(can, chi)
406}
407
408pub fn lookup_ten_gods(day_can: &str, target_can: &str) -> Result<ThapThanResultDto, String> {
409 let day = amlich_core::HeavenlyStem::try_from(day_can)?;
410 let target = amlich_core::HeavenlyStem::try_from(target_can)?;
411 let result = amlich_core::get_thap_than(day, target);
412 Ok(ThapThanResultDto::from(&result))
413}
414
415pub fn lookup_kua(birth_year: i32, gender: amlich_core::Gender) -> KuaResultDto {
416 let result = amlich_core::compute_kua(birth_year, gender);
417 KuaResultDto::from(&result)
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn projection_rejects_unknown_fields() {
426 let value = serde_json::json!({"a": {"b": 1}});
427 let err = project_fields(&value, &["a.c".to_string()]).expect_err("should fail");
428 assert!(err.contains("unknown field path"));
429 }
430
431 #[test]
432 fn projection_keeps_nested_shape() {
433 let value = serde_json::json!({"a": {"b": 1, "c": 2}, "d": 3});
434 let projected =
435 project_fields(&value, &["a.b".to_string(), "d".to_string()]).expect("projected");
436 assert_eq!(projected["a"]["b"], 1);
437 assert_eq!(projected["d"], 3);
438 assert!(projected["a"].get("c").is_none());
439 }
440
441 #[test]
442 fn day_bundle_hides_fortune_when_not_included() {
443 let query = DateQuery {
444 day: 10,
445 month: 2,
446 year: 2024,
447 timezone: None,
448 ruleset_id: None,
449 event_kind: None,
450 enabled_pack_ids: vec![],
451 };
452 let bundle = get_day_bundle(&query, &[Include::Base, Include::CanChi]).expect("bundle");
453 assert!(bundle.day_fortune.is_none());
454 assert!(bundle.daily_recommendations.is_none());
455 assert!(bundle.canchi.is_some());
456 }
457
458 #[test]
459 fn day_bundle_exposes_top_level_metadata() {
460 let query = DateQuery {
461 day: 10,
462 month: 2,
463 year: 2024,
464 timezone: Some(7.0),
465 ruleset_id: None,
466 event_kind: None,
467 enabled_pack_ids: vec![],
468 };
469
470 let bundle = get_day_bundle(&query, &[]).expect("bundle");
471 assert_eq!(bundle.schema_version, SCHEMA_VERSION);
472 assert_eq!(bundle.ruleset_id, "vn_baseline_v1");
473 assert_eq!(bundle.ruleset_version, "v1");
474 assert_eq!(bundle.profile, "baseline");
475 assert!(!bundle.generated_at.is_empty());
476 assert_eq!(bundle.meta().ruleset_id, bundle.ruleset_id);
477 }
478}