--- Gregorian calendar conversions and holidays.
-- Ported from "Calendrical Calculations" (4th edition)
-- by Nachum Dershowitz and Edward M. Reingold.
-- Original Lisp code (CALENDRICA 4.0) is Apache 2.0 licensed.
-- @module calendrica-gregorian
-- @release 0.1 2026-07-19

local M = {}

-- Forward declarations needed for mutual recursion / forward references
local gregorian_date, gregorian_leap_year, fixed_from_gregorian, gregorian_year_from_fixed
local gregorian_new_year, gregorian_from_fixed, kday_on_or_before
local kday_before, kday_after, nth_kday, first_kday, last_kday

local basic = require("calendrica-basic")


-- === Month constants ===

local JANUARY = 1
local FEBRUARY = 2
local MARCH = 3
local APRIL = 4
local MAY = 5
local JUNE = 6
local JULY = 7
local AUGUST = 8
local SEPTEMBER = 9
local OCTOBER = 10
local NOVEMBER = 11
local DECEMBER = 12


-- === Epoch ===

local GREGORIAN_EPOCH = basic.rd(1)


-- === Date constructor ===

--- Construct a Gregorian date from year, month, and day.
-- @tparam number year Gregorian year.
-- @tparam number month Month (1–12).
-- @tparam number day Day of month.
-- @treturn table {year, month, day}
function M.gregorian_date(year, month, day)
  return {year, month, day}
end
gregorian_date = M.gregorian_date


-- === Leap year ===

--- True if `g_year` is a leap year on the Gregorian calendar.
-- @tparam number g_year Gregorian year.
-- @treturn boolean
function M.gregorian_leap_year(g_year)
  return g_year % 4 == 0
    and (g_year % 400 == 0 or g_year % 100 ~= 0)
end
gregorian_leap_year = M.gregorian_leap_year


-- === Conversion ===

--- Fixed date equivalent to the Gregorian date `g_date`.
-- @tparam table g_date Gregorian date {year, month, day}.
-- @treturn number Fixed date.
function M.fixed_from_gregorian(g_date)
  local month = basic.standard_month(g_date)
  local day   = basic.standard_day(g_date)
  local year  = basic.standard_year(g_date)
  local correction
  if month <= 2 then
    correction = 0
  elseif gregorian_leap_year(year) then
    correction = -1
  else
    correction = -2
  end
  return (GREGORIAN_EPOCH - 1)              -- Days before start of calendar.
    + 365 * (year - 1)                        -- Ordinary days since epoch.
    + basic.quotient(year - 1, 4)             -- Julian leap days since epoch...
    - basic.quotient(year - 1, 100)           -- ...minus century years...
    + basic.quotient(year - 1, 400)           -- ...plus 400-year cycles.
    + basic.quotient(367 * month - 362, 12)   -- Days in prior months this year.
    + correction                              -- Correct for 28- or 29-day Feb.
    + day                                     -- Days so far this month.
end
fixed_from_gregorian = M.fixed_from_gregorian

--- Gregorian year corresponding to the fixed `date`.
-- @tparam number date Fixed date.
-- @treturn number Gregorian year.
function M.gregorian_year_from_fixed(date)
  local d0   = date - GREGORIAN_EPOCH      -- Prior days.
  local n400 = basic.quotient(d0, 146097)    -- Completed 400-year cycles.
  local d1   = d0 % 146097                   -- Prior days not in n400.
  local n100 = basic.quotient(d1, 36524)     -- 100-year cycles not in n400.
  local d2   = d1 % 36524                    -- Prior days not in n400 or n100.
  local n4   = basic.quotient(d2, 1461)      -- 4-year cycles not in n400 or n100.
  local d3   = d2 % 1461                     -- Prior days not in n400, n100, or n4.
  local n1   = basic.quotient(d3, 365)       -- Years not in n400, n100, or n4.
  local year = 400 * n400 + 100 * n100 + 4 * n4 + n1
  if n100 == 4 or n1 == 4 then
    return year      -- Date is day 366 in a leap year.
  else
    return year + 1  -- Date is ordinal day (1 + d3 % 365) in (year + 1).
  end
end
gregorian_year_from_fixed = M.gregorian_year_from_fixed

--- Gregorian date {year, month, day} corresponding to fixed `date`.
-- @tparam number date Fixed date.
-- @treturn table {year, month, day}
function M.gregorian_from_fixed(date)
  local year       = gregorian_year_from_fixed(date)
  local prior_days = date - gregorian_new_year(year)
  local correction
  if date < fixed_from_gregorian(gregorian_date(year, MARCH, 1)) then
    correction = 0
  elseif gregorian_leap_year(year) then
    correction = 1
  else
    correction = 2
  end
  local month = basic.quotient(12 * (prior_days + correction) + 373, 367)
  local day   = 1 + date
    - fixed_from_gregorian(gregorian_date(year, month, 1))
  return gregorian_date(year, month, day)
end
gregorian_from_fixed = M.gregorian_from_fixed


-- === Year boundaries ===

--- Fixed date of January 1 in `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.gregorian_new_year(g_year)
  return fixed_from_gregorian(gregorian_date(g_year, JANUARY, 1))
end
gregorian_new_year = M.gregorian_new_year

--- Fixed date of December 31 in `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.gregorian_year_end(g_year)
  return fixed_from_gregorian(gregorian_date(g_year, DECEMBER, 31))
end

--- Half-open interval of fixed dates spanning Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn table Interval {jan1_of_year, jan1_of_next_year}.
function M.gregorian_year_range(g_year)
  return basic.interval(
    gregorian_new_year(g_year),
    gregorian_new_year(g_year + 1)
  )
end
local gregorian_year_range = M.gregorian_year_range


-- === Date arithmetic ===

--- Number of days from Gregorian date `g_date1` until `g_date2`.
-- @tparam table g_date1 Start date {year, month, day}.
-- @tparam table g_date2 End date {year, month, day}.
-- @treturn number Number of days.
function M.gregorian_date_difference(g_date1, g_date2)
  return fixed_from_gregorian(g_date2) - fixed_from_gregorian(g_date1)
end
local gregorian_date_difference = M.gregorian_date_difference

-- Day number in year of Gregorian date g_date.
local function day_number(g_date)
  return gregorian_date_difference(
    gregorian_date(basic.standard_year(g_date) - 1, DECEMBER, 31),
    g_date
  )
end

-- Days remaining in year after Gregorian date g_date.
local function days_remaining(g_date)
  return gregorian_date_difference(
    g_date,
    gregorian_date(basic.standard_year(g_date), DECEMBER, 31)
  )
end

-- Last day of month g_month in Gregorian year g_year.
local function last_day_of_gregorian_month(g_year, g_month)
  local next_year  = g_month == 12 and g_year + 1 or g_year
  local next_month = basic.amod(g_month + 1, 12)
  return gregorian_date_difference(
    gregorian_date(g_year, g_month, 1),
    gregorian_date(next_year, next_month, 1)
  )
end


-- === Alternative formulas (from the book) ===

-- Alternative fixed-date from Gregorian date (local).
local function alt_fixed_from_gregorian(g_date) -- luacheck: ignore
  local month   = basic.standard_month(g_date)
  local day     = basic.standard_day(g_date)
  local year    = basic.standard_year(g_date)
  local m_prime = (month - 3) % 12
  local y_prime = year - basic.quotient(m_prime, 10)
  return (GREGORIAN_EPOCH - 1)
    - 306                                        -- Days in March..December.
    + 365 * y_prime                              -- Ordinary days.
    + basic.sigma(
        { basic.to_radix(y_prime, {4, 25, 4}), {97, 24, 1, 0} },
        function(y, a) return y * a end
      )
    + basic.quotient(3 * m_prime + 2, 5)         -- Days in prior months.
    + 30 * m_prime
    + day                                        -- Days so far this month.
end

-- Alternative Gregorian date from fixed date (local).
local function alt_gregorian_from_fixed(date) -- luacheck: ignore
  local y = gregorian_year_from_fixed(
    GREGORIAN_EPOCH - 1 + date + 306
  )
  local prior_days = date - fixed_from_gregorian(
    gregorian_date(y - 1, MARCH, 1)
  )
  local month = basic.amod(
    basic.quotient(5 * prior_days + 2, 153) + 3,
    12
  )
  local year = y - basic.quotient(month + 9, 12)
  local day  = 1 + date
    - fixed_from_gregorian(gregorian_date(year, month, 1))
  return gregorian_date(year, month, day)
end

-- Alternative Gregorian year from fixed date (local).
local function alt_gregorian_year_from_fixed(date) -- luacheck: ignore
  local approx = basic.quotient(
    date - GREGORIAN_EPOCH + 2,
    146097 / 400
  )
  local start = GREGORIAN_EPOCH
    + 365 * approx
    + basic.sigma(
        { basic.to_radix(approx, {4, 25, 4}), {97, 24, 1, 0} },
        function(y, a) return y * a end
      )
  if date < start then
    return approx
  else
    return approx + 1
  end
end


-- === k-day helpers ===

--- Fixed date of the `k`-day on or before fixed `date`.
-- @tparam number k Day of week (0=Sunday..6=Saturday).
-- @tparam number date Fixed date.
-- @treturn number Fixed date.
function M.kday_on_or_before(k, date)
  return date - basic.day_of_week_from_fixed(date - k)
end
kday_on_or_before = M.kday_on_or_before

--- Fixed date of the `k`-day on or after fixed `date`.
-- @tparam number k Day of week.
-- @tparam number date Fixed date.
-- @treturn number Fixed date.
function M.kday_on_or_after(k, date)
  return kday_on_or_before(k, date + 6)
end
local kday_on_or_after = M.kday_on_or_after

--- Fixed date of the `k`-day nearest to fixed `date`.
-- @tparam number k Day of week.
-- @tparam number date Fixed date.
-- @treturn number Fixed date.
function M.kday_nearest(k, date)
  return kday_on_or_before(k, date + 3)
end
local kday_nearest = M.kday_nearest

--- Fixed date of the `k`-day strictly after fixed `date`.
-- @tparam number k Day of week.
-- @tparam number date Fixed date.
-- @treturn number Fixed date.
function M.kday_after(k, date)
  return kday_on_or_before(k, date + 7)
end
kday_after = M.kday_after

--- Fixed date of the `k`-day strictly before fixed `date`.
-- @tparam number k Day of week.
-- @tparam number date Fixed date.
-- @treturn number Fixed date.
function M.kday_before(k, date)
  return kday_on_or_before(k, date - 1)
end
kday_before = M.kday_before

--- Return the `n`-th `k`-day relative to Gregorian date `g_date`.
-- If `n` > 0, the `n`-th k-day on or after `g_date`.
-- If `n` < 0, the `n`-th k-day on or before `g_date`.
-- If `n` = 0, returns bogus.
-- @tparam number n Occurrence count (positive = after, negative = before).
-- @tparam number k Day of week.
-- @tparam table g_date Gregorian date.
-- @treturn number Fixed date, or "bogus" when n = 0.
function M.nth_kday(n, k, g_date)
  if n > 0 then
    return 7 * n + kday_before(k, fixed_from_gregorian(g_date))
  elseif n < 0 then
    return 7 * n + kday_after(k, fixed_from_gregorian(g_date))
  else
    return basic.BOGUS
  end
end
nth_kday = M.nth_kday

--- Fixed date of the first `k`-day on or after Gregorian date `g_date`.
-- @tparam number k Day of week.
-- @tparam table g_date Gregorian date.
-- @treturn number Fixed date.
function M.first_kday(k, g_date)
  return nth_kday(1, k, g_date)
end
first_kday = M.first_kday

--- Fixed date of the last `k`-day on or before Gregorian date `g_date`.
-- @tparam number k Day of week.
-- @tparam table g_date Gregorian date.
-- @treturn number Fixed date.
function M.last_kday(k, g_date)
  return nth_kday(-1, k, g_date)
end
last_kday = M.last_kday


-- === US holidays ===

--- Fixed date of United States Independence Day in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.independence_day(g_year)
  return fixed_from_gregorian(gregorian_date(g_year, JULY, 4))
end

--- Fixed date of United States Labor Day in `g_year` (first Monday in September).
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.labor_day(g_year)
  return first_kday(basic.MONDAY, gregorian_date(g_year, SEPTEMBER, 1))
end

--- Fixed date of United States Memorial Day in `g_year` (last Monday in May).
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.memorial_day(g_year)
  return last_kday(basic.MONDAY, gregorian_date(g_year, MAY, 31))
end

--- Fixed date of United States Election Day in `g_year`
-- (Tuesday after the first Monday in November).
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.election_day(g_year)
  return first_kday(basic.TUESDAY, gregorian_date(g_year, NOVEMBER, 2))
end

--- Fixed date of start of US daylight saving time in `g_year` (second Sunday in March).
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.daylight_saving_start(g_year)
  return nth_kday(2, basic.SUNDAY, gregorian_date(g_year, MARCH, 1))
end

--- Fixed date of end of US daylight saving time in `g_year` (first Sunday in November).
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.daylight_saving_end(g_year)
  return first_kday(basic.SUNDAY, gregorian_date(g_year, NOVEMBER, 1))
end


-- === Christian holidays ===

--- Fixed date of Christmas in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.christmas(g_year)
  return fixed_from_gregorian(gregorian_date(g_year, DECEMBER, 25))
end

--- Fixed date of Advent in Gregorian year `g_year` (Sunday closest to November 30).
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.advent(g_year)
  return kday_nearest(
    basic.SUNDAY,
    fixed_from_gregorian(gregorian_date(g_year, NOVEMBER, 30))
  )
end

--- Fixed date of Epiphany in the US in Gregorian year `g_year`
-- (first Sunday after January 1).
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.epiphany(g_year)
  return first_kday(basic.SUNDAY, gregorian_date(g_year, JANUARY, 2))
end


-- === Unlucky Fridays ===

-- List of Friday-the-13ths within range.
local function unlucky_fridays_in_range(range)
  local a    = basic.begin(range)
  local b    = basic.end_(range)
  local fri  = kday_on_or_after(basic.FRIDAY, a)
  local result = {}
  while a <= fri and fri < b do
    local date = gregorian_from_fixed(fri)
    if basic.standard_day(date) == 13 then
      result[#result + 1] = fri
    end
    fri = fri + 7
  end
  return result
end

--- List of Friday-the-13ths in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn {number,...} Fixed dates of all Friday the 13ths in the year.
function M.unlucky_fridays(g_year)
  return unlucky_fridays_in_range(gregorian_year_range(g_year))
end



-- === Exports ===

--- January month number.
M.JANUARY                   = JANUARY
--- February month number.
M.FEBRUARY                  = FEBRUARY
--- March month number.
M.MARCH                     = MARCH
--- April month number.
M.APRIL                     = APRIL
--- May month number.
M.MAY                       = MAY
--- June month number.
M.JUNE                      = JUNE
--- July month number.
M.JULY                      = JULY
--- August month number.
M.AUGUST                    = AUGUST
--- September month number.
M.SEPTEMBER                 = SEPTEMBER
--- October month number.
M.OCTOBER                   = OCTOBER
--- November month number.
M.NOVEMBER                  = NOVEMBER
--- December month number.
M.DECEMBER                  = DECEMBER

--- Day number (1..366) of Gregorian date `g_date` within its year.
-- @function day_number
-- @tparam table g_date Gregorian date {year, month, day}.
-- @treturn number Day number.
M.day_number = day_number

--- Days remaining in the year after Gregorian date `g_date`.
-- @function days_remaining
-- @tparam table g_date Gregorian date {year, month, day}.
-- @treturn number Days remaining.
M.days_remaining = days_remaining

--- Last day of month `g_month` in Gregorian year `g_year`.
-- @function last_day_of_gregorian_month
-- @tparam number g_year  Gregorian year.
-- @tparam number g_month Gregorian month.
-- @treturn number Last day (28, 29, 30, or 31).
M.last_day_of_gregorian_month = last_day_of_gregorian_month

return M
