package com.margelo.nitro.lunardatepicker.utils import com.margelo.nitro.lunardatepicker.LDP_Range import com.margelo.nitro.lunardatepicker.constants.DataConstants import java.time.Instant import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import kotlin.math.PI import kotlin.math.floor import kotlin.math.sin /** * Data class for lunar date information */ data class LunarDate(val day: Int, val month: Int, val year: Int) /** * Service for converting between JavaScript timestamps and Java LocalDate objects * Also provides lunar calendar conversion functionality with caching for improved performance */ class DateConverter { companion object { // Constants matching iOS Julian constants private const val DAY_OFFSET = 2415021.076998695 private const val LUNAR_MONTH_DAYS = 29.530588853 private const val DAY_THRESHOLD = 2299161 private const val DAY_CONSTANT = 32045 private const val DAY_CONSTANT_OLD = 32083 private val FORMATTER = DateTimeFormatter.ofPattern(DataConstants.Format.ISO_DATE) private const val TAG = DataConstants.LogTags.DATE_CONVERTER } // Cache instance for lunar date calculations private val lunarDateCache = LunarDateCache.getInstance() /** * Converts a JavaScript timestamp (milliseconds since epoch) to a LocalDate * @param timestamp JavaScript timestamp in milliseconds * @return LocalDate object */ fun dateFromJavaScriptTimestamp(timestamp: Double): LocalDate { return Instant.ofEpochMilli(timestamp.toLong()) .atZone(ZoneId.systemDefault()) .toLocalDate() } /** * Converts a LocalDate to a JavaScript timestamp (milliseconds since epoch) * @param date LocalDate object * @return JavaScript timestamp in milliseconds */ fun javaScriptTimestampFromDate(date: LocalDate): Double { return date.atStartOfDay(ZoneId.systemDefault()) .toInstant() .toEpochMilli() .toDouble() } /** * Parses a date string in YYYY-MM-DD format to a LocalDate * @param dateString Date string in YYYY-MM-DD format * @return LocalDate object, or null if parsing fails */ fun dateFromString(dateString: String): LocalDate? { return try { LocalDate.parse(dateString, FORMATTER) } catch (e: Exception) { null } } /** * Converts a LocalDate to a string in YYYY-MM-DD format * @param date LocalDate object * @return Date string in YYYY-MM-DD format */ fun stringFromDate(date: LocalDate): String { return date.format(FORMATTER) } /** * Creates a Range from a single LocalDate (for single date selection) * @param date The selected date * @return Range with from date string and null to date string */ fun rangeFromDate(date: LocalDate): LDP_Range { return LDP_Range( from = stringFromDate(date), to = null ) } /** * Creates a Range from two LocalDates (for range selection) * @param fromDate The start date of the range * @param toDate The end date of the range * @return Range with both from and to date strings */ fun rangeFromDates(fromDate: LocalDate, toDate: LocalDate): LDP_Range { return LDP_Range( from = stringFromDate(fromDate), to = stringFromDate(toDate) ) } /** * Gets the start of day for a given LocalDate with timezone * @param date Input date * @param timeZone Timezone for conversion * @return ZonedDateTime representing the start of the day */ fun startOfDay(date: LocalDate, timeZone: ZoneId = ZoneId.systemDefault()): ZonedDateTime { return date.atStartOfDay(timeZone) } /** * Gets the end of day for a given LocalDate with timezone * @param date Input date * @param timeZone Timezone for conversion * @return ZonedDateTime representing the end of the day */ fun endOfDay(date: LocalDate, timeZone: ZoneId = ZoneId.systemDefault()): ZonedDateTime { return date.atTime(LocalTime.MAX).atZone(timeZone) } /** * Adds days to a LocalDate * @param date Base date * @param days Number of days to add (can be negative) * @return New LocalDate with days added */ fun addDays(date: LocalDate, days: Int): LocalDate { return date.plusDays(days.toLong()) } /** * Adds months to a LocalDate * @param date Base date * @param months Number of months to add (can be negative) * @return New LocalDate with months added */ fun addMonths(date: LocalDate, months: Int): LocalDate { return date.plusMonths(months.toLong()) } /** * Converts a LocalDate to Vietnamese lunar date with caching for improved performance * @param date LocalDate to convert * @param timeZone TimeZone for conversion * @return LunarDate object with lunar day, month, year */ fun getVietnameseLunarDate(date: LocalDate, timeZone: ZoneId): LunarDate { // Try to get from cache first lunarDateCache.get(date, timeZone)?.let { cachedResult -> return LunarDate(cachedResult.day, cachedResult.month, cachedResult.year) } // Cache miss - calculate and cache the result val day = date.dayOfMonth val month = date.monthValue val year = date.year val dayNumber = jdFromDate(day, month, year) val timeZoneOffset = timeZone.rules.getOffset(date.atStartOfDay()).totalSeconds / 3600 // Convert to hours val (lunarYear, lunarMonth, lunarDay) = calculateLunarDate( dayNumber, timeZoneOffset, date, year ) val result = LunarDate(lunarDay, lunarMonth, lunarYear) // Cache the result for future use lunarDateCache.put(date, timeZone, result) return result } /** * Preloads lunar date cache for a given date range * This can be called during app initialization for better performance */ fun preloadLunarDateCache(startDate: LocalDate, endDate: LocalDate, timeZone: ZoneId) { lunarDateCache.preloadRange(startDate, endDate, timeZone) { date, tz -> // Use the original calculation method for preloading val day = date.dayOfMonth val month = date.monthValue val year = date.year val dayNumber = jdFromDate(day, month, year) val timeZoneOffset = tz.rules.getOffset(date.atStartOfDay()).totalSeconds / 3600 val (lunarYear, lunarMonth, lunarDay) = calculateLunarDate( dayNumber, timeZoneOffset, date, year ) LunarDate(lunarDay, lunarMonth, lunarYear) } } /** * Gets cache statistics for performance monitoring */ fun getCacheStats(): LunarDateCache.CacheStats { return lunarDateCache.getCacheStats() } /** * Clears the lunar date cache */ fun clearLunarDateCache() { lunarDateCache.clearCache() } // MARK: - Private Lunar Calendar Methods (ported from iOS) private fun jdFromDate(day: Int, month: Int, year: Int): Int { val a = (14 - month) / 12 val y = year + 4800 - a val m = month + 12 * a - 3 val jd = day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - DAY_CONSTANT return if (jd < DAY_THRESHOLD) { day + (153 * m + 2) / 5 + 365 * y + y / 4 - DAY_CONSTANT_OLD } else { jd } } private fun getNewMoonDay(k: Int, timeZone: Int): Int { val T = k.toDouble() / 1236.85 val T2 = T * T val T3 = T2 * T val dr = PI / 180 val Jd1 = 2415020.75933 + LUNAR_MONTH_DAYS * k + 0.0001178 * T2 - 0.000000155 * T3 + 0.00033 * sin((166.56 + 132.87 * T - 0.009173 * T2) * dr) val M = 359.2242 + 29.10535608 * k - 0.0000333 * T2 - 0.00000347 * T3 val Mpr = 306.0253 + 385.81691806 * k + 0.0107306 * T2 + 0.00001236 * T3 val F = 21.2964 + 390.67050646 * k - 0.0016528 * T2 - 0.00000239 * T3 var C1 = (0.1734 - 0.000393 * T) * sin(dr * M) C1 += 0.0021 * sin(2 * dr * M) C1 -= 0.4068 * sin(dr * Mpr) C1 += 0.0161 * sin(2 * dr * Mpr) C1 -= 0.0004 * sin(3 * dr * Mpr) C1 += 0.0104 * sin(2 * dr * F) C1 -= 0.0051 * sin(dr * (M + Mpr)) C1 -= 0.0074 * sin(dr * (M - Mpr)) C1 += 0.0004 * sin(dr * (2 * F + M)) C1 -= 0.0004 * sin(dr * (2 * F - M)) C1 -= 0.0006 * sin(dr * (2 * F + Mpr)) C1 += 0.0010 * sin(dr * (2 * F - Mpr)) C1 += 0.0005 * sin(dr * (2 * Mpr + M)) val deltaT = if (T < -11) { 0.001 + 0.000839 * T + 0.0002261 * T2 - 0.00000845 * T3 - 0.000000081 * T * T3 } else { -0.000278 + 0.000265 * T + 0.000262 * T2 } val JdNew = Jd1 + C1 - deltaT return floor(JdNew + 0.5 + timeZone.toDouble() / 24).toInt() } private fun getSunLongitude(jdn: Int, timeZone: Int): Int { val T = (jdn.toDouble() - 2451545.5 - timeZone.toDouble() / 24) / 36525 val T2 = T * T val dr = PI / 180 val M = 357.52910 + 35999.05030 * T - 0.0001559 * T2 - 0.00000048 * T * T2 val L0 = 280.46645 + 36000.76983 * T + 0.0003032 * T2 var DL = (1.914600 - 0.004817 * T - 0.000014 * T2) * sin(dr * M) DL += (0.019993 - 0.000101 * T) * sin(dr * 2 * M) DL += 0.000290 * sin(dr * 3 * M) var L = (L0 + DL) * dr L -= 2 * PI * floor(L / (2 * PI)) return floor(L / PI * 6).toInt() } private fun getLunarMonth11(year: Int, timeZone: Int, date: LocalDate): Int { val endOfYear = LocalDate.of(year, 12, 31) val d = endOfYear.dayOfMonth val m = endOfYear.monthValue val y = endOfYear.year val off = jdFromDate(d, m, y) - 2415021 val k = floor(off.toDouble() / 29.530588853).toInt() var nm = getNewMoonDay(k, timeZone) if (getSunLongitude(nm, timeZone) >= 9) { nm = getNewMoonDay(k - 1, timeZone) } return nm } private fun getLeapMonthOffset(a11: Int, timeZone: Int): Int { val k = floor((a11.toDouble() - 2415021.076998695) / 29.530588853 + 0.5).toInt() var last = getSunLongitude(getNewMoonDay(k + 1, timeZone), timeZone) for (i in 2..14) { val arc = getSunLongitude(getNewMoonDay(k + i, timeZone), timeZone) if (arc == last) return i - 1 last = arc } return 0 } private fun calculateLunarDate( dayNumber: Int, timeZone: Int, date: LocalDate, year: Int ): Triple { val k = floor((dayNumber.toDouble() - DAY_OFFSET) / LUNAR_MONTH_DAYS).toInt() var monthStart = getNewMoonDay(k + 1, timeZone) if (monthStart > dayNumber) { monthStart = getNewMoonDay(k, timeZone) } var a11 = getLunarMonth11(year, timeZone, date) var b11 = getLunarMonth11(year + 1, timeZone, date) var lunarYear = year + 1 if (a11 >= monthStart) { lunarYear = year a11 = getLunarMonth11(year - 1, timeZone, date) b11 = getLunarMonth11(year, timeZone, date) } val lunarDay = dayNumber - monthStart + 1 val diff = floor((monthStart - a11).toDouble() / 29).toInt() var lunarMonth = diff + 11 if (b11 - a11 > 365) { val leapMonth = getLeapMonthOffset(a11, timeZone) if (diff >= leapMonth) { lunarMonth -= 1 } } if (lunarMonth > 12) { lunarMonth -= 12 } if (lunarMonth >= 11 && diff < 4) { lunarYear -= 1 } return Triple(lunarYear, lunarMonth, lunarDay) } }