--- Chinese calendar conversions.
-- 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-chinese
-- @release 0.1 2026-07-19

local M = {}

local basic     = require("calendrica-basic")
local gregorian = require("calendrica-gregorian")
local astro     = require("calendrica-astro")


-- === Epoch ===

-- Fixed date of start of the Chinese calendar (February 15, -2636 Gregorian).
local CHINESE_EPOCH = gregorian.fixed_from_gregorian(
  gregorian.gregorian_date(-2636, gregorian.FEBRUARY, 15)
)


-- === Augury constants ===

-- Lichun does not occur (double-blind year).
local WIDOW = 0

-- Lichun occurs once at the end.
local BLIND = 1

-- Lichun occurs once at the start.
local BRIGHT = 2

-- Lichun occurs twice (double-happiness).
local DOUBLE_BRIGHT = 3

-- Elapsed months at start of Chinese sexagesimal month cycle.
local CHINESE_MONTH_NAME_EPOCH = 57

-- RD date of start of Chinese sexagesimal day cycle.
local CHINESE_DAY_NAME_EPOCH = basic.rd(45)


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

-- Chinese date constructor.
local function chinese_date(cycle, year, month, leap, day)
  return {cycle, year, month, leap, day}
end

-- Chinese date accessors.
local function chinese_cycle(date)  return date[1] end
local function chinese_year(date)   return date[2] end
local function chinese_month(date)  return date[3] end
local function chinese_leap(date)   return date[4] end
local function chinese_day(date)    return date[5] end


-- === Location ===

-- Location of Beijing; time zone varies with tee.
local function chinese_location(tee)
  local year = gregorian.gregorian_year_from_fixed(math.floor(tee))
  if year < 1929 then
    return astro.location(
      astro.angle(39, 55, 0), astro.angle(116, 25, 0),
      astro.mt(43.5), astro.hr(1397/180)
    )
  else
    return astro.location(
      astro.angle(39, 55, 0), astro.angle(116, 25, 0),
      astro.mt(43.5), astro.hr(8)
    )
  end
end

-- Universal time of midnight at start of fixed date in China.
local function midnight_in_china(date)
  return astro.universal_from_standard(date, chinese_location(date))
end

-- Moment of first time at or after tee when solar longitude will be lambda.
local function chinese_solar_longitude_on_or_after(lambda, tee)
  local sun = astro.solar_longitude_after(
    lambda,
    astro.universal_from_standard(tee, chinese_location(tee))
  )
  return astro.standard_from_universal(sun, chinese_location(sun))
end


-- === Solar terms ===

-- Last Chinese major solar term (zhongqi) before fixed date.
local function current_major_solar_term(date)
  local s = astro.solar_longitude(
    astro.universal_from_standard(date, chinese_location(date))
  )
  return basic.amod(2 + basic.quotient(s, 30), 12)
end

-- Moment (in Beijing) of first Chinese major solar term on or after fixed date.
local function major_solar_term_on_or_after(date)
  local s = astro.solar_longitude(midnight_in_china(date))
  local l = (30 * math.ceil(s / 30)) % 360
  return chinese_solar_longitude_on_or_after(l, date)
end

-- Last Chinese minor solar term (jieqi) before date.
local function current_minor_solar_term(date)
  local s = astro.solar_longitude(
    astro.universal_from_standard(date, chinese_location(date))
  )
  return basic.amod(3 + basic.quotient(s - 15, 30), 12)
end

-- Moment (in Beijing) of first Chinese minor solar term on or after fixed date.
local function minor_solar_term_on_or_after(date)
  local s = astro.solar_longitude(midnight_in_china(date))
  local l = (30 * math.ceil((s - 15) / 30) + 15) % 360
  return chinese_solar_longitude_on_or_after(l, date)
end


-- === New moon calculations ===

-- Fixed date (Beijing) of first new moon before fixed date.
local function chinese_new_moon_before(date)
  local tee = astro.new_moon_before(midnight_in_china(date))
  return math.floor(
    astro.standard_from_universal(tee, chinese_location(tee))
  )
end

-- Fixed date (Beijing) of first new moon on or after fixed date.
local function chinese_new_moon_on_or_after(date)
  local tee = astro.new_moon_at_or_after(midnight_in_china(date))
  return math.floor(
    astro.standard_from_universal(tee, chinese_location(tee))
  )
end

-- True if Chinese lunar month starting on date has no major solar term.
local function chinese_no_major_solar_term(date)
  return current_major_solar_term(date)
    == current_major_solar_term(
         chinese_new_moon_on_or_after(date + 1)
       )
end


-- === Winter solstice ===

-- Fixed date of winter solstice in Chinese zone on or before fixed date.
local function chinese_winter_solstice_on_or_before(date)
  local approx = astro.estimate_prior_solar_longitude(
    astro.WINTER, midnight_in_china(date + 1)
  )
  return basic.next(
    math.floor(approx) - 1,
    function(day)
      return astro.WINTER < astro.solar_longitude(
        midnight_in_china(day + 1)
      )
    end
  )
end


-- === New year ===

-- Fixed date of Chinese New Year in sui containing date.
local function chinese_new_year_in_sui(date)
  local s1      = chinese_winter_solstice_on_or_before(date)
  local s2      = chinese_winter_solstice_on_or_before(s1 + 370)
  local m12     = chinese_new_moon_on_or_after(s1 + 1)
  local m13     = chinese_new_moon_on_or_after(m12 + 1)
  local next_m11 = chinese_new_moon_before(s2 + 1)
  if basic.round_half_to_even(
      (next_m11 - m12) / astro.MEAN_SYNODIC_MONTH
    ) == 12
    and (chinese_no_major_solar_term(m12)
         or chinese_no_major_solar_term(m13))
  then
    return chinese_new_moon_on_or_after(m13 + 1)
  else
    return m13
  end
end

-- Fixed date of Chinese New Year on or before fixed date.
local function chinese_new_year_on_or_before(date)
  local new_year = chinese_new_year_in_sui(date)
  if date >= new_year then
    return new_year
  else
    return chinese_new_year_in_sui(date - 180)
  end
end

-- True if there is a leap month on or after m_prime and on or before m (Beijing).
local function chinese_prior_leap_month(m_prime, m) -- luacheck: ignore
  return m >= m_prime
    and (chinese_no_major_solar_term(m)
         or chinese_prior_leap_month(
              m_prime, chinese_new_moon_before(m)
            ))
end

--- Fixed date of Chinese New Year in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.chinese_new_year(g_year)
  return chinese_new_year_on_or_before(
    gregorian.fixed_from_gregorian(
      gregorian.gregorian_date(g_year, gregorian.JULY, 1)
    )
  )
end




-- === Asian calendar factory ===
-- The Lisp source hardcodes Beijing for chinese_from_fixed / fixed_from_chinese.
-- Following the calcal R package, we generalise via a location function so that
-- Japanese, Korean, and Vietnamese variants share the same algorithm.

local function asian_calendar(locfn)
  local function midnight(date)
    return astro.universal_from_standard(date, locfn(date))
  end
  local function cur_major_term(date)
    local s = astro.solar_longitude(astro.universal_from_standard(date, locfn(date)))
    return basic.amod(2 + basic.quotient(s, 30), 12)
  end
  local function no_major_term(date)
    return cur_major_term(date) == cur_major_term(
      math.floor(astro.standard_from_universal(
        astro.new_moon_at_or_after(midnight(date + 1)), locfn(midnight(date + 1)))))
  end
  local function new_moon_before(date)
    local tee = astro.new_moon_before(midnight(date))
    return math.floor(astro.standard_from_universal(tee, locfn(tee)))
  end
  local function new_moon_on_or_after(date)
    local tee = astro.new_moon_at_or_after(midnight(date))
    return math.floor(astro.standard_from_universal(tee, locfn(tee)))
  end
  local function winter_solstice_on_or_before(date)
    local approx = astro.estimate_prior_solar_longitude(
      astro.WINTER, midnight(date + 1))
    return basic.next(
      math.floor(approx) - 1,
      function(day)
        return astro.WINTER < astro.solar_longitude(midnight(day + 1))
      end
    )
  end
  local prior_leap_month
  prior_leap_month = function(m_prime, m)
    return m >= m_prime
      and (no_major_term(m) or prior_leap_month(m_prime, new_moon_before(m)))
  end
  local function new_year_on_or_before(date)
    local function new_year_in_sui(d)
      local s1       = winter_solstice_on_or_before(d)
      local s2       = winter_solstice_on_or_before(s1 + 370)
      local m12      = new_moon_on_or_after(s1 + 1)
      local m13      = new_moon_on_or_after(m12 + 1)
      local next_m11 = new_moon_before(s2 + 1)
      if basic.round_half_to_even(
          (next_m11 - m12) / astro.MEAN_SYNODIC_MONTH) == 12
        and (no_major_term(m12) or no_major_term(m13))
      then
        return new_moon_on_or_after(m13 + 1)
      else
        return m13
      end
    end
    local ny = new_year_in_sui(date)
    return date >= ny and ny or new_year_in_sui(date - 180)
  end

  local from_fixed, to_fixed
  from_fixed = function(date)
    local s1       = winter_solstice_on_or_before(date)
    local s2       = winter_solstice_on_or_before(s1 + 370)
    local m12      = new_moon_on_or_after(s1 + 1)
    local next_m11 = new_moon_before(s2 + 1)
    local m        = new_moon_before(date + 1)
    local leap_year = basic.round_half_to_even(
      (next_m11 - m12) / astro.MEAN_SYNODIC_MONTH) == 12
    local month = basic.amod(
      basic.round_half_to_even((m - m12) / astro.MEAN_SYNODIC_MONTH)
      - (leap_year and prior_leap_month(m12, m) and 1 or 0),
      12
    )
    local leap_month = leap_year
      and no_major_term(m)
      and not prior_leap_month(m12, new_moon_before(m))
    local elapsed_years = math.floor(
      1.5 - month / 12 + (date - CHINESE_EPOCH) / astro.MEAN_TROPICAL_YEAR)
    local cycle = 1 + basic.quotient(elapsed_years - 1, 60)
    local year  = basic.amod(elapsed_years, 60)
    local day   = 1 + date - m
    return chinese_date(cycle, year, month, leap_month, day)
  end
  to_fixed = function(c_date)
    local cycle  = chinese_cycle(c_date)
    local year   = chinese_year(c_date)
    local month  = chinese_month(c_date)
    local leap   = chinese_leap(c_date)
    local day    = chinese_day(c_date)
    local mid_year = math.floor(
      CHINESE_EPOCH
      + ((cycle - 1) * 60 + (year - 1) + 1/2) * astro.MEAN_TROPICAL_YEAR)
    local ny = new_year_on_or_before(mid_year)
    local p  = new_moon_on_or_after(ny + (month - 1) * 29)
    local d  = from_fixed(p)
    local prior_new_moon
    if month == chinese_month(d) and leap == chinese_leap(d) then
      prior_new_moon = p
    else
      prior_new_moon = new_moon_on_or_after(p + 1)
    end
    return prior_new_moon + day - 1
  end
  return from_fixed, to_fixed
end


-- === Japanese, Korean, Vietnamese locations ===

-- Location for Japanese calendar; varies with tee.
local function japanese_location(tee)
  local year = gregorian.gregorian_year_from_fixed(math.floor(tee))
  if year < 1888 then
    return astro.location(
      35.7, astro.angle(139, 46, 0), astro.mt(24), astro.hr(9 + 143/450)
    )
  else
    return astro.location(35, 135, astro.mt(0), astro.hr(9))
  end
end

-- Location for Korean calendar (Seoul); varies with tee.
local function korean_location(tee)
  local z
  if tee < gregorian.fixed_from_gregorian(
      gregorian.gregorian_date(1908, gregorian.APRIL, 1)) then
    z = 3809/450
  elseif tee < gregorian.fixed_from_gregorian(
      gregorian.gregorian_date(1912, gregorian.JANUARY, 1)) then
    z = 8.5
  elseif tee < gregorian.fixed_from_gregorian(
      gregorian.gregorian_date(1954, gregorian.MARCH, 21)) then
    z = 9
  elseif tee < gregorian.fixed_from_gregorian(
      gregorian.gregorian_date(1961, gregorian.AUGUST, 10)) then
    z = 8.5
  else
    z = 9
  end
  return astro.location(
    astro.angle(37, 34, 0), astro.angle(126, 58, 0), astro.mt(0), astro.hr(z)
  )
end

-- Equivalent Korean year to Chinese cycle and year.
local function korean_year(cycle, year)
  return 60 * cycle + year - 364
end

-- Location for Vietnamese calendar (Hanoi); varies with tee.
local function vietnamese_location(tee)
  local z
  if tee < gregorian.gregorian_new_year(1968) then
    z = 8
  else
    z = 7
  end
  return astro.location(
    astro.angle(21, 2, 0), astro.angle(105, 51, 0), astro.mt(12), astro.hr(z)
  )
end


-- === Conversion ===

local _cn_from, _cn_to = asian_calendar(chinese_location)
local _jp_from, _jp_to = asian_calendar(japanese_location)
local _ko_from, _ko_to = asian_calendar(korean_location)
local _vn_from, _vn_to = asian_calendar(vietnamese_location)

--- Chinese date {cycle, year, month, leap, day} of fixed `date`.
-- @function chinese_from_fixed
-- @tparam number date Fixed date.
-- @treturn table {cycle, year, month, leap, day}
M.chinese_from_fixed = _cn_from

--- Fixed date of Chinese date `c_date`.
-- @function fixed_from_chinese
-- @tparam table c_date Chinese date {cycle, year, month, leap, day}.
-- @treturn number Fixed date.
M.fixed_from_chinese = _cn_to

--- Japanese date {cycle, year, month, leap, day} of fixed `date`.
-- Uses Tokyo meridian (pre-1888) or JST zone (1888+).
-- @function japanese_from_fixed
-- @tparam number date Fixed date.
-- @treturn table {cycle, year, month, leap, day}
M.japanese_from_fixed = _jp_from

--- Fixed date of Japanese date `j_date`.
-- @function fixed_from_japanese
-- @tparam table j_date Japanese date {cycle, year, month, leap, day}.
-- @treturn number Fixed date.
M.fixed_from_japanese = _jp_to

--- Korean date {year, month, leap, day} of fixed `date`.
-- Uses Seoul city hall meridian with historically accurate time zones.
-- Year is the continuous Korean year (cycle * 60 + sexagesimal_year - 364).
-- @function korean_from_fixed
-- @tparam number date Fixed date.
-- @treturn table {year, month, leap, day}
function M.korean_from_fixed(date)
  local d = _ko_from(date)
  return {korean_year(chinese_cycle(d), chinese_year(d)),
          chinese_month(d), chinese_leap(d), chinese_day(d)}
end

--- Fixed date of Korean date `k_date`.
-- @function fixed_from_korean
-- @tparam table k_date Korean date {year, month, leap, day}.
-- @treturn number Fixed date.
function M.fixed_from_korean(k_date)
  local year  = k_date[1]
  local cycle = math.floor((year + 364) / 60)
  local y     = basic.amod(year + 364, 60)
  return _ko_to(chinese_date(cycle, y, k_date[2], k_date[3], k_date[4]))
end

--- Vietnamese date {cycle, year, month, leap, day} of fixed `date`.
-- Uses Hanoi meridian (UTC+8 before 1968, UTC+7 after).
-- @function vietnamese_from_fixed
-- @tparam number date Fixed date.
-- @treturn table {cycle, year, month, leap, day}
M.vietnamese_from_fixed = _vn_from

--- Fixed date of Vietnamese date `v_date`.
-- @function fixed_from_vietnamese
-- @tparam table v_date Vietnamese date {cycle, year, month, leap, day}.
-- @treturn number Fixed date.
M.fixed_from_vietnamese = _vn_to


-- === Sexagesimal names ===

-- Chinese sexagesimal name constructor.
local function chinese_name(stem, branch)
  return {stem, branch}
end

-- Chinese name accessors.
local function chinese_stem(name)   return name[1] end
local function chinese_branch(name) return name[2] end

-- The n-th name of the Chinese sexagesimal cycle.
local function chinese_sexagesimal_name(n)
  return chinese_name(basic.amod(n, 10), basic.amod(n, 12))
end

-- Number of names from c_name1 to the next occurrence of c_name2.
local function chinese_name_difference(c_name1, c_name2)
  local stem1   = chinese_stem(c_name1)
  local stem2   = chinese_stem(c_name2)
  local branch1 = chinese_branch(c_name1)
  local branch2 = chinese_branch(c_name2)
  local stem_difference   = stem2 - stem1
  local branch_difference = branch2 - branch1
  return basic.amod(
    stem_difference + 25 * (branch_difference - stem_difference),
    60
  )
end

-- Sexagesimal name for Chinese year of any cycle.
local function chinese_year_name(year)
  return chinese_sexagesimal_name(year)
end

-- Sexagesimal name for month of Chinese year.
local function chinese_month_name(month, year)
  local elapsed_months = 12 * (year - 1) + (month - 1)
  return chinese_sexagesimal_name(elapsed_months - CHINESE_MONTH_NAME_EPOCH)
end

-- Chinese sexagesimal name for date.
local function chinese_day_name(date)
  return chinese_sexagesimal_name(date - CHINESE_DAY_NAME_EPOCH)
end

-- Latest date on or before fixed date with the given Chinese name.
local function chinese_day_name_on_or_before(name, date)
  return basic.mod3(
    chinese_name_difference(chinese_day_name(0), name),
    date, date - 60
  )
end


-- === Chinese holidays and special dates ===

--- Fixed date of the Dragon Festival (Double Fifth) in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.dragon_festival(g_year)
  local elapsed_years = 1 + g_year
    - gregorian.gregorian_year_from_fixed(CHINESE_EPOCH)
  local cycle = 1 + basic.quotient(elapsed_years - 1, 60)
  local year  = basic.amod(elapsed_years, 60)
  return M.fixed_from_chinese(chinese_date(cycle, year, 5, false, 5))
end


--- Fixed date of Qingming (Clear and Bright) in Gregorian year `g_year`.
-- @tparam number g_year Gregorian year.
-- @treturn number Fixed date.
function M.qing_ming(g_year)
  return math.floor(
    minor_solar_term_on_or_after(
      gregorian.fixed_from_gregorian(
        gregorian.gregorian_date(g_year, gregorian.MARCH, 30)
      )
    )
  )
end


-- Age at fixed date given Chinese birthdate; bogus if date is before birthdate.
local function chinese_age(birthdate, date)
  local today = M.chinese_from_fixed(date)
  if date >= M.fixed_from_chinese(birthdate) then
    return 60 * (chinese_cycle(today) - chinese_cycle(birthdate))
      + chinese_year(today) - chinese_year(birthdate)
      + 1
  else
    return basic.BOGUS
  end
end

-- Marriage augury type of Chinese year in cycle.
local function chinese_year_marriage_augury(cycle, year)
  local new_year = M.fixed_from_chinese(
    chinese_date(cycle, year, 1, false, 1)
  )
  local c = year == 60 and cycle + 1 or cycle
  local y = year == 60 and 1 or year + 1
  local next_new_year = M.fixed_from_chinese(
    chinese_date(c, y, 1, false, 1)
  )
  local first_minor_term      = current_minor_solar_term(new_year)
  local next_first_minor_term = current_minor_solar_term(next_new_year)
  if first_minor_term == 1 and next_first_minor_term == 12 then
    return WIDOW
  elseif first_minor_term == 1 and next_first_minor_term ~= 12 then
    return BLIND
  elseif first_minor_term ~= 1 and next_first_minor_term == 12 then
    return BRIGHT
  else
    return DOUBLE_BRIGHT
  end
end


-- === Exports ===

--- Major solar term (zhongqi, 1..12) in effect on fixed `date`.
-- @function current_major_solar_term
-- @tparam number date Fixed date.
-- @treturn number Solar term index (1..12).
M.current_major_solar_term = current_major_solar_term

--- Moment (Beijing time) of first major solar term on or after fixed `date`.
-- @function major_solar_term_on_or_after
-- @tparam number date Fixed date.
-- @treturn number Moment.
M.major_solar_term_on_or_after = major_solar_term_on_or_after

--- Minor solar term (jieqi, 1..12) in effect on fixed `date`.
-- @function current_minor_solar_term
-- @tparam number date Fixed date.
-- @treturn number Solar term index (1..12).
M.current_minor_solar_term = current_minor_solar_term

--- Moment (Beijing time) of first minor solar term on or after fixed `date`.
-- @function minor_solar_term_on_or_after
-- @tparam number date Fixed date.
-- @treturn number Moment.
M.minor_solar_term_on_or_after = minor_solar_term_on_or_after

--- Sexagesimal name {stem, branch} for Chinese year `year` of any cycle.
-- @function chinese_year_name
-- @tparam number year Year within cycle (1..60).
-- @treturn table {stem, branch}
M.chinese_year_name = chinese_year_name

--- Sexagesimal name {stem, branch} for `month` of Chinese `year`.
-- @function chinese_month_name
-- @tparam number month Month number.
-- @tparam number year  Year within cycle.
-- @treturn table {stem, branch}
M.chinese_month_name = chinese_month_name

--- Chinese sexagesimal day-name {stem, branch} for fixed `date`.
-- @function chinese_day_name
-- @tparam number date Fixed date.
-- @treturn table {stem, branch}
M.chinese_day_name = chinese_day_name

--- Latest fixed date on or before `date` with the given Chinese sexagesimal `name`.
-- @function chinese_day_name_on_or_before
-- @tparam table  name Sexagesimal name {stem, branch}.
-- @tparam number date Fixed date.
-- @treturn number Fixed date.
M.chinese_day_name_on_or_before = chinese_day_name_on_or_before

--- The n-th name of the Chinese sexagesimal cycle.
-- @function chinese_sexagesimal_name
-- @tparam number n Position in cycle.
-- @treturn table {stem, branch}
M.chinese_sexagesimal_name = chinese_sexagesimal_name

--- Number of names from `c_name1` to the next occurrence of `c_name2` in the sexagesimal cycle.
-- @function chinese_name_difference
-- @tparam table c_name1 Sexagesimal name {stem, branch}.
-- @tparam table c_name2 Sexagesimal name {stem, branch}.
-- @treturn number Count (1..60).
M.chinese_name_difference = chinese_name_difference

--- Age in Chinese reckoning at fixed `date` given Chinese `birthdate`.
-- Returns BOGUS if date is before birthdate.
-- @function chinese_age
-- @tparam table  birthdate Chinese date {cycle, year, month, leap, day}.
-- @tparam number date      Fixed date.
-- @treturn number Age (or BOGUS).
M.chinese_age = chinese_age

--- Marriage augury type for Chinese year `year` of `cycle`.
-- Returns one of WIDOW, BLIND, BRIGHT, or DOUBLE_BRIGHT.
-- @function chinese_year_marriage_augury
-- @tparam number cycle Cycle number.
-- @tparam number year  Year within cycle (1..60).
-- @treturn number Augury constant.
M.chinese_year_marriage_augury = chinese_year_marriage_augury

--- Year type: no major solar term in either adjacent month (most auspicious).
M.WIDOW        = WIDOW
--- Year type: no major solar term in the current month.
M.BLIND        = BLIND
--- Year type: major solar term in the current month only.
M.BRIGHT       = BRIGHT
--- Year type: major solar term in both the current and an adjacent month.
M.DOUBLE_BRIGHT = DOUBLE_BRIGHT

return M
