using System.Globalization; using System.Runtime.InteropServices; namespace Jint.Native.Temporal; /// /// Provides calendar operations for non-ISO calendars using .NET's built-in Calendar classes. /// Supports: Chinese, Dangi, Hebrew, Persian, Coptic, Ethiopic, EthioAA, Indian, Islamic-Umalqura. /// Gregorian-based calendars (iso8601, gregory, japanese, roc, buddhist) are handled /// directly in TemporalHelpers using ISO arithmetic. /// internal static class NonIsoCalendars { // Lazy calendar instances to avoid startup overhead private static ChineseLunisolarCalendar? _chineseCalendar; private static KoreanLunisolarCalendar? _koreanCalendar; private static HebrewCalendar? _hebrewCalendar; private static PersianCalendar? _persianCalendar; private static UmAlQuraCalendar? _umAlQuraCalendar; private static ChineseLunisolarCalendar ChineseCal => _chineseCalendar ??= new ChineseLunisolarCalendar(); private static KoreanLunisolarCalendar DangiCal => _koreanCalendar ??= new KoreanLunisolarCalendar(); private static HebrewCalendar HebrewCal => _hebrewCalendar ??= new HebrewCalendar(); private static PersianCalendar PersianCal => _persianCalendar ??= new PersianCalendar(); private static UmAlQuraCalendar UmAlQuraCal => _umAlQuraCalendar ??= new UmAlQuraCalendar(); // Epoch day constants (days since Unix epoch 1970-01-01) // Coptic epoch: Coptic year 1, month 1, day 1 = proleptic Gregorian August 29, 284 CE // Verified: Coptic (1687, 1, 1) = ISO September 11, 1970 private const long CopticEpochDays = -615558; // Ethiopic epoch: Ethiopic year 1, month 1, day 1 = proleptic Gregorian August 27, 8 CE // Verified: Ethiopic (1963, 1, 1) = ISO September 11, 1970 private const long EthiopicEpochDays = -716367; // EthioAA epoch: EthioAA year 1, month 1, day 1 // Verified: EthioAA (7463, 1, 1) = ISO September 11, 1970 private const long EthioAAEpochDays = -2725242; /// /// Result of converting an ISO date to a non-ISO calendar date. /// [StructLayout(LayoutKind.Auto)] internal readonly record struct CalendarDate( int Year, int Month, // ordinal month (1-based, includes leap months) string MonthCode, // M01-M12 or M##L for leap months int Day, bool IsLeapMonth, int MonthsInYear, int DaysInMonth, int DaysInYear, bool InLeapYear); /// /// Returns true if the calendar is a non-ISO calendar supported by this adapter. /// internal static bool IsNonIsoCalendar(string calendar) => calendar is "chinese" or "dangi" or "hebrew" or "persian" or "coptic" or "ethiopic" or "ethioaa" or "indian" or "islamic-umalqura" or "islamic-civil" or "islamic-tbla"; /// /// Converts an ISO date to calendar-specific fields. /// When is supplied and its options expose a non-default /// , the conversion is delegated to that provider. /// internal static CalendarDate IsoToCalendarDate(string calendar, in IsoDate isoDate, Engine? engine = null) { var provider = engine?.Options.Temporal.CalendarProvider; if (provider is not null && provider != DefaultCalendarProvider.Instance && provider.IsSupported(calendar)) { var fields = provider.IsoToCalendarFields(calendar, isoDate.Year, isoDate.Month, isoDate.Day); return new CalendarDate( fields.Year, fields.Month, fields.MonthCode, fields.Day, fields.IsLeapMonth, fields.MonthsInYear, fields.DaysInMonth, fields.DaysInYear, fields.InLeapYear); } try { return calendar switch { "chinese" => LunisolarToCalendarDate(ChineseCal, isoDate), "dangi" => LunisolarToCalendarDate(DangiCal, isoDate), "hebrew" => HebrewToCalendarDate(isoDate), "persian" => PersianToCalendarDate(isoDate), "coptic" => FixedEpochToCalendarDate(CopticEpochDays, isoDate), "ethiopic" => FixedEpochToCalendarDate(EthiopicEpochDays, isoDate), "ethioaa" => FixedEpochToCalendarDate(EthioAAEpochDays, isoDate), "indian" => IndianToCalendarDate(isoDate), "islamic-umalqura" => IslamicUmalquraToCalendarDate(isoDate), "islamic-civil" => IslamicCivilToCalendarDate(isoDate, 1948439L), "islamic-tbla" => IslamicCivilToCalendarDate(isoDate, 1948438L), _ => throw new NotSupportedException($"Calendar '{calendar}' not supported by NonIsoCalendars") }; } catch (ArgumentOutOfRangeException) { // Fall back to ISO-like fields when the ISO date is outside the .NET calendar's range var monthCode = $"M{isoDate.Month:D2}"; var daysInMonth = IsoDate.IsoDateInMonth(isoDate.Year, isoDate.Month); var daysInYear = IsoDate.IsLeapYear(isoDate.Year) ? 366 : 365; return new CalendarDate(isoDate.Year, isoDate.Month, monthCode, isoDate.Day, false, 12, daysInMonth, daysInYear, IsoDate.IsLeapYear(isoDate.Year)); } } /// /// Converts a calendar date to an ISO date. Returns null if the date is invalid with overflow "reject". /// When is supplied and its options expose a non-default /// , the conversion is delegated to that provider. /// internal static IsoDate? CalendarDateToIso(string calendar, int year, string? monthCode, int month, int day, string overflow, Engine? engine = null) { var provider = engine?.Options.Temporal.CalendarProvider; if (provider is not null && provider != DefaultCalendarProvider.Instance && provider.IsSupported(calendar)) { var iso = provider.CalendarFieldsToIso(calendar, year, monthCode, month, day, overflow); return iso is null ? null : new IsoDate(iso.Value.Year, iso.Value.Month, iso.Value.Day); } var result = calendar switch { "chinese" => LunisolarDateToIso(ChineseCal, year, monthCode, month, day, overflow), "dangi" => LunisolarDateToIso(DangiCal, year, monthCode, month, day, overflow), "hebrew" => HebrewDateToIso(year, monthCode, month, day, overflow), "persian" => PersianDateToIso(year, monthCode, month, day, overflow), "coptic" => FixedEpochDateToIso(CopticEpochDays, 13, year, monthCode, month, day, overflow), "ethiopic" => FixedEpochDateToIso(EthiopicEpochDays, 13, year, monthCode, month, day, overflow), "ethioaa" => FixedEpochDateToIso(EthioAAEpochDays, 13, year, monthCode, month, day, overflow), "indian" => IndianDateToIso(year, monthCode, month, day, overflow), "islamic-umalqura" => IslamicUmalquraDateToIso(year, monthCode, month, day, overflow), "islamic-civil" => IslamicCivilTabularDateToIso(year, monthCode, month, day, overflow, 1948439L), "islamic-tbla" => IslamicCivilTabularDateToIso(year, monthCode, month, day, overflow, 1948438L), _ => throw new NotSupportedException($"Calendar '{calendar}' not supported by NonIsoCalendars") }; // If calendar conversion failed (e.g., year out of .NET calendar range), // fall back to treating fields as ISO (best effort) if (result is null && !string.Equals(overflow, "reject", StringComparison.Ordinal)) { // Don't fall back for fundamentally-invalid monthCodes (out-of-range display number, // or leap variant on a calendar that doesn't support it / a different leap month). // The Hebrew M02L → ISO 5779-02 fallback was masking spec-mandated RangeErrors. if (monthCode is not null && !TryValidateMonthCode(calendar, monthCode, out var validatedDisplayMonth)) { return null; } var isoMonth = month > 0 ? month : (monthCode is not null ? int.Parse(monthCode.AsSpan(1, 2), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture) : 1); return TemporalHelpers.RegulateIsoDate(year, Clamp(isoMonth, 1, 12), day, overflow); } return result; } /// /// Adds years and months to an ISO date using calendar-specific reckoning. /// Years are added preserving monthCode semantics (not ordinal month). /// Months are added as ordinal month steps. /// /// /// Note: is consulted only for IsoToCalendarFields / /// CalendarFieldsToIso. Higher-level calendar arithmetic (this method, CalendarDateUntil, /// MaxDaysForChineseLeapMonth, etc.) currently uses the BCL/inline implementations /// regardless of the registered provider. A custom provider that needs different /// add/until semantics will need this helper threaded through too. /// internal static IsoDate CalendarDateAdd(string calendar, in IsoDate isoDate, int years, int months, string overflow) { // For calendars that use epoch-day arithmetic (coptic/ethiopic/ethioaa) // or Indian calendar, handle them directly if (calendar is "coptic" or "ethiopic" or "ethioaa") { return FixedEpochCalendarDateAdd(calendar, isoDate, years, months, overflow); } if (calendar is "indian") { return IndianCalendarDateAdd(isoDate, years, months, overflow); } if (calendar is "islamic-civil" or "islamic-tbla" or "islamic-umalqura") { return IslamicTabularCalendarDateAdd(calendar, isoDate, years, months, overflow); } var cal = GetCalendar(calendar); var calDate = IsoToCalendarDate(calendar, isoDate); var newYear = calDate.Year + years; int newOrdinalMonth; // When adding years, preserve monthCode (not ordinal month) if (years != 0 && calDate.IsLeapMonth) { // Was on a leap month - check if it exists in the new year var leapOrdinal = GetLeapMonthOrdinal(calendar, cal, newYear); if (leapOrdinal > 0 && GetLeapDisplayMonth(calendar, leapOrdinal) == GetLeapDisplayMonth(calendar, GetLeapMonthOrdinal(calendar, cal, calDate.Year))) { // Same leap month exists in new year newOrdinalMonth = leapOrdinal; } else { // Leap month doesn't exist in new year if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { throw new InvalidOperationException("reject"); } // Constrain to the calendar-specific non-leap equivalent. For chinese/dangi the // leap month follows the same display number (M04L → M04). For hebrew the leap // M05L (Adar I) is followed by M06 (Adar) in non-leap years, so it advances by 1. var baseMonthCode = NonLeapEquivalentMonthCode(calendar, calDate.MonthCode); newOrdinalMonth = MonthCodeToOrdinal(calendar, cal, newYear, baseMonthCode, overflow); } } else if (years != 0) { // Not a leap month - resolve the same monthCode in the new year newOrdinalMonth = MonthCodeToOrdinal(calendar, cal, newYear, calDate.MonthCode, overflow); } else { newOrdinalMonth = calDate.Month; } // Add months (ordinal stepping) if (months != 0) { newOrdinalMonth += months; var monthsInYear = GetMonthsInYear(calendar, cal, newYear); while (newOrdinalMonth > monthsInYear) { newOrdinalMonth -= monthsInYear; newYear++; monthsInYear = GetMonthsInYear(calendar, cal, newYear); } while (newOrdinalMonth < 1) { newYear--; newOrdinalMonth += GetMonthsInYear(calendar, cal, newYear); } } // Constrain day var maxDay = GetDaysInMonthCal(cal, newYear, newOrdinalMonth); var newDay = calDate.Day; if (newDay > maxDay) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { throw new InvalidOperationException("reject"); } newDay = maxDay; } // Convert back to ISO try { var dt = cal.ToDateTime(newYear, newOrdinalMonth, newDay, 0, 0, 0, 0); return new IsoDate(dt.Year, dt.Month, dt.Day); } catch (ArgumentOutOfRangeException) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { throw new InvalidOperationException("reject"); } // Fallback: clamp to calendar's valid range return ClampToCalendarRange(cal, newYear, newOrdinalMonth, newDay); } } /// /// Computes the difference between two ISO dates using calendar-specific reckoning. /// Mirrors the temporal-polyfill HelperBase.untilCalendar algorithm: /// (1) compute years from a "diffInYearSign" formula based on monthCode/day position, /// (2) optionally pre-skip whole-cycle months for largestUnit=month, /// (3) iterate ±1 month at a time, comparing in calendar-field space with the day /// un-constrained (so a day forced down by AddCalendar still counts as "past target"), /// (4) measure remaining days as ISO epoch-day diff. /// internal static DurationRecord CalendarDateUntil(string calendar, in IsoDate one, in IsoDate two, string largestUnit) { var rawSign = TemporalHelpers.CompareIsoDates(one, two); if (rawSign == 0) { return new DurationRecord(0, 0, 0, 0, 0, 0, 0, 0, 0, 0); } var epochOne = TemporalHelpers.IsoDateToDays(one.Year, one.Month, one.Day); var epochTwo = TemporalHelpers.IsoDateToDays(two.Year, two.Month, two.Day); if (string.Equals(largestUnit, "week", StringComparison.Ordinal)) { var totalDays = (int) (epochTwo - epochOne); var w = totalDays / 7; return new DurationRecord(0, 0, w, totalDays - w * 7, 0, 0, 0, 0, 0, 0); } if (!string.Equals(largestUnit, "year", StringComparison.Ordinal) && !string.Equals(largestUnit, "month", StringComparison.Ordinal)) { // "day" or smaller: the date portion is just the ISO-day diff. return new DurationRecord(0, 0, 0, (int) (epochTwo - epochOne), 0, 0, 0, 0, 0, 0); } // Spec convention: sign = +1 if two > one (forward), -1 if backward. var sign = -rawSign; var calOne = IsoToCalendarDate(calendar, one); var calTwo = IsoToCalendarDate(calendar, two); var years = 0; var diffYears = calTwo.Year - calOne.Year; if (diffYears != 0) { int diffInYearSign; var mcCmp = string.CompareOrdinal(calTwo.MonthCode, calOne.MonthCode); if (mcCmp > 0) diffInYearSign = 1; else if (mcCmp < 0) diffInYearSign = -1; else diffInYearSign = System.Math.Sign(calTwo.Day - calOne.Day); var isOneFurtherInYear = diffInYearSign * sign < 0; years = isOneFurtherInYear ? diffYears - sign : diffYears; // Strict overshoot follow-up using un-constrained-day comparison: this catches the // case where AddCalendar's constrain mode forced the day down to fit a shorter target // month. The spec treats "would-be day exists in target's month?" as the overshoot // criterion, so an intermediate that landed at the target only because the day was // truncated should NOT count as a full year. Chinese/Hebrew leap-month wrapping plus // day-overflow within the same year boundary all flow through this check. if (years != 0) { var check = CalendarDateAdd(calendar, one, years, 0, "constrain"); var checkCal = IsoToCalendarDate(calendar, check); var notional = checkCal.Day != calOne.Day ? calOne.Day : checkCal.Day; var ccd = CompareCalendarFields(calTwo.Year, calTwo.MonthCode, calTwo.Day, checkCal.Year, checkCal.MonthCode, notional); if (ccd * sign < 0) { years -= sign; } } } var months = 0; if (string.Equals(largestUnit, "month", StringComparison.Ordinal)) { if (years != 0) { // Whole-year skip estimate. The iteration loop below corrects for // year-length variation in lunisolar calendars (Hebrew/Chinese/Dangi). var monthsPerCycle = calendar is "coptic" or "ethiopic" or "ethioaa" ? 13 : 12; months = years * monthsPerCycle; } years = 0; } var currentIso = years != 0 || months != 0 ? CalendarDateAdd(calendar, one, years, months, "constrain") : one; while (true) { var prevMonths = months; months += sign; var nextIso = CalendarDateAdd(calendar, one, years, months, "constrain"); var nextCal = IsoToCalendarDate(calendar, nextIso); // Un-constrain the day in calendar-field space: if AddCalendar forced day down // to fit the target month, we still want to consider "next" as standing at the // source's original day for comparison (matches polyfill behavior). var notionalDay = nextCal.Day != calOne.Day ? calOne.Day : nextCal.Day; var ccd = CompareCalendarFields(calTwo.Year, calTwo.MonthCode, calTwo.Day, nextCal.Year, nextCal.MonthCode, notionalDay); // Continue while ccd * sign >= 0 (next has not yet passed two). if (ccd * sign < 0) { months = prevMonths; break; } currentIso = nextIso; } var epochCurrent = TemporalHelpers.IsoDateToDays(currentIso.Year, currentIso.Month, currentIso.Day); var days = (int) (epochTwo - epochCurrent); return new DurationRecord(years, months, 0, days, 0, 0, 0, 0, 0, 0); } /// /// Returns the maximum number of days the given Chinese/Dangi LEAP monthCode has /// across any year in the search window around the canonical 1972 anchor. Used by /// PlainMonthDay to decide whether to fall back to the regular (non-leap) monthCode. /// Returns 0 if the monthCode is invalid or not a leap monthCode. /// internal static int MaxDaysForChineseLeapMonth(string calendar, string monthCode) { if (calendar is not ("chinese" or "dangi")) return 30; if (monthCode.Length != 4 || monthCode[3] != 'L') return 0; var cal = GetCalendar(calendar); var approxYear = IsoYearToCalendarYear(calendar, cal, 1972); var maxSeen = 0; for (var y = approxYear - 75; y <= approxYear + 75; y++) { var leapOrdinal = GetLeapMonthOrdinal(calendar, cal, y); if (leapOrdinal <= 0) continue; try { var ord = MonthCodeToOrdinal(calendar, cal, y, monthCode, "reject"); if (ord <= 0) continue; var dim = GetDaysInMonthCal(cal, y, ord); if (dim > maxSeen) maxSeen = dim; } catch { // monthCode doesn't exist this year } } return maxSeen; } /// Same as but for a REGULAR monthCode. internal static int MaxDaysForChineseRegularMonth(string calendar, string monthCode) { if (calendar is not ("chinese" or "dangi")) return 30; if (monthCode.Length != 3) return 0; var cal = GetCalendar(calendar); var approxYear = IsoYearToCalendarYear(calendar, cal, 1972); var maxSeen = 0; for (var y = approxYear - 75; y <= approxYear + 75; y++) { try { var ord = MonthCodeToOrdinal(calendar, cal, y, monthCode, "reject"); if (ord <= 0) continue; var dim = GetDaysInMonthCal(cal, y, ord); if (dim > maxSeen) maxSeen = dim; } catch { // monthCode doesn't exist this year } } return maxSeen; } /// /// Compares two calendar-field tuples (year, monthCode, day) lexicographically. /// MonthCode comparison is ordinal-string (so "M05" < "M05L" < "M06"), which matches the /// chronological order of months within a year for all supported calendars. /// Returns +1 if A > B, -1 if A < B, 0 if equal. /// private static int CompareCalendarFields(int aYear, string aMonthCode, int aDay, int bYear, string bMonthCode, int bDay) { if (aYear != bYear) { return aYear > bYear ? 1 : -1; } var mcCmp = string.CompareOrdinal(aMonthCode, bMonthCode); if (mcCmp != 0) { return mcCmp > 0 ? 1 : -1; } if (aDay != bDay) { return aDay > bDay ? 1 : -1; } return 0; } /// /// Returns the era string for a non-ISO calendar. /// internal static string? CalendarEra(string calendar, in CalendarDate calDate) { return calendar switch { // Single-era calendars: always use the primary era "hebrew" => "am", "persian" => "ap", "coptic" => "am", "ethioaa" => "aa", "indian" => "shaka", "chinese" or "dangi" => null, // no eras // Multi-era calendars: era depends on year "ethiopic" => calDate.Year >= 1 ? "am" : "aa", "islamic-umalqura" or "islamic-civil" or "islamic-tbla" => calDate.Year >= 1 ? "ah" : "bh", _ => null }; } /// /// Returns the eraYear for a non-ISO calendar. /// internal static int? CalendarEraYear(string calendar, in CalendarDate calDate) { return calendar switch { // Single-era calendars: eraYear = year directly "hebrew" => calDate.Year, "persian" => calDate.Year, "coptic" => calDate.Year, "ethioaa" => calDate.Year, "indian" => calDate.Year, "chinese" or "dangi" => null, // no eras // Multi-era calendars: eraYear depends on year "ethiopic" => calDate.Year >= 1 ? calDate.Year : calDate.Year + 5500, "islamic-umalqura" or "islamic-civil" or "islamic-tbla" => calDate.Year >= 1 ? calDate.Year : 1 - calDate.Year, _ => null }; } /// /// Returns the non-leap monthCode equivalent for a leap monthCode in a given calendar. /// Used when constraining a leap-month date into a year that doesn't have that leap month. /// chinese/dangi: M0NL → M0N (same display number, drop the 'L' suffix). /// hebrew: M05L (Adar I) → M06 (Adar). M05L is Hebrew's only valid leap monthCode /// (validated upstream by ); in non-leap years its sole /// non-leap equivalent is M06 (the regular Adar). /// Non-leap monthCodes are returned unchanged. /// internal static string NonLeapEquivalentMonthCode(string calendar, string monthCode) { if (monthCode.Length != 4 || monthCode[3] != 'L') { return monthCode; } if (string.Equals(calendar, "hebrew", StringComparison.Ordinal)) { // Hebrew has exactly one leap monthCode: M05L (Adar I) → M06 (Adar). return "M06"; } // chinese, dangi: drop the trailing 'L' return monthCode.Substring(0, 3); } /// /// Resolves a monthCode string to an ordinal month number for a given calendar year. /// Returns the ordinal month, or throws/constrains based on overflow. /// internal static int MonthCodeToOrdinal(string calendar, Calendar? cal, int year, string monthCode, string overflow) { var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture); if (calendar is "persian" or "indian" or "islamic-umalqura" or "islamic-civil" or "islamic-tbla") { // These calendars have 12 months and no leap months // Invalid monthCode (M13+, M00, or any leap month) is always rejected if (isLeap || displayMonth < 1 || displayMonth > 12) { throw new InvalidOperationException("reject"); } return displayMonth; } if (calendar is "coptic" or "ethiopic" or "ethioaa") { // 13-month calendars with no leap months // Invalid monthCode (M14+, M00, or any leap month) is always rejected if (isLeap || displayMonth < 1 || displayMonth > 13) { throw new InvalidOperationException("reject"); } return displayMonth; } var leapOrdinal = GetLeapMonthOrdinal(calendar, cal, year); if (isLeap) { if (leapOrdinal <= 0) { // No leap month this year if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { throw new InvalidOperationException("reject"); } // Constrain: use the base month return displayMonth < leapOrdinal || leapOrdinal <= 0 ? displayMonth : displayMonth + 1; } var leapDisplay = GetLeapDisplayMonth(calendar, leapOrdinal); if (displayMonth != leapDisplay) { // Wrong leap month if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { throw new InvalidOperationException("reject"); } // Constrain: use the base month return displayMonth < leapOrdinal ? displayMonth : displayMonth + 1; } return leapOrdinal; } // Non-leap month if (leapOrdinal > 0 && displayMonth >= leapOrdinal) { return displayMonth + 1; } return displayMonth; } /// /// Returns the maximum valid display-month for a calendar. monthCode "M{n}" with n outside /// [1, MaxDisplayMonth(calendar)] is fundamentally invalid and must throw RangeError /// regardless of overflow option. Returns null for ISO/Gregorian calendars (which use the /// standard 12-month rule via different validation paths). /// internal static int? MaxDisplayMonth(string calendar) { return calendar switch { "coptic" or "ethiopic" or "ethioaa" => 13, "chinese" or "dangi" or "hebrew" => 12, // leap variants share display number 1-12 "persian" or "indian" => 12, "islamic-umalqura" or "islamic-civil" or "islamic-tbla" => 12, _ => null }; } /// /// Validates a monthCode's display-month part against a calendar's range and leap-month /// support. Returns true and emits the parsed display month if the monthCode is fundamentally /// valid for the calendar (display number in [1, MaxDisplayMonth] and, for leap variants /// ("M##L"), the calendar supports leap months). Returns false otherwise. The display month /// is also emitted on the false path when the structure parsed successfully (so callers can /// build precise error messages) — only structurally malformed monthCodes leave it at 0. /// This validation is overflow-independent per spec. /// internal static bool TryValidateMonthCode(string calendar, string monthCode, out int displayMonth) { displayMonth = 0; if (monthCode.Length < 3 || monthCode[0] != 'M') { return false; } if (!int.TryParse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture, out displayMonth)) { return false; } var max = MaxDisplayMonth(calendar); if (max is null) { return true; // Unknown calendar — defer to caller } if (displayMonth < 1 || displayMonth > max.Value) { return false; } var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; if (isLeap) { // chinese / dangi support leap variants of any month (M01L–M12L can occur) // hebrew has exactly one leap monthCode: M05L (Adar I). M01L, M02L, …, M04L, // M06L, …, M12L are fundamentally invalid. // Other non-ISO calendars don't support leap months at all. return calendar switch { "chinese" or "dangi" => true, "hebrew" => displayMonth == 5, _ => false, }; } return true; } /// /// Returns true if (month, monthCode) is internally consistent for a given non-ISO calendar /// year — i.e. both refer to the same calendar month. Used for spec-mandated mismatch /// validation that must happen regardless of overflow option. /// internal static bool MonthAndMonthCodeAgree(string calendar, int year, int month, string monthCode) { if (month <= 0) { return true; } Calendar? cal; try { cal = calendar switch { "coptic" or "ethiopic" or "ethioaa" or "indian" => null, "islamic-civil" or "islamic-tbla" => null, _ => GetCalendar(calendar) }; } catch (NotSupportedException) { // Calendar not handled here — defer to caller's downstream validation. return true; } try { var resolved = MonthCodeToOrdinal(calendar, cal, year, monthCode, "reject"); return resolved == month; } catch (InvalidOperationException) { // monthCode out of range or otherwise unresolvable for this year — let the // downstream CalendarDateToIso path produce the appropriate error. return true; } } /// /// Validates that month and monthCode are consistent for a non-ISO calendar. /// If only monthCode is provided, resolves it to an ordinal month. /// If only month is provided, uses the ordinal directly. /// If both are provided, validates consistency. /// internal static int ResolveMonthAndMonthCode(string calendar, int year, int month, string? monthCode, string overflow) { Calendar? cal = calendar is "coptic" or "ethiopic" or "ethioaa" or "indian" ? null : GetCalendar(calendar); if (monthCode is not null && month > 0) { // Both provided - validate consistency var resolvedOrdinal = MonthCodeToOrdinal(calendar, cal, year, monthCode, overflow); if (resolvedOrdinal != month) { throw new InvalidOperationException("reject"); // month and monthCode do not match } return resolvedOrdinal; } if (monthCode is not null) { return MonthCodeToOrdinal(calendar, cal, year, monthCode, overflow); } if (month > 0) { // Validate ordinal month is in range var maxMonths = GetMonthsInYear(calendar, cal, year); if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { return Clamp(month, 1, maxMonths); } if (month < 1 || month > maxMonths) { throw new InvalidOperationException("reject"); } return month; } throw new InvalidOperationException("reject"); // neither month nor monthCode provided } /// /// Finds a calendar year where (year, monthCode, day) converts to an ISO date /// with the given ISO reference year. Used for PlainMonthDay without explicit year. /// internal static int FindCalendarReferenceYear(string calendar, int isoReferenceYear, string monthCode, int day) { // For calendars that don't use .NET Calendar class if (calendar is "coptic" or "ethiopic" or "ethioaa") { return FindFixedEpochReferenceYear(calendar, isoReferenceYear, monthCode, day); } if (calendar is "indian") { return FindIndianReferenceYear(isoReferenceYear, monthCode, day); } if (calendar is "islamic-civil" or "islamic-tbla") { return FindIslamicTabularReferenceYear(calendar, isoReferenceYear, monthCode, day); } var cal = GetCalendar(calendar); // Widen the search window for lunisolar calendars (chinese/dangi/hebrew) where rare // leap months (e.g. chinese M02L only ~every 25-30 years) won't appear within ±12 years // of the canonical anchor. Bounded by typical .NET BCL ranges (chinese 1901-2100, // dangi 918-2050, hebrew 5343-5999 ≈ ISO 1583-2239). var window = calendar is "chinese" or "dangi" or "hebrew" ? 75 : 12; // For lunisolar calendars, a calendar year spans parts of two ISO years. // Spec algorithm: find the LATEST calendar year y such that ToIso(y, monthCode, day) has // ISO year ≤ isoReferenceYear AND the day is valid (not constrained) in that year's // monthCode. This produces the "latest representable" reference year, which is what tests // like reference-year-1972 (M05L hebrew → 1970, M02 D30 hebrew → 1971, M12 D30 islamic // → 1971) require. We fall back to a constrained-day match only if no exact-day year // exists within the search window. var approxYear = IsoYearToCalendarYear(calendar, cal, isoReferenceYear); var isLeapMonthCode = monthCode.Length == 4 && monthCode[3] == 'L'; var bestYear = int.MinValue; var bestIsoTicks = long.MinValue; var upperBound = new DateTime(isoReferenceYear, 12, 31).Ticks; for (var y = approxYear - window; y <= approxYear + window; y++) { if (isLeapMonthCode) { var leapOrdinal = GetLeapMonthOrdinal(calendar, cal, y); if (leapOrdinal <= 0) { continue; } } try { var ordinal = MonthCodeToOrdinal(calendar, cal, y, monthCode, "reject"); var maxDay = GetDaysInMonthCal(cal, y, ordinal); if (day > maxDay) { continue; // day not valid in this year — skip in pass 1 } var dt = cal.ToDateTime(y, ordinal, day, 0, 0, 0, 0); // Pick the LATEST valid ISO date that's ≤ end-of-refYear. When two calendar // years both produce ISO dates in refYear (e.g. Hebrew M04 D26 in 5732 → 1972-01 // and 5733 → 1972-12), the spec says use the later one. if (dt.Ticks <= upperBound && dt.Ticks > bestIsoTicks) { bestIsoTicks = dt.Ticks; bestYear = y; } } catch { // This year doesn't have the requested monthCode } } if (bestYear != int.MinValue) { return bestYear; } // Pass 1.5: rare leap months (chinese M09L/M10L/M11L, etc.) may not occur in any year // ≤ isoReferenceYear. Look forward for the SOONEST future year that admits the // un-constrained day; per the spec note in chinese-leap-month-codes-common, "uncommon" // leap months use a reference year in the near future when no past year qualifies. if (isLeapMonthCode) { var futureYear = int.MinValue; var futureIsoTicks = long.MaxValue; for (var y = approxYear - window; y <= approxYear + window; y++) { var leapOrdinal = GetLeapMonthOrdinal(calendar, cal, y); if (leapOrdinal <= 0) continue; try { var ordinal = MonthCodeToOrdinal(calendar, cal, y, monthCode, "reject"); var maxDay = GetDaysInMonthCal(cal, y, ordinal); if (day > maxDay) continue; var dt = cal.ToDateTime(y, ordinal, day, 0, 0, 0, 0); if (dt.Ticks > upperBound && dt.Ticks < futureIsoTicks) { futureIsoTicks = dt.Ticks; futureYear = y; } } catch { // skip } } if (futureYear != int.MinValue) { return futureYear; } } // Fallback: when no year has the day un-constrained, pick the year where this monthCode // holds the MOST days (so day constrains as little as possible). Tiebreak by latest ISO // date ≤ refYear ceiling. This matches the spec for cases like Hebrew M02 D31 constrain // → 30 (find the year where Cheshvan has 30 days, not 29) with refYear=1971. var fallbackYear = int.MinValue; var fallbackMaxDay = 0; var fallbackKey = long.MinValue; for (var y = approxYear - window; y <= approxYear + window; y++) { if (isLeapMonthCode) { var leapOrdinal = GetLeapMonthOrdinal(calendar, cal, y); if (leapOrdinal <= 0) { continue; } } try { var ordinal = MonthCodeToOrdinal(calendar, cal, y, monthCode, "reject"); var maxDay = GetDaysInMonthCal(cal, y, ordinal); var clampedDay = System.Math.Min(day, maxDay); var dt = cal.ToDateTime(y, ordinal, clampedDay, 0, 0, 0, 0); if (dt.Ticks > upperBound) { continue; } if (maxDay > fallbackMaxDay || (maxDay == fallbackMaxDay && dt.Ticks > fallbackKey)) { fallbackMaxDay = maxDay; fallbackKey = dt.Ticks; fallbackYear = y; } } catch { // This year doesn't have the requested monthCode } } return fallbackYear != int.MinValue ? fallbackYear : approxYear; } #region Private Helpers private static int Clamp(int value, int min, int max) { return value < min ? min : value > max ? max : value; } /// /// Determines if a Hebrew year is a leap year using the 19-year Metonic cycle. /// Works for any Hebrew year, including those outside .NET HebrewCalendar's range. /// In each 19-year cycle, years 3, 6, 8, 11, 14, 17, 19 are leap years. /// private static bool IsHebrewLeapYearAlgorithmic(int year) { // Use mathematical modulo (always non-negative) for correct behavior on year ≤ 0. long v = ((long) 7 * year + 1L) % 19L; if (v < 0) v += 19L; return v < 7L; } // Hebrew arithmetic calendar (Reingold–Dershowitz). Used as a proleptic fallback for years // outside the .NET HebrewCalendar range (which only supports Hebrew years 5343–5999, // ≈ ISO 1583–2239). Year 1 (Tishrei 1) = JDN 347998 (= ISO −3760-09-07 proleptic Gregorian). private const long HebrewEpochJdn = 347998L; /// /// Mathematical floor division, returning floor(a/b). Differs from C#'s truncating "/" /// for negative dividends — required for the Reingold–Dershowitz Hebrew formulas to /// extend correctly into proleptic (year ≤ 0) territory. /// private static long FloorDiv(long a, long b) { long q = a / b; if ((a ^ b) < 0L && q * b != a) q--; return q; } /// Mathematical floor modulo, returning a non-negative residue (when b is positive). private static long FloorMod(long a, long b) { long r = a % b; if (r != 0L && (r ^ b) < 0L) r += b; return r; } private static long HebrewElapsedDays(int year) { long monthsElapsed = FloorDiv(235L * year - 234L, 19L); long partsElapsed = 12084L + 13753L * monthsElapsed; long days = 29L * monthsElapsed + FloorDiv(partsElapsed, 25920L); // Lo Adu Rosh dehiyya: postpone Tishrei 1 to avoid Sun/Wed/Fri. if (FloorMod(3L * (days + 1L), 7L) < 3L) days++; return days; } private static int HebrewYearLengthCorrection(int year) { long ny0 = HebrewElapsedDays(year - 1); long ny1 = HebrewElapsedDays(year); long ny2 = HebrewElapsedDays(year + 1); if (ny2 - ny1 == 356L) return 2; if (ny1 - ny0 == 382L) return 1; return 0; } private static long HebrewYearStartJdn(int year) { return HebrewEpochJdn + HebrewElapsedDays(year) + HebrewYearLengthCorrection(year); } private static int HebrewYearLength(int year) { return (int) (HebrewYearStartJdn(year + 1) - HebrewYearStartJdn(year)); } private static int HebrewDaysInMonthOrdinal(int year, int ordinalMonth) { bool leap = IsHebrewLeapYearAlgorithmic(year); switch (ordinalMonth) { case 1: return 30; // Tishrei case 2: // Cheshvan: 30 if "complete" year (length 355/385), else 29 { var len = HebrewYearLength(year); return len == 355 || len == 385 ? 30 : 29; } case 3: // Kislev: 29 if "deficient" year (length 353/383), else 30 { var len = HebrewYearLength(year); return len == 353 || len == 383 ? 29 : 30; } case 4: return 29; // Tevet case 5: return 30; // Shevat } if (leap) { switch (ordinalMonth) { case 6: return 30; // Adar I (M05L) case 7: return 29; // Adar II (M06) case 8: return 30; // Nisan case 9: return 29; // Iyar case 10: return 30; // Sivan case 11: return 29; // Tammuz case 12: return 30; // Av case 13: return 29; // Elul } } else { switch (ordinalMonth) { case 6: return 29; // Adar (M06) case 7: return 30; // Nisan case 8: return 29; // Iyar case 9: return 30; // Sivan case 10: return 29; // Tammuz case 11: return 30; // Av case 12: return 29; // Elul } } return 0; } private static int HebrewMonthCodeToOrdinal(int year, string monthCode) { bool leap = IsHebrewLeapYearAlgorithmic(year); if (string.Equals(monthCode, "M05L", StringComparison.Ordinal)) { return leap ? 6 : -1; } if (monthCode.Length != 3 || monthCode[0] != 'M') { return -1; } if (!int.TryParse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture, out var displayMonth) || displayMonth < 1 || displayMonth > 12) { return -1; } if (displayMonth <= 5) return displayMonth; return leap ? displayMonth + 1 : displayMonth; } private static string HebrewOrdinalToMonthCode(int year, int ordinalMonth, out bool isLeapMonth) { bool leap = IsHebrewLeapYearAlgorithmic(year); if (leap) { if (ordinalMonth <= 5) { isLeapMonth = false; return $"M{ordinalMonth:D2}"; } if (ordinalMonth == 6) { isLeapMonth = true; return "M05L"; } isLeapMonth = false; return $"M{ordinalMonth - 1:D2}"; } isLeapMonth = false; return $"M{ordinalMonth:D2}"; } private static CalendarDate HebrewAlgorithmicFromIso(in IsoDate isoDate) { long jdn = IsoToJulianDay(isoDate.Year, isoDate.Month, isoDate.Day); long days = jdn - HebrewEpochJdn; int yearEst = (int) (days / 365L) + 1; while (HebrewYearStartJdn(yearEst + 1) <= jdn) yearEst++; while (HebrewYearStartJdn(yearEst) > jdn) yearEst--; int year = yearEst; long dayOfYear = jdn - HebrewYearStartJdn(year); // 0-based int ordinalMonth = 1; while (true) { var dim = HebrewDaysInMonthOrdinal(year, ordinalMonth); if (dayOfYear < dim) break; dayOfYear -= dim; ordinalMonth++; } int day = (int) dayOfYear + 1; var monthCode = HebrewOrdinalToMonthCode(year, ordinalMonth, out var isLeapMonth); int monthsInYear = IsHebrewLeapYearAlgorithmic(year) ? 13 : 12; int daysInMonth = HebrewDaysInMonthOrdinal(year, ordinalMonth); int daysInYear = HebrewYearLength(year); bool isLeapYear = IsHebrewLeapYearAlgorithmic(year); return new CalendarDate(year, ordinalMonth, monthCode, day, isLeapMonth, monthsInYear, daysInMonth, daysInYear, isLeapYear); } private static IsoDate? HebrewAlgorithmicToIso(int year, int ordinalMonth, int day) { long jdn = HebrewYearStartJdn(year); for (int m = 1; m < ordinalMonth; m++) { jdn += HebrewDaysInMonthOrdinal(year, m); } jdn += day - 1; return JdnToIso(jdn); } private static Calendar GetCalendar(string calendar) { return calendar switch { "chinese" => ChineseCal, "dangi" => DangiCal, "hebrew" => HebrewCal, "persian" => PersianCal, "islamic-umalqura" => UmAlQuraCal, _ => throw new NotSupportedException($"Calendar '{calendar}' not supported") }; } /// /// Converts an ISO year to an approximate calendar year. /// private static int IsoYearToCalendarYear(string calendar, Calendar cal, int isoYear) { try { // Use July 1 of the ISO year as a reference point var dt = new DateTime(System.Math.Max(1, System.Math.Min(isoYear, 9999)), 7, 1); if (dt >= cal.MinSupportedDateTime && dt <= cal.MaxSupportedDateTime) { return cal.GetYear(dt); } } catch { // Fall through } return isoYear; } /// /// Gets the ordinal position of the leap month in a given year. /// Returns 0 if the year has no leap month. /// private static int GetLeapMonthOrdinal(string calendar, Calendar? cal, int year) { if (calendar is "persian" or "coptic" or "ethiopic" or "ethioaa" or "indian" or "islamic-umalqura") { return 0; // These calendars never have leap months } if (calendar is "hebrew") { try { return HebrewCal.IsLeapYear(year) ? 6 : 0; } catch { // Out of .NET range: use algorithmic 19-year cycle // Years 3, 6, 8, 11, 14, 17, 19 of each 19-year cycle are leap years return IsHebrewLeapYearAlgorithmic(year) ? 6 : 0; } } // Chinese/Dangi: use EastAsianLunisolarCalendar.GetLeapMonth if (cal is null) { return 0; } try { return ((EastAsianLunisolarCalendar) cal).GetLeapMonth(year); } catch { return 0; } } /// /// Gets the display month number of the leap month at the given ordinal. /// For Chinese/Dangi: leapOrdinal 5 → display 4 (M04L). /// For Hebrew: leapOrdinal 6 → display 5 (M05L). /// private static int GetLeapDisplayMonth(string calendar, int leapOrdinal) { return leapOrdinal - 1; } /// /// Gets the number of months in a calendar year. /// private static int GetMonthsInYear(string calendar, Calendar? cal, int year) { if (calendar is "persian" or "indian" or "islamic-umalqura" or "islamic-civil" or "islamic-tbla") { return 12; } if (calendar is "coptic" or "ethiopic" or "ethioaa") { return 13; } if (cal is null) { return 12; } try { return cal.GetMonthsInYear(year); } catch { // For Hebrew out-of-range, use algorithmic leap year detection if (calendar is "hebrew") { return IsHebrewLeapYearAlgorithmic(year) ? 13 : 12; } return 12; } } /// /// Gets the number of days in a calendar month. /// private static int GetDaysInMonthCal(Calendar? cal, int year, int month) { if (cal is null) { return 30; // fallback for calendars without .NET Calendar } try { return cal.GetDaysInMonth(year, month); } catch { return 30; // fallback } } /// /// Clamps a date to the calendar's supported range. /// private static IsoDate ClampToCalendarRange(Calendar cal, int year, int month, int day) { var dt = cal.MaxSupportedDateTime; return new IsoDate(dt.Year, dt.Month, dt.Day); } #endregion #region Lunisolar (Chinese/Dangi) Calendar private static CalendarDate LunisolarToCalendarDate(EastAsianLunisolarCalendar cal, in IsoDate isoDate) { var dt = new DateTime(isoDate.Year, isoDate.Month, isoDate.Day); // If outside supported range, fall back to ISO-like fields if (dt < cal.MinSupportedDateTime || dt > cal.MaxSupportedDateTime) { throw new ArgumentOutOfRangeException(nameof(isoDate)); } var year = cal.GetYear(dt); var ordinalMonth = cal.GetMonth(dt); var day = cal.GetDayOfMonth(dt); var leapMonth = cal.GetLeapMonth(year); bool isLeapMonth; string monthCode; if (leapMonth > 0 && ordinalMonth >= leapMonth) { if (ordinalMonth == leapMonth) { var displayMonth = leapMonth - 1; isLeapMonth = true; monthCode = $"M{displayMonth:D2}L"; } else { var displayMonth = ordinalMonth - 1; isLeapMonth = false; monthCode = $"M{displayMonth:D2}"; } } else { isLeapMonth = false; monthCode = $"M{ordinalMonth:D2}"; } var monthsInYear = leapMonth > 0 ? 13 : 12; var daysInMonth = cal.GetDaysInMonth(year, ordinalMonth); var daysInYear = cal.GetDaysInYear(year); var inLeapYear = leapMonth > 0; return new CalendarDate(year, ordinalMonth, monthCode, day, isLeapMonth, monthsInYear, daysInMonth, daysInYear, inLeapYear); } private static IsoDate? LunisolarDateToIso(EastAsianLunisolarCalendar cal, int year, string? monthCode, int month, int day, string overflow) { int ordinalMonth; if (monthCode is not null) { var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); // Display month must be 1-12 (no calendar has display month > 12) if (displayMonth < 1 || displayMonth > 12) { return null; } var leapMonth = 0; try { leapMonth = cal.GetLeapMonth(year); } catch { // Year out of range } if (isLeap) { if (leapMonth <= 0 || leapMonth - 1 != displayMonth) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } // Constrain: use the non-leap version of the month ordinalMonth = leapMonth > 0 && displayMonth >= leapMonth ? displayMonth + 1 : displayMonth; } else { ordinalMonth = leapMonth; } } else { if (leapMonth > 0 && displayMonth >= leapMonth) { ordinalMonth = displayMonth + 1; } else { ordinalMonth = displayMonth; } } // Validate month matches ordinal if both provided if (month > 0 && month != ordinalMonth) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } // monthCode takes precedence } } else { ordinalMonth = month; } // Constrain ordinal month to valid range var maxMonths = 12; try { maxMonths = cal.GetLeapMonth(year) > 0 ? 13 : 12; } catch { // Year out of range } if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { ordinalMonth = Clamp(ordinalMonth, 1, maxMonths); } else if (ordinalMonth < 1 || ordinalMonth > maxMonths) { return null; } // Constrain day int maxDay; try { maxDay = cal.GetDaysInMonth(year, ordinalMonth); } catch { maxDay = 30; } if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { day = Clamp(day, 1, maxDay); } else if (day < 1 || day > maxDay) { return null; } try { var dt = cal.ToDateTime(year, ordinalMonth, day, 0, 0, 0, 0); return new IsoDate(dt.Year, dt.Month, dt.Day); } catch { return null; } } #endregion #region Hebrew Calendar private static CalendarDate HebrewToCalendarDate(in IsoDate isoDate) { var cal = HebrewCal; // ISO year may fall outside System.DateTime's range (1–9999 in BCL); for those years // we cannot construct a DateTime at all, so route directly to the algorithmic path. if (isoDate.Year < 1 || isoDate.Year > 9999) { return HebrewAlgorithmicFromIso(isoDate); } var dt = new DateTime(isoDate.Year, isoDate.Month, isoDate.Day); if (dt < cal.MinSupportedDateTime || dt > cal.MaxSupportedDateTime) { return HebrewAlgorithmicFromIso(isoDate); } var year = cal.GetYear(dt); var ordinalMonth = cal.GetMonth(dt); var day = cal.GetDayOfMonth(dt); var isLeapYear = cal.IsLeapYear(year); bool isLeapMonth; string monthCode; if (isLeapYear) { // Leap year: 13 months // ordinals 1-5 → M01-M05 // ordinal 6 → M05L (Adar I, the intercalary month) // ordinals 7-13 → M06-M12 if (ordinalMonth <= 5) { monthCode = $"M{ordinalMonth:D2}"; isLeapMonth = false; } else if (ordinalMonth == 6) { monthCode = "M05L"; isLeapMonth = true; } else { monthCode = $"M{ordinalMonth - 1:D2}"; isLeapMonth = false; } } else { // Non-leap year: 12 months, ordinals 1-12 → M01-M12 monthCode = $"M{ordinalMonth:D2}"; isLeapMonth = false; } var monthsInYear = isLeapYear ? 13 : 12; var daysInMonth = cal.GetDaysInMonth(year, ordinalMonth); var daysInYear = cal.GetDaysInYear(year); return new CalendarDate(year, ordinalMonth, monthCode, day, isLeapMonth, monthsInYear, daysInMonth, daysInYear, isLeapYear); } private static IsoDate? HebrewDateToIso(int year, string? monthCode, int month, int day, string overflow) { var cal = HebrewCal; int ordinalMonth; if (monthCode is not null) { var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); // Display month must be 1-12 if (displayMonth < 1 || displayMonth > 12) { return null; } bool yearIsLeap; try { yearIsLeap = cal.IsLeapYear(year); } catch { yearIsLeap = IsHebrewLeapYearAlgorithmic(year); } if (isLeap) { // Hebrew has only one leap monthCode: M05L (Adar I). M01L, M02L, …, M04L, // M06L, …, M12L are fundamentally invalid for the Hebrew calendar — reject // unconditionally regardless of overflow option. if (displayMonth != 5) { return null; } if (!yearIsLeap) { // M05L exists only in leap years; constrain to M06 (Adar) in non-leap years // unless overflow is "reject". if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } // In Hebrew, M05L (Adar I) constrains to M06 (Adar) in non-leap years. // M06 occupies ordinal 6 in non-leap years (after Shevat=M05). ordinalMonth = 6; } else { ordinalMonth = 6; // M05L → ordinal 6 in leap year (Adar I) } } else { if (yearIsLeap && displayMonth >= 6) { ordinalMonth = displayMonth + 1; // M06 → ordinal 7, etc. } else { ordinalMonth = displayMonth; } } // Validate month matches ordinal if both provided if (month > 0 && month != ordinalMonth) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } } } else { ordinalMonth = month; } // Constrain ordinal month int maxMonths; bool yearInDotNetRange = true; try { maxMonths = cal.IsLeapYear(year) ? 13 : 12; } catch { yearInDotNetRange = false; maxMonths = IsHebrewLeapYearAlgorithmic(year) ? 13 : 12; } if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { ordinalMonth = Clamp(ordinalMonth, 1, maxMonths); } else if (ordinalMonth < 1 || ordinalMonth > maxMonths) { return null; } // Constrain day int maxDay; try { maxDay = yearInDotNetRange ? cal.GetDaysInMonth(year, ordinalMonth) : HebrewDaysInMonthOrdinal(year, ordinalMonth); } catch { maxDay = HebrewDaysInMonthOrdinal(year, ordinalMonth); } if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { day = Clamp(day, 1, maxDay); } else if (day < 1 || day > maxDay) { return null; } if (!yearInDotNetRange) { return HebrewAlgorithmicToIso(year, ordinalMonth, day); } try { var dt = cal.ToDateTime(year, ordinalMonth, day, 0, 0, 0, 0); return new IsoDate(dt.Year, dt.Month, dt.Day); } catch { return HebrewAlgorithmicToIso(year, ordinalMonth, day); } } #endregion #region Persian Calendar // Persian arithmetic calendar (2820-year cycle, Reingold–Dershowitz / Birashk). // Used as a proleptic fallback for years outside the .NET PersianCalendar range // (which only supports years 1–9999, ≈ ISO 622–10620). The 2820-year cycle has // 683 leap years; year 1 starts at JDN 1948321 = ISO 622-03-22 (proleptic Gregorian). private const long PersianEpochJdn = 1948321L; private static bool IsPersianLeapYearAlgorithmic(int year) { long yp = (long) year - 474L; long mod = ((yp % 2820L) + 2820L) % 2820L; long yc = mod + 474L; return ((yc + 38L) * 682L) % 2816L < 682L; } private static long PersianYearStartJdn(int year) { long yp = (long) year - 474L; long mod = ((yp % 2820L) + 2820L) % 2820L; long cycle = (yp - mod) / 2820L; long yc = mod + 474L; return PersianEpochJdn + cycle * 1029983L + 365L * (yc - 1L) + (yc * 682L - 110L) / 2816L; } private static int PersianAlgorithmicDaysInMonth(int year, int month) { if (month <= 6) return 31; if (month <= 11) return 30; return IsPersianLeapYearAlgorithmic(year) ? 30 : 29; } private static CalendarDate PersianAlgorithmicFromIso(in IsoDate isoDate) { long jdn = IsoToJulianDay(isoDate.Year, isoDate.Month, isoDate.Day); // Estimate Persian year then walk to exact. long days = jdn - PersianEpochJdn; int yearEst = (int) (days / 365L) + 1; while (PersianYearStartJdn(yearEst + 1) <= jdn) yearEst++; while (PersianYearStartJdn(yearEst) > jdn) yearEst--; int year = yearEst; long dayOfYear = jdn - PersianYearStartJdn(year) + 1; // 1-based int month, day; if (dayOfYear <= 186) { month = (int) ((dayOfYear - 1) / 31) + 1; day = (int) (dayOfYear - (month - 1) * 31); } else { long remaining = dayOfYear - 186; month = (int) ((remaining - 1) / 30) + 7; day = (int) (remaining - (month - 7) * 30); } bool isLeap = IsPersianLeapYearAlgorithmic(year); var monthCode = $"M{month:D2}"; return new CalendarDate(year, month, monthCode, day, false, 12, PersianAlgorithmicDaysInMonth(year, month), isLeap ? 366 : 365, isLeap); } private static IsoDate? PersianAlgorithmicToIso(int year, int month, int day) { long jdn = PersianYearStartJdn(year); for (int m = 1; m < month; m++) { jdn += PersianAlgorithmicDaysInMonth(year, m); } jdn += day - 1; return JdnToIso(jdn); } private static CalendarDate PersianToCalendarDate(in IsoDate isoDate) { var cal = PersianCal; if (isoDate.Year < 1 || isoDate.Year > 9999) { return PersianAlgorithmicFromIso(isoDate); } var dt = new DateTime(isoDate.Year, isoDate.Month, isoDate.Day); if (dt < cal.MinSupportedDateTime || dt > cal.MaxSupportedDateTime) { return PersianAlgorithmicFromIso(isoDate); } var year = cal.GetYear(dt); var ordinalMonth = cal.GetMonth(dt); var day = cal.GetDayOfMonth(dt); var isLeapYear = cal.IsLeapYear(year); var monthCode = $"M{ordinalMonth:D2}"; var daysInMonth = cal.GetDaysInMonth(year, ordinalMonth); var daysInYear = cal.GetDaysInYear(year); return new CalendarDate(year, ordinalMonth, monthCode, day, false, 12, daysInMonth, daysInYear, isLeapYear); } private static IsoDate? PersianDateToIso(int year, string? monthCode, int month, int day, string overflow) { var cal = PersianCal; var ordinalMonth = month; if (monthCode is not null) { var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); if (isLeap) { // Persian has no leap months if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } ordinalMonth = displayMonth; } else { ordinalMonth = displayMonth; } if (month > 0 && month != ordinalMonth) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } } } // Constrain month if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { ordinalMonth = Clamp(ordinalMonth, 1, 12); } else if (ordinalMonth < 1 || ordinalMonth > 12) { return null; } // Constrain day int maxDay; try { maxDay = cal.GetDaysInMonth(year, ordinalMonth); } catch { // Fallback: use the algorithmic computation, which works for all years. maxDay = PersianAlgorithmicDaysInMonth(year, ordinalMonth); } if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { day = Clamp(day, 1, maxDay); } else if (day < 1 || day > maxDay) { return null; } try { var dt = cal.ToDateTime(year, ordinalMonth, day, 0, 0, 0, 0); return new IsoDate(dt.Year, dt.Month, dt.Day); } catch { // Year is outside .NET PersianCalendar range — use algorithmic conversion. return PersianAlgorithmicToIso(year, ordinalMonth, day); } } #endregion #region Fixed-Epoch Calendars (Coptic/Ethiopic/EthioAA) /// /// Returns the number of leap years from year 1 through year n (inclusive) /// for calendars where year % 4 == 3 is a leap year. /// private static long FixedEpochLeapCount(long n) { if (n < 3) return 0; return (n - 3) / 4 + 1; } /// /// Returns true if a fixed-epoch calendar year is a leap year. /// Leap years are those where year % 4 == 3 (years 3, 7, 11, ...). /// private static bool IsFixedEpochLeapYear(int year) { // Handle negative years: need mathematical modulo (always non-negative) var mod = ((year % 4) + 4) % 4; return mod == 3; } /// /// Returns the number of days from the calendar epoch to the start of the given year. /// private static long FixedEpochDaysToYear(int year) { if (year >= 1) { return (long) (year - 1) * 365 + FixedEpochLeapCount(year - 1); } // For years <= 0 (proleptic): count backwards // Year 0 is 1 year before year 1, year -1 is 2 years before, etc. // Each year has 365 or 366 days. In the proleptic direction, // we need to handle the leap pattern correctly. // Year Y (Y <= 0): daysToYear(Y) = -(daysFromYear(Y) to year 1) // daysFromYear(Y) to year 1 = sum of days in years Y, Y+1, ..., 0 // For simplicity, use the formula: (Y-1)*365 + leapCount // where leapCount handles negative years var y = (long) year - 1; // years elapsed (negative) // For negative y, floor(y/4) gives the correct negative leap count // But our leap rule is year%4==3, so for year Y: // Leap years below 1: -1 (mod4=3), -5 (mod4=3), -9 (mod4=3), ... // Count of leap years from Y to 0 inclusive where ((k%4)+4)%4 == 3: // k = -1, -5, -9, ..., Y (if Y matches pattern) // Total days = y * 365 + (negative leap count) // Use a different approach: convert to positive counting // Let p = 1 - year (p >= 1 for year <= 0) // The leap years in range [year, 0] are at positions where mod4==3 // Equivalently, leap count = FixedEpochLeapCount(p) considering the shift // Actually, for proleptic years, the leap pattern mirrors: // Year -1: (-1+4)%4=3, leap. Year 0: 0%4=0, not leap. // Year -2: (-2+4)%4=2, not. Year -3: (-3+4)%4=1, not. Year -4: (-4+4)%4=0, not. // Year -5: (-5+8)%4=3, leap. // So leap years: -1, -5, -9, ... i.e., every 4 years starting from -1 // Count of leap years from year to -1 (inclusive, for years year..0 that are leap): // = count of k in [year, 0] where ((k%4)+4)%4 == 3 // = count of k in [year, -1] where ((k%4)+4)%4 == 3 (since year 0 is not leap) // The leap years are -1, -5, -9, ..., down to year // If year <= -1: count = floor((-year - 1) / 4) + 1 when ((-year-1)%4+4)%4... actually // count = floor((- year) / 4) ... let me just count directly: // Years from (year) to (-1) that are leap: // These are: -1, -5, -9, ..., last >= year // First (largest): -1. Step: -4. // Count = floor((-1 - year) / 4) + 1 when year <= -1 // Days from epoch to year Y (Y <= 0): // = -(days from Y to 1) = -( sum_{k=Y}^{0} daysInYear(k) ) // daysInYear(k) = 366 if leap(k), else 365 // = -( (1-Y)*365 + leapsBetween(Y, 0) ) long p = 1 - year; // number of years from Y to 0 inclusive long leapsBetween; if (year <= -1) { leapsBetween = (-1 - year) / 4 + 1; } else { // year == 0 leapsBetween = 0; } return -(p * 365 + leapsBetween); } /// /// Returns the number of days in the given month of a fixed-epoch calendar year. /// Months 1-12 have 30 days each. Month 13 has 5 days (6 in leap year). /// private static int FixedEpochDaysInMonth(int year, int month) { if (month >= 1 && month <= 12) return 30; if (month == 13) return IsFixedEpochLeapYear(year) ? 6 : 5; return 30; // fallback } /// /// Converts a fixed-epoch calendar date to epoch days (days since Unix epoch). /// private static long FixedEpochToEpochDays(long calendarEpochDays, int year, int month, int day) { return calendarEpochDays + FixedEpochDaysToYear(year) + (month - 1) * 30 + day - 1; } /// /// Converts epoch days to a fixed-epoch calendar date. /// private static void EpochDaysToFixedEpoch(long calendarEpochDays, long epochDays, out int year, out int month, out int day) { var dfc = epochDays - calendarEpochDays; // days from calendar epoch // Approximate year if (dfc >= 0) { year = (int) (dfc / 365) + 1; // Adjust: we may have overshot while (FixedEpochDaysToYear(year) > dfc) { year--; } while (FixedEpochDaysToYear(year + 1) <= dfc) { year++; } } else { year = (int) (dfc / 366); // conservative (may undershoot) while (FixedEpochDaysToYear(year) > dfc) { year--; } while (FixedEpochDaysToYear(year + 1) <= dfc) { year++; } } var dayOfYear = (int) (dfc - FixedEpochDaysToYear(year)); // 0-based month = dayOfYear / 30 + 1; if (month > 13) month = 13; day = dayOfYear - (month - 1) * 30 + 1; } /// /// Converts an ISO date to a fixed-epoch calendar date (Coptic/Ethiopic/EthioAA). /// private static CalendarDate FixedEpochToCalendarDate(long calendarEpochDays, in IsoDate isoDate) { var epochDays = TemporalHelpers.IsoDateToDays(isoDate.Year, isoDate.Month, isoDate.Day); EpochDaysToFixedEpoch(calendarEpochDays, epochDays, out var year, out var month, out var day); var monthCode = $"M{month:D2}"; var isLeapYear = IsFixedEpochLeapYear(year); var daysInMonth = FixedEpochDaysInMonth(year, month); var daysInYear = isLeapYear ? 366 : 365; return new CalendarDate(year, month, monthCode, day, false, 13, daysInMonth, daysInYear, isLeapYear); } /// /// Converts a fixed-epoch calendar date to an ISO date. /// private static IsoDate? FixedEpochDateToIso(long calendarEpochDays, int maxMonth, int year, string? monthCode, int month, int day, string overflow) { var ordinalMonth = month; if (monthCode is not null) { var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); if (isLeap) { // These calendars have no leap months if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } ordinalMonth = displayMonth; } else { // Validate display month range (1-13 for coptic/ethiopic/ethioaa) if (displayMonth < 1 || displayMonth > maxMonth) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } ordinalMonth = Clamp(displayMonth, 1, maxMonth); } else { ordinalMonth = displayMonth; } } if (month > 0 && month != ordinalMonth) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } } } // Constrain month if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { ordinalMonth = Clamp(ordinalMonth, 1, maxMonth); } else if (ordinalMonth < 1 || ordinalMonth > maxMonth) { return null; } // Constrain day var maxDay = FixedEpochDaysInMonth(year, ordinalMonth); if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { day = Clamp(day, 1, maxDay); } else if (day < 1 || day > maxDay) { return null; } var epochDays = FixedEpochToEpochDays(calendarEpochDays, year, ordinalMonth, day); return TemporalHelpers.DaysToIsoDate(epochDays); } /// /// CalendarDateAdd for fixed-epoch calendars (Coptic/Ethiopic/EthioAA). /// private static IsoDate FixedEpochCalendarDateAdd(string calendar, in IsoDate isoDate, int years, int months, string overflow) { var epochDays = GetCalendarEpochDays(calendar); var isoEpochDays = TemporalHelpers.IsoDateToDays(isoDate.Year, isoDate.Month, isoDate.Day); EpochDaysToFixedEpoch(epochDays, isoEpochDays, out var calYear, out var calMonth, out var calDay); var newYear = calYear + years; var newMonth = calMonth; // Add months (ordinal stepping through 13-month years) if (months != 0) { newMonth += months; while (newMonth > 13) { newMonth -= 13; newYear++; } while (newMonth < 1) { newYear--; newMonth += 13; } } // Constrain day var maxDay = FixedEpochDaysInMonth(newYear, newMonth); var newDay = calDay; if (newDay > maxDay) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { throw new InvalidOperationException("reject"); } newDay = maxDay; } var resultEpochDays = FixedEpochToEpochDays(epochDays, newYear, newMonth, newDay); return TemporalHelpers.DaysToIsoDate(resultEpochDays); } /// /// Finds a calendar reference year for a fixed-epoch calendar such that /// (calendarYear, monthCode, day) maps to an ISO date in isoReferenceYear. /// private static int FindFixedEpochReferenceYear(string calendar, int isoReferenceYear, string monthCode, int day) { var epochDays = GetCalendarEpochDays(calendar); var monthNum = MonthCodeToMonthNumber(monthCode); // For M13 and days that require a leap year, search for a leap calendar year // that maps to an ISO year near the reference year. // Strategy: try ISO reference years starting from the target and going back, // preferring years where the month has the maximum number of days. var bestYear = 0; var bestMaxDay = 0; var bestIsoYear = 0; for (var isoOffset = 0; isoOffset <= 4; isoOffset++) { var targetIsoYear = isoReferenceYear - isoOffset; var midYearDays = TemporalHelpers.IsoDateToDays(targetIsoYear, 7, 1); EpochDaysToFixedEpoch(epochDays, midYearDays, out var approxYear, out _, out _); for (var y = approxYear - 2; y <= approxYear + 2; y++) { var maxDay = FixedEpochDaysInMonth(y, monthNum); var clampedDay = System.Math.Min(day, maxDay); var isoDate = FixedEpochDateToIso(epochDays, 13, y, monthCode, 0, clampedDay, "constrain"); if (isoDate is null) { continue; } // Prefer years where the month has more days (to maximize valid range) if (maxDay > bestMaxDay || (maxDay == bestMaxDay && System.Math.Abs(isoDate.Value.Year - isoReferenceYear) < System.Math.Abs(bestIsoYear - isoReferenceYear))) { bestYear = y; bestMaxDay = maxDay; bestIsoYear = isoDate.Value.Year; } // If we found a perfect match (day fits and ISO year matches), use it if (day <= maxDay && isoDate.Value.Year == targetIsoYear) { return y; } } } if (bestYear != 0) { return bestYear; } // Fallback: approximate var fallbackDays = TemporalHelpers.IsoDateToDays(isoReferenceYear, 7, 1); EpochDaysToFixedEpoch(epochDays, fallbackDays, out var fallbackYear, out _, out _); return fallbackYear; } private static long GetCalendarEpochDays(string calendar) { return calendar switch { "coptic" => CopticEpochDays, "ethiopic" => EthiopicEpochDays, "ethioaa" => EthioAAEpochDays, _ => throw new NotSupportedException($"Calendar '{calendar}' does not use fixed epoch") }; } /// /// Extracts the numeric month from a monthCode string (e.g., "M05" -> 5, "M13" -> 13). /// private static int MonthCodeToMonthNumber(string monthCode) { return int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); } #endregion #region Indian (Saka) Calendar /// /// Returns true if an Indian (Saka) calendar year is a leap year. /// An Indian year is a leap year if the corresponding Gregorian year (sakaYear + 78) is a leap year. /// private static bool IsIndianLeapYear(int sakaYear) { return IsoDate.IsLeapYear(sakaYear + 78); } /// /// Returns the number of days in the given month of an Indian calendar year. /// Month 1 (Chaitra): 30 days (31 in leap year). /// Months 2-6: 31 days each. /// Months 7-12: 30 days each. /// private static int IndianDaysInMonth(int sakaYear, int month) { if (month == 1) return IsIndianLeapYear(sakaYear) ? 31 : 30; if (month >= 2 && month <= 6) return 31; return 30; // months 7-12 } /// /// Converts an ISO date to an Indian (Saka) calendar date. /// private static CalendarDate IndianToCalendarDate(in IsoDate isoDate) { var isoYear = isoDate.Year; // Day of year in ISO calendar (1-based) var isoDayOfYear = DayOfYear(isoYear, isoDate.Month, isoDate.Day); int sakaYear; int sakaDayOfYear; // 0-based within Saka year // Chaitra 1 is always ISO day 81: // Non-leap: Jan(31) + Feb(28) + Mar 22 = 81 // Leap: Jan(31) + Feb(29) + Mar 21 = 81 const int chaitraStartDoy = 81; if (isoDayOfYear >= chaitraStartDoy) { // We're in the current Saka year sakaYear = isoYear - 78; sakaDayOfYear = isoDayOfYear - chaitraStartDoy; // 0-based } else { // We're still in the previous Saka year (before Chaitra of current ISO year) sakaYear = isoYear - 79; // Day of year within previous Saka year var prevIsoYear = isoYear - 1; var prevYearDays = IsoDate.IsLeapYear(prevIsoYear) ? 366 : 365; sakaDayOfYear = (prevYearDays - chaitraStartDoy) + isoDayOfYear; } // Convert sakaDayOfYear (0-based) to month/day var isLeapYear = IsIndianLeapYear(sakaYear); var remaining = sakaDayOfYear; var month = 1; for (var m = 1; m <= 12; m++) { var dim = IndianDaysInMonth(sakaYear, m); if (remaining < dim) { month = m; break; } remaining -= dim; month = m + 1; } if (month > 12) month = 12; // safety clamp var day = remaining + 1; var monthCode = $"M{month:D2}"; var daysInMonth = IndianDaysInMonth(sakaYear, month); var daysInYear = isLeapYear ? 366 : 365; return new CalendarDate(sakaYear, month, monthCode, day, false, 12, daysInMonth, daysInYear, isLeapYear); } /// /// Converts an Indian (Saka) calendar date to an ISO date. /// private static IsoDate? IndianDateToIso(int year, string? monthCode, int month, int day, string overflow) { var ordinalMonth = month; if (monthCode is not null) { var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); if (isLeap) { // Indian has no leap months if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } ordinalMonth = displayMonth; } else { ordinalMonth = displayMonth; } if (month > 0 && month != ordinalMonth) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } } } // Constrain month if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { ordinalMonth = Clamp(ordinalMonth, 1, 12); } else if (ordinalMonth < 1 || ordinalMonth > 12) { return null; } // Constrain day var maxDay = IndianDaysInMonth(year, ordinalMonth); if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { day = Clamp(day, 1, maxDay); } else if (day < 1 || day > maxDay) { return null; } // Convert to ISO: compute the day-of-year offset // Chaitra 1 of Saka year Y = March 22 (or 21) of ISO year Y + 78 var isoYear = year + 78; var isLeapYear = IsoDate.IsLeapYear(isoYear); // Compute Saka day of year (0-based) var sakaDoy = 0; for (var m = 1; m < ordinalMonth; m++) { sakaDoy += IndianDaysInMonth(year, m); } sakaDoy += day - 1; // Chaitra 1 = March 22 (non-leap) or March 21 (leap) of isoYear // Day 81 of ISO year (1-based), in both cases var isoDoy = 81 + sakaDoy; // 1-based ISO day of year var totalDaysInIsoYear = isLeapYear ? 366 : 365; int resultIsoYear; int resultIsoDoy; if (isoDoy > totalDaysInIsoYear) { // Wraps to next ISO year resultIsoYear = isoYear + 1; resultIsoDoy = isoDoy - totalDaysInIsoYear; } else { resultIsoYear = isoYear; resultIsoDoy = isoDoy; } // Convert ISO day-of-year to month/day return DayOfYearToIsoDate(resultIsoYear, resultIsoDoy); } /// /// CalendarDateAdd for the Indian (Saka) calendar. /// private static IsoDate IndianCalendarDateAdd(in IsoDate isoDate, int years, int months, string overflow) { var calDate = IndianToCalendarDate(isoDate); var newYear = calDate.Year + years; var newMonth = calDate.Month; // Add months if (months != 0) { newMonth += months; while (newMonth > 12) { newMonth -= 12; newYear++; } while (newMonth < 1) { newYear--; newMonth += 12; } } // Constrain day var maxDay = IndianDaysInMonth(newYear, newMonth); var newDay = calDate.Day; if (newDay > maxDay) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { throw new InvalidOperationException("reject"); } newDay = maxDay; } var result = IndianDateToIso(newYear, null, newMonth, newDay, overflow); return result ?? isoDate; // fallback to input if conversion fails } /// /// Finds a reference year for the Indian calendar. /// private static int FindIndianReferenceYear(int isoReferenceYear, string monthCode, int day) { // Indian year ≈ ISO year - 78 var approxYear = isoReferenceYear - 78; for (var y = approxYear - 1; y <= approxYear + 1; y++) { var ordinal = MonthCodeToMonthNumber(monthCode); var maxDay = IndianDaysInMonth(y, ordinal); var clampedDay = System.Math.Min(day, maxDay); var isoDate = IndianDateToIso(y, null, ordinal, clampedDay, "constrain"); if (isoDate?.Year == isoReferenceYear) { return y; } } return approxYear; } /// /// Returns the day of year (1-based) for an ISO date. /// private static int DayOfYear(int year, int month, int day) { int[] monthDays = IsoDate.IsLeapYear(year) ? new[] { 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 } : new[] { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 }; return monthDays[month - 1] + day; } /// /// Converts an ISO day of year (1-based) to an ISO date. /// private static IsoDate DayOfYearToIsoDate(int year, int dayOfYear) { int[] monthDays = IsoDate.IsLeapYear(year) ? new[] { 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } : new[] { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; var remaining = dayOfYear; for (var m = 0; m < 12; m++) { if (remaining <= monthDays[m]) { return new IsoDate(year, m + 1, remaining); } remaining -= monthDays[m]; } return new IsoDate(year, 12, 31); // safety fallback } #endregion #region Islamic Umm al-Qura Calendar /// /// Converts an ISO date to an Islamic Umm al-Qura calendar date. /// Uses .NET's UmAlQuraCalendar when within range, falls back to islamic-civil algorithm otherwise. /// private static CalendarDate IslamicUmalquraToCalendarDate(in IsoDate isoDate) { var cal = UmAlQuraCal; // ISO year may fall outside System.DateTime's 1–9999 range; route directly to the // tabular fallback for those years rather than letting `new DateTime` throw and the // outer catch return ISO-like fields. if (isoDate.Year < 1 || isoDate.Year > 9999) { return IslamicCivilToCalendarDate(isoDate); } var dt = new DateTime(isoDate.Year, isoDate.Month, isoDate.Day); if (dt >= cal.MinSupportedDateTime && dt <= cal.MaxSupportedDateTime) { var year = cal.GetYear(dt); var ordinalMonth = cal.GetMonth(dt); var day = cal.GetDayOfMonth(dt); var isLeapYear = cal.IsLeapYear(year); var monthCode = $"M{ordinalMonth:D2}"; var daysInMonth = cal.GetDaysInMonth(year, ordinalMonth); var daysInYear = cal.GetDaysInYear(year); return new CalendarDate(year, ordinalMonth, monthCode, day, false, 12, daysInMonth, daysInYear, isLeapYear); } // Fall back to islamic-civil algorithm for dates outside UmAlQura range return IslamicCivilToCalendarDate(isoDate); } /// /// Islamic civil calendar conversion (fallback for out-of-range UmAlQura dates). /// private static CalendarDate IslamicCivilToCalendarDate(in IsoDate isoDate, long epochJdn = 1948439L) { // Convert ISO to Julian Day Number var jdn = IsoToJulianDay(isoDate.Year, isoDate.Month, isoDate.Day); // Forward formula: JDN = yearDays + monthDays + day + epoch (1-based day), // so JDN(1,1,1) = epoch + 1. To make inverse 0-based, subtract epoch + 1. var daysFromEpoch = jdn - epochJdn - 1; // 30-year cycle with 11 leap years var cycle30 = daysFromEpoch / 10631; var remaining = daysFromEpoch % 10631; if (remaining < 0) { cycle30--; remaining += 10631; } // Year within cycle var yearInCycle = 0; var daysSoFar = 0L; for (var y = 0; y < 30; y++) { var daysInYear = IsIslamicCivilLeapYear(y + 1) ? 355 : 354; if (daysSoFar + daysInYear > remaining) { yearInCycle = y; break; } daysSoFar += daysInYear; } var year = (int) (cycle30 * 30 + yearInCycle + 1); remaining -= daysSoFar; // Month within year - use cumulative month days matching IslamicToJdn var month = 1; for (var m = 1; m <= 12; m++) { var nextMonthStart = IslamicCumulativeMonthDays(m + 1); if (remaining < nextMonthStart) { month = m; break; } if (m == 12) month = 12; } var monthOffset = IslamicCumulativeMonthDays(month); var day = (int) (remaining - monthOffset) + 1; var isLeapYear = IsIslamicCivilLeapYear(year); var monthCode = $"M{month:D2}"; var dim = IslamicCivilDaysInMonth(year, month); var diy = isLeapYear ? 355 : 354; return new CalendarDate(year, month, monthCode, day, false, 12, dim, diy, isLeapYear); } private static bool IsIslamicCivilLeapYear(int year) { // Positive mod: ((year % 30) + 30) % 30 to handle negative years var mod = ((year % 30) + 30) % 30; // Leap years in 30-year cycle: 2, 5, 7, 10, 13, 16, 18, 21, 24, 26, 29 return mod is 2 or 5 or 7 or 10 or 13 or 16 or 18 or 21 or 24 or 26 or 29; } private static int IslamicCivilDaysInMonth(int year, int month) { // Odd months have 30 days, even months have 29 days // except month 12 in leap years has 30 days if (month % 2 == 1) return 30; if (month == 12 && IsIslamicCivilLeapYear(year)) return 30; return 29; } /// /// Converts an ISO date (proleptic Gregorian) to a Julian Day Number. /// Uses Howard Hinnant's epoch-day formula (via TemporalHelpers.IsoDateToDays) plus the /// constant offset 2440588 (= JDN of 1970-01-01) so that the result is correct for any /// proleptic ISO year — the older Fliegel–Van Flandern formula written here previously /// used truncating C# integer division and broke for years < −4800. /// private static long IsoToJulianDay(int year, int month, int day) { return TemporalHelpers.IsoDateToDays(year, month, day) + 2440588L; } /// /// Converts an Islamic Umm al-Qura calendar date to an ISO date. /// Uses .NET's UmAlQuraCalendar when within range, falls back to islamic-civil algorithm otherwise. /// private static IsoDate? IslamicUmalquraDateToIso(int year, string? monthCode, int month, int day, string overflow) { var ordinalMonth = month; if (monthCode is not null) { var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); if (isLeap) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } ordinalMonth = displayMonth; } else { ordinalMonth = displayMonth; } if (month > 0 && month != ordinalMonth) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } } } // Constrain month if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { ordinalMonth = Clamp(ordinalMonth, 1, 12); } else if (ordinalMonth < 1 || ordinalMonth > 12) { return null; } // Try UmAlQura calendar first var cal = UmAlQuraCal; try { // Check if year is in UmAlQura's supported range var minYear = cal.GetYear(cal.MinSupportedDateTime); var maxYear = cal.GetYear(cal.MaxSupportedDateTime); if (year >= minYear && year <= maxYear) { var maxDay = cal.GetDaysInMonth(year, ordinalMonth); if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { day = Clamp(day, 1, maxDay); } else if (day < 1 || day > maxDay) { return null; } var dt = cal.ToDateTime(year, ordinalMonth, day, 0, 0, 0, 0); return new IsoDate(dt.Year, dt.Month, dt.Day); } } catch { // Fall through to islamic-civil } // Fall back to islamic-civil algorithm var maxDayCivil = IslamicCivilDaysInMonth(year, ordinalMonth); if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { day = Clamp(day, 1, maxDayCivil); } else if (day < 1 || day > maxDayCivil) { return null; } return IslamicCivilDateToIso(year, ordinalMonth, day); } /// /// Converts an Islamic civil date to an ISO date using the tabular algorithm. /// private static IsoDate? IslamicCivilDateToIso(int year, int month, int day) { // Islamic civil uses Friday epoch var jdn = IslamicToJdn(year, month, day, 1948439L); return JdnToIso(jdn); } /// /// Cumulative days at the start of each Islamic month (0-indexed). /// M01=0, M02=30, M03=59, M04=89, M05=118, M06=148, M07=177, M08=207, M09=236, M10=266, M11=295, M12=325 /// private static int IslamicCumulativeMonthDays(int month) { // Odd months have 30 days, even months have 29 days // Cumulative: sum of alternating 30, 29, 30, 29, ... var m = month - 1; return m * 30 - m / 2; // = 30*m - floor(m/2) for m months before } private static long IslamicToJdn(int year, int month, int day, long epoch) { var monthDays = (long) IslamicCumulativeMonthDays(month); var yearDays = (long) (year - 1) * 354L + (long) System.Math.Floor((3.0 + 11.0 * year) / 30.0); return monthDays + yearDays + day + epoch; } private static IsoDate? JdnToIso(long jdn) { // The standard Fliegel–Van Flandern formula assumes positive intermediate values, so // (a + 32044) and (4a + 3) need to stay non-negative. For very negative JDNs (deep // proleptic Hebrew/Persian/Islamic dates), shift forward by an integer number of // 400-year Gregorian cycles, run the formula, then subtract those cycles' years. const long Cycle = 146097L; // days per 400-year Gregorian cycle long shift = 0L; if (jdn + 32044L < 0L) { shift = (-(jdn + 32044L) + Cycle - 1L) / Cycle; } long jdnAdj = jdn + shift * Cycle; var a = jdnAdj + 32044L; var b = (4 * a + 3) / 146097; var c = a - 146097 * b / 4; var d = (4 * c + 3) / 1461; var e = c - 1461 * d / 4; var m = (5 * e + 2) / 153; var isoDay = (int) (e - (153 * m + 2) / 5 + 1); var isoMonth = (int) (m + 3 - 12 * (m / 10)); var isoYear = (int) (100 * b + d - 4800 + m / 10) - (int) (shift * 400L); return new IsoDate(isoYear, isoMonth, isoDay); } #endregion #region Islamic Civil/Tbla Calendar /// /// Converts an Islamic tabular calendar date to an ISO date. /// islamic-civil uses Friday epoch (JDN 1948440), islamic-tbla uses Thursday epoch (JDN 1948439). /// private static IsoDate? IslamicCivilTabularDateToIso(int year, string? monthCode, int month, int day, string overflow, long epoch) { var ordinalMonth = month; if (monthCode is not null) { var isLeap = monthCode.Length == 4 && monthCode[3] == 'L'; var displayMonth = int.Parse(monthCode.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture); if (isLeap) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } ordinalMonth = displayMonth; } else { ordinalMonth = displayMonth; } if (month > 0 && month != ordinalMonth) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { return null; } } } // Constrain month if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { ordinalMonth = Clamp(ordinalMonth, 1, 12); } else if (ordinalMonth < 1 || ordinalMonth > 12) { return null; } // Constrain day var maxDay = IslamicCivilDaysInMonth(year, ordinalMonth); if (string.Equals(overflow, "constrain", StringComparison.Ordinal)) { day = Clamp(day, 1, maxDay); } else if (day < 1 || day > maxDay) { return null; } var jdn = IslamicToJdn(year, ordinalMonth, day, epoch); return JdnToIso(jdn); } /// /// CalendarDateAdd for Islamic calendars (islamic-civil/islamic-tbla/islamic-umalqura). /// private static IsoDate IslamicTabularCalendarDateAdd(string calendar, in IsoDate isoDate, int years, int months, string overflow) { var calDate = IsoToCalendarDate(calendar, isoDate); var newYear = calDate.Year + years; var newMonth = calDate.Month; // Add months if (months != 0) { newMonth += months; while (newMonth > 12) { newMonth -= 12; newYear++; } while (newMonth < 1) { newYear--; newMonth += 12; } } // Constrain day - use calendar-specific days in month int maxDay; if (calendar is "islamic-umalqura") { var cal = UmAlQuraCal; try { var minYear = cal.GetYear(cal.MinSupportedDateTime); var maxYear = cal.GetYear(cal.MaxSupportedDateTime); if (newYear >= minYear && newYear <= maxYear) { maxDay = cal.GetDaysInMonth(newYear, newMonth); } else { maxDay = IslamicCivilDaysInMonth(newYear, newMonth); } } catch { maxDay = IslamicCivilDaysInMonth(newYear, newMonth); } } else { maxDay = IslamicCivilDaysInMonth(newYear, newMonth); } var newDay = calDate.Day; if (newDay > maxDay) { if (string.Equals(overflow, "reject", StringComparison.Ordinal)) { throw new InvalidOperationException("reject"); } newDay = maxDay; } // Convert back to ISO using the appropriate calendar var result = CalendarDateToIso(calendar, newYear, null, newMonth, newDay, overflow); return result ?? isoDate; } /// /// Finds a reference year for Islamic tabular calendars. /// private static int FindIslamicTabularReferenceYear(string calendar, int isoReferenceYear, string monthCode, int day) { long epoch = calendar is "islamic-tbla" ? 1948438L : 1948439L; var monthNum = MonthCodeToMonthNumber(monthCode); // Approximate Islamic year from ISO year: Islamic year ≈ (ISO - 622) * 33/32 var approxYear = (int) ((isoReferenceYear - 622.0) * 33.0 / 32.0); // Pass 1: find the LATEST calendar year where the day is valid (un-constrained) AND // ISO date ≤ end-of-refYear. Required for cases like Islamic M12 D30 where M12 only has // 30 days in leap years (refYear=1971 expected). Compares full ISO date so that ties on // year are broken by month/day. var bestYear = int.MinValue; var bestKey = long.MinValue; var upperBound = (long) isoReferenceYear * 10000 + 1231; for (var y = approxYear - 5; y <= approxYear + 5; y++) { var maxDay = IslamicCivilDaysInMonth(y, monthNum); if (day > maxDay) { continue; } var jdn = IslamicToJdn(y, monthNum, day, epoch); var isoDate = JdnToIso(jdn); if (!isoDate.HasValue) continue; var key = (long) isoDate.Value.Year * 10000 + isoDate.Value.Month * 100 + isoDate.Value.Day; if (key <= upperBound && key > bestKey) { bestKey = key; bestYear = y; } } if (bestYear != int.MinValue) { return bestYear; } // Fallback: pick the year with the most days in this monthCode (so day constrains as // little as possible), tiebreak by latest ISO date ≤ refYear. var fallbackYear = int.MinValue; var fallbackMaxDay = 0; var fallbackKey = long.MinValue; for (var y = approxYear - 5; y <= approxYear + 5; y++) { var maxDay = IslamicCivilDaysInMonth(y, monthNum); var clampedDay = System.Math.Min(day, maxDay); var jdn = IslamicToJdn(y, monthNum, clampedDay, epoch); var isoDate = JdnToIso(jdn); if (!isoDate.HasValue) continue; var key = (long) isoDate.Value.Year * 10000 + isoDate.Value.Month * 100 + isoDate.Value.Day; if (key > upperBound) continue; if (maxDay > fallbackMaxDay || (maxDay == fallbackMaxDay && key > fallbackKey)) { fallbackMaxDay = maxDay; fallbackKey = key; fallbackYear = y; } } return fallbackYear != int.MinValue ? fallbackYear : approxYear; } #endregion }