67.9 KiB · text History 2c2327e
//! Built-in holiday calendars: countries and their states or provinces, plus
//! religious and cultural calendars. Every date is computed from a rule, so
//! nothing is downloaded and any year works.
//!
//! To add a calendar, add a `CalendarDef` to `CALENDARS`. A region names its
//! country in `parent` and lists only the days it adds to the country's.

use crate::astro;
use chrono::{Datelike, Duration, Local, Months, NaiveDate, Weekday};
use icu_calendar::cal::{ChineseTraditional, Hebrew, Hijri, Persian};
use icu_calendar::types::LeapStatus;
use icu_calendar::Date;
use std::collections::HashSet;
use Weekday::{Fri, Mon, Sun, Thu, Tue, Wed};

#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
    Country,
    Region,
    Religious,
    /// Moon phases and fiscal years
    Other,
    /// External iCal calendars
    Subscribed,
}

/// Hindu lunar months, amanta (each month runs new moon to new moon)
#[allow(dead_code)]
mod masa {
    pub const CHAITRA: u8 = 0;
    pub const VAISHAKHA: u8 = 1;
    pub const JYESHTHA: u8 = 2;
    pub const ASHADHA: u8 = 3;
    pub const SHRAVANA: u8 = 4;
    pub const BHADRAPADA: u8 = 5;
    pub const ASHVIN: u8 = 6;
    pub const KARTIKA: u8 = 7;
    pub const MARGASHIRSHA: u8 = 8;
    pub const PAUSHA: u8 = 9;
    pub const MAGHA: u8 = 10;
    pub const PHALGUNA: u8 = 11;
}

/// Tithis (lunar days) counted from 0: 0-14 the waxing half, 15-29 the waning half
#[allow(dead_code)]
mod tithi {
    pub const fn shukla(n: u8) -> u8 {
        n - 1
    }
    pub const fn krishna(n: u8) -> u8 {
        n + 14
    }
    pub const PURNIMA: u8 = 14;
    pub const AMAVASYA: u8 = 29;
}

/// When in the day (IST) a Hindu festival's tithi has to be in effect
#[derive(Clone, Copy, Debug)]
pub enum At {
    Sunrise,
    Midday,
    Afternoon,
    Evening,
    Midnight,
}

impl At {
    fn hours(self) -> f64 {
        match self {
            At::Sunrise => 6.0,
            At::Midday => 12.0,
            At::Afternoon => 14.0,
            At::Evening => 19.0,
            At::Midnight => 23.5,
        }
    }
}

/// Calendars other than the Gregorian one that holidays are fixed in
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Lunar {
    Hebrew,
    /// Umm al-Qura, the Saudi calendar
    Hijri,
    Chinese,
    Persian,
}

#[derive(Clone, Copy, Debug)]
enum Rule {
    Fixed(u32, u32),
    /// The nth weekday of a month, moved by some days (the day after Thanksgiving is +1)
    Nth(u32, Weekday, u8, i64),
    Last(u32, Weekday),
    /// The last weekday before a date: Victoria Day is the Monday before May 25
    Before(u32, u32, Weekday),
    /// The first weekday on or after a date
    OnOrAfter(u32, u32, Weekday),
    /// Days from Western Easter Sunday
    Easter(i64),
    /// Days from Orthodox Easter Sunday
    OrthodoxEaster(i64),
    /// A month and day of the Julian calendar
    Julian(u32, u32),
    /// A month number and day in another calendar, moved by some days.
    /// Hebrew months count from Tishrei, so Adar is 6 (Adar II in leap years).
    Lunar(Lunar, u8, u8, i64),
    /// Ireland: the first Monday in February, or Feb 1 when that's a Friday
    StBrigid,
    /// A Hindu lunar month and tithi, moved by some days (Holi is the day after Holika Dahan)
    Hindu(u8, u8, At, i64),
    /// Holika Dahan: the day (IST) Bhadra ends, halfway through Phalguna's full-moon tithi
    HolikaDahan(i64),
    /// The day (IST) the Sun enters a sidereal longitude: 270 is Makara
    Sankranti(u16),
    /// Thailand's Buddhist days: full moons counted from Asalha Bucha, moved by some days
    ThaiMoon(i32, i64),
}

/// How a holiday that falls on a weekend is given a weekday off
#[derive(Clone, Copy, Debug, PartialEq)]
enum Shift {
    None,
    /// Saturday moves to Friday, Sunday to Monday (US federal)
    NearestWeekday,
    /// The next weekday that isn't already a holiday (UK, Ireland, Canada, Australia)
    NextFreeWeekday,
}

#[derive(Debug)]
pub struct Day {
    name: &'static str,
    rule: Rule,
    since: Option<i32>,
    /// Gets an "(observed)" weekday when it falls on a weekend
    observed: bool,
}

impl Day {
    const fn new(name: &'static str, rule: Rule) -> Self {
        Day {
            name,
            rule,
            since: None,
            observed: false,
        }
    }
    const fn obs(mut self) -> Self {
        self.observed = true;
        self
    }
    const fn since(mut self, year: i32) -> Self {
        self.since = Some(year);
        self
    }
}

const fn fixed(name: &'static str, month: u32, day: u32) -> Day {
    Day::new(name, Rule::Fixed(month, day))
}
const fn nth(name: &'static str, month: u32, weekday: Weekday, n: u8) -> Day {
    Day::new(name, Rule::Nth(month, weekday, n, 0))
}
const fn nth_plus(name: &'static str, month: u32, weekday: Weekday, n: u8, days: i64) -> Day {
    Day::new(name, Rule::Nth(month, weekday, n, days))
}
const fn last(name: &'static str, month: u32, weekday: Weekday) -> Day {
    Day::new(name, Rule::Last(month, weekday))
}
const fn before(name: &'static str, month: u32, day: u32, weekday: Weekday) -> Day {
    Day::new(name, Rule::Before(month, day, weekday))
}
const fn on_or_after(name: &'static str, month: u32, day: u32, weekday: Weekday) -> Day {
    Day::new(name, Rule::OnOrAfter(month, day, weekday))
}
const fn easter(name: &'static str, days: i64) -> Day {
    Day::new(name, Rule::Easter(days))
}
const fn orthodox(name: &'static str, days: i64) -> Day {
    Day::new(name, Rule::OrthodoxEaster(days))
}
const fn julian(name: &'static str, month: u32, day: u32) -> Day {
    Day::new(name, Rule::Julian(month, day))
}
const fn lunar(name: &'static str, cal: Lunar, month: u8, day: u8) -> Day {
    Day::new(name, Rule::Lunar(cal, month, day, 0))
}
const fn hindu(name: &'static str, month: u8, tithi: u8, at: At) -> Day {
    Day::new(name, Rule::Hindu(month, tithi, at, 0))
}
const fn hindu_plus(name: &'static str, month: u8, tithi: u8, at: At, days: i64) -> Day {
    Day::new(name, Rule::Hindu(month, tithi, at, days))
}
const fn thai_moon(name: &'static str, from_asalha: i32, days: i64) -> Day {
    Day::new(name, Rule::ThaiMoon(from_asalha, days))
}
const fn lunar_plus(name: &'static str, cal: Lunar, month: u8, day: u8, days: i64) -> Day {
    Day::new(name, Rule::Lunar(cal, month, day, days))
}

/// Works out a year's days for calendars whose names vary, like moon phases with their times
type Generator = fn(i32) -> Vec<(NaiveDate, String)>;

#[derive(Debug)]
pub struct CalendarDef {
    pub key: &'static str,
    pub name: &'static str,
    /// The country a state or province belongs to
    pub parent: Option<&'static str>,
    pub kind: Kind,
    pub note: Option<&'static str>,
    shift: Shift,
    days: &'static [Day],
    /// Calendars whose day names are worked out too, like moon phases with their times
    generated: Option<Generator>,
}

impl CalendarDef {
    const fn country(
        key: &'static str,
        name: &'static str,
        shift: Shift,
        days: &'static [Day],
    ) -> Self {
        CalendarDef {
            key,
            name,
            parent: None,
            kind: Kind::Country,
            note: None,
            shift,
            days,
            generated: None,
        }
    }
    const fn region(
        key: &'static str,
        name: &'static str,
        parent: &'static str,
        shift: Shift,
        days: &'static [Day],
    ) -> Self {
        CalendarDef {
            key,
            name,
            parent: Some(parent),
            kind: Kind::Region,
            note: None,
            shift,
            days,
            generated: None,
        }
    }
    const fn religious(key: &'static str, name: &'static str, days: &'static [Day]) -> Self {
        CalendarDef {
            key,
            name,
            parent: None,
            kind: Kind::Religious,
            note: None,
            shift: Shift::None,
            days,
            generated: None,
        }
    }
    const fn other(key: &'static str, name: &'static str, generated: Generator) -> Self {
        CalendarDef {
            key,
            name,
            parent: None,
            kind: Kind::Other,
            note: None,
            shift: Shift::None,
            days: &[],
            generated: Some(generated),
        }
    }
    const fn note(mut self, note: &'static str) -> Self {
        self.note = Some(note);
        self
    }
}

#[derive(Clone, Debug)]
pub struct Holiday {
    pub date: NaiveDate,
    pub name: String,
    pub calendar: &'static CalendarDef,
    /// What an iCal event says about itself, shown by `i` in place of a blurb
    pub details: Option<Details>,
}

#[derive(Clone, Debug)]
pub struct Details {
    pub text: String,
    pub url: Option<String>,
}

use Shift::{NearestWeekday as US_SHIFT, NextFreeWeekday as NEXT_FREE};

#[rustfmt::skip]
pub static CALENDARS: &[CalendarDef] = &[
    // United States
    CalendarDef::country("us", "United States", US_SHIFT, &[
        fixed("New Year's Day", 1, 1).obs(),
        nth("Martin Luther King Jr. Day", 1, Mon, 3).since(1986),
        nth("Presidents' Day", 2, Mon, 3),
        last("Memorial Day", 5, Mon),
        fixed("Juneteenth", 6, 19).obs().since(2021),
        fixed("Independence Day", 7, 4).obs(),
        nth("Labor Day", 9, Mon, 1),
        nth("Columbus Day / Indigenous Peoples' Day", 10, Mon, 2),
        fixed("Veterans Day", 11, 11).obs(),
        nth("Thanksgiving", 11, Thu, 4),
        fixed("Christmas Day", 12, 25).obs(),
    ]),
    CalendarDef::region("us-observances", "US observances", "us", Shift::None, &[
        fixed("Groundhog Day", 2, 2),
        fixed("Valentine's Day", 2, 14),
        fixed("St. Patrick's Day", 3, 17),
        fixed("Earth Day", 4, 22),
        nth("Mother's Day", 5, Sun, 2),
        fixed("Flag Day", 6, 14),
        nth("Father's Day", 6, Sun, 3),
        fixed("Halloween", 10, 31),
        nth_plus("Election Day", 11, Mon, 1, 1),
        fixed("New Year's Eve", 12, 31),
    ]).note("Not days off"),
    CalendarDef::region("us-ak", "Alaska", "us", US_SHIFT, &[
        last("Seward's Day", 3, Mon),
        fixed("Alaska Day", 10, 18).obs(),
    ]),
    CalendarDef::region("us-ca", "California", "us", US_SHIFT, &[
        fixed("César Chávez Day", 3, 31).obs(),
        nth("Native American Day", 9, Fri, 4),
        nth_plus("Day after Thanksgiving", 11, Thu, 4, 1),
    ]),
    CalendarDef::region("us-ct", "Connecticut", "us", US_SHIFT, &[
        fixed("Lincoln's Birthday", 2, 12).obs(),
        easter("Good Friday", -2),
    ]),
    CalendarDef::region("us-dc", "District of Columbia", "us", US_SHIFT, &[
        fixed("DC Emancipation Day", 4, 16).obs().since(2005),
    ]),
    CalendarDef::region("us-hi", "Hawaii", "us", US_SHIFT, &[
        fixed("Prince Kūhiō Day", 3, 26).obs(),
        fixed("King Kamehameha Day", 6, 11).obs(),
        nth("Statehood Day", 8, Fri, 3),
    ]),
    CalendarDef::region("us-il", "Illinois", "us", US_SHIFT, &[
        fixed("Lincoln's Birthday", 2, 12).obs(),
        nth("Casimir Pulaski Day", 3, Mon, 1),
    ]),
    CalendarDef::region("us-la", "Louisiana", "us", US_SHIFT, &[
        easter("Mardi Gras", -47),
        easter("Good Friday", -2),
    ]),
    CalendarDef::region("us-ma", "Massachusetts", "us", US_SHIFT, &[
        nth("Patriots' Day", 4, Mon, 3),
    ]),
    CalendarDef::region("us-md", "Maryland", "us", US_SHIFT, &[
        nth_plus("American Indian Heritage Day", 11, Thu, 4, 1),
    ]),
    CalendarDef::region("us-me", "Maine", "us", US_SHIFT, &[
        nth("Patriots' Day", 4, Mon, 3),
    ]),
    CalendarDef::region("us-nj", "New Jersey", "us", US_SHIFT, &[
        fixed("Lincoln's Birthday", 2, 12).obs(),
        easter("Good Friday", -2),
        nth_plus("Election Day", 11, Mon, 1, 1),
    ]),
    CalendarDef::region("us-nv", "Nevada", "us", US_SHIFT, &[
        last("Nevada Day", 10, Fri),
        nth_plus("Family Day", 11, Thu, 4, 1),
    ]),
    CalendarDef::region("us-ny", "New York", "us", US_SHIFT, &[
        fixed("Lincoln's Birthday", 2, 12).obs(),
        nth_plus("Election Day", 11, Mon, 1, 1),
    ]),
    CalendarDef::region("us-pa", "Pennsylvania", "us", US_SHIFT, &[
        fixed("Flag Day", 6, 14),
        nth_plus("Day after Thanksgiving", 11, Thu, 4, 1),
    ]),
    CalendarDef::region("us-ri", "Rhode Island", "us", US_SHIFT, &[
        nth("Victory Day", 8, Mon, 2),
    ]),
    CalendarDef::region("us-tx", "Texas", "us", US_SHIFT, &[
        fixed("Texas Independence Day", 3, 2),
        fixed("San Jacinto Day", 4, 21),
        fixed("Lyndon Baines Johnson Day", 8, 27),
        nth_plus("Day after Thanksgiving", 11, Thu, 4, 1),
    ]),
    CalendarDef::region("us-ut", "Utah", "us", US_SHIFT, &[
        fixed("Pioneer Day", 7, 24).obs(),
    ]),
    CalendarDef::region("us-vt", "Vermont", "us", US_SHIFT, &[
        nth("Town Meeting Day", 3, Tue, 1),
        fixed("Bennington Battle Day", 8, 16).obs(),
    ]),
    CalendarDef::region("us-wv", "West Virginia", "us", US_SHIFT, &[
        fixed("West Virginia Day", 6, 20).obs(),
        nth_plus("Day after Thanksgiving", 11, Thu, 4, 1),
    ]),

    // Canada
    CalendarDef::country("ca", "Canada", NEXT_FREE, &[
        fixed("New Year's Day", 1, 1).obs(),
        easter("Good Friday", -2),
        easter("Easter Monday", 1),
        before("Victoria Day", 5, 25, Mon),
        fixed("Canada Day", 7, 1).obs(),
        nth("Labour Day", 9, Mon, 1),
        fixed("National Day for Truth and Reconciliation", 9, 30).obs().since(2021),
        nth("Thanksgiving", 10, Mon, 2),
        fixed("Remembrance Day", 11, 11).obs(),
        fixed("Christmas Day", 12, 25).obs(),
        fixed("Boxing Day", 12, 26).obs(),
    ]),
    CalendarDef::region("ca-ab", "Alberta", "ca", NEXT_FREE, &[
        nth("Family Day", 2, Mon, 3),
        nth("Heritage Day", 8, Mon, 1),
    ]),
    CalendarDef::region("ca-bc", "British Columbia", "ca", NEXT_FREE, &[
        nth("Family Day", 2, Mon, 3),
        nth("British Columbia Day", 8, Mon, 1),
    ]),
    CalendarDef::region("ca-mb", "Manitoba", "ca", NEXT_FREE, &[
        nth("Louis Riel Day", 2, Mon, 3),
        nth("Terry Fox Day", 8, Mon, 1),
    ]),
    CalendarDef::region("ca-nb", "New Brunswick", "ca", NEXT_FREE, &[
        nth("Family Day", 2, Mon, 3),
        nth("New Brunswick Day", 8, Mon, 1),
    ]),
    CalendarDef::region("ca-ns", "Nova Scotia", "ca", NEXT_FREE, &[
        nth("Heritage Day", 2, Mon, 3),
        nth("Natal Day", 8, Mon, 1),
    ]),
    CalendarDef::region("ca-on", "Ontario", "ca", NEXT_FREE, &[
        nth("Family Day", 2, Mon, 3),
        nth("Civic Holiday", 8, Mon, 1),
    ]),
    CalendarDef::region("ca-pe", "Prince Edward Island", "ca", NEXT_FREE, &[
        nth("Islander Day", 2, Mon, 3),
    ]),
    CalendarDef::region("ca-qc", "Quebec", "ca", NEXT_FREE, &[
        before("National Patriots' Day", 5, 25, Mon),
        fixed("Fête nationale du Québec", 6, 24).obs(),
    ]),
    CalendarDef::region("ca-sk", "Saskatchewan", "ca", NEXT_FREE, &[
        nth("Family Day", 2, Mon, 3),
        nth("Saskatchewan Day", 8, Mon, 1),
    ]),

    // United Kingdom
    CalendarDef::country("gb", "United Kingdom", NEXT_FREE, &[
        fixed("New Year's Day", 1, 1).obs(),
        easter("Good Friday", -2),
        nth("Early May bank holiday", 5, Mon, 1),
        last("Spring bank holiday", 5, Mon),
        fixed("Christmas Day", 12, 25).obs(),
        fixed("Boxing Day", 12, 26).obs(),
    ]),
    CalendarDef::region("gb-eng", "England", "gb", NEXT_FREE, &[
        easter("Easter Monday", 1),
        last("Summer bank holiday", 8, Mon),
    ]),
    CalendarDef::region("gb-nir", "Northern Ireland", "gb", NEXT_FREE, &[
        fixed("St Patrick's Day", 3, 17).obs(),
        easter("Easter Monday", 1),
        fixed("Battle of the Boyne", 7, 12).obs(),
        last("Summer bank holiday", 8, Mon),
    ]),
    CalendarDef::region("gb-sct", "Scotland", "gb", NEXT_FREE, &[
        fixed("2nd January", 1, 2).obs(),
        nth("Summer bank holiday", 8, Mon, 1),
        fixed("St Andrew's Day", 11, 30).obs(),
    ]),
    CalendarDef::region("gb-wls", "Wales", "gb", NEXT_FREE, &[
        easter("Easter Monday", 1),
        last("Summer bank holiday", 8, Mon),
    ]),

    // Ireland
    CalendarDef::country("ie", "Ireland", NEXT_FREE, &[
        fixed("New Year's Day", 1, 1).obs(),
        Day::new("St Brigid's Day", Rule::StBrigid).since(2023),
        fixed("St Patrick's Day", 3, 17).obs(),
        easter("Easter Monday", 1),
        nth("May bank holiday", 5, Mon, 1),
        nth("June bank holiday", 6, Mon, 1),
        nth("August bank holiday", 8, Mon, 1),
        last("October bank holiday", 10, Mon),
        fixed("Christmas Day", 12, 25).obs(),
        fixed("St Stephen's Day", 12, 26).obs(),
    ]),

    // Germany
    CalendarDef::country("de", "Germany", Shift::None, &[
        fixed("New Year's Day", 1, 1),
        easter("Good Friday", -2),
        easter("Easter Monday", 1),
        fixed("Labour Day", 5, 1),
        easter("Ascension Day", 39),
        easter("Whit Monday", 50),
        fixed("German Unity Day", 10, 3),
        fixed("Christmas Day", 12, 25),
        fixed("Second Day of Christmas", 12, 26),
    ]),
    CalendarDef::region("de-bw", "Baden-Württemberg", "de", Shift::None, &[
        fixed("Epiphany", 1, 6),
        easter("Corpus Christi", 60),
        fixed("All Saints' Day", 11, 1),
    ]),
    CalendarDef::region("de-by", "Bavaria", "de", Shift::None, &[
        fixed("Epiphany", 1, 6),
        easter("Corpus Christi", 60),
        fixed("Assumption Day", 8, 15),
        fixed("All Saints' Day", 11, 1),
    ]).note("Assumption Day is only a holiday in mostly Catholic municipalities"),
    CalendarDef::region("de-be", "Berlin", "de", Shift::None, &[
        fixed("International Women's Day", 3, 8).since(2019),
    ]),
    CalendarDef::region("de-bb", "Brandenburg", "de", Shift::None, &[
        easter("Easter Sunday", 0),
        easter("Whit Sunday", 49),
        fixed("Reformation Day", 10, 31),
    ]),
    CalendarDef::region("de-hb", "Bremen", "de", Shift::None, &[
        fixed("Reformation Day", 10, 31).since(2018),
    ]),
    CalendarDef::region("de-hh", "Hamburg", "de", Shift::None, &[
        fixed("Reformation Day", 10, 31).since(2018),
    ]),
    CalendarDef::region("de-he", "Hesse", "de", Shift::None, &[
        easter("Corpus Christi", 60),
    ]),
    CalendarDef::region("de-mv", "Mecklenburg-Vorpommern", "de", Shift::None, &[
        fixed("International Women's Day", 3, 8).since(2023),
        fixed("Reformation Day", 10, 31),
    ]),
    CalendarDef::region("de-ni", "Lower Saxony", "de", Shift::None, &[
        fixed("Reformation Day", 10, 31).since(2018),
    ]),
    CalendarDef::region("de-nw", "North Rhine-Westphalia", "de", Shift::None, &[
        easter("Corpus Christi", 60),
        fixed("All Saints' Day", 11, 1),
    ]),
    CalendarDef::region("de-rp", "Rhineland-Palatinate", "de", Shift::None, &[
        easter("Corpus Christi", 60),
        fixed("All Saints' Day", 11, 1),
    ]),
    CalendarDef::region("de-sl", "Saarland", "de", Shift::None, &[
        easter("Corpus Christi", 60),
        fixed("Assumption Day", 8, 15),
        fixed("All Saints' Day", 11, 1),
    ]),
    CalendarDef::region("de-sn", "Saxony", "de", Shift::None, &[
        fixed("Reformation Day", 10, 31),
        before("Repentance and Prayer Day", 11, 23, Wed),
    ]),
    CalendarDef::region("de-st", "Saxony-Anhalt", "de", Shift::None, &[
        fixed("Epiphany", 1, 6),
        fixed("Reformation Day", 10, 31),
    ]),
    CalendarDef::region("de-sh", "Schleswig-Holstein", "de", Shift::None, &[
        fixed("Reformation Day", 10, 31).since(2018),
    ]),
    CalendarDef::region("de-th", "Thuringia", "de", Shift::None, &[
        fixed("World Children's Day", 9, 20).since(2019),
        fixed("Reformation Day", 10, 31),
    ]),

    // France
    CalendarDef::country("fr", "France", Shift::None, &[
        fixed("New Year's Day", 1, 1),
        easter("Easter Monday", 1),
        fixed("Labour Day", 5, 1),
        fixed("Victory in Europe Day", 5, 8),
        easter("Ascension Day", 39),
        easter("Whit Monday", 50),
        fixed("Bastille Day", 7, 14),
        fixed("Assumption Day", 8, 15),
        fixed("All Saints' Day", 11, 1),
        fixed("Armistice Day", 11, 11),
        fixed("Christmas Day", 12, 25),
    ]),

    // Mexico
    CalendarDef::country("mx", "Mexico", Shift::None, &[
        fixed("New Year's Day", 1, 1),
        nth("Constitution Day", 2, Mon, 1),
        nth("Benito Juárez's Birthday", 3, Mon, 3),
        fixed("Labour Day", 5, 1),
        fixed("Independence Day", 9, 16),
        nth("Revolution Day", 11, Mon, 3),
        fixed("Christmas Day", 12, 25),
    ]),

    // Australia
    CalendarDef::country("au", "Australia", NEXT_FREE, &[
        fixed("New Year's Day", 1, 1).obs(),
        fixed("Australia Day", 1, 26).obs(),
        easter("Good Friday", -2),
        easter("Easter Monday", 1),
        fixed("Anzac Day", 4, 25),
        fixed("Christmas Day", 12, 25).obs(),
        fixed("Boxing Day", 12, 26).obs(),
    ]),
    CalendarDef::region("au-act", "Australian Capital Territory", "au", NEXT_FREE, &[
        nth("Canberra Day", 3, Mon, 2),
        easter("Easter Saturday", -1),
        easter("Easter Sunday", 0),
        on_or_after("Reconciliation Day", 5, 27, Mon).since(2018),
        nth("King's Birthday", 6, Mon, 2),
        nth("Labour Day", 10, Mon, 1),
    ]),
    CalendarDef::region("au-nsw", "New South Wales", "au", NEXT_FREE, &[
        easter("Easter Saturday", -1),
        easter("Easter Sunday", 0),
        nth("King's Birthday", 6, Mon, 2),
        nth("Labour Day", 10, Mon, 1),
    ]),
    CalendarDef::region("au-nt", "Northern Territory", "au", NEXT_FREE, &[
        easter("Easter Saturday", -1),
        nth("May Day", 5, Mon, 1),
        nth("King's Birthday", 6, Mon, 2),
        nth("Picnic Day", 8, Mon, 1),
    ]),
    CalendarDef::region("au-qld", "Queensland", "au", NEXT_FREE, &[
        easter("Easter Saturday", -1),
        easter("Easter Sunday", 0),
        nth("Labour Day", 5, Mon, 1),
        nth("King's Birthday", 10, Mon, 1),
    ]),
    CalendarDef::region("au-sa", "South Australia", "au", NEXT_FREE, &[
        nth("Adelaide Cup Day", 3, Mon, 2),
        easter("Easter Saturday", -1),
        nth("King's Birthday", 6, Mon, 2),
        nth("Labour Day", 10, Mon, 1),
    ]),
    CalendarDef::region("au-tas", "Tasmania", "au", NEXT_FREE, &[
        nth("Eight Hours Day", 3, Mon, 2),
        nth("King's Birthday", 6, Mon, 2),
    ]),
    CalendarDef::region("au-vic", "Victoria", "au", NEXT_FREE, &[
        nth("Labour Day", 3, Mon, 2),
        easter("Easter Saturday", -1),
        easter("Easter Sunday", 0),
        nth("King's Birthday", 6, Mon, 2),
        nth("Melbourne Cup Day", 11, Tue, 1),
    ]),
    CalendarDef::region("au-wa", "Western Australia", "au", NEXT_FREE, &[
        nth("Labour Day", 3, Mon, 1),
        nth("Western Australia Day", 6, Mon, 1),
        last("King's Birthday", 9, Mon),
    ]).note("King's Birthday is proclaimed each year; it's usually the last Monday of September"),

    // India
    CalendarDef::country("in", "India", Shift::None, &[
        fixed("Republic Day", 1, 26),
        fixed("Independence Day", 8, 15),
        fixed("Gandhi Jayanti", 10, 2),
    ]).note("National holidays only; turn on Hindu, Islamic and others for the festivals"),

    // Thailand
    CalendarDef::country("th", "Thailand", NEXT_FREE, &[
        fixed("New Year's Day", 1, 1).obs(),
        thai_moon("Makha Bucha", -5, 0),
        fixed("Chakri Memorial Day", 4, 6).obs(),
        fixed("Songkran", 4, 13).obs(),
        fixed("Songkran", 4, 14).obs(),
        fixed("Songkran", 4, 15).obs(),
        fixed("Labour Day", 5, 1).obs(),
        fixed("Coronation Day", 5, 4).obs().since(2020),
        thai_moon("Visakha Bucha", -2, 0),
        fixed("Queen Suthida's Birthday", 6, 3).obs().since(2019),
        thai_moon("Asalha Bucha", 0, 0),
        thai_moon("Khao Phansa", 0, 1),
        fixed("King Vajiralongkorn's Birthday", 7, 28).obs().since(2017),
        fixed("Mother's Day", 8, 12).obs(),
        fixed("King Bhumibol Memorial Day", 10, 13).obs().since(2017),
        thai_moon("Ok Phansa", 3, 0),
        fixed("Chulalongkorn Day", 10, 23).obs(),
        thai_moon("Loy Krathong", 4, 0),
        fixed("Father's Day", 12, 5).obs(),
        fixed("Constitution Day", 12, 10).obs(),
        fixed("New Year's Eve", 12, 31).obs(),
    ]).note("Buddhist days follow the full moon; Thailand's almanac can put them a day earlier"),

    // Religious and cultural
    CalendarDef::religious("christian", "Christian (Western)", &[
        fixed("Epiphany", 1, 6),
        easter("Shrove Tuesday", -47),
        easter("Ash Wednesday", -46),
        easter("Palm Sunday", -7),
        easter("Maundy Thursday", -3),
        easter("Good Friday", -2),
        easter("Easter Sunday", 0),
        easter("Ascension Day", 39),
        easter("Pentecost", 49),
        fixed("All Saints' Day", 11, 1),
        on_or_after("First Sunday of Advent", 11, 27, Sun),
        fixed("Christmas Eve", 12, 24),
        fixed("Christmas Day", 12, 25),
    ]),
    CalendarDef::religious("orthodox", "Christian (Orthodox)", &[
        julian("Orthodox Christmas", 12, 25),
        julian("Theophany", 1, 6),
        orthodox("Orthodox Palm Sunday", -7),
        orthodox("Holy Friday", -2),
        orthodox("Pascha", 0),
        orthodox("Orthodox Ascension", 39),
        orthodox("Orthodox Pentecost", 49),
    ]),
    CalendarDef::religious("jewish", "Jewish", &[
        lunar("Tu BiShvat", Lunar::Hebrew, 5, 15),
        lunar("Purim", Lunar::Hebrew, 6, 14),
        lunar("Passover", Lunar::Hebrew, 7, 15),
        lunar("Last day of Passover", Lunar::Hebrew, 7, 22),
        lunar("Lag BaOmer", Lunar::Hebrew, 8, 18),
        lunar("Shavuot", Lunar::Hebrew, 9, 6),
        lunar("Tisha B'Av", Lunar::Hebrew, 11, 9),
        lunar("Rosh Hashanah", Lunar::Hebrew, 1, 1),
        lunar("Yom Kippur", Lunar::Hebrew, 1, 10),
        lunar("Sukkot", Lunar::Hebrew, 1, 15),
        lunar("Shemini Atzeret", Lunar::Hebrew, 1, 22),
        lunar("Simchat Torah", Lunar::Hebrew, 1, 23),
        lunar("Hanukkah", Lunar::Hebrew, 3, 25),
    ]).note("Each day begins at sundown the evening before"),
    CalendarDef::religious("islamic", "Islamic", &[
        lunar("Islamic New Year", Lunar::Hijri, 1, 1),
        lunar("Ashura", Lunar::Hijri, 1, 10),
        lunar("Mawlid", Lunar::Hijri, 3, 12),
        lunar("Isra and Mi'raj", Lunar::Hijri, 7, 27),
        lunar("Ramadan begins", Lunar::Hijri, 9, 1),
        lunar("Laylat al-Qadr", Lunar::Hijri, 9, 27),
        lunar("Eid al-Fitr", Lunar::Hijri, 10, 1),
        lunar("Day of Arafah", Lunar::Hijri, 12, 9),
        lunar("Eid al-Adha", Lunar::Hijri, 12, 10),
    ]).note("Umm al-Qura dates; local moon sighting can move them a day. Each day begins at sundown the evening before"),
    CalendarDef::religious("chinese", "Chinese (lunar)", &[
        lunar_plus("Lunar New Year's Eve", Lunar::Chinese, 1, 1, -1),
        lunar("Lunar New Year", Lunar::Chinese, 1, 1),
        lunar("Lantern Festival", Lunar::Chinese, 1, 15),
        lunar("Dragon Boat Festival", Lunar::Chinese, 5, 5),
        lunar("Qixi", Lunar::Chinese, 7, 7),
        lunar("Ghost Festival", Lunar::Chinese, 7, 15),
        lunar("Mid-Autumn Festival", Lunar::Chinese, 8, 15),
        lunar("Double Ninth Festival", Lunar::Chinese, 9, 9),
    ]),
    CalendarDef::religious("persian", "Persian", &[
        lunar("Nowruz", Lunar::Persian, 1, 1),
        lunar("Sizdah Bedar", Lunar::Persian, 1, 13),
        lunar("Yalda Night", Lunar::Persian, 9, 30),
    ]),
    CalendarDef::religious("hindu", "Hindu", &[
        Day::new("Makar Sankranti / Pongal", Rule::Sankranti(270)),
        hindu("Vasant Panchami", masa::MAGHA, tithi::shukla(5), At::Midday),
        hindu("Maha Shivaratri", masa::MAGHA, tithi::krishna(14), At::Midnight),
        Day::new("Holika Dahan", Rule::HolikaDahan(0)),
        Day::new("Holi", Rule::HolikaDahan(1)),
        hindu("Ugadi / Gudi Padwa", masa::CHAITRA, tithi::shukla(1), At::Sunrise),
        hindu("Ram Navami", masa::CHAITRA, tithi::shukla(9), At::Midday),
        hindu("Hanuman Jayanti", masa::CHAITRA, tithi::PURNIMA, At::Sunrise),
        hindu("Akshaya Tritiya", masa::VAISHAKHA, tithi::shukla(3), At::Midday),
        hindu("Guru Purnima", masa::ASHADHA, tithi::PURNIMA, At::Sunrise),
        hindu("Raksha Bandhan", masa::SHRAVANA, tithi::PURNIMA, At::Sunrise),
        hindu("Krishna Janmashtami", masa::SHRAVANA, tithi::krishna(8), At::Midnight),
        hindu("Ganesh Chaturthi", masa::BHADRAPADA, tithi::shukla(4), At::Midday),
        hindu("Navaratri begins", masa::ASHVIN, tithi::shukla(1), At::Sunrise),
        hindu("Dussehra", masa::ASHVIN, tithi::shukla(10), At::Afternoon),
        hindu("Karva Chauth", masa::ASHVIN, tithi::krishna(4), At::Evening),
        hindu("Dhanteras", masa::ASHVIN, tithi::krishna(13), At::Evening),
        hindu("Diwali", masa::ASHVIN, tithi::AMAVASYA, At::Evening),
        hindu("Govardhan Puja", masa::KARTIKA, tithi::shukla(1), At::Sunrise),
        hindu("Bhai Dooj", masa::KARTIKA, tithi::shukla(2), At::Midday),
        hindu("Chhath Puja", masa::KARTIKA, tithi::shukla(6), At::Sunrise),
    ]).note("Worked out for India (IST) from the Moon's position; regional calendars can differ by a day"),
    CalendarDef::religious("buddhist", "Buddhist", &[
        hindu("Magha Puja", masa::MAGHA, tithi::PURNIMA, At::Sunrise),
        fixed("Nirvana Day", 2, 15),
        hindu("Vesak", masa::VAISHAKHA, tithi::PURNIMA, At::Sunrise),
        lunar("Buddha's Birthday (East Asia)", Lunar::Chinese, 4, 8),
        hindu("Asalha Puja", masa::ASHADHA, tithi::PURNIMA, At::Sunrise),
        lunar("Ullambana", Lunar::Chinese, 7, 15),
        hindu("Pavarana", masa::ASHVIN, tithi::PURNIMA, At::Sunrise),
        fixed("Bodhi Day", 12, 8),
    ]).note("Full moon days as kept in South Asia; Thailand's are in the Thailand calendar"),

    // Moon and fiscal
    CalendarDef::other("lunar", "Moon phases", moon_phases)
        .note("Times are in your time zone; full moons carry their traditional names"),
    CalendarDef::other("fiscal", "Fiscal quarters (calendar year)", fiscal_jan),
    CalendarDef::other("fiscal-apr", "Fiscal year from April 1", fiscal_apr)
        .note("India, Canada, Japan, the UK government and others; years are named for the year they end"),
    CalendarDef::other("fiscal-jul", "Fiscal year from July 1", fiscal_jul)
        .note("Australia, many US states and schools; years are named for the year they end"),
    CalendarDef::other("fiscal-oct", "Fiscal year from October 1", fiscal_oct)
        .note("The US federal government; years are named for the year they end"),
    CalendarDef::other("fiscal-retail", "Retail 4-5-4 (NRF)", retail_454)
        .note("Ends on the Saturday nearest January 31; years are named for the year they begin"),
];

/// Calendars share motd reminder defaults by group
#[derive(Debug, PartialEq)]
pub struct Group {
    pub key: &'static str,
    pub name: &'static str,
}

pub static GROUPS: [Group; 4] = [
    Group {
        key: "countries",
        name: "countries",
    },
    Group {
        key: "religious",
        name: "religious and cultural",
    },
    Group {
        key: "other",
        name: "moon and fiscal",
    },
    Group {
        key: "subscribed",
        name: "subscribed (iCal)",
    },
];

pub fn group_of(cal: &CalendarDef) -> &'static Group {
    match cal.kind {
        Kind::Country | Kind::Region => &GROUPS[0],
        Kind::Religious => &GROUPS[1],
        Kind::Other => &GROUPS[2],
        Kind::Subscribed => &GROUPS[3],
    }
}

/// The calendars in a group, each country followed by its states
pub fn in_group(group: &Group) -> impl Iterator<Item = &'static CalendarDef> + '_ {
    CALENDARS
        .iter()
        .chain(subscribed())
        .filter(move |c| group_of(c) == group && c.kind != Kind::Region)
}

pub fn find(key: &str) -> Option<&'static CalendarDef> {
    CALENDARS
        .iter()
        .chain(subscribed())
        .find(|c| c.key.eq_ignore_ascii_case(key))
}

/// Subscribed iCal calendars, made into calendars once their rows are read
static SUBSCRIBED: std::sync::Mutex<Vec<&'static CalendarDef>> = std::sync::Mutex::new(vec![]);

fn subscribed() -> Vec<&'static CalendarDef> {
    SUBSCRIBED.lock().map(|s| s.clone()).unwrap_or_default()
}

/// Make a subscription's calendar, once per key and url. They live for the whole run,
/// like the built-in ones, so holidays can point at them.
pub fn register_subscription(key: &str, url: &str) -> &'static CalendarDef {
    let Ok(mut subs) = SUBSCRIBED.lock() else {
        return &CALENDARS[0];
    };
    if let Some(c) = subs.iter().find(|c| c.key == key && c.note == Some(url)) {
        return c;
    }
    subs.retain(|c| c.key != key);
    let key: &'static str = Box::leak(key.to_string().into_boxed_str());
    let url: &'static str = Box::leak(url.to_string().into_boxed_str());
    let def: &'static CalendarDef = Box::leak(Box::new(CalendarDef {
        key,
        name: key,
        parent: None,
        kind: Kind::Subscribed,
        note: Some(url),
        shift: Shift::None,
        days: &[],
        generated: None,
    }));
    subs.push(def);
    subs.sort_by_key(|c| c.key);
    def
}

/// Forget a subscription that's been removed
pub fn unregister_subscription(key: &str) {
    if let Ok(mut subs) = SUBSCRIBED.lock() {
        subs.retain(|c| c.key != key);
    }
}

/// Whether a name is taken by a built-in calendar or a group
pub fn is_builtin(key: &str) -> bool {
    CALENDARS.iter().any(|c| c.key.eq_ignore_ascii_case(key))
        || GROUPS.iter().any(|g| g.key.eq_ignore_ascii_case(key))
}

/// The states or provinces of a country
pub fn regions(key: &str) -> impl Iterator<Item = &'static CalendarDef> + '_ {
    CALENDARS.iter().filter(move |c| c.parent == Some(key))
}

/// Every holiday in `year` from the calendars named in `keys`, sorted by date
pub fn holidays_in(year: i32, keys: &[String]) -> Vec<Holiday> {
    let mut out = CALENDARS
        .iter()
        .filter(|c| keys.iter().any(|k| k.eq_ignore_ascii_case(c.key)))
        .flat_map(|c| {
            // A region's observed days can't land on its country's holidays
            let taken = c
                .parent
                .and_then(find)
                .map(|p| {
                    occurrences(p, year, &HashSet::new())
                        .into_iter()
                        .map(|h| h.date)
                        .collect()
                })
                .unwrap_or_default();
            occurrences(c, year, &taken)
        })
        .collect::<Vec<_>>();
    for c in subscribed() {
        if keys.iter().any(|k| k.eq_ignore_ascii_case(c.key)) {
            out.extend(crate::ical::holidays_in(c, year));
        }
    }
    out.sort_by(|a, b| a.date.cmp(&b.date).then(a.name.cmp(&b.name)));
    out
}

fn occurrences(cal: &'static CalendarDef, year: i32, taken: &HashSet<NaiveDate>) -> Vec<Holiday> {
    // Neighbouring years too, since an observed day can cross new year
    let mut base: Vec<(NaiveDate, &Day)> = vec![];
    for y in year - 1..=year + 1 {
        for day in cal.days {
            if day.since.is_some_and(|s| y < s) {
                continue;
            }
            if let Some(date) = solar_date(day.rule, y) {
                base.push((date, day));
            }
        }
    }
    base.extend(lunar_dates(cal, year));
    base.extend(astro_dates(cal, year));
    base.sort_by_key(|(d, _)| *d);

    let mut taken = taken.clone();
    taken.extend(base.iter().map(|(d, _)| *d));
    let mut out = vec![];
    for (date, day) in base {
        if day.observed && is_weekend(date) {
            if let Some(o) = observe(cal.shift, date, &taken) {
                taken.insert(o);
                out.push(Holiday {
                    date: o,
                    name: format!("{} (observed)", day.name),
                    calendar: cal,
                    details: None,
                });
            }
        }
        out.push(Holiday {
            date,
            name: day.name.to_string(),
            calendar: cal,
            details: None,
        });
    }
    if let Some(generate) = cal.generated {
        out.extend(generate(year).into_iter().map(|(date, name)| Holiday {
            date,
            name,
            calendar: cal,
            details: None,
        }));
    }
    out.retain(|h| h.date.year() == year);
    out
}

fn is_weekend(date: NaiveDate) -> bool {
    matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
}

fn observe(shift: Shift, date: NaiveDate, taken: &HashSet<NaiveDate>) -> Option<NaiveDate> {
    match shift {
        Shift::None => None,
        Shift::NearestWeekday => match date.weekday() {
            Weekday::Sat => date.pred_opt(),
            Weekday::Sun => date.succ_opt(),
            _ => None,
        },
        Shift::NextFreeWeekday => {
            let mut d = date.succ_opt()?;
            while is_weekend(d) || taken.contains(&d) {
                d = d.succ_opt()?;
            }
            Some(d)
        }
    }
}

/// Dates for every rule except the ones fixed in another calendar
fn solar_date(rule: Rule, year: i32) -> Option<NaiveDate> {
    match rule {
        Rule::Fixed(m, d) => NaiveDate::from_ymd_opt(year, m, d),
        Rule::Nth(m, wd, n, days) => NaiveDate::from_weekday_of_month_opt(year, m, wd, n)?
            .checked_add_signed(Duration::days(days)),
        Rule::Last(m, wd) => {
            let next = match m {
                12 => NaiveDate::from_ymd_opt(year + 1, 1, 1)?,
                _ => NaiveDate::from_ymd_opt(year, m + 1, 1)?,
            };
            last_weekday_before(next, wd)
        }
        Rule::Before(m, d, wd) => last_weekday_before(NaiveDate::from_ymd_opt(year, m, d)?, wd),
        Rule::OnOrAfter(m, d, wd) => {
            let start = NaiveDate::from_ymd_opt(year, m, d)?;
            let ahead = (7 + wd.num_days_from_monday() as i64
                - start.weekday().num_days_from_monday() as i64)
                % 7;
            start.checked_add_signed(Duration::days(ahead))
        }
        Rule::Easter(days) => easter_sunday(year)?.checked_add_signed(Duration::days(days)),
        Rule::OrthodoxEaster(days) => {
            orthodox_easter_sunday(year)?.checked_add_signed(Duration::days(days))
        }
        Rule::Julian(m, d) => julian_to_gregorian(year, m, d),
        Rule::StBrigid => {
            let feb1 = NaiveDate::from_ymd_opt(year, 2, 1)?;
            match feb1.weekday() {
                Weekday::Fri => Some(feb1),
                _ => NaiveDate::from_weekday_of_month_opt(year, 2, Mon, 1),
            }
        }
        Rule::Lunar(..)
        | Rule::Hindu(..)
        | Rule::HolikaDahan(_)
        | Rule::Sankranti(_)
        | Rule::ThaiMoon(..) => None,
    }
}

/// The last `wd` strictly before `date`
fn last_weekday_before(date: NaiveDate, wd: Weekday) -> Option<NaiveDate> {
    let back =
        (7 + date.weekday().num_days_from_monday() as i64 - wd.num_days_from_monday() as i64 - 1)
            % 7
            + 1;
    date.checked_sub_signed(Duration::days(back))
}

/// Walk the year once, converting each day into the other calendars this one uses
fn lunar_dates(cal: &'static CalendarDef, year: i32) -> Vec<(NaiveDate, &'static Day)> {
    let rules = cal
        .days
        .iter()
        .filter_map(|d| match d.rule {
            Rule::Lunar(kind, m, day, offset) => Some((d, kind, m, day, offset)),
            _ => None,
        })
        .collect::<Vec<_>>();
    if rules.is_empty() {
        return vec![];
    }
    let (Some(first), Some(end)) = (
        NaiveDate::from_ymd_opt(year, 1, 1),
        NaiveDate::from_ymd_opt(year, 12, 31),
    ) else {
        return vec![];
    };
    // Room either side for rules that move the day, like Lunar New Year's Eve
    let margin = Duration::days(7);
    let mut out = vec![];
    let mut date = first - margin;
    while date <= end + margin {
        for &(day, kind, m, d, offset) in &rules {
            if lunar_parts(kind, date) == Some((m, false, d)) {
                if let Some(hit) = date.checked_add_signed(Duration::days(offset)) {
                    if hit.year() == year {
                        out.push((hit, day));
                    }
                }
            }
        }
        date += Duration::days(1);
    }
    out
}

/// (month number, is a leap month, day of month) of a date in another calendar
fn lunar_parts(kind: Lunar, date: NaiveDate) -> Option<(u8, bool, u8)> {
    let iso = Date::try_new_iso(date.year(), date.month() as u8, date.day() as u8).ok()?;
    let (month, day) = match kind {
        Lunar::Hebrew => {
            let x = iso.to_calendar(Hebrew);
            (x.month(), x.day_of_month().0)
        }
        Lunar::Hijri => {
            let x = iso.to_calendar(Hijri::new_umm_al_qura());
            (x.month(), x.day_of_month().0)
        }
        Lunar::Chinese => {
            let x = iso.to_calendar(ChineseTraditional::new());
            (x.month(), x.day_of_month().0)
        }
        Lunar::Persian => {
            let x = iso.to_calendar(Persian);
            (x.month(), x.day_of_month().0)
        }
    };
    Some((month.number(), month.leap_status() == LeapStatus::Leap, day))
}

/// India's time zone, which Hindu festival dates are worked out in
const IST: f64 = 5.5;
/// Thailand's
const ICT: f64 = 7.0;
/// Where Hindu sunrise times are taken, as most panchangs do
const NEW_DELHI: (f64, f64) = (28.61, 77.21);

/// The new moons around a year, with the sidereal sign (0 Mesha .. 11 Meena) the Sun is in at each
struct HinduMonths {
    new_moons: Vec<(f64, u8)>,
}

impl HinduMonths {
    fn around(year: i32) -> Option<Self> {
        let from = astro::jd_of(NaiveDate::from_ymd_opt(year - 1, 11, 1)?, 0.0);
        let to = astro::jd_of(NaiveDate::from_ymd_opt(year + 1, 3, 1)?, 0.0);
        let new_moons = astro::moon_phases(from, to, 0.0)
            .into_iter()
            .map(|jd| (jd, (astro::sidereal_sun_longitude(jd) / 30.0) as u8))
            .collect();
        Some(HinduMonths { new_moons })
    }

    /// The lunar month at a moment, and whether it's an adhika (leap) month.
    /// A month is named for the sign after the one the Sun is in when it begins,
    /// and it's adhika when the Sun hasn't changed sign by the time it ends.
    fn month(&self, jd: f64) -> Option<(u8, bool)> {
        let idx = self.new_moons.iter().rposition(|(nm, _)| *nm <= jd)?;
        let (_, sign) = self.new_moons[idx];
        let (_, next) = self.new_moons.get(idx + 1)?;
        Some(((sign + 1) % 12, sign == *next))
    }
}

fn tithi_at(jd: f64) -> u8 {
    (astro::elongation(jd) / 12.0) as u8 % 30
}

/// Full moon dates in Thailand around a year
fn thai_full_moons(year: i32) -> Vec<NaiveDate> {
    let (Some(from), Some(to)) = (
        NaiveDate::from_ymd_opt(year - 1, 1, 1),
        NaiveDate::from_ymd_opt(year + 1, 12, 31),
    ) else {
        return vec![];
    };
    astro::moon_phases(astro::jd_of(from, 0.0), astro::jd_of(to, 0.0), 180.0)
        .into_iter()
        .map(|jd| astro::local(jd, ICT).date())
        .collect()
}

/// Rules that need the Sun and Moon: Hindu tithis, sankrantis and Thai full moons
fn astro_dates(cal: &'static CalendarDef, year: i32) -> Vec<(NaiveDate, &'static Day)> {
    let mut out = vec![];
    let (Some(first), Some(end)) = (
        NaiveDate::from_ymd_opt(year, 1, 1),
        NaiveDate::from_ymd_opt(year, 12, 31),
    ) else {
        return out;
    };
    let mut months = None;
    let mut full_moons = None;
    for day in cal.days {
        match day.rule {
            Rule::Hindu(month, target, at, offset) => {
                let Some(months) = months.get_or_insert_with(|| HinduMonths::around(year)) else {
                    continue;
                };
                let when = |d: NaiveDate| match at {
                    At::Sunrise => astro::sunrise(d, NEW_DELHI.0, NEW_DELHI.1),
                    _ => astro::jd_of(d, at.hours() - IST),
                };
                // A festival falls on the first day its tithi is in effect at its time of day.
                // A tithi can be skipped, starting and ending between two of those times;
                // then it's kept on the day that holds most of the gap between them.
                let mut d = first - Duration::days(7);
                let mut prev = tithi_at(when(d - Duration::days(1)));
                while d <= end + Duration::days(7) {
                    let now = tithi_at(when(d));
                    let skipped = prev == (target + 29) % 30 && now == (target + 1) % 30;
                    let hit = match skipped {
                        true if at.hours() < 12.0 => Some((d - Duration::days(1), when(d) - 0.5)),
                        true => Some((d, when(d) - 0.5)),
                        false if now == target && prev != target => Some((d, when(d))),
                        false => None,
                    };
                    if let Some((hit, moment)) = hit {
                        let in_month = months.month(moment) == Some((month, false));
                        if let Some(h) = hit.checked_add_signed(Duration::days(offset)) {
                            if in_month && h.year() == year {
                                out.push((h, day));
                            }
                        }
                    }
                    prev = now;
                    d += Duration::days(1);
                }
            }
            Rule::HolikaDahan(offset) => {
                let Some(months) = months.get_or_insert_with(|| HinduMonths::around(year)) else {
                    continue;
                };
                let from = astro::jd_of(first, -IST - 24.0 * 7.0);
                let to = astro::jd_of(end, 24.0 * 8.0 - IST);
                for jd in astro::moon_phases(from, to, 174.0) {
                    if months.month(jd) != Some((masa::PHALGUNA, false)) {
                        continue;
                    }
                    if let Some(h) = astro::local(jd, IST)
                        .date()
                        .checked_add_signed(Duration::days(offset))
                    {
                        if h.year() == year {
                            out.push((h, day));
                        }
                    }
                }
            }
            Rule::Sankranti(longitude) => {
                let from = astro::jd_of(first, -IST);
                let to = astro::jd_of(end, 24.0 - IST);
                for jd in astro::sidereal_ingress(from, to, longitude as f64) {
                    out.push((astro::local(jd, IST).date(), day));
                }
            }
            Rule::ThaiMoon(from_asalha, offset) => {
                let moons = full_moons.get_or_insert_with(|| thai_full_moons(year));
                // Asalha Bucha is the full moon of the eighth month, which lands between
                // early July and early August; leap years (athikamat) push it to the later one
                let (Some(lo), Some(hi)) = (
                    NaiveDate::from_ymd_opt(year, 7, 4),
                    NaiveDate::from_ymd_opt(year, 8, 2),
                ) else {
                    continue;
                };
                let Some(asalha) = moons.iter().position(|d| *d > lo && *d <= hi) else {
                    continue;
                };
                let idx = asalha as i64 + from_asalha as i64;
                if let Some(d) = usize::try_from(idx).ok().and_then(|i| moons.get(i)) {
                    if let Some(h) = d.checked_add_signed(Duration::days(offset)) {
                        if h.year() == year {
                            out.push((h, day));
                        }
                    }
                }
            }
            _ => {}
        }
    }
    out
}

/// New, first quarter, full and last quarter moons in the local time zone,
/// with the full moons' traditional (North American) names
fn moon_phases(year: i32) -> Vec<(NaiveDate, String)> {
    let (Some(first), Some(end)) = (
        NaiveDate::from_ymd_opt(year, 1, 1),
        NaiveDate::from_ymd_opt(year, 12, 31),
    ) else {
        return vec![];
    };
    let (from, to) = (astro::jd_of(first, -24.0), astro::jd_of(end, 48.0));
    let local = |jd: f64| astro::utc(jd).with_timezone(&Local).naive_local();

    let mut out = vec![];
    for (angle, name) in [
        (0.0, "🌑 New Moon"),
        (90.0, "🌓 First Quarter"),
        (270.0, "🌗 Last Quarter"),
    ] {
        for jd in astro::moon_phases(from, to, angle) {
            let t = local(jd);
            out.push((t.date(), format!("{} {}", name, t.format("%H:%M"))));
        }
    }

    const NAMES: [&str; 12] = [
        "Wolf",
        "Snow",
        "Worm",
        "Pink",
        "Flower",
        "Strawberry",
        "Buck",
        "Sturgeon",
        "Corn",
        "Hunter's",
        "Beaver",
        "Cold",
    ];
    let full = astro::moon_phases(from, to, 180.0);
    // The Harvest Moon is the full moon nearest the September equinox, and the Hunter's Moon follows it
    let equinox = NaiveDate::from_ymd_opt(year, 9, 1)
        .map(|d| astro::jd_of(d, 0.0))
        .and_then(|sep| astro::sun_ingress(sep, sep + 40.0, 180.0).first().copied());
    let harvest = equinox.and_then(|eq| {
        (0..full.len()).min_by(|a, b| (full[*a] - eq).abs().total_cmp(&(full[*b] - eq).abs()))
    });
    for (i, jd) in full.iter().enumerate() {
        let t = local(*jd);
        let same_month = |other: &f64| {
            let o = local(*other);
            o.year() == t.year() && o.month() == t.month()
        };
        let name = match harvest {
            Some(h) if h == i => "Harvest",
            Some(h) if h + 1 == i => "Hunter's",
            // The second full moon in a calendar month
            _ if i > 0 && same_month(&full[i - 1]) => "Blue",
            _ => NAMES[t.month0() as usize],
        };
        out.push((
            t.date(),
            format!("🌕 Full Moon ({} Moon) {}", name, t.format("%H:%M")),
        ));
    }
    out
}

/// Quarter starts and ends for a fiscal year beginning on the first of `start_month`.
/// Years are named for the calendar year they end in.
fn fiscal_quarters(year: i32, start_month: u32) -> Vec<(NaiveDate, String)> {
    let mut out = vec![];
    for fy_start in year - 1..=year {
        let Some(start) = NaiveDate::from_ymd_opt(fy_start, start_month, 1) else {
            continue;
        };
        let fy = match start_month {
            1 => fy_start,
            _ => fy_start + 1,
        };
        for q in 0..4u32 {
            let (Some(qs), Some(next)) = (
                start.checked_add_months(Months::new(3 * q)),
                start.checked_add_months(Months::new(3 * q + 3)),
            ) else {
                continue;
            };
            let Some(qe) = next.pred_opt() else { continue };
            let (begin, end) = match q {
                0 => (
                    format!("FY{} and Q1 begin", fy),
                    format!("FY{} Q1 ends", fy),
                ),
                3 => (
                    format!("FY{} Q4 begins", fy),
                    format!("FY{} and Q4 end", fy),
                ),
                _ => (
                    format!("FY{} Q{} begins", fy, q + 1),
                    format!("FY{} Q{} ends", fy, q + 1),
                ),
            };
            out.push((qs, begin));
            out.push((qe, end));
        }
    }
    out
}

fn fiscal_jan(year: i32) -> Vec<(NaiveDate, String)> {
    fiscal_quarters(year, 1)
}
fn fiscal_apr(year: i32) -> Vec<(NaiveDate, String)> {
    fiscal_quarters(year, 4)
}
fn fiscal_jul(year: i32) -> Vec<(NaiveDate, String)> {
    fiscal_quarters(year, 7)
}
fn fiscal_oct(year: i32) -> Vec<(NaiveDate, String)> {
    fiscal_quarters(year, 10)
}

/// The retail 4-5-4 calendar (NRF): the year ends on the Saturday nearest Jan 31,
/// quarters are 13 weeks, and a 53-week year gets the extra week in Q4.
/// Years are named for the calendar year they begin in.
fn retail_454(year: i32) -> Vec<(NaiveDate, String)> {
    let start = |y: i32| {
        let jan31 = NaiveDate::from_ymd_opt(y, 1, 31)?;
        let to_sat = (Weekday::Sat.num_days_from_monday() as i64
            - jan31.weekday().num_days_from_monday() as i64
            + 7)
            % 7;
        let sat = match to_sat {
            0..=3 => jan31 + Duration::days(to_sat),
            _ => jan31 - Duration::days(7 - to_sat),
        };
        sat.succ_opt()
    };
    let mut out = vec![];
    for fy in year - 1..=year {
        let (Some(s), Some(next)) = (start(fy), start(fy + 1)) else {
            continue;
        };
        let weeks = (next - s).num_days() / 7;
        for q in 0..4i64 {
            let qs = s + Duration::weeks(13 * q);
            let qe = match q {
                3 => next - Duration::days(1),
                _ => qs + Duration::weeks(13) - Duration::days(1),
            };
            let (begin, end) = match q {
                0 => (
                    format!("FY{} and Q1 begin", fy),
                    format!("FY{} Q1 ends", fy),
                ),
                3 => (
                    format!("FY{} Q4 begins", fy),
                    format!("FY{} and Q4 end ({} weeks)", fy, weeks),
                ),
                _ => (
                    format!("FY{} Q{} begins", fy, q + 1),
                    format!("FY{} Q{} ends", fy, q + 1),
                ),
            };
            out.push((qs, begin));
            out.push((qe, end));
        }
    }
    out
}

/// A short description of a holiday and where to read more
pub struct About {
    pub blurb: String,
    pub url: String,
}

/// calendar key (or *), holiday name, Wikipedia article, blurb
static INFO: &str = include_str!("holiday_info.tsv");

fn wikipedia(article: &str) -> String {
    let mut url = "https://en.wikipedia.org/wiki/".to_string();
    for b in article.replace(' ', "_").bytes() {
        match b {
            b'A'..=b'Z'
            | b'a'..=b'z'
            | b'0'..=b'9'
            | b'_'
            | b'-'
            | b'.'
            | b'('
            | b')'
            | b','
            | b'\'' => url.push(b as char),
            _ => url.push_str(&format!("%{:02X}", b)),
        }
    }
    url
}

fn info_entry(cal: &CalendarDef, name: &str) -> Option<(&'static str, &'static str)> {
    let entries = INFO
        .lines()
        .filter(|l| !l.starts_with('#') && !l.trim().is_empty())
        .filter_map(|l| {
            let mut parts = l.splitn(4, '\t');
            Some((parts.next()?, parts.next()?, parts.next()?, parts.next()?))
        })
        .filter(|(_, n, _, _)| *n == name)
        .collect::<Vec<_>>();
    // The calendar's own entry, then its country's, then the general one
    [Some(cal.key), cal.parent, Some("*")]
        .into_iter()
        .flatten()
        .find_map(|k| entries.iter().find(|(c, _, _, _)| *c == k))
        .map(|(_, _, article, blurb)| (*article, *blurb))
}

pub fn about(h: &Holiday) -> About {
    if let Some(d) = &h.details {
        return About {
            blurb: d.text.clone(),
            url: d.url.clone().unwrap_or_default(),
        };
    }
    let name = h.name.trim_end_matches(" (observed)");
    let found = |article: &str, blurb: &str| About {
        blurb: blurb.to_string(),
        url: wikipedia(article),
    };
    if let Some((article, blurb)) = info_entry(h.calendar, name) {
        let mut about = found(article, blurb);
        if name != h.name {
            about
                .blurb
                .push_str(" This year it falls on a weekend, so the day off moves to here.");
        }
        return about;
    }
    // Generated days: moon phases and fiscal quarters
    let full = |moon: &str| {
        match moon {
        "Harvest" => "The full moon nearest the autumn equinox, rising near sunset for several nights running and once giving farmers extra light for the harvest.",
        "Hunter's" => "The full moon after the Harvest Moon, traditionally when hunters, by its light, stocked up for winter.",
        "Blue" => "The second full moon in a calendar month, which happens about every two to three years; hence \"once in a blue moon\".",
        _ => "The Moon is opposite the Sun and fully lit. Each month's full moon has a traditional North American name, often from the Algonquin or colonial almanacs.",
    }
    };
    match name.split_whitespace().next() {
        Some("🌑") => found("New moon", "The Moon is between the Earth and the Sun and its lit side faces away from us, so it's invisible; the start of lunar months in many calendars."),
        Some("🌓") => found("Lunar phase", "Half of the Moon's disc is lit, on the right as seen from the northern hemisphere, a week after the new moon; it's visible in the afternoon and evening."),
        Some("🌗") => found("Lunar phase", "Half of the Moon's disc is lit, on the left as seen from the northern hemisphere, a week after the full moon; it rises around midnight."),
        Some("🌕") => {
            let moon = name
                .split_once('(')
                .and_then(|(_, rest)| rest.split_once(" Moon)"))
                .map(|(m, _)| m)
                .unwrap_or("");
            let article = match moon {
                "Harvest" => "Harvest moon",
                "Hunter's" => "Hunter's moon",
                "Blue" => "Blue moon",
                _ => "Full moon",
            };
            found(article, full(moon))
        }
        _ if h.calendar.key == "fiscal-retail" => found("4–4–5 calendar", "The retail 4-5-4 calendar used by US retailers (from the National Retail Federation): quarters of 13 weeks split into months of 4, 5 and 4 weeks, so every quarter has the same number of weekends."),
        _ if h.calendar.key.starts_with("fiscal") => found("Fiscal year", "A fiscal year is the 12 months an organisation or government budgets and reports by, split into four quarters; years are usually named for the year they end in."),
        _ => About {
            blurb: h
                .calendar
                .note
                .map(|n| n.to_string())
                .unwrap_or_else(|| format!("A holiday in the {} calendar.", h.calendar.name)),
            url: format!(
                "https://en.wikipedia.org/wiki/Special:Search?search={}",
                name.replace(' ', "+")
            ),
        },
    }
}

/// Western Easter Sunday (the anonymous Gregorian algorithm)
fn easter_sunday(y: i32) -> Option<NaiveDate> {
    let a = y % 19;
    let b = y / 100;
    let c = y % 100;
    let d = b / 4;
    let e = b % 4;
    let f = (b + 8) / 25;
    let g = (b - f + 1) / 3;
    let h = (19 * a + b - d - g + 15) % 30;
    let i = c / 4;
    let k = c % 4;
    let l = (32 + 2 * e + 2 * i - h - k) % 7;
    let m = (a + 11 * h + 22 * l) / 451;
    let month = (h + l - 7 * m + 114) / 31;
    let day = (h + l - 7 * m + 114) % 31 + 1;
    NaiveDate::from_ymd_opt(y, month as u32, day as u32)
}

/// Orthodox Easter Sunday: Meeus' Julian algorithm, converted to Gregorian
fn orthodox_easter_sunday(y: i32) -> Option<NaiveDate> {
    let a = y % 4;
    let b = y % 7;
    let c = y % 19;
    let d = (19 * c + 15) % 30;
    let e = (2 * a + 4 * b - d + 34) % 7;
    let month = (d + e + 114) / 31;
    let day = (d + e + 114) % 31 + 1;
    julian_to_gregorian(y, month as u32, day as u32)
}

/// A Julian calendar date as a Gregorian one, via the Julian day number
fn julian_to_gregorian(year: i32, month: u32, day: u32) -> Option<NaiveDate> {
    let a = (14 - month as i64) / 12;
    let y = year as i64 + 4800 - a;
    let m = month as i64 + 12 * a - 3;
    let jdn = day as i64 + (153 * m + 2) / 5 + 365 * y + y / 4 - 32083;
    // JDN 1721426 is 0001-01-01 in the proleptic Gregorian calendar
    NaiveDate::from_num_days_from_ce_opt((jdn - 1721425) as i32)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn on(year: i32, key: &str, name: &str) -> Vec<NaiveDate> {
        holidays_in(year, &[key.to_string()])
            .into_iter()
            .filter(|h| h.name == name)
            .map(|h| h.date)
            .collect()
    }
    fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(y, m, d).unwrap()
    }

    #[test]
    fn every_holiday_has_a_blurb() {
        for cal in CALENDARS {
            for day in cal.days {
                assert!(
                    info_entry(cal, day.name).is_some(),
                    "no entry in holiday_info.tsv for {} ({})",
                    day.name,
                    cal.key
                );
            }
        }
        for line in INFO
            .lines()
            .filter(|l| !l.starts_with('#') && !l.is_empty())
        {
            assert_eq!(line.split('\t').count(), 4, "bad line: {}", line);
        }
        let h = holidays_in(
            2026,
            &["us".to_string(), "mx".to_string(), "lunar".to_string()],
        );
        let pick = |cal: &str, name: &str| {
            about(
                h.iter()
                    .find(|h| h.calendar.key == cal && h.name.starts_with(name))
                    .unwrap(),
            )
        };
        assert!(pick("mx", "Independence Day")
            .url
            .ends_with("Independence_Day_(Mexico)"));
        assert!(pick("us", "Independence Day")
            .url
            .ends_with("Independence_Day_(United_States)"));
        assert!(pick("us", "Independence Day (observed)")
            .blurb
            .contains("weekend"));
        assert!(pick("lunar", "🌕 Full Moon (Blue")
            .url
            .ends_with("Blue_moon"));
        assert_eq!(
            wikipedia("Māgha Pūjā"),
            "https://en.wikipedia.org/wiki/M%C4%81gha_P%C5%ABj%C4%81"
        );
    }

    #[test]
    fn keys_are_unique_and_parents_exist() {
        let mut seen = HashSet::new();
        for c in CALENDARS {
            assert!(seen.insert(c.key), "duplicate key {}", c.key);
            if let Some(p) = c.parent {
                assert!(find(p).is_some(), "{} has unknown parent {}", c.key, p);
            }
        }
    }

    #[test]
    fn easter() {
        assert_eq!(easter_sunday(2024), Some(ymd(2024, 3, 31)));
        assert_eq!(easter_sunday(2026), Some(ymd(2026, 4, 5)));
        assert_eq!(easter_sunday(2027), Some(ymd(2027, 3, 28)));
        assert_eq!(orthodox_easter_sunday(2024), Some(ymd(2024, 5, 5)));
        assert_eq!(orthodox_easter_sunday(2026), Some(ymd(2026, 4, 12)));
        assert_eq!(
            on(2026, "orthodox", "Orthodox Christmas"),
            vec![ymd(2026, 1, 7)]
        );
    }

    #[test]
    fn us() {
        assert_eq!(on(2026, "us", "Thanksgiving"), vec![ymd(2026, 11, 26)]);
        assert_eq!(on(2026, "us", "Memorial Day"), vec![ymd(2026, 5, 25)]);
        assert_eq!(
            on(2026, "us", "Martin Luther King Jr. Day"),
            vec![ymd(2026, 1, 19)]
        );
        // July 4 2026 is a Saturday
        assert_eq!(
            on(2026, "us", "Independence Day (observed)"),
            vec![ymd(2026, 7, 3)]
        );
        // Jan 1 2022 was a Saturday, observed on Dec 31 2021
        assert_eq!(
            on(2021, "us", "New Year's Day (observed)"),
            vec![ymd(2021, 12, 31)]
        );
        assert!(on(2020, "us", "Juneteenth").is_empty());
        assert_eq!(on(2026, "us-ny", "Election Day"), vec![ymd(2026, 11, 3)]);
        // Nov 1 2024 was a Friday: Thanksgiving the 28th, the day after the 29th
        assert_eq!(
            on(2024, "us-ca", "Day after Thanksgiving"),
            vec![ymd(2024, 11, 29)]
        );
        assert_eq!(on(2026, "us-ma", "Patriots' Day"), vec![ymd(2026, 4, 20)]);
    }

    #[test]
    fn uk_substitutes() {
        // Christmas 2021 was a Saturday and Boxing Day a Sunday
        assert_eq!(
            on(2021, "gb", "Christmas Day (observed)"),
            vec![ymd(2021, 12, 27)]
        );
        assert_eq!(
            on(2021, "gb", "Boxing Day (observed)"),
            vec![ymd(2021, 12, 28)]
        );
        // Christmas 2022 was a Sunday: Boxing Day stays on Monday
        assert_eq!(
            on(2022, "gb", "Christmas Day (observed)"),
            vec![ymd(2022, 12, 27)]
        );
        // Jan 1 2022 was a Saturday: New Year's on the 3rd, so Scotland's 2nd moves to the 4th
        assert_eq!(
            on(2022, "gb-sct", "2nd January (observed)"),
            vec![ymd(2022, 1, 4)]
        );
        assert_eq!(
            on(2026, "gb-eng", "Summer bank holiday"),
            vec![ymd(2026, 8, 31)]
        );
    }

    #[test]
    fn other_countries() {
        assert_eq!(on(2026, "ca", "Victoria Day"), vec![ymd(2026, 5, 18)]);
        assert_eq!(on(2025, "ca", "Victoria Day"), vec![ymd(2025, 5, 19)]);
        assert_eq!(
            on(2026, "de-sn", "Repentance and Prayer Day"),
            vec![ymd(2026, 11, 18)]
        );
        assert_eq!(on(2026, "ie", "St Brigid's Day"), vec![ymd(2026, 2, 2)]);
        // Feb 1 2030 is a Friday
        assert_eq!(on(2030, "ie", "St Brigid's Day"), vec![ymd(2030, 2, 1)]);
        assert_eq!(
            on(2026, "christian", "First Sunday of Advent"),
            vec![ymd(2026, 11, 29)]
        );
    }

    #[test]
    fn jewish() {
        assert_eq!(on(2026, "jewish", "Rosh Hashanah"), vec![ymd(2026, 9, 12)]);
        assert_eq!(on(2026, "jewish", "Yom Kippur"), vec![ymd(2026, 9, 21)]);
        assert_eq!(on(2026, "jewish", "Passover"), vec![ymd(2026, 4, 2)]);
        assert_eq!(on(2026, "jewish", "Hanukkah"), vec![ymd(2026, 12, 5)]);
        assert_eq!(on(2026, "jewish", "Purim"), vec![ymd(2026, 3, 3)]);
        // 5784 was a leap year, Purim falls in Adar II
        assert_eq!(on(2024, "jewish", "Purim"), vec![ymd(2024, 3, 24)]);
    }

    #[test]
    fn islamic() {
        assert_eq!(
            on(2026, "islamic", "Ramadan begins"),
            vec![ymd(2026, 2, 18)]
        );
        assert_eq!(on(2026, "islamic", "Eid al-Fitr"), vec![ymd(2026, 3, 20)]);
        // The Hijri year is shorter, so a Gregorian year can hold two
        assert_eq!(on(2008, "islamic", "Islamic New Year").len(), 2);
    }

    #[test]
    fn hindu() {
        for (y, name, m, d) in [
            (2025, "Maha Shivaratri", 2, 26),
            (2025, "Holi", 3, 14),
            (2025, "Krishna Janmashtami", 8, 16),
            (2025, "Diwali", 10, 20),
            (2025, "Chhath Puja", 10, 27),
            (2023, "Chhath Puja", 11, 19),
            (2026, "Makar Sankranti / Pongal", 1, 14),
            (2026, "Holika Dahan", 3, 3),
            (2026, "Holi", 3, 4),
            (2026, "Ugadi / Gudi Padwa", 3, 19),
            // 2026 has an adhika Jyeshtha, which pushes Guru Purnima to late July
            (2026, "Guru Purnima", 7, 29),
            (2026, "Ganesh Chaturthi", 9, 14),
            (2026, "Dussehra", 10, 20),
            (2026, "Diwali", 11, 8),
        ] {
            assert_eq!(on(y, "hindu", name), vec![ymd(y, m, d)], "{} {}", name, y);
        }
        assert_eq!(on(2026, "buddhist", "Vesak"), vec![ymd(2026, 5, 1)]);
    }

    #[test]
    fn thailand() {
        // 2026 is a Thai leap year (athikamat), so the Buddhist days come a month later
        assert_eq!(on(2026, "th", "Makha Bucha"), vec![ymd(2026, 3, 3)]);
        assert_eq!(on(2026, "th", "Visakha Bucha"), vec![ymd(2026, 5, 31)]);
        assert_eq!(on(2026, "th", "Asalha Bucha"), vec![ymd(2026, 7, 29)]);
        assert_eq!(on(2025, "th", "Makha Bucha"), vec![ymd(2025, 2, 12)]);
        assert_eq!(on(2025, "th", "Loy Krathong"), vec![ymd(2025, 11, 5)]);
    }

    #[test]
    fn moon_and_fiscal() {
        let full = holidays_in(2026, &["lunar".to_string()])
            .into_iter()
            .filter(|h| h.name.contains("Full Moon"))
            .collect::<Vec<_>>();
        assert_eq!(full.len(), 13);
        assert!(full.iter().any(|h| h.name.contains("Blue Moon")));
        assert!(full.iter().any(|h| h.name.contains("Harvest Moon")));
        assert_eq!(
            on(2026, "fiscal-oct", "FY2027 and Q1 begin"),
            vec![ymd(2026, 10, 1)]
        );
        assert_eq!(
            on(2026, "fiscal-apr", "FY2026 and Q4 end"),
            vec![ymd(2026, 3, 31)]
        );
        assert_eq!(
            on(2026, "fiscal-retail", "FY2026 and Q1 begin"),
            vec![ymd(2026, 2, 1)]
        );
        // Fiscal 2023 was a 53-week retail year
        assert_eq!(
            on(2024, "fiscal-retail", "FY2023 and Q4 end (53 weeks)"),
            vec![ymd(2024, 2, 3)]
        );
    }

    #[test]
    fn chinese_and_persian() {
        assert_eq!(
            on(2026, "chinese", "Lunar New Year"),
            vec![ymd(2026, 2, 17)]
        );
        assert_eq!(
            on(2026, "chinese", "Lunar New Year's Eve"),
            vec![ymd(2026, 2, 16)]
        );
        assert_eq!(
            on(2026, "chinese", "Mid-Autumn Festival"),
            vec![ymd(2026, 9, 25)]
        );
        assert_eq!(on(2025, "persian", "Nowruz"), vec![ymd(2025, 3, 21)]);
    }
}