--- Hebrew 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-hebrew
-- @release 0.1 2026-07-19

local M = {}

local basic     = require("calendrica-basic")
local julian    = require("calendrica-julian")
local gregorian = require("calendrica-gregorian")
local coptic    = require("calendrica-coptic-ethiopic")


-- === Month constants ===

local NISAN = 1

-- Iyyar month number
local IYYAR      = 2

-- Sivan month number
local SIVAN      = 3

-- Tammuz month number
local TAMMUZ     = 4

-- Av month number
local AV         = 5

-- Elul month number
local ELUL       = 6

local TISHRI = 7

-- Marheshvan month number
local MARHESHVAN = 8

-- Kislev month number
local KISLEV     = 9

-- Tevet month number
local TEVET      = 10

-- Shevat month number
local SHEVAT     = 11

-- Adar month number
local ADAR       = 12

-- Adar II month number
local ADARII     = 13


-- === Epoch ===

-- Fixed date of start of the Hebrew calendar (Tishri 1, 1 AM).
local HEBREW_EPOCH = julian.fixed_from_julian(
  julian.julian_date(julian.bce(3761), julian.OCTOBER, 7)
)


-- === Date constructor and accessors ===

--- Construct a Hebrew date {year, month, day}.
-- @tparam number year  Hebrew year.
-- @tparam number month Hebrew month.
-- @tparam number day   Hebrew day.
-- @treturn table {year, month, day}
function M.hebrew_date(year, month, day)
  return {year, month, day}
end
local hebrew_date = M.hebrew_date


-- === Year predicates ===

-- True if h_year is a Hebrew leap year.
local function hebrew_leap_year(h_year)
  return ((1 + 7 * h_year) % 19) < 7
end

-- True if h_year is a sabbatical year.
local function hebrew_sabbatical_year(h_year)
  return h_year % 7 == 0
end


-- === Month helpers ===

-- Last month of Hebrew year h_year.
local function last_month_of_hebrew_year(h_year)
  if hebrew_leap_year(h_year) then
    return ADARII
  else
    return ADAR
  end
end

-- Forward declarations needed because long_marheshvan/short_kislev
-- reference days_in_hebrew_year which is defined later.
local days_in_hebrew_year

-- True if Marheshvan is long in Hebrew year h_year.
local function long_marheshvan(h_year)
  local d = days_in_hebrew_year(h_year)
  return d == 355 or d == 385
end

-- True if Kislev is short in Hebrew year h_year.
local function short_kislev(h_year)
  local d = days_in_hebrew_year(h_year)
  return d == 353 or d == 383
end

-- Last day of month h_month in Hebrew year h_year.
local function last_day_of_hebrew_month(h_year, h_month)
  local short_months = {
    [IYYAR]  = true,
    [TAMMUZ] = true,
    [ELUL]   = true,
    [TEVET]  = true,
    [ADARII] = true,
  }
  if short_months[h_month]
    or (h_month == ADAR and not hebrew_leap_year(h_year))
    or (h_month == MARHESHVAN and not long_marheshvan(h_year))
    or (h_month == KISLEV and short_kislev(h_year))
  then
    return 29
  else
    return 30
  end
end


-- === Molad ===

-- Moment of mean conjunction (molad) of h_month in Hebrew h_year.
local function molad(h_year, h_month)
  -- Treat Nisan as start of year.
  local y
  if h_month < TISHRI then
    y = h_year + 1
  else
    y = h_year
  end
  local months_elapsed = (h_month - TISHRI)
    + basic.quotient(235 * y - 234, 19)
  return HEBREW_EPOCH
    - 876/25920
    + months_elapsed * (29 + 12/24 + 793/25920)
end


-- === Calendar arithmetic ===

-- Days elapsed from noon prior to epoch to molad of Tishri of h_year.
local function hebrew_calendar_elapsed_days(h_year)
  local months_elapsed = basic.quotient(235 * h_year - 234, 19)
  local parts_elapsed  = 12084 + 13753 * months_elapsed
  local days = 29 * months_elapsed
    + basic.quotient(parts_elapsed, 25920)
  if (3 * (days + 1)) % 7 < 3 then  -- Sunday, Wednesday, or Friday
    return days + 1  -- Delay one day.
  else
    return days
  end
end

-- Delays to start of Hebrew year h_year to keep year length in range.
local function hebrew_year_length_correction(h_year)
  local ny0 = hebrew_calendar_elapsed_days(h_year - 1)
  local ny1 = hebrew_calendar_elapsed_days(h_year)
  local ny2 = hebrew_calendar_elapsed_days(h_year + 1)
  if ny2 - ny1 == 356 then  -- Next year would be too long.
    return 2
  elseif ny1 - ny0 == 382 then  -- Previous year too short.
    return 1
  else
    return 0
  end
end

-- Fixed date of Hebrew new year h_year.
local function hebrew_new_year(h_year)
  return HEBREW_EPOCH
    + hebrew_calendar_elapsed_days(h_year)
    + hebrew_year_length_correction(h_year)
end

-- Number of days in Hebrew year h_year.
days_in_hebrew_year = function(h_year)
  return hebrew_new_year(h_year + 1) - hebrew_new_year(h_year)
end


-- === Conversion ===

--- Fixed date of Hebrew date `h_date`.
-- @tparam table h_date Hebrew date {year, month, day}.
-- @treturn number Fixed date.
function M.fixed_from_hebrew(h_date)
  local month = basic.standard_month(h_date)
  local day   = basic.standard_day(h_date)
  local year  = basic.standard_year(h_date)

  local days = hebrew_new_year(year) + day - 1

  if month < TISHRI then
    -- Add days in prior months this year before and after Nisan.
    days = days + basic.sum(
      function(m) return last_day_of_hebrew_month(year, m) end,
      TISHRI,
      function(m) return m <= last_month_of_hebrew_year(year) end
    )
    days = days + basic.sum(
      function(m) return last_day_of_hebrew_month(year, m) end,
      NISAN,
      function(m) return m < month end
    )
  else
    -- Add days in prior months this year.
    days = days + basic.sum(
      function(m) return last_day_of_hebrew_month(year, m) end,
      TISHRI,
      function(m) return m < month end
    )
  end

  return days
end
local fixed_from_hebrew = M.fixed_from_hebrew

--- Hebrew date {year, month, day} corresponding to fixed `date`.
-- @tparam number date Fixed date.
-- @treturn table {year, month, day}
function M.hebrew_from_fixed(date)
  -- Approximate year (average Hebrew year length = 35975351/98496 ≈ 365.25).
  local approx = 1 + basic.quotient(
    (date - HEBREW_EPOCH) * 98496, 35975351
  )
  -- Search forward for the actual year.
  local year = basic.final(
    approx - 1,
    function(y) return hebrew_new_year(y) <= date end
  )
  -- Starting month: Tishri or Nisan depending on position in year.
  local start
  if date < fixed_from_hebrew(hebrew_date(year, NISAN, 1)) then
    start = TISHRI
  else
    start = NISAN
  end
  -- Search forward from start month.
  local month = basic.next(
    start,
    function(m)
      return date <= fixed_from_hebrew(
        hebrew_date(year, m, last_day_of_hebrew_month(year, m))
      )
    end
  )
  -- Calculate day by subtraction.
  local day = 1 + date - fixed_from_hebrew(hebrew_date(year, month, 1))
  return hebrew_date(year, month, day)
end
local hebrew_from_fixed = M.hebrew_from_fixed

-- Fixed date of the molad that occurs moon days into the week.
local function fixed_from_molad(moon) -- luacheck: ignore
  local r = (74377 * moon - 2879/2160) % 7
  return basic.fixed_from_moment(
    molad(1, TISHRI) + r * 765433
  )
end


-- === Jewish holidays ===

-- Forward declaration for hebrew_in_gregorian (used by hanukkah).
local hebrew_in_gregorian

--- Fixed date of Yom Kippur occurring in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.yom_kippur(g_year)
  local h_year = 1 + g_year
    - gregorian.gregorian_year_from_fixed(HEBREW_EPOCH)
  return fixed_from_hebrew(hebrew_date(h_year, TISHRI, 10))
end

--- Fixed dates of Rosh Hashanah (Hebrew New Year, Tishri 1) in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn {number,...} Fixed dates.
function M.rosh_hashanah(g_year)
  return hebrew_in_gregorian(TISHRI, 1, g_year)
end

--- Fixed dates of Sukkot (Tishri 15) in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn {number,...} Fixed dates.
function M.sukkot(g_year)
  return hebrew_in_gregorian(TISHRI, 15, g_year)
end

--- Fixed dates of Shavuot (Sivan 6) in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn {number,...} Fixed dates.
function M.shavuot(g_year)
  return hebrew_in_gregorian(SIVAN, 6, g_year)
end

--- Fixed date of Passover occurring in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.passover(g_year)
  local h_year = g_year
    - gregorian.gregorian_year_from_fixed(HEBREW_EPOCH)
  return fixed_from_hebrew(hebrew_date(h_year, NISAN, 15))
end
local passover = M.passover

-- Elapsed weeks and days in the omer at date. Returns BOGUS if not in omer.
local function omer(date)
  local c = date - passover(gregorian.gregorian_year_from_fixed(date))
  if c >= 1 and c <= 49 then
    return {basic.quotient(c, 7), c % 7}
  else
    return basic.BOGUS
  end
end

--- Fixed date of Purim occurring in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.purim(g_year)
  local h_year = g_year
    - gregorian.gregorian_year_from_fixed(HEBREW_EPOCH)
  local last_month = last_month_of_hebrew_year(h_year)
  return fixed_from_hebrew(hebrew_date(h_year, last_month, 14))
end
local purim = M.purim

--- Fixed date of Ta'anit Esther occurring in Gregorian year `g_year`.
-- If Purim is on Sunday, returns the prior Thursday; otherwise the day before.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.ta_anit_esther(g_year)
  local purim_date = purim(g_year)
  if basic.day_of_week_from_fixed(purim_date) == basic.SUNDAY then
    return purim_date - 3  -- Prior Thursday.
  else
    return purim_date - 1
  end
end

--- Fixed date of Tishah be-Av occurring in Gregorian year `g_year`.
-- If the ninth of Av is Saturday, returns the next day.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.tishah_be_av(g_year)
  local h_year = g_year
    - gregorian.gregorian_year_from_fixed(HEBREW_EPOCH)
  local av9 = fixed_from_hebrew(hebrew_date(h_year, AV, 9))
  if basic.day_of_week_from_fixed(av9) == basic.SATURDAY then
    return av9 + 1
  else
    return av9
  end
end

--- Fixed dates of Hanukkah (first day) occurring in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn table List of fixed dates.
function M.hanukkah(g_year)
  return hebrew_in_gregorian(KISLEV, 25, g_year)
end

--- Fixed date of Yom ha-Zikkaron occurring in Gregorian year `g_year`.
-- Observance is moved if Iyyar 4 falls on Thursday, Friday, or Sunday.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.yom_ha_zikkaron(g_year)
  local h_year = g_year
    - gregorian.gregorian_year_from_fixed(HEBREW_EPOCH)
  local iyyar4 = fixed_from_hebrew(hebrew_date(h_year, IYYAR, 4))
  local dow = basic.day_of_week_from_fixed(iyyar4)
  if dow == basic.THURSDAY or dow == basic.FRIDAY then
    -- Move to Wednesday.
    return gregorian.kday_before(basic.WEDNESDAY, iyyar4)
  elseif dow == basic.SUNDAY then
    return iyyar4 + 1
  else
    return iyyar4
  end
end


-- === Hebrew date in Gregorian year ===

-- Fixed dates of Hebrew h_month/h_day that fall in Gregorian year g_year.
hebrew_in_gregorian = function(h_month, h_day, g_year)
  local jan1 = gregorian.gregorian_new_year(g_year)
  local y    = basic.standard_year(hebrew_from_fixed(jan1))
  local date0 = fixed_from_hebrew(hebrew_date(y,     h_month, h_day))
  local date1 = fixed_from_hebrew(hebrew_date(y + 1, h_month, h_day))
  local date2 = fixed_from_hebrew(hebrew_date(y + 2, h_month, h_day))
  return basic.list_range(
    {date0, date1, date2},
    gregorian.gregorian_year_range(g_year)
  )
end


-- === Birkath ha-Hama ===

-- Fixed dates of Birkath ha-Hama in Gregorian year g_year, if it occurs.
local function birkath_ha_hama(g_year)
  local dates  = coptic.coptic_in_gregorian(7, 30, g_year)
  if #dates > 0
    and basic.standard_year(coptic.coptic_from_fixed(dates[1])) % 28 == 17
  then
    return dates
  else
    return {}
  end
end

-- Fixed dates of Sh'ela in Gregorian year g_year.
local function sh_ela(g_year)
  return coptic.coptic_in_gregorian(3, 26, g_year)
end


-- === Hebrew birthday and yahrzeit ===

-- Fixed date of anniversary of Hebrew birthdate in Hebrew year h_year.
local function hebrew_birthday(birthdate, h_year)
  local birth_day   = basic.standard_day(birthdate)
  local birth_month = basic.standard_month(birthdate)
  local birth_year  = basic.standard_year(birthdate)
  -- If born in Adar of a normal year or Adar II of a leap year,
  -- use the same day in the last month of the target year.
  if birth_month == last_month_of_hebrew_year(birth_year) then
    return fixed_from_hebrew(
      hebrew_date(h_year, last_month_of_hebrew_year(h_year), birth_day)
    )
  else
    return fixed_from_hebrew(
      hebrew_date(h_year, birth_month, 1)
    ) + birth_day - 1
  end
end

-- Fixed dates of birthdate anniversary in Gregorian year g_year.
local function hebrew_birthday_in_gregorian(birthdate, g_year)
  local jan1  = gregorian.gregorian_new_year(g_year)
  local y     = basic.standard_year(hebrew_from_fixed(jan1))
  local date0 = hebrew_birthday(birthdate, y)
  local date1 = hebrew_birthday(birthdate, y + 1)
  local date2 = hebrew_birthday(birthdate, y + 2)
  return basic.list_range(
    {date0, date1, date2},
    gregorian.gregorian_year_range(g_year)
  )
end

-- Fixed date of yahrzeit of death_date in Hebrew year h_year.
local function yahrzeit(death_date, h_year)
  local death_day   = basic.standard_day(death_date)
  local death_month = basic.standard_month(death_date)
  local death_year  = basic.standard_year(death_date)

  if death_month == MARHESHVAN
    and death_day == 30
    and not long_marheshvan(death_year + 1)
  then
    -- Marheshvan 30: use the day before Kislev 1.
    return fixed_from_hebrew(hebrew_date(h_year, KISLEV, 1)) - 1

  elseif death_month == KISLEV
    and death_day == 30
    and short_kislev(death_year + 1)
  then
    -- Kislev 30: use the day before Tevet 1.
    return fixed_from_hebrew(hebrew_date(h_year, TEVET, 1)) - 1

  elseif death_month == ADARII then
    -- Adar II: use same day in last month of target year.
    return fixed_from_hebrew(
      hebrew_date(h_year, last_month_of_hebrew_year(h_year), death_day)
    )

  elseif death_day == 30
    and death_month == ADAR
    and not hebrew_leap_year(h_year)
  then
    -- 30 Adar I in a non-leap year: use last day of Shevat.
    return fixed_from_hebrew(hebrew_date(h_year, SHEVAT, 30))

  else
    -- Normal case.
    return fixed_from_hebrew(
      hebrew_date(h_year, death_month, 1)
    ) + death_day - 1
  end
end

-- Fixed dates of yahrzeit of death_date in Gregorian year g_year.
local function yahrzeit_in_gregorian(death_date, g_year)
  local jan1  = gregorian.gregorian_new_year(g_year)
  local y     = basic.standard_year(hebrew_from_fixed(jan1))
  local date0 = yahrzeit(death_date, y)
  local date1 = yahrzeit(death_date, y + 1)
  local date2 = yahrzeit(death_date, y + 2)
  return basic.list_range(
    {date0, date1, date2},
    gregorian.gregorian_year_range(g_year)
  )
end


-- === Possible days of week ===

-- Shift each weekday in list l by cap_delta days.
local function shift_days(l, cap_delta)
  local result = {}
  for _, d in ipairs(l) do
    result[#result + 1] = (d + cap_delta) % 7
  end
  return result
end

-- Possible days of week on which Hebrew h_month/h_day can fall.
local function possible_hebrew_days(h_month, h_day) -- luacheck: ignore
  local h_date0 = hebrew_date(5, NISAN, 1)
  -- Use a leap year with full pattern.
  local h_year
  if h_month > ELUL then
    h_year = 6
  else
    h_year = 5
  end
  local h_date = hebrew_date(h_year, h_month, h_day)
  local n = fixed_from_hebrew(h_date)
           - fixed_from_hebrew(h_date0)
  local basic_days = {basic.TUESDAY, basic.THURSDAY, basic.SATURDAY}
  local extra
  if h_month == MARHESHVAN and h_day == 30 then
    extra = {}
  elseif h_month == KISLEV and h_day < 30 then
    extra = {basic.MONDAY, basic.WEDNESDAY, basic.FRIDAY}
  elseif h_month == KISLEV and h_day == 30 then
    extra = {basic.MONDAY}
  elseif h_month == TEVET or h_month == SHEVAT then
    extra = {basic.SUNDAY, basic.MONDAY}
  elseif h_month == ADAR and h_day < 30 then
    extra = {basic.SUNDAY, basic.MONDAY}
  else
    extra = {basic.SUNDAY}
  end
  local combined = {}
  for _, d in ipairs(basic_days) do combined[#combined + 1] = d end
  for _, d in ipairs(extra)      do combined[#combined + 1] = d end
  return shift_days(combined, n)
end


-- === Exports ===

--- Fixed dates of Hebrew month/day `h_month`/`h_day` falling in Gregorian year `g_year`.
-- @function hebrew_in_gregorian
-- @tparam number h_month Hebrew month.
-- @tparam number h_day   Hebrew day.
-- @tparam number g_year  Gregorian year.
-- @treturn {number,...} Fixed dates.
M.hebrew_in_gregorian = hebrew_in_gregorian

--- True if `h_year` is a Hebrew leap year.
-- @function hebrew_leap_year
-- @tparam number h_year Hebrew year.
-- @treturn boolean
M.hebrew_leap_year = hebrew_leap_year

--- True if `h_year` is a Hebrew sabbatical (shmita) year.
-- @function hebrew_sabbatical_year
-- @tparam number h_year Hebrew year.
-- @treturn boolean
M.hebrew_sabbatical_year = hebrew_sabbatical_year

--- Day of the Omer for fixed `date` (1..49), or BOGUS outside the Omer period.
-- Returns {weeks, days} where weeks is complete weeks and days is remaining days.
-- @function omer
-- @tparam number date Fixed date.
-- @treturn table|number {weeks, days} or BOGUS.
M.omer = omer

--- Fixed dates of Birkath HaHama in Gregorian year `g_year`.
-- Occurs when Nisan 1 falls on Wednesday in a year divisible by 28 (solar cycle).
-- @function birkath_ha_hama
-- @tparam number g_year Gregorian year.
-- @treturn {number,...} Fixed dates (empty if not occurring this year).
M.birkath_ha_hama = birkath_ha_hama

--- Fixed dates of Sh'ela (prayer for rain) in Gregorian year `g_year`.
-- @function sh_ela
-- @tparam number g_year Gregorian year.
-- @treturn {number,...} Fixed dates.
M.sh_ela = sh_ela

--- Fixed dates of Hebrew `birthdate` anniversary falling in Gregorian year `g_year`.
-- @function hebrew_birthday_in_gregorian
-- @tparam table  birthdate Hebrew date {year, month, day}.
-- @tparam number g_year    Gregorian year.
-- @treturn {number,...} Fixed dates.
M.hebrew_birthday_in_gregorian = hebrew_birthday_in_gregorian

--- Fixed dates of yahrzeit for Hebrew `death_date` falling in Gregorian year `g_year`.
-- @function yahrzeit_in_gregorian
-- @tparam table  death_date Hebrew date {year, month, day}.
-- @tparam number g_year     Gregorian year.
-- @treturn {number,...} Fixed dates.
M.yahrzeit_in_gregorian = yahrzeit_in_gregorian

--- Nisan month number (1).
M.NISAN             = NISAN
--- Iyyar month number (2).
M.IYYAR             = IYYAR
--- Sivan month number (3).
M.SIVAN             = SIVAN
--- Tammuz month number (4).
M.TAMMUZ            = TAMMUZ
--- Av month number (5).
M.AV                = AV
--- Elul month number (6).
M.ELUL              = ELUL
--- Tishri month number (7).
M.TISHRI            = TISHRI
--- Marheshvan month number (8).
M.MARHESHVAN        = MARHESHVAN
--- Kislev month number (9).
M.KISLEV            = KISLEV
--- Tevet month number (10).
M.TEVET             = TEVET
--- Shevat month number (11).
M.SHEVAT            = SHEVAT
--- Adar month number (12).
M.ADAR              = ADAR
--- Adar II month number (13, leap years only).
M.ADARII            = ADARII

return M
