#!/usr/bin/env python
"""Lunisolar calendar calculations.

Utilities for computing the dates of holidays based on lunar or solar events.

The definition for Chinese New Year comes from:
Aslaksen, Helmer. "The Mathematics of the Chinese Calendar",
http://www.math.nus.edu.sg/aslaksen/calendar/cal.pdf

This utility uses NOVAS (http://aa.usno.navy.mil/software/novas/novas_info.php)
to predict the position of the sun:
Barron, E. G., Kaplan, G. H., Bangert, J., Bartlett, J. L., Puatua, W., Harris,
W., & Barrett, P.  (2011) "Naval Observatory Vector Astrometry Software (NOVAS)
Version 3.1, Introducing a Python Edition," Bull. AAS, 43, 2011. (Abstract)

I also found the following book very helpful to understand the underlying
concepts for these predictions:
Jean H. Meeus. 1991. Astronomical Algorithms. Willmann-Bell, Incorporated.
"""

import contextlib
import io
import operator as op
import pathlib
import re

import click
import novas.compat as novas
import numpy as np
import pandas as pd
import toolz
from novas.compat import eph_manager

from exchange_calendars.calendar_helpers import UTC

# ruff: noqa: T201


@contextlib.contextmanager
def ephemeris():
    """A context manager for managing the novas ephemeris file."""
    eph_manager.ephem_open()
    try:
        yield
    finally:
        eph_manager.ephem_close()


def load_precomputed_data(root_dir):
    """Load saved data.

    Parameters
    ----------
    root_dir : path-like
        The directory to load the data from.

    Returns
    -------
    values : np.ndarray[float64]
        The precomputed data.
    minutes : np.ndarray[M8[m]]
        The minute labels for the data.
    """
    root_dir = pathlib.Path(root_dir)

    pattern = re.compile(r"(.*)_(.*)")
    files = [(p, pattern.match(p.name)) for p in root_dir.iterdir()]
    files = list(filter(lambda t: t[1] is not None, files))
    files = sorted(files, key=lambda t: np.datetime64(t[1][1]))

    values = []
    minutes = []
    for path, match in files:
        values.append(np.fromfile(str(path)))
        minutes.append(
            np.arange(
                match[1],
                np.datetime64(match[2]) + np.timedelta64(1, "m"),
                dtype="M8[m]",
            )
        )

    return np.hstack(values), np.hstack(minutes)


def smooth(arr, window):
    """Smooth an array by taking a rolling arithmetic mean.

    Parameters
    ----------
    arr : np.ndarray
        The array to smooth.
    window : int
        The size of the window to use when taking the mean.

    Returns
    -------
    smoothed : np.ndarray
        The smoothed array of length ``len(arr) - window // 2``.
    """
    return np.convolve(np.ones(window) / window, arr, mode="valid")


def local_cmp(f, arr):
    """Compare elements of an array to its direct neighbors.

    Parameters
    ----------
    f : callable
        The comparison to apply.
    arr : np.ndarray
        The array to check.

    Returns
    -------
    ix : np.ndarray[int64]
        An array of indices where ``f(arr[i], arr[i + 1])`` and
        ``f(arr[i - 1], arr[i])`` are true. index 0 and -1 are considered
        False.

    Examples
    --------
    >>> import operator as op
    # 100 samples of a path of 5 rotations
    >>> rad = np.fmod(np.linspace(0, 5 * 2 * np.pi, 100), 2 * np.pi)
    >>> sin = np.sin(rad)
    >>> local_minima_ix = local_cmp(op.lt, sin)

    # NOTE: this is not all -1 because we only took 100 samples so
    # 3 * np.pi / 2 doesn't appear exactly in the ``rad`` array.
    >>> sin[local_minima_ix]
    array([-0.99886734, -0.99383846, -0.98982144, -0.99685478, -0.99987413])

    >>> rad[local_minima_ix]
    array([ 4.75998887,  4.82345539,  4.56958931,  4.63305583,  4.69652235])
    """
    return np.flatnonzero(
        np.r_[False, f(arr[1:], arr[:-1])] & np.r_[f(arr[:-1], arr[1:]), False]
    )


def smoothed_local_cmp(f, arr, window):
    """Smooth an array and then compare elements to its direct neighbors.

    Parameters
    ----------
    f : callable
        The comparison to apply.
    arr : np.ndarray
        The array to check.
    window : int
        The size of the window to use when smoothing the array.

    Returns
    -------
    ix : np.ndarray[int64]
        An array of indices where ``f(arr[i], arr[i + 1])`` and
        ``f(arr[i - 1], arr[i])`` are true. index 0 and -1 are considered
        False.
    """
    return local_cmp(f, smooth(arr, window)) + window // 2


def utc_to_jd_tt(ts):
    """Convert UTC datetimes to Julian Date Terrestrial Time.

    Parameters
    ----------
    ts : np.ndarray[M8[s]]
        The timestamps to convert.

    Returns
    -------
    jd_tt : np.ndarray[f8]
        The julian date terrestrial time for the UTC values.
    """
    # lookup the corrections stored on this function object
    corrections = utc_to_jd_tt.corrections

    jd_utc = ts.astype("M8[s]").view("i8") / 86400 + 2440587.5
    correction_ix = np.searchsorted(
        corrections["time"],
        ts,
        side="right",
    )
    correction_ix[correction_ix == len(corrections)] -= 1
    return jd_utc + corrections["julian_days"][correction_ix]


def T(year, month, day):  # noqa: N802
    return np.datetime64(f"{year}-{month:02d}-{day:02d}", "D")


# UTC to JD TT adjustment factors from:
# https://www.usno.navy.mil/USNO/earth-orientation/eo-products/long-term
utc_to_jd_tt.corrections = np.array(
    [
        (T(1700, 1, 1), 32.184),
        (T(1973, 2, 1), 43.4724),
        (T(1973, 3, 1), 43.5648),
        (T(1973, 4, 1), 43.6737),
        (T(1973, 5, 1), 43.7782),
        (T(1973, 6, 1), 43.8763),
        (T(1973, 7, 1), 43.9562),
        (T(1973, 8, 1), 44.0315),
        (T(1973, 9, 1), 44.1132),
        (T(1973, 10, 1), 44.1982),
        (T(1973, 11, 1), 44.2952),
        (T(1973, 12, 1), 44.3936),
        (T(1974, 1, 1), 44.4840),
        (T(1974, 2, 1), 44.5646),
        (T(1974, 3, 1), 44.6425),
        (T(1974, 4, 1), 44.7386),
        (T(1974, 5, 1), 44.8370),
        (T(1974, 6, 1), 44.9302),
        (T(1974, 7, 1), 44.9986),
        (T(1974, 8, 1), 45.0583),
        (T(1974, 9, 1), 45.1284),
        (T(1974, 10, 1), 45.2064),
        (T(1974, 11, 1), 45.2980),
        (T(1974, 12, 1), 45.3897),
        (T(1975, 1, 1), 45.4761),
        (T(1975, 2, 1), 45.5632),
        (T(1975, 3, 1), 45.6450),
        (T(1975, 4, 1), 45.7374),
        (T(1975, 5, 1), 45.8284),
        (T(1975, 6, 1), 45.9133),
        (T(1975, 7, 1), 45.9820),
        (T(1975, 8, 1), 46.0407),
        (T(1975, 9, 1), 46.1067),
        (T(1975, 10, 1), 46.1825),
        (T(1975, 11, 1), 46.2788),
        (T(1975, 12, 1), 46.3713),
        (T(1976, 1, 1), 46.4567),
        (T(1976, 2, 1), 46.5445),
        (T(1976, 3, 1), 46.6311),
        (T(1976, 4, 1), 46.7302),
        (T(1976, 5, 1), 46.8283),
        (T(1976, 6, 1), 46.9247),
        (T(1976, 7, 1), 46.9970),
        (T(1976, 8, 1), 47.0709),
        (T(1976, 9, 1), 47.1450),
        (T(1976, 10, 1), 47.2361),
        (T(1976, 11, 1), 47.3413),
        (T(1976, 12, 1), 47.4319),
        (T(1977, 1, 1), 47.5214),
        (T(1977, 2, 1), 47.6049),
        (T(1977, 3, 1), 47.6837),
        (T(1977, 4, 1), 47.7781),
        (T(1977, 5, 1), 47.8771),
        (T(1977, 6, 1), 47.9687),
        (T(1977, 7, 1), 48.0348),
        (T(1977, 8, 1), 48.0942),
        (T(1977, 9, 1), 48.1608),
        (T(1977, 10, 1), 48.2460),
        (T(1977, 11, 1), 48.3438),
        (T(1977, 12, 1), 48.4355),
        (T(1978, 1, 1), 48.5344),
        (T(1978, 2, 1), 48.6324),
        (T(1978, 3, 1), 48.7294),
        (T(1978, 4, 1), 48.8365),
        (T(1978, 5, 1), 48.9353),
        (T(1978, 6, 1), 49.0319),
        (T(1978, 7, 1), 49.1013),
        (T(1978, 8, 1), 49.1591),
        (T(1978, 9, 1), 49.2285),
        (T(1978, 10, 1), 49.3070),
        (T(1978, 11, 1), 49.4018),
        (T(1978, 12, 1), 49.4945),
        (T(1979, 1, 1), 49.5861),
        (T(1979, 2, 1), 49.6805),
        (T(1979, 3, 1), 49.7602),
        (T(1979, 4, 1), 49.8556),
        (T(1979, 5, 1), 49.9489),
        (T(1979, 6, 1), 50.0347),
        (T(1979, 7, 1), 50.1018),
        (T(1979, 8, 1), 50.1622),
        (T(1979, 9, 1), 50.2260),
        (T(1979, 10, 1), 50.2968),
        (T(1979, 11, 1), 50.3831),
        (T(1979, 12, 1), 50.4599),
        (T(1980, 1, 1), 50.5387),
        (T(1980, 2, 1), 50.6160),
        (T(1980, 3, 1), 50.6866),
        (T(1980, 4, 1), 50.7658),
        (T(1980, 5, 1), 50.8454),
        (T(1980, 6, 1), 50.9187),
        (T(1980, 7, 1), 50.9761),
        (T(1980, 8, 1), 51.0278),
        (T(1980, 9, 1), 51.0843),
        (T(1980, 10, 1), 51.1538),
        (T(1980, 11, 1), 51.2319),
        (T(1980, 12, 1), 51.3063),
        (T(1981, 1, 1), 51.3808),
        (T(1981, 2, 1), 51.4526),
        (T(1981, 3, 1), 51.5160),
        (T(1981, 4, 1), 51.5985),
        (T(1981, 5, 1), 51.6809),
        (T(1981, 6, 1), 51.7573),
        (T(1981, 7, 1), 51.8133),
        (T(1981, 8, 1), 51.8532),
        (T(1981, 9, 1), 51.9014),
        (T(1981, 10, 1), 51.9603),
        (T(1981, 11, 1), 52.0328),
        (T(1981, 12, 1), 52.0985),
        (T(1982, 1, 1), 52.1668),
        (T(1982, 2, 1), 52.2316),
        (T(1982, 3, 1), 52.2938),
        (T(1982, 4, 1), 52.3680),
        (T(1982, 5, 1), 52.4465),
        (T(1982, 6, 1), 52.5179),
        (T(1982, 7, 1), 52.5751),
        (T(1982, 8, 1), 52.6178),
        (T(1982, 9, 1), 52.6668),
        (T(1982, 10, 1), 52.7340),
        (T(1982, 11, 1), 52.8056),
        (T(1982, 12, 1), 52.8792),
        (T(1983, 1, 1), 52.9565),
        (T(1983, 2, 1), 53.0445),
        (T(1983, 3, 1), 53.1268),
        (T(1983, 4, 1), 53.2197),
        (T(1983, 5, 1), 53.3024),
        (T(1983, 6, 1), 53.3747),
        (T(1983, 7, 1), 53.4335),
        (T(1983, 8, 1), 53.4778),
        (T(1983, 9, 1), 53.5300),
        (T(1983, 10, 1), 53.5845),
        (T(1983, 11, 1), 53.6523),
        (T(1983, 12, 1), 53.7256),
        (T(1984, 1, 1), 53.7882),
        (T(1984, 2, 1), 53.8367),
        (T(1984, 3, 1), 53.8830),
        (T(1984, 4, 1), 53.9443),
        (T(1984, 5, 1), 54.0042),
        (T(1984, 6, 1), 54.0536),
        (T(1984, 7, 1), 54.0856),
        (T(1984, 8, 1), 54.1084),
        (T(1984, 9, 1), 54.1463),
        (T(1984, 10, 1), 54.1914),
        (T(1984, 11, 1), 54.2452),
        (T(1984, 12, 1), 54.2958),
        (T(1985, 1, 1), 54.3427),
        (T(1985, 2, 1), 54.3911),
        (T(1985, 3, 1), 54.4320),
        (T(1985, 4, 1), 54.4898),
        (T(1985, 5, 1), 54.5456),
        (T(1985, 6, 1), 54.5977),
        (T(1985, 7, 1), 54.6355),
        (T(1985, 8, 1), 54.6532),
        (T(1985, 9, 1), 54.6776),
        (T(1985, 10, 1), 54.7174),
        (T(1985, 11, 1), 54.7741),
        (T(1985, 12, 1), 54.8253),
        (T(1986, 1, 1), 54.8712),
        (T(1986, 2, 1), 54.9161),
        (T(1986, 3, 1), 54.9580),
        (T(1986, 4, 1), 54.9997),
        (T(1986, 5, 1), 55.0476),
        (T(1986, 6, 1), 55.0912),
        (T(1986, 7, 1), 55.1132),
        (T(1986, 8, 1), 55.1328),
        (T(1986, 9, 1), 55.1532),
        (T(1986, 10, 1), 55.1898),
        (T(1986, 11, 1), 55.2415),
        (T(1986, 12, 1), 55.2838),
        (T(1987, 1, 1), 55.3222),
        (T(1987, 2, 1), 55.3613),
        (T(1987, 3, 1), 55.4063),
        (T(1987, 4, 1), 55.4629),
        (T(1987, 5, 1), 55.5111),
        (T(1987, 6, 1), 55.5524),
        (T(1987, 7, 1), 55.5812),
        (T(1987, 8, 1), 55.6004),
        (T(1987, 9, 1), 55.6262),
        (T(1987, 10, 1), 55.6656),
        (T(1987, 11, 1), 55.7168),
        (T(1987, 12, 1), 55.7698),
        (T(1988, 1, 1), 55.8197),
        (T(1988, 2, 1), 55.8615),
        (T(1988, 3, 1), 55.9130),
        (T(1988, 4, 1), 55.9663),
        (T(1988, 5, 1), 56.0220),
        (T(1988, 6, 1), 56.0700),
        (T(1988, 7, 1), 56.0939),
        (T(1988, 8, 1), 56.1105),
        (T(1988, 9, 1), 56.1314),
        (T(1988, 10, 1), 56.1611),
        (T(1988, 11, 1), 56.2068),
        (T(1988, 12, 1), 56.2582),
        (T(1989, 1, 1), 56.3000),
        (T(1989, 2, 1), 56.3399),
        (T(1989, 3, 1), 56.3790),
        (T(1989, 4, 1), 56.4283),
        (T(1989, 5, 1), 56.4804),
        (T(1989, 6, 1), 56.5352),
        (T(1989, 7, 1), 56.5697),
        (T(1989, 8, 1), 56.5983),
        (T(1989, 9, 1), 56.6328),
        (T(1989, 10, 1), 56.6739),
        (T(1989, 11, 1), 56.7332),
        (T(1989, 12, 1), 56.7972),
        (T(1990, 1, 1), 56.8553),
        (T(1990, 2, 1), 56.9111),
        (T(1990, 3, 1), 56.9755),
        (T(1990, 4, 1), 57.0471),
        (T(1990, 5, 1), 57.1136),
        (T(1990, 6, 1), 57.1738),
        (T(1990, 7, 1), 57.2226),
        (T(1990, 8, 1), 57.2597),
        (T(1990, 9, 1), 57.3073),
        (T(1990, 10, 1), 57.3643),
        (T(1990, 11, 1), 57.4334),
        (T(1990, 12, 1), 57.5016),
        (T(1991, 1, 1), 57.5653),
        (T(1991, 2, 1), 57.6333),
        (T(1991, 3, 1), 57.6973),
        (T(1991, 4, 1), 57.7711),
        (T(1991, 5, 1), 57.8407),
        (T(1991, 6, 1), 57.9058),
        (T(1991, 7, 1), 57.9576),
        (T(1991, 8, 1), 57.9975),
        (T(1991, 9, 1), 58.0425),
        (T(1991, 10, 1), 58.1043),
        (T(1991, 11, 1), 58.1679),
        (T(1991, 12, 1), 58.2389),
        (T(1992, 1, 1), 58.3092),
        (T(1992, 2, 1), 58.3833),
        (T(1992, 3, 1), 58.4537),
        (T(1992, 4, 1), 58.5401),
        (T(1992, 5, 1), 58.6228),
        (T(1992, 6, 1), 58.6917),
        (T(1992, 7, 1), 58.7410),
        (T(1992, 8, 1), 58.7836),
        (T(1992, 9, 1), 58.8405),
        (T(1992, 10, 1), 58.8986),
        (T(1992, 11, 1), 58.9714),
        (T(1992, 12, 1), 59.0438),
        (T(1993, 1, 1), 59.1218),
        (T(1993, 2, 1), 59.2003),
        (T(1993, 3, 1), 59.2747),
        (T(1993, 4, 1), 59.3574),
        (T(1993, 5, 1), 59.4434),
        (T(1993, 6, 1), 59.5242),
        (T(1993, 7, 1), 59.5850),
        (T(1993, 8, 1), 59.6343),
        (T(1993, 9, 1), 59.6928),
        (T(1993, 10, 1), 59.7588),
        (T(1993, 11, 1), 59.8386),
        (T(1993, 12, 1), 59.9111),
        (T(1994, 1, 1), 59.9844),
        (T(1994, 2, 1), 60.0564),
        (T(1994, 3, 1), 60.1231),
        (T(1994, 4, 1), 60.2042),
        (T(1994, 5, 1), 60.2804),
        (T(1994, 6, 1), 60.3530),
        (T(1994, 7, 1), 60.4012),
        (T(1994, 8, 1), 60.4440),
        (T(1994, 9, 1), 60.4900),
        (T(1994, 10, 1), 60.5578),
        (T(1994, 11, 1), 60.6324),
        (T(1994, 12, 1), 60.7059),
        (T(1995, 1, 1), 60.7853),
        (T(1995, 2, 1), 60.8663),
        (T(1995, 3, 1), 60.9387),
        (T(1995, 4, 1), 61.0277),
        (T(1995, 5, 1), 61.1103),
        (T(1995, 6, 1), 61.1870),
        (T(1995, 7, 1), 61.2454),
        (T(1995, 8, 1), 61.2881),
        (T(1995, 9, 1), 61.3378),
        (T(1995, 10, 1), 61.4036),
        (T(1995, 11, 1), 61.4760),
        (T(1995, 12, 1), 61.5525),
        (T(1996, 1, 1), 61.6287),
        (T(1996, 2, 1), 61.6846),
        (T(1996, 3, 1), 61.7433),
        (T(1996, 4, 1), 61.8132),
        (T(1996, 5, 1), 61.8823),
        (T(1996, 6, 1), 61.9497),
        (T(1996, 7, 1), 61.9969),
        (T(1996, 8, 1), 62.0343),
        (T(1996, 9, 1), 62.0714),
        (T(1996, 10, 1), 62.1202),
        (T(1996, 11, 1), 62.1809),
        (T(1996, 12, 1), 62.2382),
        (T(1997, 1, 1), 62.2950),
        (T(1997, 2, 1), 62.3506),
        (T(1997, 3, 1), 62.3995),
        (T(1997, 4, 1), 62.4754),
        (T(1997, 5, 1), 62.5463),
        (T(1997, 6, 1), 62.6136),
        (T(1997, 7, 1), 62.6571),
        (T(1997, 8, 1), 62.6942),
        (T(1997, 9, 1), 62.7383),
        (T(1997, 10, 1), 62.7926),
        (T(1997, 11, 1), 62.8567),
        (T(1997, 12, 1), 62.9146),
        (T(1998, 1, 1), 62.9659),
        (T(1998, 2, 1), 63.0217),
        (T(1998, 3, 1), 63.0807),
        (T(1998, 4, 1), 63.1462),
        (T(1998, 5, 1), 63.2053),
        (T(1998, 6, 1), 63.2599),
        (T(1998, 7, 1), 63.2844),
        (T(1998, 8, 1), 63.2961),
        (T(1998, 9, 1), 63.3126),
        (T(1998, 10, 1), 63.3422),
        (T(1998, 11, 1), 63.3871),
        (T(1998, 12, 1), 63.4339),
        (T(1999, 1, 1), 63.4673),
        (T(1999, 2, 1), 63.4979),
        (T(1999, 3, 1), 63.5319),
        (T(1999, 4, 1), 63.5679),
        (T(1999, 5, 1), 63.6104),
        (T(1999, 6, 1), 63.6444),
        (T(1999, 7, 1), 63.6642),
        (T(1999, 8, 1), 63.6739),
        (T(1999, 9, 1), 63.6926),
        (T(1999, 10, 1), 63.7147),
        (T(1999, 11, 1), 63.7518),
        (T(1999, 12, 1), 63.7927),
        (T(2000, 1, 1), 63.8285),
        (T(2000, 2, 1), 63.8557),
        (T(2000, 3, 1), 63.8804),
        (T(2000, 4, 1), 63.9075),
        (T(2000, 5, 1), 63.9393),
        (T(2000, 6, 1), 63.9691),
        (T(2000, 7, 1), 63.9799),
        (T(2000, 8, 1), 63.9833),
        (T(2000, 9, 1), 63.9938),
        (T(2000, 10, 1), 64.0093),
        (T(2000, 11, 1), 64.0400),
        (T(2000, 12, 1), 64.0670),
        (T(2001, 1, 1), 64.0908),
        (T(2001, 2, 1), 64.1068),
        (T(2001, 3, 1), 64.1282),
        (T(2001, 4, 1), 64.1584),
        (T(2001, 5, 1), 64.1833),
        (T(2001, 6, 1), 64.2094),
        (T(2001, 7, 1), 64.2117),
        (T(2001, 8, 1), 64.2073),
        (T(2001, 9, 1), 64.2116),
        (T(2001, 10, 1), 64.2223),
        (T(2001, 11, 1), 64.2500),
        (T(2001, 12, 1), 64.2761),
        (T(2002, 1, 1), 64.2998),
        (T(2002, 2, 1), 64.3192),
        (T(2002, 3, 1), 64.3450),
        (T(2002, 4, 1), 64.3735),
        (T(2002, 5, 1), 64.3943),
        (T(2002, 6, 1), 64.4151),
        (T(2002, 7, 1), 64.4132),
        (T(2002, 8, 1), 64.4118),
        (T(2002, 9, 1), 64.4097),
        (T(2002, 10, 1), 64.4168),
        (T(2002, 11, 1), 64.4329),
        (T(2002, 12, 1), 64.4511),
        (T(2003, 1, 1), 64.4734),
        (T(2003, 2, 1), 64.4893),
        (T(2003, 3, 1), 64.5053),
        (T(2003, 4, 1), 64.5269),
        (T(2003, 5, 1), 64.5471),
        (T(2003, 6, 1), 64.5597),
        (T(2003, 7, 1), 64.5512),
        (T(2003, 8, 1), 64.5371),
        (T(2003, 9, 1), 64.5359),
        (T(2003, 10, 1), 64.5415),
        (T(2003, 11, 1), 64.5544),
        (T(2003, 12, 1), 64.5654),
        (T(2004, 1, 1), 64.5736),
        (T(2004, 2, 1), 64.5891),
        (T(2004, 3, 1), 64.6015),
        (T(2004, 4, 1), 64.6176),
        (T(2004, 5, 1), 64.6374),
        (T(2004, 6, 1), 64.6549),
        (T(2004, 7, 1), 64.6530),
        (T(2004, 8, 1), 64.6379),
        (T(2004, 9, 1), 64.6372),
        (T(2004, 10, 1), 64.6400),
        (T(2004, 11, 1), 64.6543),
        (T(2004, 12, 1), 64.6723),
        (T(2005, 1, 1), 64.6876),
        (T(2005, 2, 1), 64.7052),
        (T(2005, 3, 1), 64.7313),
        (T(2005, 4, 1), 64.7575),
        (T(2005, 5, 1), 64.7811),
        (T(2005, 6, 1), 64.8001),
        (T(2005, 7, 1), 64.7995),
        (T(2005, 8, 1), 64.7876),
        (T(2005, 9, 1), 64.7831),
        (T(2005, 10, 1), 64.7921),
        (T(2005, 11, 1), 64.8096),
        (T(2005, 12, 1), 64.8311),
        (T(2006, 1, 1), 64.8452),
        (T(2006, 2, 1), 64.8597),
        (T(2006, 3, 1), 64.8850),
        (T(2006, 4, 1), 64.9175),
        (T(2006, 5, 1), 64.9480),
        (T(2006, 6, 1), 64.9794),
        (T(2006, 7, 1), 64.9895),
        (T(2006, 8, 1), 65.0028),
        (T(2006, 9, 1), 65.0138),
        (T(2006, 10, 1), 65.0371),
        (T(2006, 11, 1), 65.0773),
        (T(2006, 12, 1), 65.1122),
        (T(2007, 1, 1), 65.1464),
        (T(2007, 2, 1), 65.1833),
        (T(2007, 3, 1), 65.2145),
        (T(2007, 4, 1), 65.2494),
        (T(2007, 5, 1), 65.2921),
        (T(2007, 6, 1), 65.3279),
        (T(2007, 7, 1), 65.3413),
        (T(2007, 8, 1), 65.3452),
        (T(2007, 9, 1), 65.3496),
        (T(2007, 10, 1), 65.3711),
        (T(2007, 11, 1), 65.3972),
        (T(2007, 12, 1), 65.4296),
        (T(2008, 1, 1), 65.4574),
        (T(2008, 2, 1), 65.4868),
        (T(2008, 3, 1), 65.5152),
        (T(2008, 4, 1), 65.5450),
        (T(2008, 5, 1), 65.5781),
        (T(2008, 6, 1), 65.6127),
        (T(2008, 7, 1), 65.6288),
        (T(2008, 8, 1), 65.6370),
        (T(2008, 9, 1), 65.6493),
        (T(2008, 10, 1), 65.6760),
        (T(2008, 11, 1), 65.7097),
        (T(2008, 12, 1), 65.7461),
        (T(2009, 1, 1), 65.7768),
        (T(2009, 2, 1), 65.8025),
        (T(2009, 3, 1), 65.8237),
        (T(2009, 4, 1), 65.8595),
        (T(2009, 5, 1), 65.8973),
        (T(2009, 6, 1), 65.9323),
        (T(2009, 7, 1), 65.9509),
        (T(2009, 8, 1), 65.9534),
        (T(2009, 9, 1), 65.9628),
        (T(2009, 10, 1), 65.9839),
        (T(2009, 11, 1), 66.0147),
        (T(2009, 12, 1), 66.0421),
        (T(2010, 1, 1), 66.0699),
        (T(2010, 2, 1), 66.0961),
        (T(2010, 3, 1), 66.1310),
        (T(2010, 4, 1), 66.1683),
        (T(2010, 5, 1), 66.2072),
        (T(2010, 6, 1), 66.2356),
        (T(2010, 7, 1), 66.2409),
        (T(2010, 8, 1), 66.2335),
        (T(2010, 9, 1), 66.2349),
        (T(2010, 10, 1), 66.2441),
        (T(2010, 11, 1), 66.2751),
        (T(2010, 12, 1), 66.3054),
        (T(2011, 1, 1), 66.3246),
        (T(2011, 2, 1), 66.3406),
        (T(2011, 3, 1), 66.3624),
        (T(2011, 4, 1), 66.3957),
        (T(2011, 5, 1), 66.4289),
        (T(2011, 6, 1), 66.4619),
        (T(2011, 7, 1), 66.4749),
        (T(2011, 8, 1), 66.4751),
        (T(2011, 9, 1), 66.4829),
        (T(2011, 10, 1), 66.5056),
        (T(2011, 11, 1), 66.5383),
        (T(2011, 12, 1), 66.5706),
        (T(2012, 1, 1), 66.6030),
        (T(2012, 2, 1), 66.6340),
        (T(2012, 3, 1), 66.6569),
        (T(2012, 4, 1), 66.6925),
        (T(2012, 5, 1), 66.7289),
        (T(2012, 6, 1), 66.7579),
        (T(2012, 7, 1), 66.7708),
        (T(2012, 8, 1), 66.7740),
        (T(2012, 9, 1), 66.7846),
        (T(2012, 10, 1), 66.8103),
        (T(2012, 11, 1), 66.8401),
        (T(2012, 12, 1), 66.8779),
        (T(2013, 1, 1), 66.9069),
        (T(2013, 2, 1), 66.9443),
        (T(2013, 3, 1), 66.9763),
        (T(2013, 4, 1), 67.0258),
        (T(2013, 5, 1), 67.0716),
        (T(2013, 6, 1), 67.1100),
        (T(2013, 7, 1), 67.1266),
        (T(2013, 8, 1), 67.1331),
        (T(2013, 9, 1), 67.1458),
        (T(2013, 10, 1), 67.1717),
        (T(2013, 11, 1), 67.2091),
        (T(2013, 12, 1), 67.2460),
        (T(2014, 1, 1), 67.2810),
        (T(2014, 2, 1), 67.3136),
        (T(2014, 3, 1), 67.3457),
        (T(2014, 4, 1), 67.3890),
        (T(2014, 5, 1), 67.4318),
        (T(2014, 6, 1), 67.4666),
        (T(2014, 7, 1), 67.4859),
        (T(2014, 8, 1), 67.4989),
        (T(2014, 9, 1), 67.5111),
        (T(2014, 10, 1), 67.5353),
        (T(2014, 11, 1), 67.5711),
        (T(2014, 12, 1), 67.6070),
        (T(2015, 1, 1), 67.6439),
        (T(2015, 2, 1), 67.6765),
        (T(2015, 3, 1), 67.7117),
        (T(2015, 4, 1), 67.7591),
        (T(2015, 5, 1), 67.8012),
        (T(2015, 6, 1), 67.8402),
        (T(2015, 7, 1), 67.8606),
        (T(2015, 8, 1), 67.8822),
        (T(2015, 9, 1), 67.9120),
        (T(2015, 10, 1), 67.9547),
        (T(2015, 11, 1), 68.0055),
        (T(2015, 12, 1), 68.0514),
        (T(2016, 1, 1), 68.1024),
        (T(2016, 2, 1), 68.1577),
        (T(2016, 3, 1), 68.2044),
        (T(2016, 4, 1), 68.2665),
        (T(2016, 5, 1), 68.3188),
        (T(2016, 6, 1), 68.3704),
        (T(2016, 7, 1), 68.3964),
        (T(2016, 8, 1), 68.4095),
        (T(2016, 9, 1), 68.4305),
        (T(2016, 10, 1), 68.4630),
        (T(2016, 11, 1), 68.5078),
        (T(2016, 12, 1), 68.5537),
        (T(2017, 1, 1), 68.5927),
        (T(2017, 2, 1), 68.6298),
        (T(2017, 3, 1), 68.6671),
        (T(2017, 4, 1), 68.7135),
        (T(2017, 5, 1), 68.7623),
        (T(2017, 6, 1), 68.8033),
        (T(2017, 7, 1), 68.8245),
        (T(2017, 8, 1), 68.8373),
        (T(2017, 9, 1), 68.8477),
        (T(2017, 10, 1), 68.8689),
        (T(2017, 11, 1), 68.9006),
        (T(2017, 12, 1), 68.9355),
        (T(2018, 1, 1), 68.9677),
        (T(2018, 3, 1), 69.14),
        (T(2018, 6, 1), 69.3),
        (T(2018, 9, 1), 69.3),
        (T(2019, 1, 1), 69.5),
        (T(2019, 3, 1), 69.6),
        (T(2019, 6, 1), 69.7),
        (T(2019, 9, 1), 69.8),
        (T(2020, 1, 1), 69.9),
        (T(2020, 3, 1), 70.0),
        (T(2020, 6, 1), 70.0),
        (T(2020, 9, 1), 70.0),
        (T(2021, 1, 1), 70.0),
        (T(2021, 3, 1), 70.0),
        (T(2021, 6, 1), 70.0),
        (T(2021, 9, 1), 70.0),
        (T(2022, 1, 1), 70.0),
        (T(2022, 3, 1), 70.0),
        (T(2022, 6, 1), 71.0),
        (T(2022, 9, 1), 71.0),
        (T(2023, 1, 1), 71.0),
        (T(2023, 3, 1), 71.0),
        (T(2023, 6, 1), 71.0),
        (T(2023, 9, 1), 71.0),
        (T(2024, 1, 1), 71.0),
        (T(2024, 3, 1), 71.0),
        (T(2024, 6, 1), 71.0),
        (T(2024, 9, 1), 71.0),
        (T(2025, 1, 1), 71.0),
        (T(2025, 3, 1), 72.0),
        (T(2025, 6, 1), 72.0),
        (T(2025, 9, 1), 72.0),
        (T(2026, 1, 1), 72.0),
    ],
    dtype=[("time", "M8[D]"), ("julian_days", "f8")],
)
# seconds in a julian day
utc_to_jd_tt.corrections["julian_days"] /= 60 * 60 * 24


def compute_ecliptic_longitude(ob, minutes):
    """Compute an objects ecliptic longitude..

    Parameters
    ----------
    minutes : np.array[M8[m]]
        The minutes to compute the longitude for.

    Returns
    -------
    rad : np.ndarray[float64]
        The ecliptic longitude of the object (in radians).

    Notes
    -----
    The ``novas`` ephemeris must be opened before this can be called.
    """
    longitude_degrees = np.array(
        [
            novas.equ2ecl(
                jd_tt,
                # right ascension and declination
                *novas.app_planet(jd_tt, ob)[:2],
                # true equator and equinox of date
                coord_sys=1,
            )[0]
            for jd_tt in utc_to_jd_tt(minutes)
        ]
    )
    return np.deg2rad(longitude_degrees)


def lunar_ecliptic_longitude_block(root, minutes):
    """Calculate and save one block of moon phase data.

    Parameters
    ----------
    root : pathlib.Path
        The directory to save the data to.
    minutes : np.array[M8[m]]
        The minutes to compute the moon phase for.
    """
    ob = novas.make_object(0, 11, "Moon", None)
    el = compute_ecliptic_longitude(ob, minutes)
    path = str(root / f"{minutes[0]}_{minutes[-1]}")
    el.tofile(str(path))


def print_dates(dates, *, weekday):
    """Print an array of dates.

    Parameters
    ----------
    dates : np.ndarray[M8]
        The dates to print.
    weekday : bool
        Print the day of the week in a second column?
    """
    dates = dates.astype("M8[D]")

    if weekday:
        weekdays = pd.to_datetime(dates).weekday_name

        for d, weekday in zip(dates, weekdays, strict=True):  # noqa: PLR1704
            print(d, weekday)
    else:
        for d in dates:
            print(d)


@click.group()
def main():
    """Utilities for computing the Gregorian dates of lunisolar calendar
    events.
    """


@main.command("lunar-ecliptic-longitude")
@click.option(
    "--root-dir",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to save the data to.",
    default="lunar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "--verbose/--quiet",
    default=False,
)
@click.option(
    "--start",
    help="The start date to compute.",
    default="1980-01-01",
)
@click.option(
    "--stop",
    help="The stop date to compute.",
    default="2050-01-01",
)
def lunar_ecliptic_longitude(root_dir, verbose, start, stop):
    """Simulate the ecliptic longitude of the moon."""
    root_dir = pathlib.Path(root_dir)
    root_dir.mkdir(parents=True, exist_ok=True)

    with ephemeris():
        minutes = np.arange(start, stop, dtype="M8[m]")
        for minutes in toolz.partition_all(60 * 24 * 365, minutes):  # noqa: B020
            if verbose:
                print(f"start={minutes[0]}; stop={minutes[-1]}")

            lunar_ecliptic_longitude_block(root_dir, np.array(minutes))


def calculate_new_moon(solar_ecliptic_longitude, lunar_ecliptic_longitude, minutes):
    """Calculate when the new moon will occur.

    Parameters
    ----------
    solar_ecliptic_longitude : np.ndarray[f8]
        The ecliptic longitude of the sun.
    lunar_ecliptic_longitude : np.ndarray[f8]
        The ecliptic longitude of the moon.
    minutes : np.ndarray[M8[m]]
        The minute labels for the longitude data.

    Returns
    -------
    new_moons : np.ndarray[M8[m]]
        The minutes when a new moon occurs.
    """
    diff = np.abs(solar_ecliptic_longitude - lunar_ecliptic_longitude)
    new_moon_where = local_cmp(op.lt, diff)
    # clean up some local mins where the value isn't close to 0
    new_moon_where = new_moon_where[diff[new_moon_where] < 0.1]
    return minutes[new_moon_where]


def load_new_moons(
    solar_ecliptic_longitude, lunar_ecliptic_longitude, *, return_solar_data=False
):
    """Load the new moon minutes from the precomputed data.

    Parameters
    ----------
    solar_ecliptic_longitude : path-like
        The root dir to read solar ecliptic longitude data from.
    lunar_ecliptic_longitude : path-like
        The root dir to read lunar ecliptic longitude data from.
    return_solar_data : bool, optional
        Also return the solar data.

    Returns
    -------
    new_moons : np.ndarray[M8[m]]
        The minutes of the new moons.
    """
    solar_elon, solar_minutes = load_precomputed_data(solar_ecliptic_longitude)
    lunar_elon, lunar_minutes = load_precomputed_data(lunar_ecliptic_longitude)

    if not np.all(solar_minutes == lunar_minutes):
        raise ValueError(
            "mismatched minutes for solar and lunar ecliptic longitude",
        )

    new_moons = calculate_new_moon(
        solar_elon,
        lunar_elon,
        # the minutes are equal so it doesn't matter which we use
        solar_minutes,
    )
    if return_solar_data:
        return new_moons, solar_elon, solar_minutes

    return new_moons


@main.command("new-moon")
@click.option(
    "--solar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read solar ecliptic longitude data from.",
    default="solar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "--lunar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read lunar ecliptic longitude data from.",
    default="lunar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "-o",
    "--output",
    type=click.File("w"),
    default=None,
    help="The output to write the array of the minutes for each new moon.",
)
def new_moon(solar_ecliptic_longitude, lunar_ecliptic_longitude, output):
    """Calculate the minute of each new moon."""
    new_moon_minutes = load_new_moons(
        solar_ecliptic_longitude,
        lunar_ecliptic_longitude,
    )
    if output is None:
        print(new_moon_minutes)
    else:
        new_moon_minutes.tofile(output)


def solar_ecliptic_longitude_block(root, minutes):
    """Compute and save the sun's ecliptic longitude.

    Parameters
    ----------
    minutes : np.array[M8[m]]
        The minutes to compute the sun's position.
    """
    ob = novas.make_object(0, 10, "Sun", None)
    el = compute_ecliptic_longitude(ob, minutes)
    path = str(root / f"{minutes[0]}_{minutes[-1]}")
    el.tofile(str(path))


@main.command("solar-ecliptic-longitude")
@click.option(
    "--root-dir",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to save the data to.",
    default="solar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "--verbose/--quiet",
    default=False,
)
@click.option(
    "--start",
    help="The start date to compute.",
    default="1980-01-01",
)
@click.option(
    "--stop",
    help="The stop date to compute.",
    default="2050-01-01",
)
def solar_solar_ecliptic_longitude(root_dir, verbose, start, stop):
    """Simulate the ecliptic longitude of the sun."""
    root_dir = pathlib.Path(root_dir)
    root_dir.mkdir(parents=True, exist_ok=True)

    with ephemeris():
        minutes = np.arange(start, stop, dtype="M8[m]")
        for minutes in toolz.partition_all(60 * 24 * 365, minutes):  # noqa: B020
            if verbose:
                print(f"start={minutes[0]}; stop={minutes[-1]}")

            solar_ecliptic_longitude_block(root_dir, np.array(minutes))


def minutes_at_phase(longitude, minutes, phase):
    """Find the minutes where the longitude is nearest to ``phase``.

    Parameters
    ----------
    longitude : np.ndarray[float64]
        The longitude (in radians) of the sun.
    minutes : np.ndarray[M8[m]]
        The minute labels for the longitude values.
    phase : float
        The target longitude to find the minutes for.

    Returns
    -------
    minutes_at_phase : np.ndarray[M8[m]]
        The minutes where ``longitude`` is closest to ``phase``.
    """
    return minutes[local_cmp(op.lt, np.abs(longitude - phase))]


def calculate_new_year(solar_longitude, minutes_for_longitude, new_moons):
    """Calculate the Chinese new year new moon times.

    Parameters
    ----------
    solar_ecliptic_longitude_dir : path-like
        The root dir to read solar ecliptic longitude data from.
    new_moons : np.ndarray[M8[m]]
        The sorted minutes when the new moon occurs.

    Returns
    -------
    new_years : np.ndarray[M8[m]]
        The minutes when the first new moon of each year occurs.
    """
    months, eleventh_month_ix = chinese_month_start(
        new_moons,
        solar_longitude,
        minutes_for_longitude,
        return_11th_month=True,
    )
    new_year_ix = eleventh_month_ix + 2
    return months[new_year_ix[new_year_ix < len(months)]]


def utc_to_chinese_date(arr):
    """Convert UTC minutes to a chinese date.

    Parameters
    ----------
    arr : np.array[M8[m]]
        The datetimes to convert.

    Returns
    -------
    dates : np.array[M8[ns]]
        An array of datetime64[ns] at midnight corresponding the date in China
        of the input.
    """
    ts = pd.to_datetime(arr).tz_localize(UTC).tz_convert("Asia/Shanghai")
    out = ts.normalize().tz_localize(None)
    if isinstance(out, pd.Timestamp):
        return out.asm8
    return out.values


@main.command("chinese-new-year")
@click.option(
    "--lunar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read lunar ecliptic longitude data from.",
    default="lunar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "--solar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read solar ecliptic longitude data from.",
    default="solar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "-o",
    "--output",
    type=click.File("w"),
    default=None,
    help="The output to write the dates of the Chinese New Year.",
)
@click.option(
    "--weekday/--no-weekday",
    default=False,
    help="If printing to stdout, include the weekday?",
)
def chinese_new_year(
    lunar_ecliptic_longitude, solar_ecliptic_longitude, output, weekday
):
    """Calculate the Gregorian date of Chinese New Year."""
    new_moons, solar_elon, solar_minutes = load_new_moons(
        solar_ecliptic_longitude,
        lunar_ecliptic_longitude,
        return_solar_data=True,
    )

    chinese_new_year = calculate_new_year(
        solar_elon,
        solar_minutes,
        new_moons,
    )

    chinese_new_year = utc_to_chinese_date(chinese_new_year)
    if output is None:
        print_dates(chinese_new_year, weekday=weekday)
    else:
        chinese_new_year.tofile(output)


@main.command("qingming-festival")
@click.option(
    "--solar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read solar ecliptic longitude data from.",
    default="solar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "-o",
    "--output",
    type=click.File("w"),
    default=None,
    help="The output to write the dates of the Qingming Festival.",
)
@click.option(
    "--weekday/--no-weekday",
    default=False,
    help="If printing to stdout, include the weekday?",
)
def qingming_festival(solar_ecliptic_longitude, output, weekday):
    """Calculate the Gregorian date of Qingming Festival."""
    longitude, minutes = load_precomputed_data(solar_ecliptic_longitude)

    # The Qingming (清明) festival is celebrated on the calendar day of the
    # first minor solar period (节气). A solar period is defined as the sun
    # moving 1/24th of the ecliptic, with 0 being the vernal equinox (春分),
    # and 1/2 of a rotation being the autumnal equinox (秋分).
    qingming_minute = minutes_at_phase(longitude, minutes, np.pi / 12)

    qingming_festival_date = utc_to_chinese_date(qingming_minute)
    if output is None:
        print_dates(qingming_festival_date, weekday=weekday)
    else:
        qingming_festival_date.tofile(output)


def chinese_month_start(
    new_moons, solar_longitude, minutes, *, return_11th_month=False
):
    """Get the start date of each lunar month (excluding leap months).

    Parameters
    ----------
    new_moons : np.ndarray[M8[m]]
        The minute of each new moon.
    solar_longitude : np.ndarray[f8]
        An array of the ecliptical longitude of the sun.
    minutes : np.ndarray[M8[m]]
        The labels for ``solar_longitude``.
    return_11th_month : bool, optional
        Return the index of the 11th month.

    Returns
    -------
    month_starts : np.ndarray[M8[m]]
        The minute of the new moon that starts each real month.
    """
    winter_solstice = minutes_at_phase(
        solar_longitude,
        minutes,
        3 * np.pi / 2,
    )
    sui_start_index = (
        utc_to_chinese_date(new_moons)
        > utc_to_chinese_date(winter_solstice).reshape(-1, 1)
    ).argmax(axis=1) - 1

    get_next_sui_ix = iter(sui_start_index).__next__

    next_sui_ix = get_next_sui_ix()
    next_sui = new_moons[next_sui_ix]
    prev_sui_ix = next_sui_ix - 13

    seen_skipped_month = False
    month_starts = []

    zhongqi = minutes_at_phase(
        np.fmod(solar_longitude, np.pi / 6),
        minutes,
        0,
    )[1:]
    contains_zhongqi_ix = (
        utc_to_chinese_date(zhongqi).reshape(-1, 1) < utc_to_chinese_date(new_moons)
    ).argmax(axis=1) - 1
    months_with_zhongqi = np.unique(new_moons[contains_zhongqi_ix])
    possible_skipped_months = np.setdiff1d(new_moons, months_with_zhongqi)

    for new_moon in new_moons:
        if new_moon == next_sui:
            seen_skipped_month = False

        possible_skipped_month = new_moon in possible_skipped_months
        skip = (
            possible_skipped_month
            and not seen_skipped_month
            # check the number of new moons between the two sui to guard
            # against fake leap months (e.g. 8th month of 2033)
            and next_sui_ix - prev_sui_ix != 12
        )

        if new_moon == next_sui:
            prev_sui_ix = next_sui_ix
            try:
                next_sui_ix = get_next_sui_ix()
                next_sui = new_moons[next_sui_ix]
            except StopIteration:
                pass

        if skip:
            # we only skip the first month without a zhongqi in a given year
            seen_skipped_month = True
        else:
            month_starts.append(new_moon)

    month_starts = np.array(month_starts)
    if return_11th_month:
        return (month_starts, np.searchsorted(month_starts, new_moons[sui_start_index]))
    return month_starts


def nth_day_of_nth_chinese_month(
    lunar_ecliptic_longitude, solar_ecliptic_longitude, month, day
):
    """Helper for computing the nth day of the nth lunar month from the cached
    data.

    Parameters
    ----------
    lunar_ecliptic_longitude : path-like
        The root dir to read lunar ecliptic longitude data from.
    solar_ecliptic_longitude : path-like
        The root dir to read solar ecliptic longitude data from.
    month : int
        The one indexed month number.
    day : int
        The one indexed day number.

    Returns
    -------
    dates : np.ndarray[M8[D]]
        The date of the holiday each year.
    """
    new_moons, solar_elon, solar_minutes = load_new_moons(
        solar_ecliptic_longitude,
        lunar_ecliptic_longitude,
        return_solar_data=True,
    )

    months = chinese_month_start(new_moons, solar_elon, solar_minutes)
    chinese_new_year = calculate_new_year(
        solar_elon,
        solar_minutes,
        new_moons,
    )
    new_year_month_ix = np.searchsorted(months, chinese_new_year)

    # subtract one from month because it is 1-indexed
    month_start = utc_to_chinese_date(months[new_year_month_ix + (month - 1)])
    # subtract one from day because it is 1-indexed.
    return month_start + np.timedelta64(day - 1, "D")


@main.command("china-buddhas-birthday")
@click.option(
    "--lunar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read lunar ecliptic longitude data from.",
    default="lunar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "--solar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read solar ecliptic longitude data from.",
    default="solar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "-o",
    "--output",
    type=click.File("w"),
    default=None,
    help="The output to write the dates of Buddha's Birthday.",
)
@click.option(
    "--weekday/--no-weekday",
    default=False,
    help="If printing to stdout, include the weekday?",
)
def china_buddhas_birthday(
    lunar_ecliptic_longitude, solar_ecliptic_longitude, output, weekday
):
    """Calculate the Gregorian date of China's observance of Buddha's Birthday."""
    # Buddhas birthday is the 8th day (one indexed) of the 4th chinese lunar
    # month (not counting leap months).
    buddhas_birthday = nth_day_of_nth_chinese_month(
        lunar_ecliptic_longitude,
        solar_ecliptic_longitude,
        month=4,
        day=8,
    )
    if output is None:
        print_dates(buddhas_birthday, weekday=weekday)
    else:
        buddhas_birthday.tofile(output)


@main.command("dragon-boat-festival")
@click.option(
    "--lunar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read lunar ecliptic longitude data from.",
    default="lunar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "--solar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read solar ecliptic longitude data from.",
    default="solar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "-o",
    "--output",
    type=click.File("w"),
    default=None,
    help="The output to write the dates of the Dragon Boat Festival.",
)
@click.option(
    "--weekday/--no-weekday",
    default=False,
    help="If printing to stdout, include the weekday?",
)
def dragon_boat_festival(
    lunar_ecliptic_longitude, solar_ecliptic_longitude, output, weekday
):
    """Calculate the Gregorian date of the Dragon Boat Festival."""
    # The Dragon Boat Festival is the 5th day of the 5th month.
    # month (not counting leap months).
    dragon_boat_festival = nth_day_of_nth_chinese_month(
        lunar_ecliptic_longitude,
        solar_ecliptic_longitude,
        month=5,
        day=5,
    )
    if output is None:
        print_dates(dragon_boat_festival, weekday=weekday)
    else:
        dragon_boat_festival.tofile(output)


@main.command("mid-autumn-festival")
@click.option(
    "--lunar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read lunar ecliptic longitude data from.",
    default="lunar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "--solar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read solar ecliptic longitude data from.",
    default="solar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "-o",
    "--output",
    type=click.File("w"),
    default=None,
    help="The output to write the dates of the Mid Autumn Festival.",
)
@click.option(
    "--weekday/--no-weekday",
    default=False,
    help="If printing to stdout, include the weekday?",
)
def mid_autumn_festival(
    lunar_ecliptic_longitude, solar_ecliptic_longitude, output, weekday
):
    """Calculate the Gregorian date of the Mid Autumn Festival."""
    # The Mid Autumn Festival is the 15th day of the 8th month.
    # month (not counting leap months).
    mid_autumn_festival = nth_day_of_nth_chinese_month(
        lunar_ecliptic_longitude,
        solar_ecliptic_longitude,
        month=8,
        day=15,
    )
    if output is None:
        print_dates(mid_autumn_festival, weekday=weekday)
    else:
        mid_autumn_festival.tofile(output)


@main.command("double-ninth-festival")
@click.option(
    "--lunar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read lunar ecliptic longitude data from.",
    default="lunar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "--solar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read solar ecliptic longitude data from.",
    default="solar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "-o",
    "--output",
    type=click.File("w"),
    default=None,
    help="The output to write the dates of the Double Ninth Festival.",
)
@click.option(
    "--weekday/--no-weekday",
    default=False,
    help="If printing to stdout, include the weekday?",
)
def double_ninth_festival(
    lunar_ecliptic_longitude, solar_ecliptic_longitude, output, weekday
):
    """Calculate the Gregorian date of the Double Ninth Festival."""
    # The Double Ninth Festival is the 9th day of the 9th month.
    # month (not counting leap months).
    double_ninth_festival = nth_day_of_nth_chinese_month(
        lunar_ecliptic_longitude,
        solar_ecliptic_longitude,
        month=9,
        day=9,
    )
    if output is None:
        print_dates(double_ninth_festival, weekday=weekday)
    else:
        double_ninth_festival.tofile(output)


def _render_month(year, months, month, *, print_year):
    out = io.StringIO()

    # subtract one from month because it is 1-indexed
    month_start = utc_to_chinese_date(months[month - 1])
    month_end = utc_to_chinese_date(months[month])
    days = pd.date_range(month_start, month_end, closed="left")

    is_leap_month = len(days) > 30
    title = f"Lunar Month {month}{'(+)' if is_leap_month else ''}"
    if print_year:
        title += f" {year}"
    print(f"{title:^20}".rstrip(), file=out)
    gregorian_months = f"{days[0].month_name()}-{days[-1].month_name()}"
    print(f"{gregorian_months:^20}".rstrip(), file=out)
    print("Su Mo Tu We Th Fr Sa", file=out)
    print(" " * (3 * ((days[0].weekday() + 1) % 7) - 1), end="", file=out)

    for d in days:
        if d.weekday() == 6:
            print("", file=out)  # noqa: FURB105
        else:
            print(" ", end="", file=out)

        print(f"{d.day:>2}", end="", file=out)

    print("", file=out)  # noqa: FURB105
    return out.getvalue()


def _concat_lines(strings, width):
    as_lines = [string.splitlines() for string in strings]
    max_lines = max(len(lines) for lines in as_lines)
    for lines in as_lines:
        missing_lines = max_lines - len(lines)
        if missing_lines:
            lines.extend([" " * width] * missing_lines)

    rows = []
    for row_parts in zip(*as_lines):  # noqa: B905
        row_parts = list(row_parts)  # noqa: PLW2901
        for n, row_part in enumerate(row_parts):
            missing_space = width - len(row_part)
            if missing_space:
                row_parts[n] = row_part + " " * missing_space

        rows.append("   ".join(row_parts))

    return "\n".join(row.rstrip() for row in rows)


@main.command("chinese-cal")
@click.argument("month", type=int)
@click.argument("year", type=int, required=False)
@click.option(
    "--lunar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read lunar ecliptic longitude data from.",
    default="lunar-ecliptic-longitude",
    show_default=True,
)
@click.option(
    "--solar-ecliptic-longitude",
    type=click.Path(dir_okay=True, file_okay=False),
    help="The root dir to read solar ecliptic longitude data from.",
    default="solar-ecliptic-longitude",
    show_default=True,
)
def chinese_cal(month, year, lunar_ecliptic_longitude, solar_ecliptic_longitude):
    """Print a unix cal like table for a Chinese lunar month. The day numbers
    are the Gregorian days of their Gregorian month.
    """
    if year is None:
        year = month
        month = None
        full_year = True
    else:
        full_year = False

    new_moons, solar_elon, solar_minutes = load_new_moons(
        solar_ecliptic_longitude,
        lunar_ecliptic_longitude,
        return_solar_data=True,
    )

    months = chinese_month_start(new_moons, solar_elon, solar_minutes)
    chinese_new_year = calculate_new_year(
        solar_elon,
        solar_minutes,
        new_moons,
    )
    given_new_year = chinese_new_year[
        chinese_new_year.astype("M8[Y]") == np.datetime64(year - 1970, "Y")
    ]
    if len(given_new_year) == 0:
        raise ValueError(f"data does not contain {year}")

    new_year_month_ix = np.searchsorted(months, given_new_year[0])
    year_months = months[new_year_month_ix:]

    if not full_year:
        print(_render_month(year, year_months, month, print_year=True))
    else:
        month_strings = [
            [
                _render_month(
                    year,
                    year_months,
                    row * 3 + column + 1,
                    print_year=False,
                )
                for column in range(3)
            ]
            for row in range(4)
        ]
        print(f"{year:^66}\n".rstrip())
        print("\n\n".join(_concat_lines(cs, 20) for cs in month_strings))


if __name__ == "__main__":
    main()
