using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace LanMontainDesktop.Services; public enum HolidayDayType { Workday = 0, Weekend = 1, LegalHoliday = 2, AdjustedWorkday = 3, Unknown = 99 } public sealed record HolidayDayStatus( DateOnly Date, HolidayDayType DayType, string TypeNameZh, bool IsHoliday, bool IsAdjustedWorkday, string? NameZh, string? NameEn, string? TargetHolidayZh); public sealed record HolidayDisplayInfo( HolidayCountdownInfo? NextHoliday, HolidayDayStatus TodayStatus, bool UsesOnlineData); public sealed class HolidayCalendarService : IDisposable { private static readonly ChineseLunisolarCalendar LunarCalendar = new(); private sealed record HolidayTemplate( string NameZh, string NameEn, Func ResolveDateForYear); private sealed record HolidayArrangementDay( DateOnly Date, bool IsHoliday, bool IsAdjustedWorkday, string NameZh, string NameEn, string? TargetHolidayZh, bool? IsAfterAdjust); private sealed record CacheEntry(T Value, DateTimeOffset ExpireAt); private static readonly IReadOnlyDictionary HolidayNameMapZhToEn = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["\u5143\u65e6"] = "New Year's Day", ["\u6625\u8282"] = "Spring Festival", ["\u6e05\u660e\u8282"] = "Tomb-Sweeping Day", ["\u52b3\u52a8\u8282"] = "Labor Day", ["\u7aef\u5348\u8282"] = "Dragon Boat Festival", ["\u4e2d\u79cb\u8282"] = "Mid-Autumn Festival", ["\u56fd\u5e86\u8282"] = "National Day", ["\u9664\u5915"] = "Lunar New Year's Eve", ["\u521d\u4e00"] = "Spring Festival Day 1", ["\u521d\u4e8c"] = "Spring Festival Day 2", ["\u521d\u4e09"] = "Spring Festival Day 3", ["\u521d\u56db"] = "Spring Festival Day 4", ["\u521d\u4e94"] = "Spring Festival Day 5", ["\u521d\u516d"] = "Spring Festival Day 6", ["\u521d\u4e03"] = "Spring Festival Day 7" }; private static readonly IReadOnlyList HolidayTemplates = [ new HolidayTemplate( "\u5143\u65e6", "New Year's Day", year => new DateOnly(year, 1, 1)), new HolidayTemplate( "\u6625\u8282", "Spring Festival", year => TryResolveLunarHolidayDate(year, lunarMonth: 1, lunarDay: 1)), new HolidayTemplate( "\u52b3\u52a8\u8282", "Labor Day", year => new DateOnly(year, 5, 1)), new HolidayTemplate( "\u7aef\u5348\u8282", "Dragon Boat Festival", year => TryResolveLunarHolidayDate(year, lunarMonth: 5, lunarDay: 5)), new HolidayTemplate( "\u4e2d\u79cb\u8282", "Mid-Autumn Festival", year => TryResolveLunarHolidayDate(year, lunarMonth: 8, lunarDay: 15)), new HolidayTemplate( "\u56fd\u5e86\u8282", "National Day", year => new DateOnly(year, 10, 1)) ]; private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; private readonly string _baseUrl; private readonly TimeSpan _yearCacheDuration; private readonly TimeSpan _dayCacheDuration; private readonly object _cacheGate = new(); private readonly Dictionary>> _yearHolidayCache = new(); private readonly Dictionary> _dayStatusCache = new(); public HolidayCalendarService( HttpClient? httpClient = null, string baseUrl = "https://timor.tech", TimeSpan? yearCacheDuration = null, TimeSpan? dayCacheDuration = null, TimeSpan? requestTimeout = null) { _baseUrl = string.IsNullOrWhiteSpace(baseUrl) ? "https://timor.tech" : baseUrl.Trim(); _yearCacheDuration = yearCacheDuration ?? TimeSpan.FromHours(12); _dayCacheDuration = dayCacheDuration ?? TimeSpan.FromHours(3); if (httpClient is null) { _httpClient = new HttpClient { Timeout = requestTimeout ?? TimeSpan.FromSeconds(8) }; _ownsHttpClient = true; } else { _httpClient = httpClient; _ownsHttpClient = false; } } public void Dispose() { if (_ownsHttpClient) { _httpClient.Dispose(); } } public async Task GetDisplayInfoAsync( DateTime dateTime, CancellationToken cancellationToken = default) { var today = DateOnly.FromDateTime(dateTime.Date); var todayStatus = BuildFallbackDayStatus(today); var usesOnlineData = false; try { todayStatus = await GetDayStatusOnlineAsync(today, cancellationToken); usesOnlineData = true; } catch { // Keep local fallback status. } HolidayCountdownInfo? nextHoliday = null; try { nextHoliday = await GetNextHolidayOnlineAsync(today, cancellationToken); if (nextHoliday is not null) { usesOnlineData = true; } } catch { // Keep local fallback countdown. } nextHoliday ??= GetNextHoliday(dateTime); return new HolidayDisplayInfo(nextHoliday, todayStatus, usesOnlineData); } public HolidayCountdownInfo? GetNextHoliday(DateTime dateTime) { var today = DateOnly.FromDateTime(dateTime.Date); var candidates = BuildHolidayCandidates(today.Year - 1, today.Year + 3); HolidayCountdownInfo? best = null; foreach (var holiday in candidates) { if (holiday.Date < today) { continue; } if (best is null || holiday.Date < best.Date) { best = holiday; } } return best; } private async Task GetNextHolidayOnlineAsync(DateOnly today, CancellationToken cancellationToken) { var candidates = new List(); for (var year = today.Year; year <= today.Year + 2; year++) { var yearData = await GetYearHolidayDataOnlineAsync(year, cancellationToken); foreach (var item in yearData) { if (!item.IsHoliday || item.Date < today) { continue; } candidates.Add(new HolidayCountdownInfo( NameZh: item.NameZh, NameEn: item.NameEn, Date: item.Date)); } } return candidates.Count == 0 ? null : candidates.OrderBy(item => item.Date).First(); } private async Task GetDayStatusOnlineAsync(DateOnly date, CancellationToken cancellationToken) { if (TryGetDayStatusFromCache(date, out var cached)) { return cached; } var uri = BuildRequestUri($"/api/holiday/info/{date:yyyy-MM-dd}"); var responseText = await FetchAsync(uri, cancellationToken); using var document = JsonDocument.Parse(responseText); var root = document.RootElement; var code = ReadInt(root, "code"); if (code.HasValue && code.Value != 0) { throw new InvalidOperationException($"Holiday API returned error code {code.Value}."); } var typeNode = TryGetNode(root, "type"); var holidayNode = TryGetNode(root, "holiday"); var apiType = ReadInt(typeNode, "type"); var typeNameZh = ReadString(typeNode, "name") ?? string.Empty; var dayType = MapDayType(apiType); var holidayFlag = ReadBool(holidayNode, "holiday"); var isHoliday = holidayFlag ?? dayType == HolidayDayType.LegalHoliday; var nameZh = ReadString(holidayNode, "name"); var targetZh = ReadString(holidayNode, "target"); var isAdjustedWorkday = dayType == HolidayDayType.AdjustedWorkday || (!isHoliday && (!string.IsNullOrWhiteSpace(targetZh) || (nameZh?.Contains("\u8865\u73ed", StringComparison.Ordinal) ?? false))); var nameEn = ResolveHolidayEnName(nameZh, targetZh, isHoliday, isAdjustedWorkday); var result = new HolidayDayStatus( Date: date, DayType: dayType, TypeNameZh: typeNameZh, IsHoliday: isHoliday, IsAdjustedWorkday: isAdjustedWorkday, NameZh: nameZh, NameEn: nameEn, TargetHolidayZh: targetZh); SetDayStatusCache(date, result); return result; } private async Task> GetYearHolidayDataOnlineAsync(int year, CancellationToken cancellationToken) { if (TryGetYearHolidayDataFromCache(year, out var cached)) { return cached; } var uri = BuildRequestUri($"/api/holiday/year/{year}/"); var responseText = await FetchAsync(uri, cancellationToken); using var document = JsonDocument.Parse(responseText); var root = document.RootElement; var code = ReadInt(root, "code"); if (code.HasValue && code.Value != 0) { throw new InvalidOperationException($"Holiday year API returned error code {code.Value}."); } var result = new List(); var holidayRoot = TryGetNode(root, "holiday"); if (holidayRoot is not null && holidayRoot.Value.ValueKind == JsonValueKind.Object) { foreach (var property in holidayRoot.Value.EnumerateObject()) { var node = property.Value; var date = ParseDateOnly(ReadString(node, "date")) ?? ParseDateOnly(string.Create(CultureInfo.InvariantCulture, $"{year}-{property.Name}")); if (!date.HasValue) { continue; } var isHoliday = ReadBool(node, "holiday") ?? false; var nameZh = ReadString(node, "name"); if (string.IsNullOrWhiteSpace(nameZh)) { continue; } var targetZh = ReadString(node, "target"); var isAfterAdjust = ReadBool(node, "after"); var isAdjustedWorkday = !isHoliday && (!string.IsNullOrWhiteSpace(targetZh) || nameZh.Contains("\u8865\u73ed", StringComparison.Ordinal)); result.Add(new HolidayArrangementDay( Date: date.Value, IsHoliday: isHoliday, IsAdjustedWorkday: isAdjustedWorkday, NameZh: nameZh, NameEn: ResolveHolidayEnName(nameZh, targetZh, isHoliday, isAdjustedWorkday), TargetHolidayZh: targetZh, IsAfterAdjust: isAfterAdjust)); } } result.Sort((left, right) => left.Date.CompareTo(right.Date)); SetYearHolidayDataCache(year, result); return result; } private static List BuildHolidayCandidates(int yearFrom, int yearToInclusive) { var results = new List(); if (yearToInclusive < yearFrom) { return results; } for (var year = yearFrom; year <= yearToInclusive; year++) { foreach (var template in HolidayTemplates) { var date = template.ResolveDateForYear(year); if (!date.HasValue) { continue; } results.Add(new HolidayCountdownInfo( NameZh: template.NameZh, NameEn: template.NameEn, Date: date.Value)); } } results.Sort((left, right) => left.Date.CompareTo(right.Date)); return results; } private static DateOnly? TryResolveLunarHolidayDate(int lunarYear, int lunarMonth, int lunarDay) { try { var mappedMonth = MapRegularLunarMonthToRawMonth(lunarYear, lunarMonth); var gregorian = LunarCalendar.ToDateTime(lunarYear, mappedMonth, lunarDay, 0, 0, 0, 0); return DateOnly.FromDateTime(gregorian); } catch (ArgumentOutOfRangeException) { return null; } } private static int MapRegularLunarMonthToRawMonth(int lunarYear, int regularMonth) { var leapMonth = LunarCalendar.GetLeapMonth(lunarYear); if (leapMonth == 0) { return regularMonth; } // ChineseLunisolarCalendar month index inserts leap month: // if leap month is after regular month N, GetLeapMonth returns N + 1. // Months after that slot shift by +1. return leapMonth <= regularMonth ? regularMonth + 1 : regularMonth; } private static HolidayDayStatus BuildFallbackDayStatus(DateOnly date) { var dayOfWeek = date.DayOfWeek; var isWeekend = dayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; return new HolidayDayStatus( Date: date, DayType: isWeekend ? HolidayDayType.Weekend : HolidayDayType.Workday, TypeNameZh: isWeekend ? "\u5468\u672b" : "\u5de5\u4f5c\u65e5", IsHoliday: false, IsAdjustedWorkday: false, NameZh: null, NameEn: null, TargetHolidayZh: null); } private static HolidayDayType MapDayType(int? apiType) { return apiType switch { 0 => HolidayDayType.Workday, 1 => HolidayDayType.Weekend, 2 => HolidayDayType.LegalHoliday, 3 => HolidayDayType.AdjustedWorkday, _ => HolidayDayType.Unknown }; } private static string ResolveHolidayEnName( string? holidayNameZh, string? targetHolidayZh, bool isHoliday, bool isAdjustedWorkday) { if (!string.IsNullOrWhiteSpace(holidayNameZh) && HolidayNameMapZhToEn.TryGetValue(holidayNameZh, out var holidayEn)) { return holidayEn; } if (!string.IsNullOrWhiteSpace(targetHolidayZh) && HolidayNameMapZhToEn.TryGetValue(targetHolidayZh, out var targetEn)) { return isAdjustedWorkday ? $"{targetEn} Make-up Workday" : targetEn; } if (isAdjustedWorkday) { return "Make-up Workday"; } return isHoliday ? "Holiday" : "Workday"; } private async Task FetchAsync(Uri requestUri, CancellationToken cancellationToken) { using var response = await _httpClient.GetAsync(requestUri, cancellationToken); var content = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(content, 180)}"); } return content; } private Uri BuildRequestUri(string path) { var baseUrl = _baseUrl.TrimEnd('/'); var normalizedPath = path.StartsWith("/", StringComparison.Ordinal) ? path : $"/{path}"; return new Uri($"{baseUrl}{normalizedPath}", UriKind.Absolute); } private bool TryGetYearHolidayDataFromCache(int year, out IReadOnlyList value) { lock (_cacheGate) { if (_yearHolidayCache.TryGetValue(year, out var entry) && entry.ExpireAt > DateTimeOffset.UtcNow) { value = entry.Value; return true; } } value = Array.Empty(); return false; } private void SetYearHolidayDataCache(int year, IReadOnlyList value) { lock (_cacheGate) { _yearHolidayCache[year] = new CacheEntry>( value, DateTimeOffset.UtcNow.Add(_yearCacheDuration)); } } private bool TryGetDayStatusFromCache(DateOnly date, out HolidayDayStatus value) { lock (_cacheGate) { if (_dayStatusCache.TryGetValue(date, out var entry) && entry.ExpireAt > DateTimeOffset.UtcNow) { value = entry.Value; return true; } } value = BuildFallbackDayStatus(date); return false; } private void SetDayStatusCache(DateOnly date, HolidayDayStatus value) { lock (_cacheGate) { _dayStatusCache[date] = new CacheEntry( value, DateTimeOffset.UtcNow.Add(_dayCacheDuration)); } } private static JsonElement? TryGetNode(JsonElement node, params string[] path) { var current = node; foreach (var segment in path) { if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next)) { return null; } current = next; } return current; } private static string? ReadString(JsonElement? node, params string[] path) { if (!node.HasValue) { return null; } var target = path.Length == 0 ? node : TryGetNode(node.Value, path); if (!target.HasValue) { return null; } return target.Value.ValueKind switch { JsonValueKind.String => target.Value.GetString(), JsonValueKind.Number => target.Value.GetRawText(), JsonValueKind.True => "true", JsonValueKind.False => "false", _ => null }; } private static int? ReadInt(JsonElement? node, params string[] path) { if (!node.HasValue) { return null; } var target = path.Length == 0 ? node : TryGetNode(node.Value, path); if (!target.HasValue) { return null; } if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetInt32(out var number)) { return number; } if (target.Value.ValueKind == JsonValueKind.String && int.TryParse(target.Value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) { return parsed; } return null; } private static bool? ReadBool(JsonElement? node, params string[] path) { if (!node.HasValue) { return null; } var target = path.Length == 0 ? node : TryGetNode(node.Value, path); if (!target.HasValue) { return null; } if (target.Value.ValueKind == JsonValueKind.True) { return true; } if (target.Value.ValueKind == JsonValueKind.False) { return false; } if (target.Value.ValueKind == JsonValueKind.Number && target.Value.TryGetInt32(out var number)) { return number != 0; } if (target.Value.ValueKind == JsonValueKind.String) { var value = target.Value.GetString(); if (bool.TryParse(value, out var parsedBool)) { return parsedBool; } if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedInt)) { return parsedInt != 0; } } return null; } private static DateOnly? ParseDateOnly(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } if (DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly)) { return dateOnly; } if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTime)) { return DateOnly.FromDateTime(dateTime); } return null; } private static string Truncate(string? text, int maxLength) { if (string.IsNullOrEmpty(text)) { return string.Empty; } return text.Length <= maxLength ? text : $"{text[..maxLength]}..."; } public static string FormatDate(DateOnly date, bool isZh) { return isZh ? string.Create(CultureInfo.InvariantCulture, $"{date.Year}\u5e74{date.Month}\u6708{date.Day}\u65e5") : date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); } } public sealed record HolidayCountdownInfo( string NameZh, string NameEn, DateOnly Date);