# -*- coding: utf-8 -*-
#
# badidatetime/badi_calendar.py
#
__docformat__ = "restructuredtext en"

import math

from badidatetime.base_calendar import BaseCalendar
from badidatetime.gregorian_calendar import GregorianCalendar
from badidatetime._coefficients import Coefficients

__all__ = ('BahaiCalendar',)
_LEAP_CACHE = None


class BahaiCalendar(BaseCalendar, Coefficients):
    """
    Implementation of the Baha'i (Badí') Calendar.

    | WGS84--https://coordinates-converter.com/
    | https://whatismyelevation.com/location/35.63735,51.72569/Tehran--Iran-
    | https://en-us.topographic-map.com/map-g9q1h/Tehran/?center=35.69244%2C51.19492
    | https://www.google.com/maps/place/Tehran,+Tehran+Province,+Iran/@35.9098957,51.51371,9.49z/data=!4m6!3m5!1s0x3f8e02c69b919039:0x17c26479772c5928!8m2!3d35.6891975!4d51.3889736!16s%2Fm%2F025zk75?entry=ttu
    | https://gml.noaa.gov/grad/solcalc/ Sunset data
    """
    #                 latitude    longitude  zone IANA name
    _BAHAI_LOCATION = (35.69435, 51.288701, 3.5, 'Asia/Tehran')
    """
    tuple: Represents the location coordinates latitude, longitude, political
    time zone, and IANA time zone name of the Badí' orientation point in
    Tehran.
    """
    _GMT_LOCATION = (51.477928, -0.001545, 0.0)
    """
    tuple: Represents the location coordinates latitude, longitude, and
    political time zone of the GMT meridian.
    """
    # 2394645.11511552 using Meeus' algorithm
    _BADI_EPOCH = 2394643.11511551  # 2394643.11511552
    """
    float: The Badí' epoch represented by the astronomical proleptic JD
    algorithm. It represents UT time or GMT.
    """
    KULLISHAY_MIN = -5
    """
    int: Constant indicating the minimum Kull-i-shay the API supports.
    """
    KULLISHAY_MAX = 4
    """
    int: Constant indicating the maximum Kull-i-shay the API supports.
    """
    MINYEAR = -1842
    """
    int: Constant indicating the minimum year this API can represent.
    """
    MAXYEAR = 1161
    """
    int: Constant indicating the maximum year this API can represent.
    """
    PROLEPTIC_GREG_1ST_DAY = 1721423.5
    """
    float: Constant indicating the proleptic Gregorian first day or year 1.
    """
    _RD_START = -287
    """
    int: Constant indicating the minimum Rata Die.
    """
    _RD_END = None
    """
    int: Constant indicating the maximum Rata Die. Auto-generated constant
         used to find ordinals.

    :meta hide-value:
    """
    _YEAR_START = None
    """
    dict: Auto-generated constant used to find ordinals.

    :meta hide-value:
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        """
        kull_i_shay: 361-year (19^2) vahid (integer)
        vahid: (integer) 19-year vahid
        year: (integer) 1 - 19
        month: (integer) 1 - 19 plus 0 for Ayyām-i-Hā
        day: (integer) 1 - 19
        Baha'i long form date: [kull_i_shay, vahid, year, month, day]
        """
        self._bahai_date = None
        self._gc = GregorianCalendar()

    def utc_sunset(self, date: tuple, lat: float=None, lon: float=None,
                   zone: float=None) -> tuple:
        """
        Return the time of sunset in UTC time for the given Badí' Day.

        :param tuple date: A Badí' date.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param float zone: The time zone.
        :returns: The hour, minute, and second of sunset based on the
                 provided coordinates.
        :rtype: tuple
        """
        if any([True if l is None else False for l in (lat, lon, zone)]):
            lat, lon, zone = self._BAHAI_LOCATION[:3]

        jd = self.jd_from_badi_date(date[:3], lat, lon, zone)
        jd += self._HR(zone)
        return self._hms_from_decimal_day(jd + 0.5)

    def naw_ruz_g_date(self, year: int, lat: float=None, lon: float=None,
                       zone: float=None, *, hms: bool=False) -> tuple:
        """
        Return the Badí' date for Naw-Ruz from the given Badí' year.

        :param int year: A Badí' year.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param float zone: The time zone.
        :param bool hms: If True the output returns the hours, minutes, and
                         seconds as separate fields. If False the day has a
                         decimal value indicating the hours, minutes, and
                         seconds.
        :returns: A Gregorian date.
        :rtype: tuple
        """
        if any([True if l is None else False for l in (lat, lon, zone)]):
            lat, lon, zone = self._BAHAI_LOCATION[:3]

        jd = self.jd_from_badi_date((year, 1, 1), lat, lon, zone)
        jd += self._HR(zone)
        return self._gc.gregorian_date_from_jd(jd, hms=hms, exact=True)

    def first_day_of_ridvan_g_date(self, year: int, lat: float=None,
                                   lon: float=None, zone: float=None, *,
                                   hms: bool=False) -> tuple:
        """
        Find the first day of Riḍván either with or without hours, minutes,
        and seconds. If the latitude, longitude, and time zone are not given
        Riḍván time of day is determined for the city of Nur in Iran.

        :param int year: A Badí' year.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param float zone: The time zone.
        :param bool hms: If True the output returns the hours, minutes, and
                         second as separate fields. If False the day has a
                         decimal value indicating the hours, minutes, and
                         seconds.
        :returns: A Gregorian date.
        :rtype: tuple
        """
        if any([True if l is None else False for l in (lat, lon, zone)]):
            lat, lon, zone = self._BAHAI_LOCATION[:3]

        jd = self.jd_from_badi_date((year, 2, 13), lat, lon, zone)
        jd += self._HR(zone)
        return self._gc.gregorian_date_from_jd(jd, hms=hms, exact=True)

    def jd_from_badi_date(self, b_date: tuple, lat: float=None,
                          lon: float=None, zone: float=None) -> float:
        """
        Convert a Badí' short form date to Julian period day.

        .. note::

           The JD must be interoperable with different calendar code. This
           method returns a standard UT time not Badí' time. To convert to
           Badí' time you will need to add the time zone divided by 24 to
           the returned JD.

        :param tuple b_date: A short form Badí' date.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param float zone: The time zone.
        :returns: The Julian Period day.
        :rtype: float
        """
        self._check_valid_badi_date(b_date, short_in=True)
        year, month, day = b_date[:3]
        hh, mm, ss, us = self._get_hms(b_date, short_in=True)

        if month == 0:    # Ayyam-i-Ha
            days = 18 * 19
        elif month < 19:  # month 1 - 18
            days = (month - 1) * 19
        else:             # month 19
            days = 18 * 19 + 4 + self._is_leap_year(year)

        td = self._days_in_years(year - 1)
        jd = td + math.floor(self._BADI_EPOCH + 1) + days + day

        if any([True if l is None else False for l in (lat, lon, zone)]):
            lat, lon, zone = self._BAHAI_LOCATION[:3]

        # The day may have a decimal component. ex. 1.5 = (1 day and 12 hours)
        # This day is relative to UTC time, so we need to compensate for Badí'
        # time since a Badí' day starts at sunset, not at midnight.
        jd0 = self._meeus_from_exact(jd)
        coeff = self._get_day_coeff(year)
        jd0 += coeff
        jd_ss = self._sun_setting(jd0, lat, lon)
        a_ss = self._exact_from_meeus(jd_ss)
        day_frac = self._decimal_day_from_hms(hh, mm, ss, us)
        return round(a_ss + day_frac, self._ROUNDING_PLACES)

    def badi_date_from_jd(self, jd: float, lat: float=None, lon: float=None,
                          zone: float=None, *, us: bool=False,
                          short: bool=False, fraction: bool=False,
                          trim: bool=False, rtd: bool=False) -> tuple:
        """
        Convert a Julian Period day to a Badí' date.

        .. note::

           Only pass a UTC JD not a zone shifted JD.

        :param float jd: Julian Period day in the Astronomically correct
                         method and in UT time.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param float zone: The standard time zone.
        :param bool us: If True the seconds are split to seconds and
                        microseconds else if False the seconds has a partial
                        day as a decimal.
        :param bool short: If True then parse for a short date else if False
                           (default) parse for a long date.
        :param bool fraction: This will return a short date with a possible
                              fraction on the day.
        :param bool trim: Trim the us, ss, mm, and hh in that order.
        :param bool rtd: Round to day.
        :returns: The Badí' date from an Astronomically correct Julian
                  Period day.
        :rtype: tuple
        """
        assert self._xor_boolean((fraction, us, rtd)), (
            "Cannot set more than one of fraction, us, or rtd to True.")

        if any([True if l is None else False for l in (lat, lon, zone)]):
            lat, lon, zone = self._BAHAI_LOCATION[:3]

        astro_ss, frac = self._utc_to_badi_time(jd, lat, lon)
        # We need to slowly adjust the RD based on the longitude so the day
        # stays within the local time frame.
        local_civil_day = math.floor(astro_ss + lon / 360.0 + 0.5)
        civil_epoch = math.floor(self.PROLEPTIC_GREG_1ST_DAY + 0.5)
        rd = local_civil_day - civil_epoch + 1
        year = self._badi_year_from_rd(rd)
        year_start_rd = self._YEAR_START[year]
        doy = rd - year_start_rd + 1
        ayyamiha = 4 + self._is_leap_year(year)

        if doy <= 342:  # Months 1 - 18
            month = (doy - 1) // 19 + 1
            day = (doy - 1) % 19 + 1
        elif doy <= 342 + ayyamiha:  # Month 0
            month = 0
            day = doy - 342
        else:  # Month 19
            month = 19
            day = doy - (342 + ayyamiha)

        if fraction:
            b_date = year, month, round(day + frac, 6)
        elif rtd:
            day = round(day + frac)
            b_date = year, month, day

            if not short:
                b_date = self.long_date_from_short_date(b_date, trim=trim)
        else:
            trim = trim if us else True
            date = year, month, day, *self._hms_from_decimal_day(frac)
            l_date = self.long_date_from_short_date(date, trim=True)
            b_date = self.kvymdhms_from_b_date(l_date, us=us, short=short,
                                               trim=trim)

        return b_date

    def _badi_year_from_rd(self, rd: int) -> int:
        assert self._RD_START <= rd <= self._RD_END, (
            f"Invalid Rata Die value {rd} it must be between "
            f"[{self._RD_START}, {self._RD_END}].")

        # Approximate year
        y = int((rd - self._RD_START) / 365.2425) + self.MINYEAR

        # Clamp
        if y < self.MINYEAR:  # pragma: no cover
            y = self.MINYEAR
        elif y > self.MAXYEAR:
            y = self.MAXYEAR

        # Correct if necessary
        if rd < self._YEAR_START[y]:
            y -= 1
        elif rd >= self._YEAR_START.get(y + 1, self._RD_END + 1):
            y += 1  # pragma: no cover

        return y

    def short_date_from_long_date(self, b_date: tuple, *, trim: bool=False,
                                  ) -> tuple:
        """
        Convert a long date (kvymdhms) to a short date (ymdhms). In either
        case microseconds could also be provided.

        :param tuple b_date: A long form date with or without microseconds.
        :param bool trim: Trim the us, ss, mm, and hh in that order.
        :returns: The short form Badí' date.
        :rtype: tuple
        """
        kull_i_shay, vahid, year, month, day = b_date[:5]
        hh, mm, ss, us = self._get_hms(b_date)
        y = (kull_i_shay - 1) * 361 + (vahid - 1) * 19 + year
        dm = 19 if month in range(1, 20) else 4 + self._is_leap_year(y)

        # The ShortFormStruct and LongFormStruct accept seconds up to 61.
        if ss >= 60:
            mm += 1
            ss -= 1

        if mm >= 60:
            hh += 1
            mm -= 1

        if hh >= 24:
            day += 1
            hh -= 1

        if day > dm:
            if dm in (4, 5):
                month = 19
                day = 1
            else:
                month += 1
                day -= 1

        if month > 19:
            month = 1
            y += 1

        hmsms = self._trim_hms((hh, mm, ss, us)) if trim else (hh, mm, ss, us)
        date = (y, month, day) + hmsms
        self._check_valid_badi_date(date, short_in=True)
        return date

    def long_date_from_short_date(self, date: tuple, *, trim: bool=False,
                                  ) -> tuple:
        """
        Convert a date to a short date (ymdhms) to a long date (kvymdhms).

        :param tuple b_date: A short form date with or without microseconds.
        :param bool trim: Trim the us, ss, mm, and hh in that order.
        :returns: The long form Badí' date.
        :rtype: tuple
        """
        y, month, day = date[:3]
        hh, mm, ss, us = self._get_hms(date, short_in=True)
        k = y / 361
        kull_i_shay = 0 if y == 0 else math.ceil(k)
        k0 = self._truncate_decimal(k % 1, self._ROUNDING_PLACES)
        v = k0 / 19 * 361

        if v == 0:  # If there is no fraction in v
            vahid = 19
            year = 19
        else:
            vahid = math.ceil(v)
            year = math.ceil(v % 1 * 19)

        hmsms = self._trim_hms((hh, mm, ss, us)) if trim else (hh, mm, ss, us)
        b_date = (kull_i_shay, vahid, year, month, day) + hmsms
        self._check_valid_badi_date(b_date)
        return b_date

    def date_from_kvymdhms(self, b_date: tuple, *, short: bool=False) -> tuple:
        """
        Convert (Kull-i-Shay, Váḥid, year, month, day, hour, minute, second,
        us) into a (Kull-i-Shay, Váḥid, year, month, day.fraction) or
        (year, month, day.fraction) date.

        :param tuple b_date: The Badí' date in long form.
        :param bool short: If True then parse for a short date else if False
                           (default) parse for a long date.
        :returns: The long or short form Badí' date with hours, minutes,
                  seconds, and microseconds if set.
        :rtype: tuple
        """
        self._check_valid_badi_date(b_date)
        kull_i_shay, vahid, year, month, day = b_date[:5]
        hour, minute, second, us = self._get_hms(b_date)
        day += round(self._HR(hour) + self._MN(minute) + self._SEC(second) +
                     self._US(us), 6)  # Round to 6 decimal places
        date = (kull_i_shay, vahid, year, month, day)
        return (self.short_date_from_long_date(
            date, trim=True) if short else date)

    def kvymdhms_from_b_date(self, b_date: tuple, *, us: bool=False,
                             short: bool=False, trim: bool=False) -> tuple:
        """
        Convert (Kull-i-Shay, Váḥid, year, month, day.fraction) into
        (Kull-i-Shay, Váḥid, year, month, day, hour, minute, second) or if
        short is True (year, month, day, hour, minute, second). If us is
        True the seconds are split to second and microsecond.

        :param tuple b_date: The Badí' date in long form.
        :param bool us: If True the seconds are split to seconds and
                        microseconds else if False the seconds has a partial
                        day as a decimal.
        :param bool short: If True then parse for a short date else if False
                           (default) parse for a long date.
        :param bool trim: Trim the us, ss, mm, and hh in that order.
        :returns: The long or short form Badí' date with hours, minutes,
                  seconds, and microseconds if set.
        :rtype: tuple
        """
        dlen = len(b_date)

        # We need to trim any zero hh, mm, ss, us so partial days below
        # work correctly.
        if dlen > 5:
            hms = self._trim_hms(b_date[5:dlen])
            b_date = b_date[:5] + hms
            dlen = len(b_date)

        self._check_valid_badi_date(b_date)
        kull_i_shay, vahid, year, month, day = b_date[:5]

        if dlen == 5:
            hd = self._PARTIAL_DAY_TO_HOURS(day)
            hour = math.floor(hd)
            md = self._PARTIAL_HOUR_TO_MINUTE(hd)
            minute = math.floor(md)
            # Round to 6 decimal places
            second = round(self._PARTIAL_MINUTE_TO_SECOND(md), 6)
        else:
            hour = b_date[5] if dlen > 5 else 0
            minute = b_date[6] if dlen > 6 else 0
            second = b_date[7] if dlen > 7 else 0

        date = (kull_i_shay, vahid, year, month, math.floor(day))

        if us:
            hmsms = (hour, minute, *self._sec_microsec_from_seconds(second))
        else:
            hmsms = (hour, minute, second)

        date += self._trim_hms(hmsms) if trim else hmsms
        return (self.short_date_from_long_date(date, trim=trim)
                if short else date)

    def badi_date_from_gregorian_date(self, g_date: tuple, lat: float=None,
                                      lon: float=None, zone: float=None, *,
                                      us: bool=False, short: bool=False,
                                      trim: bool=False, rtd: bool=False,
                                      _exact: bool=True) -> tuple:
        """
        Get the Badí' date from the Gregorian date.

        .. note::

           The date that is passed in is in GMT date and time not the date
           and time of the time zone you want.

        :param tuple g_date: A Gregorian date.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param float zone: The standard time zone.
        :param bool us: If True the seconds are split to seconds amd
                        microseconds else if False the seconds has a partial
                        day as a decimal.
        :param bool short: If True then parse for a short date else if False
                           (default) parse for a long date.
        :param bool trim: Trim the us, ss, mm, and hh in that order.
        :param bool rtd: Round to day.
        :param bool _exact: Use the more exact Julian Period algorithm.
                            Default is True. This should generally be set to
                            True, a False value will give inaccurate results
                            and is used for testing only.
        :returns: A Badí' date long or short form.
        :rtype: tuple
        """
        jd = self._gc.jd_from_gregorian_date(g_date, exact=_exact)
        return self.badi_date_from_jd(jd, lat, lon, zone, us=us, short=short,
                                      trim=trim, rtd=rtd)

    def gregorian_date_from_badi_date(self, b_date: tuple, lat: float=None,
                                      lon: float=None, zone: float=None, *,
                                      us: bool=False, _exact: bool=True,
                                      ) -> tuple:
        """
        Get the Gregorian date from the Badí' date.

        :param tuple b_date: A Badí' date short form.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param float zone: The standard time zone.
        :param bool us: If True the seconds are split to seconds amd
                        microseconds else if False the seconds has a partial
                        day as a decimal.
        :param bool _exact: Use the more exact Julian Period algorithm.
                            Default is True. This should generally be set to
                            True, a False value, will give inaccurate results
                            and is used for testing only.
        :returns: The Gregorian date.
        :rtype: tuple
        """
        if any([True if l is None else False for l in (lat, lon, zone)]):
            lat, lon, zone = self._BAHAI_LOCATION[:3]

        jd = self.jd_from_badi_date(b_date, lat, lon, zone)
        jd += self._HR(zone)
        return self._gc.gregorian_date_from_jd(jd, hms=True, us=us,
                                               exact=_exact)

    def badi_date_from_timestamp(self, t: float, lat: float=None,
                                 lon: float=None, zone: float=None, *,
                                 us: bool=False, short: bool=False,
                                 trim: bool=False, rtd: bool=False) -> tuple:
        """
        Get the Badí' date from a POSIX timestamp.

        :param float t: Timestamp
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param float zone: The time zone.
        :param bool us: If True the seconds are split to seconds and
                        microseconds else if False the seconds has a partial
                        day as a decimal.
        :param bool short: If True then parse for a short date else if False
                           (default) parse for a long date.
        :param bool trim: Trim the us, ss, mm, and hh in that order.
        :param bool rtd: Round to day.
        :returns: A Badí' date long or short form.
        :rtype: tuple
        """
        jd = t / self._SECONDS_PER_DAY + self._POSIX_EPOCH
        return self.badi_date_from_jd(jd, lat, lon, zone, us=us, short=short,
                                      trim=trim, rtd=rtd)

    def timestamp_from_badi_date(self, date: tuple, lat: float=None,
                                 lon: float=None, zone: float=None) -> float:
        """
        Convert a Badí' date to a timestamp.

        :param tuple date: The Badí' date.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param float zone: The time zone.
        :returns: The timestamp corrected for the time zone.
        :rtype: float
        """
        jd = self.jd_from_badi_date(date, lat, lon, zone)
        return round((jd - self._POSIX_EPOCH) * self._SECONDS_PER_DAY,
                     self._ROUNDING_PLACES)

    def midday(self, date: tuple, *, hms: bool=False, _short: bool) -> tuple:
        """
        Find the midday time in hours, minutes, and seconds with fraction.
        All calculations are done in GMT.

        :param tuple date: Badí' date short or long.
        :param bool hms: If True return the hours, minutes, and seconds else
                         if False return the decimal value.
        :param bool _short: Indicates the incoming date format.
        :returns: Midday in hours, minutes, and seconds.
        :rtype: tuple
        """
        if not _short:
            b_date = self.short_date_from_long_date(date, trim=True)
        else:
            b_date = date

        jd = self.jd_from_badi_date(b_date)
        jd1 = self._meeus_from_exact(jd)
        ss0 = self._sun_setting(jd1, *self._GMT_LOCATION[:2])
        jd2 = self._meeus_from_exact(jd + 1)
        ss1 = self._sun_setting(jd2, *self._GMT_LOCATION[:2])
        ut_mid = (ss1 - ss0) / 2
        local_mid = self._local_zone_correction(ut_mid, self._GMT_LOCATION[2])
        return self._hms_from_decimal_day(local_mid) if hms else local_mid

    def _trim_hms(self, hms: tuple) -> tuple:
        """
        Trim the hours, minutes, seconds or microseconds off the date if
        zero unless a lower value was not zero.

        .. list-table:: Examples
           :widths: 18 16 66
           :header-rows: 1

           * - Examples
             - Results
             - Description
           * - (12, 30, 6, 0)
             - (12, 30, 6)
             - The zero microseconds would be trimmed.
           * - (12,  0, 6, 0)
             - (12, 0, 6)
             - The zero microseconds would be trimmed but the zero minutes
               would be left untouched.

        :param tuple hms: An hour, minute, and second object.
        :returns: An object with the lower order parts stripped off if
                  they have a zero value.
        :rtype: tuple
        """
        items = []
        has = False

        for v in reversed(hms):
            if v == 0 and not has:
                continue
            else:
                items.append(v)
                has = True

        return tuple(reversed(items))

    def _check_valid_badi_date(self, b_date: tuple, short_in: bool=False
                               ) -> None:
        """
        Check that the Kull-i-Shay, Váḥids, year, month, day, hour, minute,
        second, and microsecond values are valid.

        :param tuple b_date: A long form Badí' date.
        :param bool short_in: If True then parse for a short date else if
                              False parse for a long date. This is for
                              incoming dates not outgoing dates as in most
                              other uses of 'short'.
        :returns: Nothing
        :rtype: None
        :raises AssertionError: When a date Váḥid, year, month, day, hour,
                                minute, second, or microsecond are out of
                                range.
        """
        cycle = 20

        if not short_in:  # Long Badí' date
            kull_i_shay, vahid, year, month, day = b_date[:5]
            hour, minute, second, us = self._get_hms(b_date)
            assert (self.KULLISHAY_MIN-1 <= kull_i_shay
                    <= self.KULLISHAY_MAX+1), (
                f"Invalid kull-i-shay {kull_i_shay}, it must be in the range "
                f"of [{self.KULLISHAY_MIN}, {self.KULLISHAY_MAX}].")
            assert 1 <= vahid < cycle, (
                f"Invalid Váḥids '{vahid}' in a Kull-i-Shay’, it must be in "
                "the range of [1, 19].")
            assert 1 <= year < cycle, (
                f"Invalid year '{year}' in a Váḥid, it must be in the "
                "range of [1, 19].")
            ly = (kull_i_shay - 1) * 361 + (vahid - 1) * 19 + year
        else:  # Short Badí' date
            year, month, day = b_date[:3]
            hour, minute, second, us = self._get_hms(b_date, short_in=True)
            assert self.MINYEAR-1 <= year <= self.MAXYEAR+1, (
                f"Invalid year '{year}' it must be in the range of ["
                f"{self.MINYEAR}, {self.MAXYEAR}].")
            ly = year

        assert 0 <= month < cycle, (
            f"Invalid month '{month}', it must be in the range of [0, 19].")
        # This is Ayyām-i-Hā and could be 4 or 5 days depending on leap year.
        cycle = (5 + self._is_leap_year(ly)) if month == 0 else cycle
        assert 1 <= day < (cycle), (
            f"Invalid day '{day}' for month '{month}', it must be in the "
            f"range of [1, {cycle-1}].")
        self._check_valid_badi_time(hour, minute, second, us)

        # Check if there are any fractions that invalidate other values.
        if any((hour, minute, second)):
            assert not day % 1, (
                "If there is a part day then there can be no hours, minutes, "
                "or seconds.")

        if any((minute, second)):
            assert not hour % 1, ("If there is a part hour then there can "
                                  "be no minutes or seconds.")

        if second:
            assert not minute % 1, (
                "If there is a part minute then there can be no seconds.")

    def _check_valid_badi_time(self, hour: float, minute: float, second: float,
                               us: int, maxsec: int=60) -> None:
        """
        Check that the hour, minute, second, and microsecond values are valid.

        :param float hour: Hours
        :param float minute: Minutes
        :param float second: Seconds
        :param float us: Microseconds
        :returns: Nothing
        :rtype: None
        :raises AssertionError: When an hour, minute, second, or microsecond
                                are out of range.
        """
        assert 0 <= hour < 25, (
            f"Invalid hour '{hour}', it must be in the range of [0, 24].")
        assert 0 <= minute < 60, (
            f"Invalid minute '{minute}', it must be in the range of [0, 59].")
        assert 0 <= second < maxsec, (
            f"Invalid second '{second}', it must be in the range of "
            f"[0, {maxsec}].")
        assert 0 <= us < 1000000, (
            f"Invalid microseconds '{us}', it must be in the range of "
            "[0, 999999].")

    def _is_leap_year(self, year: int) -> bool:
        """
        Return a Boolean True if a Badí' leap year, False if not.

        :param int year: This value must be a Badí' short form year.
        :returns: A Boolean indicating if a leap year or not.
        :rtype: bool
        """
        return self._YEAR_START[year + 1] - self._YEAR_START[year] == 366

    def _get_hms(self, date: tuple, *, short_in: bool=False) -> tuple:
        """
        Parse the hours, minutes, seconds, and microseconds, if they exist
        for either the short or long form Badí' date.

        :param tuple date: A long or short form Badí' date.
        :param bool short_in: If True then parse for a short date else if False
                              parse for a long date. This is for incoming dates
                              not outgoing dates as in most other uses of
                              'short'.
        :returns: The relevant hours, minutes, and seconds.
        :rtype: tuple
        """
        t_len = len(date)
        s = 3 if short_in else 5
        hour = date[s] if t_len > s and date[s] is not None else 0
        minute = date[s+1] if t_len > s+1 and date[s+1] is not None else 0
        second = date[s+2] if t_len > s+2 and date[s+2] is not None else 0
        us = date[s+3] if t_len > s+3 and date[s+3] is not None else 0
        return hour, minute, second, us

    def _day_length(self, jd: float, lat: float, lon: float, *,
                    decimal: bool=False) -> tuple:
        """
        The hour, minute, and seconds of the day's offset either less than
        or more than 24 hours.

        :param float jd: The astronomically exact Julian Period day.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :param bool decimal: If `False` (default) return HH:MM:SS else if
                             `True` return a decimal number.
        :returns: The hour, minute, and second or a decimal number.
        :rtype: tuple or float
        """
        jd0 = math.floor(jd)
        jd1 = jd0 + 1
        # The next day
        jd1 = self._meeus_from_exact(jd1)
        ss1 = self._sun_setting(jd1, lat, lon)
        # The first day
        jd0 = self._meeus_from_exact(jd0)
        ss0 = self._sun_setting(jd0, lat, lon)
        # Subtract the first day from the next day given the total
        # hours, minutes, and seconds between them.
        ut_ss = ss1 - ss0

        if decimal:
            ret = round(ut_ss, self._ROUNDING_PLACES)
        else:
            value = list(self._hms_from_decimal_day(ut_ss))
            value[0] = 24 if value[0] == 0 else value[0]
            ret = tuple(value)

        return ret

    def _utc_to_badi_time(self, jd: float, lat: float, lon: float) -> float:
        """
        Convert UTC time to Badí' time. The JD must be in UT time. The
        resultant date and time are now authoritative.

        :param float jd: An Astronomically correct JD.
        :param float lat: The latitude.
        :param float lon: The longitude.
        :returns: The JD with the correct Badí' time.
        :rtype: float
        """
        def get_sunset(hist_jd, inc):
            ss = self._sun_setting(hist_jd + inc, lat, lon)
            return self._exact_from_meeus(ss)

        hist_jd = self._meeus_from_exact(jd)
        ss_prev = get_sunset(hist_jd, -1)
        ss_curr = get_sunset(hist_jd, 0)
        astro_ss = ss_curr if jd >= ss_curr else ss_prev
        delta = jd - astro_ss

        if delta < 0:  # pragma: no cover
            astro_ss -= 1
        elif delta >= 1:
            astro_ss += 1

        # Elapsed fraction since sunset
        frac = jd - astro_ss
        return astro_ss, frac

    def _build_badi_year_start(self):
        year_start = {}
        lon_div = self._BAHAI_LOCATION[1] / 360.0

        for year in range(self.MINYEAR - 1, self.MAXYEAR + 2):
            jd = self.jd_from_badi_date((year, 1, 1))
            jd_int = int(jd)
            frac = jd - jd_int
            rd = math.floor(jd_int - self.PROLEPTIC_GREG_1ST_DAY +
                            (frac + lon_div + 0.5 + 1e-12))
            year_start[year] = rd

        return year_start
