--- Calendar conversion API.
-- All dates are stored internally as RD (Rata Die) fixed integers.
-- Calendar fields (`year`, `month`, `day`, …) are computed lazily on first
-- access and cached on the date object.
-- @module calendrica
-- @release 0.1 2026-07-19
-- @usage
--   local calendrica = require("calendrica")
--   local d  = calendrica.new_date({year=2024, month=4, day=23}, calendrica.cal_gregorian)
--   local d2 = calendrica.as_date(d, calendrica.cal_hebrew)
--   print(d2.year, d2.month, d2.day)

local M = {}


-- === Module loading ===

local basic       = require("calendrica-basic")
local gregorian   = require("calendrica-gregorian")
local julian      = require("calendrica-julian")
local egyptian    = require("calendrica-egyptian-armenian")
local akan        = require("calendrica-akan")
local coptic      = require("calendrica-coptic-ethiopic")
local iso         = require("calendrica-iso")
local icelandic   = require("calendrica-icelandic")
local islamic     = require("calendrica-islamic")
local hebrew      = require("calendrica-hebrew")
local eccl        = require("calendrica-ecclesiastical")
local mayan       = require("calendrica-mayan")
local balinese    = require("calendrica-balinese")
local persian     = require("calendrica-persian")
local bahai       = require("calendrica-bahai")
local french      = require("calendrica-french-revolutionary")
local chinese     = require("calendrica-chinese")
local old_hindu   = require("calendrica-old-hindu")
local hindu       = require("calendrica-modern-hindu")
local tibetan     = require("calendrica-tibetan")
local astro_lunar = require("calendrica-astronomical-lunar")
local astro       = require("calendrica-astro")


-- === Date object ===

local date_mt = {}

date_mt.__index = function(self, key)
  local fields = rawget(self, "_fields")
  if not fields then
    fields = rawget(self, "_cal").from_rd(rawget(self, "_rd"))
    rawset(self, "_fields", fields)
  end
  return fields[key]
end

date_mt.__tostring = function(self)
  local fields = rawget(self, "_fields")
  if not fields then
    fields = rawget(self, "_cal").from_rd(rawget(self, "_rd"))
    rawset(self, "_fields", fields)
  end
  local parts = {}
  for _, k in ipairs(rawget(self, "_cal").granularities) do
    parts[#parts + 1] = k .. "=" .. tostring(fields[k])
  end
  return rawget(self, "_cal").name .. "(" .. table.concat(parts, ", ") .. ")"
end

local function make_date(rd, calendar)
  return setmetatable({_rd = rd, _cal = calendar}, date_mt)
end


-- === Calendar registry ===

local cal_registry = {}

local function resolve_calendar(calendar)
  if type(calendar) == "string" then
    return cal_registry[calendar] or error("unknown calendar: " .. calendar, 3)
  end
  return calendar
end

local function normalize(raw, cal)
  cal = cal or cal_registry["gregorian"]
  if type(raw) == "number" then
    return {make_date(raw, cal)}
  elseif type(raw) == "table" then
    local out = {}
    for _, rd in ipairs(raw) do out[#out + 1] = make_date(rd, cal) end
    return out
  else
    return {}
  end
end

local function as_time(moment, zone)
  if not moment or moment == basic.BOGUS then return nil end
  local frac = moment % 1
  local h    = math.floor(frac * 24)
  local m    = math.floor((frac * 24 - h) * 60)
  local s    = ((frac * 24 - h) * 60 - m) * 3600
  return {hour = h, minute = m, second = s, zone = zone}
end


-- === Core ===

--- @section Core

--- Create a new calendar definition.
-- `from_rd` and `to_rd` are the raw module functions that use positional tables;
-- `new_calendar` wraps them automatically using `granularities` as the key list.
-- @tparam string name Unique calendar name.
-- @tparam {string,...} granularities Ordered field names, e.g. `{"year","month","day"}`.
-- @tparam func from_rd Raw function: RD → positional table.
-- @tparam[opt] func to_rd Raw function: positional table → RD.
--   Omit (or pass `nil`) for read-only calendars that cannot be used as a source in `new_date`.
-- @treturn table Calendar object for use with `new_date`, `as_date`, and `granularity_names`.
function M.new_calendar(name, granularities, from_rd, to_rd)
  local function wrapped_from_rd(rd)
    local t, out = from_rd(rd), {}
    for i, k in ipairs(granularities) do out[k] = t[i] end
    return out
  end
  local wrapped_to_rd
  if to_rd then
    wrapped_to_rd = function(fields)
      local t = {}
      for i, k in ipairs(granularities) do t[i] = fields[k] end
      return to_rd(t)
    end
  end
  local cal = {
    name          = name,
    granularities = granularities,
    from_rd       = wrapped_from_rd,
    to_rd         = wrapped_to_rd,
  }
  cal_registry[name] = cal
  return cal
end
local new_calendar = M.new_calendar

--- Return the ordered granularity names of a calendar.
-- @tparam table calendar A calendar object.
-- @treturn {string,...} Ordered list of field name strings.
function M.granularity_names(calendar)
  return calendar.granularities
end

--- Construct a date from named fields and a calendar.
-- The calendar must have a `to_rd` function (i.e. must not be read-only).
-- @tparam table fields Named field table; keys must match the calendar's granularity names.
-- @tparam table|string calendar Calendar object or calendar name string (e.g. `"gregorian"`).
-- @treturn table Date object. Fields are accessible by name (e.g. `d.year`).
-- @raise If the calendar is unknown or read-only.
function M.new_date(fields, calendar)
  calendar = resolve_calendar(calendar)
  assert(calendar, "calendar required")
  assert(calendar.to_rd, "calendar is read-only (no to_rd): " .. tostring(calendar.name))
  return make_date(calendar.to_rd(fields), calendar)
end

--- Convert a date object to a different calendar.
-- The underlying RD value is reused; no arithmetic is performed.
-- @tparam table date A date object (as returned by `new_date`, `as_date`, or `today`).
-- @tparam table|string calendar Target calendar object or name string.
-- @treturn table New date object expressed in the target calendar.
-- @raise If the calendar is unknown.
function M.as_date(date, calendar)
  calendar = resolve_calendar(calendar)
  assert(calendar, "calendar required")
  return make_date(date._rd, calendar)
end

--- Return the fixed (RD) integer for a date object.
-- @tparam table date A date object (as returned by `new_date`, `as_date`, or `today`).
-- @treturn number Fixed date (Rata Die integer).
function M.as_fixed(date)
  return date._rd
end

--- Return today's date in the given calendar.
-- @tparam[opt] table|string calendar Calendar object or name string. Defaults to `cal_gregorian`.
-- @treturn table Date object for today.
-- @raise If the calendar name is unknown.
function M.today(calendar)
  local t  = os.date("*t")
  local rd = gregorian.fixed_from_gregorian({t.year, t.month, t.day})
  local cal = calendar and resolve_calendar(calendar) or cal_registry["gregorian"]
  return make_date(rd, cal)
end


-- === Pre-built calendars ===

--- @section Calendars

--- Proleptic Gregorian calendar. Granularities: `year`, `month`, `day`.
M.cal_gregorian = new_calendar("gregorian", {"year","month","day"},
  gregorian.gregorian_from_fixed, gregorian.fixed_from_gregorian)

--- Proleptic Julian calendar. Granularities: `year`, `month`, `day`.
M.cal_julian = new_calendar("julian", {"year","month","day"},
  julian.julian_from_fixed, julian.fixed_from_julian)

--- Roman calendar (Julian-based). Granularities: `year`, `month`, `event`, `count`, `leap`.
-- `event` is one of `calendrica-julian`'s `KALENDS`, `NONES`, or `IDES` constants.
M.cal_roman = new_calendar("roman", {"year","month","event","count","leap"},
  julian.roman_from_fixed, julian.fixed_from_roman)

--- ISO week-date calendar. Granularities: `year`, `week`, `day`.
M.cal_iso = new_calendar("iso", {"year","week","day"},
  iso.iso_from_fixed, iso.fixed_from_iso)

--- Ancient Egyptian calendar. Granularities: `year`, `month`, `day`.
M.cal_egyptian = new_calendar("egyptian", {"year","month","day"},
  egyptian.egyptian_from_fixed, egyptian.fixed_from_egyptian)

--- Armenian calendar (structurally identical to Egyptian). Granularities: `year`, `month`, `day`.
M.cal_armenian = new_calendar("armenian", {"year","month","day"},
  egyptian.armenian_from_fixed, egyptian.fixed_from_armenian)

--- Coptic calendar. Granularities: `year`, `month`, `day`.
M.cal_coptic = new_calendar("coptic", {"year","month","day"},
  coptic.coptic_from_fixed, coptic.fixed_from_coptic)

--- Ethiopic calendar. Granularities: `year`, `month`, `day`.
M.cal_ethiopic = new_calendar("ethiopic", {"year","month","day"},
  coptic.ethiopic_from_fixed, coptic.fixed_from_ethiopic)

--- Arithmetic Islamic (Hijri) calendar. Granularities: `year`, `month`, `day`.
M.cal_islamic = new_calendar("islamic", {"year","month","day"},
  islamic.islamic_from_fixed, islamic.fixed_from_islamic)

--- Observational Islamic calendar (astronomical). Granularities: `year`, `month`, `day`.
M.cal_islamic_observational = new_calendar("islamic-observational", {"year","month","day"},
  astro_lunar.observational_islamic_from_fixed, astro_lunar.fixed_from_observational_islamic)

--- Saudi Islamic calendar (Umm al-Qura approximation). Granularities: `year`, `month`, `day`.
--- Islamic TBLA calendar (Thursday epoch). Granularities: `year`, `month`, `day`.
M.cal_islamic_tbla = new_calendar("islamic-tbla", {"year","month","day"},
  islamic.islamic_tbla_from_fixed, islamic.fixed_from_islamic_tbla)

--- Umm al-Qura Islamic calendar. Granularities: `year`, `month`, `day`.
M.cal_islamic_umalqura = new_calendar("islamic-umalqura", {"year","month","day"},
  islamic.islamic_umalqura_from_fixed, islamic.fixed_from_islamic_umalqura)

--- Observational Saudi Islamic calendar. Granularities: `year`, `month`, `day`.
M.cal_islamic_saudi = new_calendar("islamic-saudi", {"year","month","day"},
  astro_lunar.saudi_islamic_from_fixed, astro_lunar.fixed_from_saudi_islamic)

--- Astronomical Umm al-Qura calendar (conjunction before Mecca sunset). Granularities: `year`, `month`, `day`.
M.cal_islamic_umalqura_astronomical = new_calendar("islamic-umalqura-astronomical", {"year","month","day"},
  astro_lunar.astronomical_islamic_umalqura_from_fixed, astro_lunar.fixed_from_astronomical_islamic_umalqura)

--- Turkish Islamic calendar (Diyanet crescent visibility from Ankara). Granularities: `year`, `month`, `day`.
M.cal_islamic_turkish = new_calendar("islamic-turkish", {"year","month","day"},
  astro_lunar.turkish_islamic_from_fixed, astro_lunar.fixed_from_turkish_islamic)

--- Arithmetic Hebrew calendar. Granularities: `year`, `month`, `day`.
M.cal_hebrew = new_calendar("hebrew", {"year","month","day"},
  hebrew.hebrew_from_fixed, hebrew.fixed_from_hebrew)

--- Observational Hebrew calendar (astronomical). Granularities: `year`, `month`, `day`.
M.cal_hebrew_observational = new_calendar("hebrew-observational", {"year","month","day"},
  astro_lunar.observational_hebrew_from_fixed, astro_lunar.fixed_from_observational_hebrew)

--- Astronomical Persian calendar. Granularities: `year`, `month`, `day`.
M.cal_persian = new_calendar("persian", {"year","month","day"},
  persian.persian_from_fixed, persian.fixed_from_persian)

--- Arithmetic Persian calendar. Granularities: `year`, `month`, `day`.
M.cal_persian_arithmetic = new_calendar("persian-arithmetic", {"year","month","day"},
  persian.arithmetic_persian_from_fixed, persian.fixed_from_arithmetic_persian)

--- Astronomical French Revolutionary calendar. Granularities: `year`, `month`, `day`.
M.cal_french = new_calendar("french", {"year","month","day"},
  french.french_from_fixed, french.fixed_from_french)

--- Arithmetic French Revolutionary calendar. Granularities: `year`, `month`, `day`.
M.cal_french_arithmetic = new_calendar("french-arithmetic", {"year","month","day"},
  french.arithmetic_french_from_fixed, french.fixed_from_arithmetic_french)

--- Arithmetic Bahá'í calendar. Granularities: `major`, `cycle`, `year`, `month`, `day`.
M.cal_bahai = new_calendar("bahai", {"major","cycle","year","month","day"},
  bahai.bahai_from_fixed, bahai.fixed_from_bahai)

--- Astronomical Bahá'í calendar. Granularities: `major`, `cycle`, `year`, `month`, `day`.
M.cal_bahai_astronomical = new_calendar("bahai-astronomical", {"major","cycle","year","month","day"},
  bahai.astro_bahai_from_fixed, bahai.fixed_from_astro_bahai)

--- Mayan Long Count calendar. Granularities: `baktun`, `katun`, `tun`, `uinal`, `kin`.
M.cal_mayan_long_count = new_calendar("mayan-long-count",
  {"baktun","katun","tun","uinal","kin"},
  mayan.mayan_long_count_from_fixed, mayan.fixed_from_mayan_long_count)

--- Mayan Haab calendar (read-only). Granularities: `month`, `day`.
-- Cannot be used as a source for `new_date` as Haab alone does not uniquely determine a date.
M.cal_mayan_haab = new_calendar("mayan-haab", {"month","day"},
  mayan.mayan_haab_from_fixed)

--- Mayan Tzolkin calendar (read-only). Granularities: `number`, `name`.
-- Cannot be used as a source for `new_date` as Tzolkin alone does not uniquely determine a date.
M.cal_mayan_tzolkin = new_calendar("mayan-tzolkin", {"number","name"},
  mayan.mayan_tzolkin_from_fixed)

--- Aztec Xihuitl calendar (read-only). Granularities: `month`, `day`.
M.cal_aztec_xihuitl = new_calendar("aztec-xihuitl", {"month","day"},
  mayan.aztec_xihuitl_from_fixed)

--- Aztec Tonalpohualli calendar (read-only). Granularities: `number`, `name`.
M.cal_aztec_tonalpohualli = new_calendar("aztec-tonalpohualli", {"number","name"},
  mayan.aztec_tonalpohualli_from_fixed)

--- Balinese Pawukon calendar (read-only).
-- Granularities: `luang`, `dwiwara`, `triwara`, `caturwara`, `pancawara`,
-- `sadwara`, `saptawara`, `asatawara`, `sangawara`, `dasawara`.
-- The Pawukon cycle does not uniquely determine a fixed date.
M.cal_balinese = new_calendar("balinese",
  {"luang","dwiwara","triwara","caturwara","pancawara",
   "sadwara","saptawara","asatawara","sangawara","dasawara"},
  balinese.bali_pawukon_from_fixed)

--- Chinese calendar. Granularities: `cycle`, `year`, `month`, `leap`, `day`.
M.cal_chinese = new_calendar("chinese", {"cycle","year","month","leap","day"},
  chinese.chinese_from_fixed, chinese.fixed_from_chinese)

--- Japanese lunisolar calendar. Granularities: `cycle`, `year`, `month`, `leap`, `day`.
M.cal_japanese = new_calendar("japanese", {"cycle","year","month","leap","day"},
  chinese.japanese_from_fixed, chinese.fixed_from_japanese)

--- Korean lunisolar calendar. Granularities: `year`, `month`, `leap`, `day`.
M.cal_korean = new_calendar("korean", {"year","month","leap","day"},
  chinese.korean_from_fixed, chinese.fixed_from_korean)

--- Vietnamese lunisolar calendar. Granularities: `cycle`, `year`, `month`, `leap`, `day`.
M.cal_vietnamese = new_calendar("vietnamese", {"cycle","year","month","leap","day"},
  chinese.vietnamese_from_fixed, chinese.fixed_from_vietnamese)

--- Old Hindu solar calendar (Arya Siddhanta). Granularities: `year`, `month`, `day`.
M.cal_old_hindu_solar = new_calendar("old-hindu-solar", {"year","month","day"},
  old_hindu.old_hindu_solar_from_fixed, old_hindu.fixed_from_old_hindu_solar)

--- Old Hindu lunar calendar (Arya Siddhanta). Granularities: `year`, `month`, `leap`, `day`.
M.cal_old_hindu_lunar = new_calendar("old-hindu-lunar", {"year","month","leap","day"},
  old_hindu.old_hindu_lunar_from_fixed, old_hindu.fixed_from_old_hindu_lunar)

--- Modern Hindu solar calendar (Orissa rule). Granularities: `year`, `month`, `day`.
M.cal_hindu_solar = new_calendar("hindu-solar", {"year","month","day"},
  hindu.hindu_solar_from_fixed, hindu.fixed_from_hindu_solar)

--- Astronomical Hindu solar calendar (Tamil rule). Granularities: `year`, `month`, `day`.
M.cal_hindu_solar_astronomical = new_calendar("hindu-solar-astronomical", {"year","month","day"},
  hindu.astro_hindu_solar_from_fixed, hindu.fixed_from_astro_hindu_solar)

--- Modern Hindu lunar calendar. Granularities: `year`, `month`, `leap_month`, `day`, `leap_day`.
M.cal_hindu_lunar = new_calendar("hindu-lunar",
  {"year","month","leap_month","day","leap_day"},
  hindu.hindu_lunar_from_fixed, hindu.fixed_from_hindu_lunar)

--- Astronomical Hindu lunar calendar. Granularities: `year`, `month`, `leap_month`, `day`, `leap_day`.
M.cal_hindu_lunar_astronomical = new_calendar("hindu-lunar-astronomical",
  {"year","month","leap_month","day","leap_day"},
  hindu.astro_hindu_lunar_from_fixed, hindu.fixed_from_astro_hindu_lunar)

--- Tibetan calendar. Granularities: `year`, `month`, `leap_month`, `day`, `leap_day`.
M.cal_tibetan = new_calendar("tibetan",
  {"year","month","leap_month","day","leap_day"},
  tibetan.tibetan_from_fixed, tibetan.fixed_from_tibetan)

--- Icelandic calendar. Granularities: `year`, `season`, `week`, `weekday`.
M.cal_icelandic = new_calendar("icelandic", {"year","season","week","weekday"},
  icelandic.icelandic_from_fixed, icelandic.fixed_from_icelandic)

--- Akan day-name cycle (read-only). Granularities: `prefix`, `stem`.
-- The Akan name does not uniquely determine a fixed date.
M.cal_akan = new_calendar("akan", {"prefix","stem"},
  akan.akan_name_from_fixed)

--- Ab Urbe Condita (AUC) year numbering on the Julian calendar.
-- Granularities: `year`, `month`, `day`. Year 1 AUC = 753 BCE (Julian).
M.cal_auc = new_calendar("auc", {"year","month","day"},
  function(rd)
    local t = julian.julian_from_fixed(rd)
    return {julian.auc_year_from_julian(t[1]), t[2], t[3]}
  end,
  function(t)
    return julian.fixed_from_julian({julian.julian_year_from_auc(t[1]), t[2], t[3]})
  end
)

--- Greek Olympiad numbering on the Julian calendar.
-- Granularities: `cycle`, `year`, `month`, `day`.
-- Cycle 1, year 1 = 776 BCE (Julian). `year` is 1–4 within the 4-year cycle.
M.cal_olympiad = new_calendar("olympiad", {"cycle","year","month","day"},
  function(rd)
    local t = julian.julian_from_fixed(rd)
    local o = julian.olympiad_from_julian_year(t[1])
    return {o[1], o[2], t[2], t[3]}
  end,
  function(t)
    return julian.fixed_from_julian({julian.julian_year_from_olympiad({t[1], t[2]}), t[3], t[4]})
  end
)

--- Samaritan calendar. Granularities: `year`, `month`, `day`.
M.cal_samaritan = new_calendar("samaritan", {"year","month","day"},
  astro_lunar.samaritan_from_fixed, astro_lunar.fixed_from_samaritan)

--- Babylonian calendar (astronomical). Granularities: `year`, `month`, `leap`, `day`.
M.cal_babylonian = new_calendar("babylonian", {"year","month","leap","day"},
  astro_lunar.babylonian_from_fixed, astro_lunar.fixed_from_babylonian)


-- === Holidays ===

--- @section Holidays

local holiday_fns = {}

-- Christian / Western
holiday_fns["easter"]                     = eccl.easter
holiday_fns["astronomical-easter"]        = astro_lunar.astronomical_easter
holiday_fns["orthodox-easter"]            = eccl.orthodox_easter
holiday_fns["pentecost"]                  = eccl.pentecost
holiday_fns["christmas"]                  = gregorian.christmas
holiday_fns["advent"]                     = gregorian.advent
holiday_fns["epiphany"]                   = gregorian.epiphany
holiday_fns["eastern-orthodox-christmas"] = julian.eastern_orthodox_christmas
holiday_fns["coptic-christmas"]           = coptic.coptic_christmas

-- US civic
holiday_fns["independence-day"]           = gregorian.independence_day
holiday_fns["labor-day"]                  = gregorian.labor_day
holiday_fns["memorial-day"]               = gregorian.memorial_day
holiday_fns["election-day"]               = gregorian.election_day
holiday_fns["daylight-saving-start"]      = gregorian.daylight_saving_start
holiday_fns["daylight-saving-end"]        = gregorian.daylight_saving_end

-- Jewish
holiday_fns["rosh-hashanah"]              = hebrew.rosh_hashanah
holiday_fns["sukkot"]                     = hebrew.sukkot
holiday_fns["shavuot"]                    = hebrew.shavuot
holiday_fns["passover"]                   = hebrew.passover
holiday_fns["yom-kippur"]                 = hebrew.yom_kippur
holiday_fns["purim"]                      = hebrew.purim
holiday_fns["hanukkah"]                   = hebrew.hanukkah
holiday_fns["tishah-be-av"]               = hebrew.tishah_be_av
holiday_fns["ta-anit-esther"]             = hebrew.ta_anit_esther
holiday_fns["yom-ha-zikkaron"]            = hebrew.yom_ha_zikkaron

-- Islamic (arithmetic calendar)
holiday_fns["islamic-new-year"]           = islamic.islamic_new_year
holiday_fns["ramadan"]                    = islamic.ramadan
holiday_fns["eid-al-fitr"]               = islamic.eid_al_fitr
holiday_fns["eid-al-adha"]               = islamic.eid_al_adha
holiday_fns["mawlid"]                     = islamic.mawlid

-- Persian
holiday_fns["nowruz"]                     = persian.nowruz

-- Bahá'í
holiday_fns["bahai-new-year"]             = bahai.bahai_new_year
holiday_fns["naw-ruz"]                    = bahai.naw_ruz
holiday_fns["feast-of-ridvan"]            = bahai.feast_of_ridvan
holiday_fns["birth-of-the-bab"]          = bahai.birth_of_the_bab

-- Balinese
holiday_fns["kajeng-keliwon"]             = balinese.kajeng_keliwon
holiday_fns["tumpek"]                     = balinese.tumpek

-- Chinese
holiday_fns["chinese-new-year"]           = chinese.chinese_new_year
holiday_fns["dragon-festival"]            = chinese.dragon_festival
holiday_fns["qing-ming"]                  = chinese.qing_ming

-- Hindu
holiday_fns["hindu-lunar-new-year"]       = hindu.hindu_lunar_new_year
holiday_fns["mesha-samkranti"]            = function(g_year) return math.floor(hindu.mesha_samkranti(g_year)) end
holiday_fns["diwali"]                     = hindu.diwali
holiday_fns["maha-shivaratri"]            = hindu.shiva
holiday_fns["rama-navami"]                = hindu.rama
holiday_fns["sacred-wednesdays"]          = hindu.sacred_wednesdays

-- Tibetan
holiday_fns["losar"]                      = tibetan.tibetan_new_year

-- Gregorian
holiday_fns["unlucky-fridays"]            = gregorian.unlucky_fridays

--- Return occurrences of a named holiday in a Gregorian year as date objects.
-- @tparam string name Holiday name (e.g. `"easter"`, `"hanukkah"`).
--   See `holiday_names` for the full list.
-- @tparam number g_year Gregorian year.
-- @treturn {table,...} List of date objects (may be empty if the holiday does
--   not fall in the given year).
-- @raise If `name` is not a registered holiday.
function M.holiday(name, g_year)
  local fn = holiday_fns[name]
  assert(fn, "unknown holiday: " .. tostring(name))
  return normalize(fn(g_year))
end

--- Return all holidays occurring in a Gregorian year, sorted by date.
-- @tparam number g_year Gregorian year.
-- @treturn {{name=string,dates={table,...}},...} List of `{name, dates}` records,
--   one per holiday that has at least one occurrence in the year.
function M.holidays(g_year)
  local result = {}
  for name, fn in pairs(holiday_fns) do
    local dates = normalize(fn(g_year))
    if #dates > 0 then
      result[#result + 1] = {name = name, dates = dates}
    end
  end
  table.sort(result, function(a, b) return a.dates[1]._rd < b.dates[1]._rd end)
  return result
end


-- === Enumeration ===

--- @section Enumeration

--- Return a sorted list of all registered holiday names.
-- @treturn {string,...}
function M.holiday_names()
  local names = {}
  for k in pairs(holiday_fns) do names[#names + 1] = k end
  table.sort(names)
  return names
end

--- Return a sorted list of all built-in calendar names.
-- @treturn {string,...}
function M.calendar_names()
  local names = {}
  for k in pairs(cal_registry) do names[#names + 1] = k end
  table.sort(names)
  return names
end


-- === Astronomy ===

--- @section Astronomy

--- Construct a location for use with astronomical functions.
-- @function location
-- @tparam number latitude  Degrees north (negative = south).
-- @tparam number longitude Degrees east (negative = west).
-- @tparam number elevation Metres above sea level.
-- @tparam number zone      UTC offset in hours (e.g. 2 for UTC+2).
-- @treturn table Location object.
M.location = astro.location

--- Lunar phase (0–360°) at midnight of `date`.
-- 0 = new moon, 90 = first quarter, 180 = full moon, 270 = last quarter.
-- @tparam table date A date object.
-- @treturn number Degrees.
function M.lunar_phase(date)
  return astro.lunar_phase(date._rd)
end

--- List of new-moon date objects falling in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn {table,...} Date objects (in cal_gregorian) for each new moon.
function M.new_moons(g_year)
  local start  = gregorian.gregorian_new_year(g_year)
  local finish = gregorian.gregorian_new_year(g_year + 1)
  local out, tee = {}, astro.new_moon_at_or_after(start)
  while tee < finish do
    out[#out + 1] = make_date(math.floor(tee), cal_registry["gregorian"])
    tee = astro.new_moon_at_or_after(tee + 1)
  end
  return out
end

--- List of full-moon date objects falling in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn {table,...} Date objects (in cal_gregorian) for each full moon.
function M.full_moons(g_year)
  local start  = gregorian.gregorian_new_year(g_year)
  local finish = gregorian.gregorian_new_year(g_year + 1)
  local out, tee = {}, astro.lunar_phase_at_or_after(180, start)
  while tee < finish do
    out[#out + 1] = make_date(math.floor(tee), cal_registry["gregorian"])
    tee = astro.lunar_phase_at_or_after(180, tee + 1)
  end
  return out
end

--- Standard time of sunrise on `date` at `loc`, or nil if none.
-- @tparam table date A date object.
-- @tparam table loc  A location (from `location`).
-- @treturn table|nil `{hour, minute, second, zone}` or nil.
function M.sunrise(date, loc)
  return as_time(astro.sunrise(date._rd, loc), astro.zone(loc))
end

--- Standard time of sunset on `date` at `loc`, or nil if none.
-- @tparam table date A date object.
-- @tparam table loc  A location (from `location`).
-- @treturn table|nil `{hour, minute, second, zone}` or nil.
function M.sunset(date, loc)
  return as_time(astro.sunset(date._rd, loc), astro.zone(loc))
end

--- Standard time of moonrise on `date` at `loc`, or nil if none.
-- @tparam table date A date object.
-- @tparam table loc  A location (from `location`).
-- @treturn table|nil `{hour, minute, second, zone}` or nil.
function M.moonrise(date, loc)
  return as_time(astro.moonrise(date._rd, loc), astro.zone(loc))
end

--- Standard time of moonset on `date` at `loc`, or nil if none.
-- @tparam table date A date object.
-- @tparam table loc  A location (from `location`).
-- @treturn table|nil `{hour, minute, second, zone}` or nil.
function M.moonset(date, loc)
  return as_time(astro.moonset(date._rd, loc), astro.zone(loc))
end


return M
