# Source code for pyluach.dates

    """The dates module implements classes for representing and
    manipulating several date types.

    Classes
    -------
    * BaseDate
    * CalendarDateMixin
    * JulianDay
    * GregorianDate
    * HebrewDate

    Note
    ----
    All instances of the classes in this module should be treated as read
    only. No attributes should be changed once they're created.
    """

    from __future__ import division

    from datetime import date
    from numbers import Number

    from pyluach.utils import memoize

    class BaseDate(object):
        """BaseDate is a base class for all date types.

        It provides the following arithmetic and comparison operators
        common to all child date types.

        ===================  =============================================
        Operation            Result
        ===================  =============================================
        d2 = date1 + int     New date ``int`` days after date1
        d2 = date1 - int     New date ``int`` days before date1
        int = date1 - date2  Integer equal to the absolute value of the
                             difference between date1 and date2
        date1 > date2        True if date1 occurs later than date2
        date1 < date2        True if date1 occurs earlier than date2
        date1 == date2       True if date1 occurs on the same day as date2
        date1 != date2       True if ``date1 == date2`` is False
        date1 >=, <= date2   True if both are True
        ===================  =============================================

        Any child of BaseDate that implements a ``jd`` attribute
        representing the Julian Day of that date can be compared to and
        diffed with any other valid date type.
        """

        def __hash__(self):
            return hash(self.jd)

        def __add__(self, other):
            try:
                return JulianDay(self.jd + other)._to_x(self)
            except AttributeError:
                raise TypeError('You can only add a number to a date.')

        def __sub__(self, other):
            if isinstance(other, Number):
                return JulianDay(self.jd - other)._to_x(self)
            try:
                return abs(self.jd - other.jd)
            except AttributeError:
                raise TypeError("""You can only subtract a number or another date
                                  that has a "jd" attribute from a date""")

        def __eq__(self, other):
            try:
                if self.jd == other.jd:
                    return True
                return False
            except AttributeError:
                raise TypeError(self._error_string)

        def __ne__(self, other):
            try:
                if self.jd != other.jd:
                    return True
                return False
            except AttributeError:
                raise TypeError(self._error_string)

        def __lt__(self, other):
            try:
                if self.jd < other.jd:
                    return True
                return False
            except AttributeError:
                raise TypeError(self._error_string)

        def __gt__(self, other):
            try:
                if self.jd > other.jd:
                    return True
                return False
            except AttributeError:
                raise TypeError(self._error_string)

        def __le__(self, other):
            try:
                if self.jd <= other.jd:
                    return True
                return False
            except AttributeError:
                raise TypeError(self._error_string)

        def __ge__(self, other):
            try:
                if self.jd >= other.jd:
                    return True
                return False
            except AttributeError:
                raise TypeError(self._error_string)

        def shabbos(self):
            """Return the Shabbos on or following the date.

            Returns
            -------
            Date
              Self if it's Shabbos or else the following Shabbos as
              the same date type as operated on.
            """
            return self + (7 - self.weekday())

    class CalendarDateMixin(object):
        """CalendarDateMixin is a mixin for Hebrew and Gregorian dates.

        Parameters
        ----------
        Year : int
        Month : int
        day : int

        Attributes
        ----------
        year : int
        month : int
        day : int
        jd : float
          The equivelant Julian day at midnight.
        """

        def __init__(self, year, month, day, jd=None):
            """Initialize a calendar date."""
            self.year = year
            self.month = month
            self.day = day
            self._jd = jd
            self. _error_string = ('''Only a date with a "jd" attribute can
                                  be compared to a {0}'''.format(
                                                    self.__class__.__name__)
                                  )

        def __repr__(self):
            return '{0}({1}, {2}, {3})'.format(self.__class__.__name__,
                                               self.year,
                                               self.month,
                                               self.day)

        def __str__(self):
            return '{0:04d}-{1:02d}-{2:02d}'.format(self.year, self.month, self.day)

        def __iter__(self):
            yield self.year
            yield self.month
            yield self.day

        def weekday(self):
            """Return day of week as an integer.

            Returns
            -------
            int
              An integer representing the day of the week with Sunday as 1
              through Saturday as 7.
            """
            return int(self.jd+.5+1) % 7 + 1

        def tuple(self):
            """Return date as tuple.

            Returns
            -------
            tuple of ints
              A tuple of ints in the form ``(year, month, day)``.
            """
            return (self.year, self.month, self.day)

        def dict(self):
            """Return the date as a dictionary.

            Returns
            -------
            Dict
              A dictionary in the form
              ``{'year': int, 'month': int, 'day': int}``.
            """
            return {'year': self.year, 'month': self.month, 'day': self.day}

    class JulianDay(BaseDate):
        """A JulianDay object represents a Julian Day at midnight.

        Parameters
        ----------
        day : float or int
          The julian day. Note that Julian days start at noon so day
          number 10 is represented as 9.5 which is day 10 at midnight.

        Attributes
        ----------
        day : float
          The Julian Day Number at midnight (as *n*.5)
        jd : float
          Alias for day.
        """

        def __init__(self, day):
            """Initialize a JulianDay instance."""
            if day-int(day) < .5:
                self.day = int(day) - .5
            else:
                self.day = int(day) + .5
            self.jd = self.day
            self._error_string = """Only a date with a "jd" attribute can
            be compared to a Julian Day instance."""

        def __repr__(self):
            return 'JulianDay({0})'.format(self.day)

        def __str__(self):
            return str(self.day)

        def weekday(self):
            """Return weekday of date.

            Returns
            -------
            int
              The weekday with Sunday as 1 through Saturday as 7.
            """
            return (int(self.day+.5) + 1) % 7 + 1

        @staticmethod
        def from_pydate(pydate):
            """Return a `JulianDay` from a python date object.

            Parameters
            ----------
            pydate : datetime.date
              A python standard library ``datetime.date`` instance

            Returns
            -------
            JulianDay
            """
            return GregorianDate.from_pydate(pydate).to_jd()

        @staticmethod
        def today():
            """Return instance of current Julian day from timestamp.

            Extends the built-in ``datetime.date.today()``.

            Returns
            -------
            JulianDay
              A JulianDay instance representing the current Julian day from
              the timestamp.
            """
            return GregorianDate.today().to_jd()

        def to_greg(self):
            """Convert JulianDay to a Gregorian Date.

            Returns
            -------
            GregorianDate
              The equivalent Gregorian date instance.

            Notes
            -----
            This method uses the Fliegel-Van Flandern algorithm.
            """
            jd = int(self.day + .5)
            L = jd + 68569
            n = 4*L // 146097
            L = L - (146097*n + 3) // 4
            i = (4000 * (L+1)) // 1461001
            L = L - ((1461*i) // 4) + 31
            j = (80*L) // 2447
            day = L - 2447*j // 80
            L = j // 11
            month = j + 2 - 12*L
            year = 100 * (n-49) + i + L
            if year < 1:
                year -= 1
            return GregorianDate(year, month, day, self.day)

        def to_heb(self):
            """ Convert to a Hebrew date.

            Returns
            -------
            HebrewDate
              The equivalent Hebrew date instance.
            """

            if self.day <= 347997:
                raise ValueError('Date is before creation')

            jd = int(self.day + .5)  # Try to account for half day
            jd -= 347997
            year = int(jd//365) + 2  ## try that to debug early years
            first_day = HebrewDate._elapsed_days(year)

            while first_day > jd:
                year -= 1
                first_day = HebrewDate._elapsed_days(year)

            months = [7, 8, 9, 10, 11, 12, 13, 1, 2, 3, 4, 5, 6]
            if not HebrewDate._is_leap(year):
                months.remove(13)

            days_remaining = jd - first_day
            for month in months:
                if days_remaining >= HebrewDate._month_length(year, month):
                    days_remaining -= HebrewDate._month_length(year, month)
                else:
                    return HebrewDate(year, month, days_remaining + 1, self.day)

        def _to_x(self, type_):
            """Return a date object of the given type."""

            if isinstance(type_, GregorianDate):
                return self.to_greg()
            elif isinstance(type_, HebrewDate):
                return self.to_heb()
            elif isinstance(type_, JulianDay):
                return self

        def to_pydate(self):
            """Convert to a datetime.date object.

            Returns
            -------
            datetime.date
              A standard library ``datetime.date`` instance.
            """
            return self.to_greg().to_pydate()

    class GregorianDate(BaseDate, CalendarDateMixin):
        """A GregorianDate object represents a Gregorian date (year, month, day).

        This is an idealized date with the current Gregorian calendar
        infinitely extended in both directions.

        Parameters
        ----------
        year : int
        month : int
        day : int
        jd : float, optional
          This parameter should not be assigned manually.

        Attributes
        ----------
        year : int
        month : int
        day : int
        jd : float(property)
          The corresponding Julian Day Number at midnight (as *n*.5).

        Warnings
        --------
        Although B.C.E. dates are allowed, they should be treated as
        approximations as they may return inconsistent results when converting
        between date types and using arithmetic and comparison operators!
        """

        def __init__(self, year, month, day, jd=None):
            """Initialize a GregorianDate.

            This initializer extends the CalendarDateMixin initializer
            adding in date validation specific to Gregorian dates.
            """
            if month < 1 or month > 12:
                raise ValueError('{0} is an invalid month.'.format(str(month)))
            monthlength = self._monthlength(year, month)
            if day < 1 or day > monthlength:
                raise ValueError('Given month has {0} days.'.format(monthlength))
            super(GregorianDate, self).__init__(year, month, day, jd)

        @property
        def jd(self):
            """Return the corresponding Julian day number.

            This property retrieves the corresponding Julian Day as a float
            if it was passed into the init method or already calculated, and
            if it wasn't, it calculates it and saves it for later retrievals
            and returns it.

            Returns
            -------
            float
              The Julian day number at midnight.
            """
            if self._jd is None:
                year = self.year
                month = self.month
                day = self.day
                if year < 0:
                    year += 1
                if month < 3:
                    year -= 1
                    month += 12
                month += 1
                a = year // 100
                b = 2 - a + a//4
                self._jd = (int(365.25*year) +
                            int(30.6001*month) + b + day + 1720994.5)
            return self._jd

        @staticmethod
        def from_pydate(pydate):
            """Return a `GregorianDate` instance from a python date object.

            Parameters
            ----------
            pydate : datetime.date
              A python standard library ``datetime.date`` instance.

            Returns
            -------
            GregorianDate
            """
            return GregorianDate(*pydate.timetuple()[:3])

        @staticmethod
        def today():
            """Return a GregorianDate instance for the current day.

            This static method wraps the Python standard library's
            date.today() method to get the date from the timestamp.

            Returns
            -------
            GregorianDate
              The current Gregorian date from the computer's timestamp.
            """
            return GregorianDate.from_pydate(date.today())

        @staticmethod
        def _is_leap(year):
            """Return True if year of date is a leap year, otherwise False."""
            if year < 0:
                year += 1
            if(
                (year % 4 == 0) and not
                (year % 100 == 0 and year % 400 != 0)
              ):
                    return True
            return False

        def is_leap(self):
            """Return if the date is in a leap year

            Returns
            -------
            bool
              True if the date is in a leap year, False otherwise.
            """
            return self._is_leap(self.year)

        @classmethod
        def _monthlength(cls, year, month):
            if month in [1, 3, 5, 7, 8, 10, 12]:
                return 31
            elif month != 2:
                return 30
            else:
                return 29 if cls._is_leap(year) else 28

        def to_jd(self):
            """Convert to a Julian day.

            Returns
            -------
            JulianDay
              The equivalent JulianDay instance.
            """
            return JulianDay(self.jd)

        def to_heb(self):
            """Convert to Hebrew date.

            Returns
            -------
            HebrewDate
              The equivalent HebrewDate instance.
            """
            return self.to_jd().to_heb()

        def to_pydate(self):
            """Convert to a standard library date.

            Returns
            -------
            datetime.date
              The equivalent datetime.date instance.
            """
            return date(*self.tuple())

    class HebrewDate(BaseDate, CalendarDateMixin):
        """A class for manipulating Hebrew dates.

        Parameters
        ----------
        year : int
          The Hebrew year. If the year is less than 1 it will raise a
          ValueError.

        month : int
          The Hebrew month starting with Nissan as 1 (and Tishrei as 7).
          If there is a second Adar in the year it is represented as 13.
          A month below 1 or above the last month will raise a ValueError.

        day : int
          The Hebrew day of the month. An invalid day will raise a
          ValueError.

        jd : float, optional
          This parameter should not be assigned manually.

        Attributes
        ----------
        year : int
        month : int
          The Hebrew month starting with Nissan as 1 (and Tishrei as 7).
          If there is a second Adar it is represented as 13.
        day : int
          The day of the month.
        """

        def __init__(self, year, month, day, jd=None):

            """Initialize a HebrewDate instance.

            This initializer extends the CalendarDateMixin adding validation
            specific to hebrew dates.
            """
            if year < 1:
                raise ValueError('Date supplied is before creation.')
            if month < 1 or month > 13:
                raise ValueError('{0} is an invalid month.'.format(str(month)))
            if (not self._is_leap(year)) and month == 13:
                raise ValueError('{0} is not a leap year'.format(year))
            monthlength = self._month_length(year, month)
            if day < 1 or day > monthlength:
                raise ValueError('Given month has {0} days.'.format(monthlength))
            super(HebrewDate, self).__init__(year, month, day, jd)

        @property
        def jd(self):
            """Return the corresponding Julian day number.

            This property retrieves the corresponding Julian Day as a float
            if it was passed into the init method or already calculated, and
            if it wasn't, it calculates it, saves it for later retrievals,
            and returns it.

            Returns
            -------
            float
              The Julian day number at midnight.

            """
            if self._jd is None:
                months = [7, 8, 9, 10, 11, 12, 13, 1, 2, 3, 4, 5, 6]
                if not HebrewDate._is_leap(self.year):
                    months.remove(13)

                jd = HebrewDate._elapsed_days(self.year)
                for m in months:
                    if m != self.month:
                        jd += HebrewDate._month_length(self.year, m)
                    else:
                        self._jd = jd + (self.day-1) + 347996.5

            return self._jd

        @staticmethod
        def from_pydate(pydate):
            """Return a `HebrewDate` from a python date object.

            Parameters
            ----------
            pydate : datetime.date
              A python standard library ``datetime.date`` instance

            Returns
            -------
            HebrewDate
            """
            return GregorianDate.from_pydate(pydate).to_heb()

        @staticmethod
        def today():
            """Return HebrewDate instance for the current day.

            This static method wraps the Python standard library's
            ``date.today()`` method to get the date from the timestamp.

            Returns
            -------
            HebrewDate
              The current Hebrew date from the computer's timestamp.

            Note
            ----
            This method coverts the Gregorian date from the time stamp to
            a Hebrew date, so if it is after nightfall but before
            midnight you will have to add one day, ie.
            ``today = HebrewDate.today() + 1``.
            """
            return GregorianDate.today().to_heb()

        def to_jd(self):
            """Convert to a Julian day.

            Returns
            -------
            JulianDay
              The equivalent JulianDay instance.
            """
            return JulianDay(self.jd)

        def to_greg(self):
            """Convert to a Gregorian date.

            Returns
            -------
            GregorianDate
              The equivalent GregorianDate instance.
            """
            return self.to_jd().to_greg()

        def to_pydate(self):
            """Convert to a standard library date.

            Returns
            -------
            datetime.date
              The equivalent datetime.date instance.
            """
            return self.to_greg().to_pydate()

        def to_heb(self):
            return self

        @staticmethod
        def _is_leap(year):
            if (((7*year) + 1) % 19) < 7:
                return True
            return False

        @classmethod
        @memoize(maxlen=100)
        def _elapsed_days(cls, year):
            months_elapsed = (
                          (235 * ((year-1) // 19)) + (12 * ((year-1) % 19)) +
                          (7 * ((year-1) % 19) + 1) // 19
                          )
            parts_elapsed = 204 + 793*(months_elapsed%1080)
            hours_elapsed = (5 + 12*months_elapsed + 793*(months_elapsed//1080) +
                             parts_elapsed//1080)
            conjunction_day = 1 + 29*months_elapsed + hours_elapsed//24
            conjunction_parts = 1080 * (hours_elapsed%24) + parts_elapsed%1080

            if (
                  (conjunction_parts >= 19440) or
                  (
                   (conjunction_day % 7 == 2) and
                   (conjunction_parts >= 9924) and
                   (not cls._is_leap(year))
                  ) or
                  (
                   (conjunction_day % 7 == 1) and
                   conjunction_parts >= 16789 and cls._is_leap(year - 1))):
                # if all that
                alt_day = conjunction_day + 1
            else:
                alt_day = conjunction_day
            if (alt_day % 7) in (0, 3, 5):
                alt_day += 1

            return alt_day

        @classmethod
        def _days_in_year(cls, year):
            return cls._elapsed_days(year + 1) - cls._elapsed_days(year)

        @classmethod
        def _long_cheshvan(cls, year):
            """Returns True if Cheshvan has 30 days"""
            return cls._days_in_year(year) % 10 == 5

        @classmethod
        def _short_kislev(cls, year):
            """Returns True if Kislev has 29 days"""
            return cls._days_in_year(year) % 10 == 3

        @classmethod
        def _month_length(cls, year, month):
            """Months start with Nissan (Nissan is 1 and Tishrei is 7)"""

            if month in [1, 3, 5, 7, 11]:
                return 30
            elif month in [2, 4, 6, 10, 13]:
                return 29
            elif month == 12:
                return 30 if cls._is_leap(year) else 29
            elif month == 8:   # if long Cheshvan return 30, else return 29
                return 30 if cls._long_cheshvan(year) else 29
            elif month == 9:   # if short Kislev return 29, else return 30
                return 29 if cls._short_kislev(year) else 30