<gh_stars>1-10
import abc as ABC
import collections.abc
import datetime
from collections import namedtuple
from enum import Enum
from functools import reduce, total_ordering
from numbers import Integral
from typing import Sequence

from pudzu.utils import *
from pudzu.utils import optional_import, partial

cdate = optional_import("convertdate")

# Date class supporting flexible calendars, deltas and ranges

# Calendars (gregorian, julian, astronomical; with convertdate, also: iso week, hebrew, islamic,
# french republican)

YMD = namedtuple("YMD", ["year", "month", "day"])


class InvalidDate(ValueError):
    pass


class Calendar(object):
    """Abstract base class for calendars. Supports YMD-type dates with weekdays.
    Converts to and from a date ordinal, where 1 is 1st January AD1 in proleptic Gregorian.
    Default implementations assume 1-based months and days and 0-based weekdays internally.
    """

    __metaclass__ = ABC.ABCMeta

    WEEKDAYS: Sequence[str] = (
        "Sunday",
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
    )

    # Formatting
    @property
    @ABC.abstractmethod
    def calendar_name(self):
        """Calendar name"""

    def day_name(self, ymd):
        """User-friendly formatting of day."""
        return str(ymd.day)

    def month_name(self, ymd):
        """User-friendly formatting of month."""
        return self.MONTHS[ymd.month - 1]

    def year_name(self, ymd):
        """User-friendly formatting of year."""
        return str(ymd.year)

    def weekday_name(self, weekday):
        """User-friendly formatting of week day."""
        return self.WEEKDAYS[weekday]

    def date_name(self, ymd):
        """Optional user-friendly description of precise date."""
        return "?"

    def date_format(self, ymd, weekday, format_string="{W}, {D} {M} {Y}"):
        """User-friendly formatting of a full date, used in repr."""
        return format_string.format(
            W=self.weekday_name(weekday),
            D=self.day_name(ymd),
            M=self.month_name(ymd),
            Y=self.year_name(ymd),
            N=self.date_name(ymd),
            d=ymd.day,
            m=ymd.month,
            y=ymd.year,
        )

    # Representation
    @ABC.abstractmethod
    def days_in_month(self, year, month):
        """Number of days in a given month."""

    @ABC.abstractmethod
    def months_in_year(self, year):
        """Number of months in a given year."""

    def validate(self, ymd):
        """Whether a given YMD representation is valid."""
        return (1 <= ymd.month <= self.months_in_year(ymd.year)) and (
            1 <= ymd.day <= self.days_in_month(ymd.year, ymd.month)
        )

    @ABC.abstractmethod
    def date_to_ordinal(self, ymd):
        """Convert YMD to day ordinal."""

    @ABC.abstractmethod
    def ordinal_to_date(self, ordinal):
        """Convert day ordinal to YMD tuple."""

    def weekday(self, ymd, ordinal):
        """Week day for a given date."""
        # default to the standard 7-day week
        return ordinal % len(self.WEEKDAYS)

    # Calculations
    def carry_over(self, ymd):
        """Carry over invalid dates: e.g. Feb 30 > Mar 2."""
        year, month, day = ymd
        while month > self.months_in_year(year):
            month -= self.months_in_year(year)
            year += 1
        while month < 0:
            month += self.months_in_year(year - 1)
            year -= 1
        if day > self.days_in_month(year, month):
            day, month = day - self.days_in_month(year, month), month + 1
        if month > self.months_in_year(year):
            year, month = year + 1, 1
        return YMD(year, month, day)

    def add_years(self, ymd, year_delta):
        """Add years to YMD tuple."""
        return self.carry_over(YMD(ymd.year + year_delta, ymd.month, ymd.day))

    def add_months(self, ymd, month_delta):
        """Add months to YMD tuple."""
        return self.carry_over(YMD(ymd.year, ymd.month + month_delta, ymd.day))

    def add_days(self, ymd, day_delta):
        """Add days to YMD tuple."""
        return self.ordinal_to_date(self.date_to_ordinal(ymd) + day_delta)


class DerivedCalendar(Calendar):
    """Derived calendar that uses another calendar for its internal representation.
    Used for zero-less era calendars and the civil Hebrew calendar."""

    def __init__(self, base_calendar, to_base, from_base, calendar_name=None, **overrides):
        self.calendar = base_calendar
        self.name = self.calendar.calendar_name if calendar_name is None else calendar_name
        self.to_base = partial(to_base, self)
        self.from_base = partial(from_base, self)
        for k, v in overrides.items():
            setattr(self, k, partial(v, self))

    @property
    def calendar_name(self):
        return self.name

    def day_name(self, ymd):
        return self.calendar.day_name(self.to_base(ymd))

    def month_name(self, ymd):
        return self.calendar.month_name(self.to_base(ymd))

    def year_name(self, ymd):
        return self.calendar.year_name(self.to_base(ymd))

    def weekday_name(self, weekday):
        return self.calendar.weekday_name(weekday)

    def date_name(self, ymd):
        return self.calendar.date_name(self.to_base(ymd))

    def days_in_month(self, year, month):
        return self.calendar.days_in_month(*(self.to_base(YMD(year, month, 1))[0:2]))

    def months_in_year(self, year):
        return self.calendar.months_in_year(*(self.to_base(YMD(year, 1, 1))[0:1]))

    def validate(self, ymd):
        try:
            return self.calendar.validate(self.to_base(ymd))
        except InvalidDate:
            return False

    def date_to_ordinal(self, ymd):
        return self.calendar.date_to_ordinal(self.to_base(ymd))

    def ordinal_to_date(self, ordinal):
        return self.from_base(self.calendar.ordinal_to_date(ordinal))

    def weekday(self, ymd, ordinal):
        return self.calendar.weekday(self.to_base(ymd), ordinal)

    def add_years(self, ymd, year_delta):
        return self.from_base(self.calendar.add_years(self.to_base(ymd), year_delta))

    def add_months(self, ymd, month_delta):
        return self.from_base(self.calendar.add_months(self.to_base(ymd), month_delta))

    def add_days(self, ymd, day_delta):
        return self.from_base(self.calendar.add_days(self.to_base(ymd), day_delta))


# Calendar classes


class WesternCalendar(Calendar):
    """Proleptic Western calendar that includes year 0."""

    MONTHS = (
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December",
    )

    def __init__(self, julian=False):
        self.gregorian = not julian

    @property
    def calendar_name(self):
        return "astronomical {}".format("Gregorian" if self.gregorian else "Julian")

    def year_name(self, ymd):
        return str(ymd.year)

    def days_in_month(self, year, month):
        if month == 2:
            return (
                28
                + int(year % 4 == 0)
                - int(self.gregorian and year % 100 == 0 and year % 400 != 0)
            )
        elif month in [4, 6, 9, 11]:
            return 30
        else:
            return 31

    def months_in_year(self, year):
        return 12

    def date_to_ordinal(self, ymd):
        # based on years and months since 1 March BC1
        y = ymd.year - int(ymd.month <= 2)
        m = ymd.month + 12 * int(ymd.month <= 2) - 3
        d = ymd.day + (153 * m + 2) // 5 + 365 * y + y // 4 - 306
        return d - y // 100 + y // 400 if self.gregorian else d - 2

    def ordinal_to_date(self, ordinal):
        # see http://www.tondering.dk/claus/cal/julperiod.php
        a = ordinal + 305
        b = (4 * a + 3) // 146097 if self.gregorian else 0
        c = a - (146097 * b) // 4 if self.gregorian else ordinal + 305 + 2
        d = (4 * c + 3) // 1461
        e = c - (1461 * d) // 4
        m = (5 * e + 2) // 153
        day = e - (153 * m + 2) // 5 + 1
        month = m + 3 - 12 * (m // 10)
        year = 100 * b + d + m // 10
        return YMD(year, month, day)


def _era_to_astronomical(self, ymd):
    if ymd.year == 0:
        raise InvalidDate("{} does not have a year 0.".format(self.calendar_name))
    elif ymd.year < 0:
        return YMD(ymd.year + 1, ymd.month, ymd.day)
    else:
        return ymd


def _astronomical_to_era(self, ymd):
    return YMD(ymd.year - 1, ymd.month, ymd.day) if ymd.year <= 0 else ymd


def _era_year_name(self, ymd, bc=" BC", ad=""):
    return str(abs(ymd.year)) + (bc if (ymd.year < 0) else ad)


class IslamicCalendar(Calendar):
    """Proleptic Islamic Calendar that includes year 0."""

    MONTHS = (
        "Muharram",
        "Safar",
        "Rabi' al-awwal",
        "Rabi' al-Thani",
        "Jumada al-awwal",
        "Jumada al-Thani",
        "Rajab",
        "Sha'ban",
        "Ramadan",
        "Shawwal",
        "Dhu al-Qidah",
        "Dhu al-Hijjah",
    )

    @property
    def calendar_name(self):
        return "Islamic"

    def days_in_month(self, year, month):
        return cdate.islamic.month_length(year, month)

    def months_in_year(self, year):
        return 12

    def date_to_ordinal(self, ymd):
        return floor(cdate.islamic.to_jd(*ymd) - 1721424)

    def ordinal_to_date(self, ordinal):
        return YMD(*cdate.islamic.from_jd(ordinal + 1721424))


class HebrewCalendar(Calendar):
    """Proleptic Hebrew Calendar. Months are based on the ecclesiastical year, years on the
    civil year. This means that the year increments between months 6 and 7!"""

    MONTHS = (
        "Nisan",
        "Iyar",
        "Sivan",
        "Tammuz",
        "Abh",
        "Elul",
        "Tishri",
        "Marcheshvan",
        "Kislev",
        "Tebheth",
        "Shebhat",
        "Adar",
        "<NAME>",
        "<NAME>",
    )

    @property
    def calendar_name(self):
        return "Hebrew"

    def month_name(self, ymd):
        return self.MONTHS[
            ymd.month - 1 + int(ymd.month >= 12 and self.months_in_year(ymd.year) == 13)
        ]

    def days_in_month(self, year, month):
        return cdate.hebrew.month_days(year, month)

    def months_in_year(self, year):
        return cdate.hebrew.year_months(year)

    def date_to_ordinal(self, ymd):
        return floor(cdate.hebrew.to_jd(*ymd) - 1721424)

    def ordinal_to_date(self, ordinal):
        return YMD(*cdate.hebrew.from_jd(ordinal + 1721424))

    def add_months(self, ymd, month_delta):
        raise NotImplementedError("Date deltas not supported for ecclesiastical Hebrew calendar.")

    def add_years(self, ymd, year_delta):
        raise NotImplementedError("Date deltas not supported for ecclesiastical Hebrew calendar.")


def _ecclesiastical_to_civil(self, ymd):
    if ymd.month >= 7:
        month = ymd.month - 6
    else:
        month = ymd.month + 6 + int(self.calendar.months_in_year(ymd.year) == 13)
    return YMD(ymd.year, month, ymd.day)


def _civil_to_ecclesiastical(self, ymd):
    if ymd.month <= 6 + int(self.calendar.months_in_year(ymd.year) == 13):
        month = ymd.month + 6
    else:
        month = ymd.month - 6 - int(self.calendar.months_in_year(ymd.year) == 13)
    return YMD(ymd.year, month, ymd.day)


def _hebrew_add_years(self, ymd, year_delta):
    year, month, day = ymd
    year += year_delta
    if self.months_in_year(year) > self.months_in_year(ymd.year) and month >= 6:
        month += 1  # skip past Adar A
    elif self.months_in_year(year) < self.months_in_year(ymd.year) and month >= 7:
        month -= 1  # no Adar A
    return self.carry_over(YMD(year, month, day))


class RepublicanCalendar(Calendar):
    """Proleptic French Republican Calendar"""

    WEEKDAYS = (
        "Primidi",
        "Duodi",
        "Tridi",
        "Quartidi",
        "Quintidi",
        "Sextidi",
        "Septidi",
        "Octidi",
        "Nonidi",
        "Décadi",
    )

    MONTHS = (
        "Vendémiaire",
        "Brumaire",
        "Frimaire",
        "Nivôse",
        "Pluviôse",
        "Ventôse",
        "Germinal",
        "Floréal",
        "Prairial",
        "Messidor",
        "Thermidor",
        "Fructidor",
        "Sansculottides",
    )

    @property
    def calendar_name(self):
        return "French Republican"

    def date_name(self, ymd):
        return cdate.french_republican.french_republican_days[ymd.month][ymd.day - 1]

    def date_format(self, ymd, weekday, format_string=None):
        if format_string is None:
            format_string = "{W}, {D} {M} {Y} ({N})" if ymd.month <= 12 else "{N}, {M} {Y}"
        return super().date_format(ymd, weekday, format_string)

    def days_in_month(self, year, month):
        return 30 if month <= 12 else 5 + int(cdate.french_republican.leap(year))

    def months_in_year(self, year):
        return 13

    def weekday(self, ymd, ordinal):
        return (ymd.day - 1) % len(self.WEEKDAYS)

    def date_to_ordinal(self, ymd):
        return floor(cdate.french_republican.to_jd(*ymd) - 1721424)

    def ordinal_to_date(self, ordinal):
        return YMD(*[int(x) for x in cdate.french_republican.from_jd(ordinal + 1721424)])

    def add_months(self, ymd, month_delta):
        # skip complementary month unless we start there
        extra_months = 0 if ymd.month == 13 else (ymd.month + month_delta) // 13
        return self.carry_over(YMD(ymd.year, ymd.month + month_delta + extra_months, ymd.day))


class ISOCalendar(Calendar):
    """ISO week date"""

    @property
    def calendar_name(self):
        return "ISO week"

    def month_name(self, ymd):
        return "W{:02d}".format(ymd.month)

    def date_format(self, ymd, weekday, format_string="{W}, {Y}-{M}-{D}"):
        return super().date_format(ymd, weekday, format_string)

    def days_in_month(self, year, month):
        return 7

    def months_in_year(self, year):
        return cdate.iso.weeks_per_year(year)

    def date_to_ordinal(self, ymd):
        return floor(cdate.iso.to_jd(*ymd) - 1721425)

    def ordinal_to_date(self, ordinal):
        return YMD(*[int(x) for x in cdate.iso.from_jd(ordinal + 1721425)])


# Calendars

isodate = WesternCalendar()
isoweek = ISOCalendar()
gregorian = DerivedCalendar(
    WesternCalendar(),
    _era_to_astronomical,
    _astronomical_to_era,
    "Gregorian",
    year_name=_era_year_name,
)
julian = DerivedCalendar(
    WesternCalendar(julian=True),
    _era_to_astronomical,
    _astronomical_to_era,
    "Julian",
    year_name=_era_year_name,
)
islamic = DerivedCalendar(
    IslamicCalendar(),
    _era_to_astronomical,
    _astronomical_to_era,
    year_name=partial(_era_year_name, ad=" AH", bc=" BH"),
)
hebrew = DerivedCalendar(
    HebrewCalendar(),
    _civil_to_ecclesiastical,
    _ecclesiastical_to_civil,
    add_months=Calendar.add_months,
    add_years=_hebrew_add_years,
)
republican = RepublicanCalendar()

# Dates


@total_ordering
class Date(object):
    def __init__(self, date=None, calendar=gregorian, format=None):
        self.calendar = calendar
        if date is None:
            self.ordinal = datetime.date.today().toordinal()
            self.ymd = calendar.ordinal_to_date(self.ordinal)
        elif isinstance(date, Date):
            self.ordinal = date.ordinal
            self.ymd = calendar.ordinal_to_date(self.ordinal)
        elif isinstance(date, datetime.date):
            self.ordinal = date.toordinal()
            self.ymd = calendar.ordinal_to_date(self.ordinal)
        elif isinstance(date, Integral):
            self.ordinal = int(date)
            self.ymd = calendar.ordinal_to_date(self.ordinal)
        elif non_string_sequence(date, Integral) and len(date) == 3:
            self.ymd = YMD(*date)
            if not calendar.validate(self.ymd):
                raise InvalidDate("Invalid {} date: {}".format(calendar.calendar_name, self.ymd))
            self.ordinal = calendar.date_to_ordinal(self.ymd)
        else:
            raise TypeError("Invalid input type for Date")
        self.weekday = calendar.weekday(self.ymd, self.ordinal)
        self.format = format

    def __repr__(self):
        date_string = self.calendar.date_format(
            self.ymd, self.weekday, *([self.format] if self.format else [])
        )
        return "{} [{}]".format(date_string, self.calendar.calendar_name)

    def date_format(self, format_string):
        return self.calendar.date_format(self.ymd, self.weekday, format_string)

    def __hash__(self):
        return hash(str(self.ordinal))

    def __eq__(self, other):
        if isinstance(other, Date):
            return self.ordinal == other.ordinal
        else:
            return NotImplemented

    def __lt__(self, other):
        if isinstance(other, Date):
            return self.ordinal < other.ordinal
        else:
            return NotImplemented

    def __add__(self, other):
        if isinstance(other, Integral):
            return Date(self.ordinal + int(other), calendar=self.calendar, format=self.format)
        else:
            return NotImplemented

    def __sub__(self, other):
        if isinstance(other, Date):
            return self.ordinal - other.ordinal
        elif isinstance(other, Integral):
            return Date(self.ordinal - int(other), calendar=self.calendar, format=self.format)
        else:
            return NotImplemented

    def to_calendar(self, calendar=None, format=None):
        return Date(self, calendar=calendar or self.calendar, format=format)

    def to_date(self, calendar=gregorian):
        return datetime.date.fromordinal(self.ordinal)


# Date deltas


class DateDelta(object):

    """Abstract base class for date deltas."""

    __metaclass__ = ABC.ABCMeta

    @ABC.abstractmethod
    def add_delta(self, date):
        """Add delta to a Date."""

    @ABC.abstractmethod
    def subtract_delta(self, date):
        """Subtract delta from a Date."""

    # apply delta to date
    def __radd__(self, other):
        if isinstance(other, Date):
            return self.add_delta(other)
        elif isinstance(other, datetime.date):
            return self.add_delta(Date(other)).to_date()
        else:
            return NotImplemented

    def __rsub__(self, other):
        if isinstance(other, Date):
            return self.subtract_delta(other)
        elif isinstance(other, datetime.date):
            return self.subtract_delta(Date(other)).to_date()
        else:
            return NotImplemented

    # combine deltas
    def __neg__(self):
        return DateDeltaNegate(self)

    def __add__(self, other):
        if issubclass(type(other), DateDelta):
            return DateDeltaAdd(self, other)
        else:
            return NotImplemented

    def __sub__(self, other):
        if issubclass(type(other), DateDelta):
            return DateDeltaAdd(self, DateDeltaNegate(other))
        else:
            return NotImplemented

    def __mul__(self, other):
        if isinstance(other, int):
            if other >= 0:
                return DateDeltaAdd(*([self] * other))
            else:
                return DateDeltaAdd(*([-self] * -other))
        else:
            return NotImplemented


class DateDeltaNegate(DateDelta):
    def __init__(self, datedelta):
        self.delta = datedelta

    def __repr__(self):
        return "-{}".format(self.delta)

    def add_delta(self, date):
        return self.delta.subtract_delta(date)

    def subtract_delta(self, date):
        return self.delta.add_delta(date)


class DateDeltaAdd(DateDelta):
    def __init__(self, *datedeltas):
        self.deltas = datedeltas

    def __repr__(self):
        repr = ""
        for i, delta in enumerate(self.deltas):
            if i == 0:
                repr += str(delta)
            elif isinstance(delta, DateDeltaNegate):
                repr += " - " + str(delta.delta)
            else:
                repr += " + " + str(delta)
        return "({})".format(repr)

    def add_delta(self, date):
        return reduce(lambda date, delta: delta.add_delta(date), self.deltas, date)

    def subtract_delta(self, date):
        return reduce(lambda date, delta: delta.subtract_delta(date), self.deltas, date)


class DateInterval(DateDelta):
    def __init__(self, years=0, months=0, days=0):
        self.years = years
        self.months = months
        self.days = days

    @classmethod
    def from_dates(cls, start, end, calendar=gregorian):
        start = Date(start, calendar=calendar)
        end = Date(end, calendar=calendar)
        if end < start:
            return -DateInterval.from_dates(end, start, calendar)
        years, months, days = 0, 0, 0
        current = start
        while Date(calendar.add_years(current.ymd, years + 1), calendar) <= end:
            years += 1
        current = Date(calendar.add_years(current.ymd, years), calendar)
        while Date(calendar.add_months(current.ymd, months + 1), calendar) <= end:
            months += 1
        current = Date(calendar.add_months(current.ymd, months), calendar)
        days = end - current
        return cls(years, months, days)

    def __repr__(self):
        attrs = [
            "{}={}".format(v, getattr(self, v))
            for v in ("years", "months", "days")
            if getattr(self, v) != 0
        ]
        return "DateInterval({})".format(",".join(attrs))

    def add_delta(self, date):
        ymd = date.ymd
        ymd = date.calendar.add_years(ymd, self.years)
        ymd = date.calendar.add_months(ymd, self.months)
        ymd = date.calendar.add_days(ymd, self.days)
        return Date(ymd, calendar=date.calendar, format=date.format)

    def subtract_delta(self, date):
        ymd = date.ymd
        ymd = date.calendar.add_years(ymd, -self.years)
        ymd = date.calendar.add_months(ymd, -self.months)
        ymd = date.calendar.add_days(ymd, -self.days)
        return Date(ymd, calendar=date.calendar, format=date.format)


class DateFilter(DateDelta):
    def __init__(self, filter_fn, max_date=None, min_date=None):
        self.filter = filter_fn
        self.max_date = None if max_date is None else Date(max_date)
        self.min_date = None if min_date is None else Date(min_date)

    def __repr__(self):
        return "DateFilter({})".format(self.filter)

    def add_delta(self, date):
        while self.max_date is None or date < self.max_date:
            date = Date(date.ordinal + 1, calendar=date.calendar, format=date.format)
            if self.filter(date):
                return date
        raise ValueError("Date out of bounds")

    def subtract_delta(self, date):
        while self.max_date is None or date > self.min_date:
            date = Date(date.ordinal - 1, calendar=date.calendar, format=date.format)
            if self.filter(date):
                return date
        raise ValueError("Date out of bounds")


# Date ranges


class DateRange(collections.abc.Sequence):
    def __init__(self, start, end, calendar=gregorian, format=None):
        self.start = Date(start, calendar=calendar, format=format)
        self.end = Date(end, calendar=calendar, format=format)
        if self.start > self.end:
            raise ValueError("Start date later than end date")
        self.calendar = calendar
        self.format = format

    def range_format(self, format_string):
        return format_string.format(
            D1=self.calendar.day_name(self.start.ymd),
            M1=self.calendar.month_name(self.start.ymd),
            Y1=self.calendar.year_name(self.start.ymd),
            W1=self.calendar.weekday_name(self.start.weekday),
            N1=self.calendar.date_name(self.start.ymd),
            d1=self.start.ymd.day,
            m1=self.start.ymd.month,
            y1=self.start.ymd.year,
            D2=self.calendar.day_name(self.end.ymd),
            M2=self.calendar.month_name(self.end.ymd),
            Y2=self.calendar.year_name(self.end.ymd),
            W2=self.calendar.weekday_name(self.end.weekday),
            N2=self.calendar.date_name(self.end.ymd),
            d2=self.end.ymd.day,
            m2=self.end.ymd.month,
            y2=self.end.ymd.year,
        )

    def __repr__(self):
        if self.format is not None:
            format_string = self.format
        elif self.start.ymd.year != self.end.ymd.year:
            format_string = "{D1} {M1} {Y1} - {D2} {M2} {Y2}"
        elif self.start.ymd.month != self.end.ymd.month:
            format_string = "{D1} {M1}-{D2} {M2} {Y2}"
        elif self.start.ymd.day != self.end.ymd.day:
            format_string = "{D1}-{D2} {M2} {Y2}"
        else:
            format_string = "{D2} {M2} {Y2}"
        return "{} [{}]".format(self.range_format(format_string), self.calendar.calendar_name)

    def __eq__(self, other):
        if isinstance(other, DateRange):
            return self.start == other.start and self.end == other.end
        elif isinstance(other, Date):
            return self == DateRange(other, other)
        else:
            return NotImplemented

    def __lt__(self, other):
        if isinstance(other, DateRange):
            return self.end < other.start
        elif isinstance(other, Date):
            return self < DateRange(other, other)
        else:
            return NotImplemented

    def __le__(self, other):
        if isinstance(other, DateRange):
            return self.end <= other.start
        elif isinstance(other, Date):
            return self <= DateRange(other, other)
        else:
            return NotImplemented

    def __gt__(self, other):
        if isinstance(other, DateRange):
            return self.start > other.end
        elif isinstance(other, Date):
            return self > DateRange(other, other)
        else:
            return NotImplemented

    def __ge__(self, other):
        if isinstance(other, DateRange):
            return self.start >= other.end
        elif isinstance(other, Date):
            return self >= DateRange(other, other)
        else:
            return NotImplemented

    def __contains__(self, other):
        if isinstance(other, DateRange):
            return self.start <= other.start <= other.end <= self.end
        elif isinstance(other, Date):
            return DateRange(other, other) in self
        else:
            return False

    def __len__(self):
        return self.end - self.start + 1

    def __getitem__(self, key):
        if not isinstance(key, Integral):
            raise TypeError
        date = self.start + key
        if not self.start <= date <= self.end:
            raise IndexError
        return date

    def __add__(self, other):
        if not non_string_sequence(other):
            other = (other, other)
        elif not len(other) == 2:
            return NotImplemented
        return DateRange(
            self.start + other[0], self.end + other[1], calendar=self.calendar, format=self.format
        )

    def __sub__(self, other):
        if isinstance(other, DateRange):
            other = (other.start, other.end)
        elif not non_string_sequence(other):
            other = (other, other)
        elif not len(other) == 2:
            return NotImplemented
        difference = (self.start - other[0], self.end - other[1])
        if non_string_sequence(difference, Date):
            return DateRange(
                difference[0], difference[1], calendar=self.calendar, format=self.format
            )
        else:
            return difference

    def to_calendar(self, calendar=None, format=None):
        return DateRange(self.start, self.end, calendar=calendar or self.calendar, format=format)


class DatePrecision(Enum):
    """Date precisions."""

    DAY, MONTH, YEAR, DECADE, CENTURY, MILLENNIUM = range(6)


class ApproximateDate(DateRange):

    CIRCA_RANGE = 2

    def __init__(self, date, precision, circa=False, calendar=gregorian):

        if isinstance(date, Integral):
            date = (date,)  # interpret numbers as years, not ordinals
        if non_string_sequence(date, Integral) and len(date) == 1:
            date = (date[0], calendar.months_in_year(date[0]) // 2 + 1, 1)
        elif non_string_sequence(date, Integral) and len(date) == 2:
            date = (date[0], date[1], calendar.days_in_month(date[0], date[1]) // 2 + 1)
        self.base_date = Date(date, calendar=calendar)
        self.precision = precision
        self.description = None
        self.circa = circa

        # initialise range
        year, month, _ = self.base_date.ymd
        circa_range = self.CIRCA_RANGE * int(circa)
        if precision == DatePrecision.DAY:
            delta = DateInterval(days=circa_range)
            start = self.base_date - delta
            end = self.base_date + delta
        elif precision == DatePrecision.MONTH:
            delta = DateInterval(months=circa_range)
            start = Date((year, month, 1), calendar=calendar) - delta
            end = (
                Date((year, month, calendar.days_in_month(year, month)), calendar=calendar) + delta
            )
        elif precision == DatePrecision.YEAR:
            delta = DateInterval(years=circa_range)
            last_month = calendar.months_in_year(year)
            start = Date((year, 1, 1), calendar=calendar) - delta
            end = (
                Date(
                    (year, last_month, calendar.days_in_month(year, last_month)), calendar=calendar
                )
                + delta
            )
        elif precision in (DatePrecision.DECADE, DatePrecision.CENTURY, DatePrecision.MILLENNIUM):
            n = (None, DatePrecision.DECADE, DatePrecision.CENTURY, DatePrecision.MILLENNIUM).index(
                precision
            )
            delta = DateInterval(years=circa_range * 10 ** n)
            first_year = (
                year - year % 10 ** n if year >= 0 else (year - 1) - (year - 1) % 10 ** n + 1
            )
            if not calendar.validate(YMD(first_year, 1, 1)):
                first_year += 1  # hack for missing year 0
            last_year = (
                first_year + 10 ** n - 1 - int(first_year == -(10 ** n) + 1)
            )  # count 0 as positive only
            last_month = calendar.months_in_year(last_year)
            last_day = calendar.days_in_month(last_year, last_month)
            start = Date((first_year, 1, 1), calendar=calendar) - delta
            end = Date((last_year, last_month, last_day), calendar=calendar) + delta
            self.description = "{}{}s".format(abs(year) // 10 ** n, "0" * n)
        super().__init__(start, end, calendar=calendar)

    def __repr__(self):
        if self.precision == DatePrecision.DAY:
            range = self.base_date.date_format("{D} {M} {Y}")
        elif self.precision == DatePrecision.MONTH:
            range = self.base_date.date_format("{M} {Y}")
        elif self.precision == DatePrecision.YEAR:
            range = self.base_date.date_format("{Y}")
        elif self.precision in (
            DatePrecision.DECADE,
            DatePrecision.CENTURY,
            DatePrecision.MILLENNIUM,
        ):
            range = self.base_date.date_format("{Y}").replace(
                str(abs(self.base_date.ymd.year)), self.description
            )
        else:
            return NotImplemented
        return "{}{} ({}) [{}]".format(
            "c. " * int(self.circa), range, self.precision.name.lower(), self.calendar.calendar_name
        )

import abc as ABC
import collections.abc
import datetime
from collections import namedtuple
from enum import Enum
from functools import reduce, total_ordering
from numbers import Integral
from typing import Sequence

from pudzu.utils import *
from pudzu.utils import optional_import, partial

cdate = optional_import("convertdate")

# Date class supporting flexible calendars, deltas and ranges

# Calendars (gregorian, julian, astronomical; with convertdate, also: iso week, hebrew, islamic,
# french republican)

YMD = namedtuple("YMD", ["year", "month", "day"])


class InvalidDate(ValueError):
    pass


class Calendar(object):
    """Abstract base class for calendars. Supports YMD-type dates with weekdays.
    Converts to and from a date ordinal, where 1 is 1st January AD1 in proleptic Gregorian.
    Default implementations assume 1-based months and days and 0-based weekdays internally.
    """

    __metaclass__ = ABC.ABCMeta

    WEEKDAYS: Sequence[str] = (
        "Sunday",
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
    )

    # Formatting
    @property
    @ABC.abstractmethod
    def calendar_name(self):
        """Calendar name"""

    def day_name(self, ymd):
        """User-friendly formatting of day."""
        return str(ymd.day)

    def month_name(self, ymd):
        """User-friendly formatting of month."""
        return self.MONTHS[ymd.month - 1]

    def year_name(self, ymd):
        """User-friendly formatting of year."""
        return str(ymd.year)

    def weekday_name(self, weekday):
        """User-friendly formatting of week day."""
        return self.WEEKDAYS[weekday]

    def date_name(self, ymd):
        """Optional user-friendly description of precise date."""
        return "?"

    def date_format(self, ymd, weekday, format_string="{W}, {D} {M} {Y}"):
        """User-friendly formatting of a full date, used in repr."""
        return format_string.format(
            W=self.weekday_name(weekday),
            D=self.day_name(ymd),
            M=self.month_name(ymd),
            Y=self.year_name(ymd),
            N=self.date_name(ymd),
            d=ymd.day,
            m=ymd.month,
            y=ymd.year,
        )

    # Representation
    @ABC.abstractmethod
    def days_in_month(self, year, month):
        """Number of days in a given month."""

    @ABC.abstractmethod
    def months_in_year(self, year):
        """Number of months in a given year."""

    def validate(self, ymd):
        """Whether a given YMD representation is valid."""
        return (1 <= ymd.month <= self.months_in_year(ymd.year)) and (
            1 <= ymd.day <= self.days_in_month(ymd.year, ymd.month)
        )

    @ABC.abstractmethod
    def date_to_ordinal(self, ymd):
        """Convert YMD to day ordinal."""

    @ABC.abstractmethod
    def ordinal_to_date(self, ordinal):
        """Convert day ordinal to YMD tuple."""

    def weekday(self, ymd, ordinal):
        """Week day for a given date."""
        # default to the standard 7-day week
        return ordinal % len(self.WEEKDAYS)

    # Calculations
    def carry_over(self, ymd):
        """Carry over invalid dates: e.g. Feb 30 > Mar 2."""
        year, month, day = ymd
        while month > self.months_in_year(year):
            month -= self.months_in_year(year)
            year += 1
        while month < 0:
            month += self.months_in_year(year - 1)
            year -= 1
        if day > self.days_in_month(year, month):
            day, month = day - self.days_in_month(year, month), month + 1
        if month > self.months_in_year(year):
            year, month = year + 1, 1
        return YMD(year, month, day)

    def add_years(self, ymd, year_delta):
        """Add years to YMD tuple."""
        return self.carry_over(YMD(ymd.year + year_delta, ymd.month, ymd.day))

    def add_months(self, ymd, month_delta):
        """Add months to YMD tuple."""
        return self.carry_over(YMD(ymd.year, ymd.month + month_delta, ymd.day))

    def add_days(self, ymd, day_delta):
        """Add days to YMD tuple."""
        return self.ordinal_to_date(self.date_to_ordinal(ymd) + day_delta)


class DerivedCalendar(Calendar):
    """Derived calendar that uses another calendar for its internal representation.
    Used for zero-less era calendars and the civil Hebrew calendar."""

    def __init__(self, base_calendar, to_base, from_base, calendar_name=None, **overrides):
        self.calendar = base_calendar
        self.name = self.calendar.calendar_name if calendar_name is None else calendar_name
        self.to_base = partial(to_base, self)
        self.from_base = partial(from_base, self)
        for k, v in overrides.items():
            setattr(self, k, partial(v, self))

    @property
    def calendar_name(self):
        return self.name

    def day_name(self, ymd):
        return self.calendar.day_name(self.to_base(ymd))

    def month_name(self, ymd):
        return self.calendar.month_name(self.to_base(ymd))

    def year_name(self, ymd):
        return self.calendar.year_name(self.to_base(ymd))

    def weekday_name(self, weekday):
        return self.calendar.weekday_name(weekday)

    def date_name(self, ymd):
        return self.calendar.date_name(self.to_base(ymd))

    def days_in_month(self, year, month):
        return self.calendar.days_in_month(*(self.to_base(YMD(year, month, 1))[0:2]))

    def months_in_year(self, year):
        return self.calendar.months_in_year(*(self.to_base(YMD(year, 1, 1))[0:1]))

    def validate(self, ymd):
        try:
            return self.calendar.validate(self.to_base(ymd))
        except InvalidDate:
            return False

    def date_to_ordinal(self, ymd):
        return self.calendar.date_to_ordinal(self.to_base(ymd))

    def ordinal_to_date(self, ordinal):
        return self.from_base(self.calendar.ordinal_to_date(ordinal))

    def weekday(self, ymd, ordinal):
        return self.calendar.weekday(self.to_base(ymd), ordinal)

    def add_years(self, ymd, year_delta):
        return self.from_base(self.calendar.add_years(self.to_base(ymd), year_delta))

    def add_months(self, ymd, month_delta):
        return self.from_base(self.calendar.add_months(self.to_base(ymd), month_delta))

    def add_days(self, ymd, day_delta):
        return self.from_base(self.calendar.add_days(self.to_base(ymd), day_delta))


# Calendar classes


class WesternCalendar(Calendar):
    """Proleptic Western calendar that includes year 0."""

    MONTHS = (
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December",
    )

    def __init__(self, julian=False):
        self.gregorian = not julian

    @property
    def calendar_name(self):
        return "astronomical {}".format("Gregorian" if self.gregorian else "Julian")

    def year_name(self, ymd):
        return str(ymd.year)

    def days_in_month(self, year, month):
        if month == 2:
            return (
                28
                + int(year % 4 == 0)
                - int(self.gregorian and year % 100 == 0 and year % 400 != 0)
            )
        elif month in [4, 6, 9, 11]:
            return 30
        else:
            return 31

    def months_in_year(self, year):
        return 12

    def date_to_ordinal(self, ymd):
        # based on years and months since 1 March BC1
        y = ymd.year - int(ymd.month <= 2)
        m = ymd.month + 12 * int(ymd.month <= 2) - 3
        d = ymd.day + (153 * m + 2) // 5 + 365 * y + y // 4 - 306
        return d - y // 100 + y // 400 if self.gregorian else d - 2

    def ordinal_to_date(self, ordinal):
        # see http://www.tondering.dk/claus/cal/julperiod.php
        a = ordinal + 305
        b = (4 * a + 3) // 146097 if self.gregorian else 0
        c = a - (146097 * b) // 4 if self.gregorian else ordinal + 305 + 2
        d = (4 * c + 3) // 1461
        e = c - (1461 * d) // 4
        m = (5 * e + 2) // 153
        day = e - (153 * m + 2) // 5 + 1
        month = m + 3 - 12 * (m // 10)
        year = 100 * b + d + m // 10
        return YMD(year, month, day)


def _era_to_astronomical(self, ymd):
    if ymd.year == 0:
        raise InvalidDate("{} does not have a year 0.".format(self.calendar_name))
    elif ymd.year < 0:
        return YMD(ymd.year + 1, ymd.month, ymd.day)
    else:
        return ymd


def _astronomical_to_era(self, ymd):
    return YMD(ymd.year - 1, ymd.month, ymd.day) if ymd.year <= 0 else ymd


def _era_year_name(self, ymd, bc=" BC", ad=""):
    return str(abs(ymd.year)) + (bc if (ymd.year < 0) else ad)


class IslamicCalendar(Calendar):
    """Proleptic Islamic Calendar that includes year 0."""

    MONTHS = (
        "Muharram",
        "Safar",
        "Rabi' al-awwal",
        "Rabi' al-Thani",
        "Jumada al-awwal",
        "Jumada al-Thani",
        "Rajab",
        "Sha'ban",
        "Ramadan",
        "Shawwal",
        "Dhu al-Qidah",
        "Dhu al-Hijjah",
    )

    @property
    def calendar_name(self):
        return "Islamic"

    def days_in_month(self, year, month):
        return cdate.islamic.month_length(year, month)

    def months_in_year(self, year):
        return 12

    def date_to_ordinal(self, ymd):
        return floor(cdate.islamic.to_jd(*ymd) - 1721424)

    def ordinal_to_date(self, ordinal):
        return YMD(*cdate.islamic.from_jd(ordinal + 1721424))


class HebrewCalendar(Calendar):
    """Proleptic Hebrew Calendar. Months are based on the ecclesiastical year, years on the
    civil year. This means that the year increments between months 6 and 7!"""

    MONTHS = (
        "Nisan",
        "Iyar",
        "Sivan",
        "Tammuz",
        "Abh",
        "Elul",
        "Tishri",
        "Marcheshvan",
        "Kislev",
        "Tebheth",
        "Shebhat",
        "Adar",
        "<NAME>",
        "<NAME>",
    )

    @property
    def calendar_name(self):
        return "Hebrew"

    def month_name(self, ymd):
        return self.MONTHS[
            ymd.month - 1 + int(ymd.month >= 12 and self.months_in_year(ymd.year) == 13)
        ]

    def days_in_month(self, year, month):
        return cdate.hebrew.month_days(year, month)

    def months_in_year(self, year):
        return cdate.hebrew.year_months(year)

    def date_to_ordinal(self, ymd):
        return floor(cdate.hebrew.to_jd(*ymd) - 1721424)

    def ordinal_to_date(self, ordinal):
        return YMD(*cdate.hebrew.from_jd(ordinal + 1721424))

    def add_months(self, ymd, month_delta):
        raise NotImplementedError("Date deltas not supported for ecclesiastical Hebrew calendar.")

    def add_years(self, ymd, year_delta):
        raise NotImplementedError("Date deltas not supported for ecclesiastical Hebrew calendar.")


def _ecclesiastical_to_civil(self, ymd):
    if ymd.month >= 7:
        month = ymd.month - 6
    else:
        month = ymd.month + 6 + int(self.calendar.months_in_year(ymd.year) == 13)
    return YMD(ymd.year, month, ymd.day)


def _civil_to_ecclesiastical(self, ymd):
    if ymd.month <= 6 + int(self.calendar.months_in_year(ymd.year) == 13):
        month = ymd.month + 6
    else:
        month = ymd.month - 6 - int(self.calendar.months_in_year(ymd.year) == 13)
    return YMD(ymd.year, month, ymd.day)


def _hebrew_add_years(self, ymd, year_delta):
    year, month, day = ymd
    year += year_delta
    if self.months_in_year(year) > self.months_in_year(ymd.year) and month >= 6:
        month += 1  # skip past Adar A
    elif self.months_in_year(year) < self.months_in_year(ymd.year) and month >= 7:
        month -= 1  # no Adar A
    return self.carry_over(YMD(year, month, day))


class RepublicanCalendar(Calendar):
    """Proleptic French Republican Calendar"""

    WEEKDAYS = (
        "Primidi",
        "Duodi",
        "Tridi",
        "Quartidi",
        "Quintidi",
        "Sextidi",
        "Septidi",
        "Octidi",
        "Nonidi",
        "Décadi",
    )

    MONTHS = (
        "Vendémiaire",
        "Brumaire",
        "Frimaire",
        "Nivôse",
        "Pluviôse",
        "Ventôse",
        "Germinal",
        "Floréal",
        "Prairial",
        "Messidor",
        "Thermidor",
        "Fructidor",
        "Sansculottides",
    )

    @property
    def calendar_name(self):
        return "French Republican"

    def date_name(self, ymd):
        return cdate.french_republican.french_republican_days[ymd.month][ymd.day - 1]

    def date_format(self, ymd, weekday, format_string=None):
        if format_string is None:
            format_string = "{W}, {D} {M} {Y} ({N})" if ymd.month <= 12 else "{N}, {M} {Y}"
        return super().date_format(ymd, weekday, format_string)

    def days_in_month(self, year, month):
        return 30 if month <= 12 else 5 + int(cdate.french_republican.leap(year))

    def months_in_year(self, year):
        return 13

    def weekday(self, ymd, ordinal):
        return (ymd.day - 1) % len(self.WEEKDAYS)

    def date_to_ordinal(self, ymd):
        return floor(cdate.french_republican.to_jd(*ymd) - 1721424)

    def ordinal_to_date(self, ordinal):
        return YMD(*[int(x) for x in cdate.french_republican.from_jd(ordinal + 1721424)])

    def add_months(self, ymd, month_delta):
        # skip complementary month unless we start there
        extra_months = 0 if ymd.month == 13 else (ymd.month + month_delta) // 13
        return self.carry_over(YMD(ymd.year, ymd.month + month_delta + extra_months, ymd.day))


class ISOCalendar(Calendar):
    """ISO week date"""

    @property
    def calendar_name(self):
        return "ISO week"

    def month_name(self, ymd):
        return "W{:02d}".format(ymd.month)

    def date_format(self, ymd, weekday, format_string="{W}, {Y}-{M}-{D}"):
        return super().date_format(ymd, weekday, format_string)

    def days_in_month(self, year, month):
        return 7

    def months_in_year(self, year):
        return cdate.iso.weeks_per_year(year)

    def date_to_ordinal(self, ymd):
        return floor(cdate.iso.to_jd(*ymd) - 1721425)

    def ordinal_to_date(self, ordinal):
        return YMD(*[int(x) for x in cdate.iso.from_jd(ordinal + 1721425)])


# Calendars

isodate = WesternCalendar()
isoweek = ISOCalendar()
gregorian = DerivedCalendar(
    WesternCalendar(),
    _era_to_astronomical,
    _astronomical_to_era,
    "Gregorian",
    year_name=_era_year_name,
)
julian = DerivedCalendar(
    WesternCalendar(julian=True),
    _era_to_astronomical,
    _astronomical_to_era,
    "Julian",
    year_name=_era_year_name,
)
islamic = DerivedCalendar(
    IslamicCalendar(),
    _era_to_astronomical,
    _astronomical_to_era,
    year_name=partial(_era_year_name, ad=" AH", bc=" BH"),
)
hebrew = DerivedCalendar(
    HebrewCalendar(),
    _civil_to_ecclesiastical,
    _ecclesiastical_to_civil,
    add_months=Calendar.add_months,
    add_years=_hebrew_add_years,
)
republican = RepublicanCalendar()

# Dates


@total_ordering
class Date(object):
    def __init__(self, date=None, calendar=gregorian, format=None):
        self.calendar = calendar
        if date is None:
            self.ordinal = datetime.date.today().toordinal()
            self.ymd = calendar.ordinal_to_date(self.ordinal)
        elif isinstance(date, Date):
            self.ordinal = date.ordinal
            self.ymd = calendar.ordinal_to_date(self.ordinal)
        elif isinstance(date, datetime.date):
            self.ordinal = date.toordinal()
            self.ymd = calendar.ordinal_to_date(self.ordinal)
        elif isinstance(date, Integral):
            self.ordinal = int(date)
            self.ymd = calendar.ordinal_to_date(self.ordinal)
        elif non_string_sequence(date, Integral) and len(date) == 3:
            self.ymd = YMD(*date)
            if not calendar.validate(self.ymd):
                raise InvalidDate("Invalid {} date: {}".format(calendar.calendar_name, self.ymd))
            self.ordinal = calendar.date_to_ordinal(self.ymd)
        else:
            raise TypeError("Invalid input type for Date")
        self.weekday = calendar.weekday(self.ymd, self.ordinal)
        self.format = format

    def __repr__(self):
        date_string = self.calendar.date_format(
            self.ymd, self.weekday, *([self.format] if self.format else [])
        )
        return "{} [{}]".format(date_string, self.calendar.calendar_name)

    def date_format(self, format_string):
        return self.calendar.date_format(self.ymd, self.weekday, format_string)

    def __hash__(self):
        return hash(str(self.ordinal))

    def __eq__(self, other):
        if isinstance(other, Date):
            return self.ordinal == other.ordinal
        else:
            return NotImplemented

    def __lt__(self, other):
        if isinstance(other, Date):
            return self.ordinal < other.ordinal
        else:
            return NotImplemented

    def __add__(self, other):
        if isinstance(other, Integral):
            return Date(self.ordinal + int(other), calendar=self.calendar, format=self.format)
        else:
            return NotImplemented

    def __sub__(self, other):
        if isinstance(other, Date):
            return self.ordinal - other.ordinal
        elif isinstance(other, Integral):
            return Date(self.ordinal - int(other), calendar=self.calendar, format=self.format)
        else:
            return NotImplemented

    def to_calendar(self, calendar=None, format=None):
        return Date(self, calendar=calendar or self.calendar, format=format)

    def to_date(self, calendar=gregorian):
        return datetime.date.fromordinal(self.ordinal)


# Date deltas


class DateDelta(object):

    """Abstract base class for date deltas."""

    __metaclass__ = ABC.ABCMeta

    @ABC.abstractmethod
    def add_delta(self, date):
        """Add delta to a Date."""

    @ABC.abstractmethod
    def subtract_delta(self, date):
        """Subtract delta from a Date."""

    # apply delta to date
    def __radd__(self, other):
        if isinstance(other, Date):
            return self.add_delta(other)
        elif isinstance(other, datetime.date):
            return self.add_delta(Date(other)).to_date()
        else:
            return NotImplemented

    def __rsub__(self, other):
        if isinstance(other, Date):
            return self.subtract_delta(other)
        elif isinstance(other, datetime.date):
            return self.subtract_delta(Date(other)).to_date()
        else:
            return NotImplemented

    # combine deltas
    def __neg__(self):
        return DateDeltaNegate(self)

    def __add__(self, other):
        if issubclass(type(other), DateDelta):
            return DateDeltaAdd(self, other)
        else:
            return NotImplemented

    def __sub__(self, other):
        if issubclass(type(other), DateDelta):
            return DateDeltaAdd(self, DateDeltaNegate(other))
        else:
            return NotImplemented

    def __mul__(self, other):
        if isinstance(other, int):
            if other >= 0:
                return DateDeltaAdd(*([self] * other))
            else:
                return DateDeltaAdd(*([-self] * -other))
        else:
            return NotImplemented


class DateDeltaNegate(DateDelta):
    def __init__(self, datedelta):
        self.delta = datedelta

    def __repr__(self):
        return "-{}".format(self.delta)

    def add_delta(self, date):
        return self.delta.subtract_delta(date)

    def subtract_delta(self, date):
        return self.delta.add_delta(date)


class DateDeltaAdd(DateDelta):
    def __init__(self, *datedeltas):
        self.deltas = datedeltas

    def __repr__(self):
        repr = ""
        for i, delta in enumerate(self.deltas):
            if i == 0:
                repr += str(delta)
            elif isinstance(delta, DateDeltaNegate):
                repr += " - " + str(delta.delta)
            else:
                repr += " + " + str(delta)
        return "({})".format(repr)

    def add_delta(self, date):
        return reduce(lambda date, delta: delta.add_delta(date), self.deltas, date)

    def subtract_delta(self, date):
        return reduce(lambda date, delta: delta.subtract_delta(date), self.deltas, date)


class DateInterval(DateDelta):
    def __init__(self, years=0, months=0, days=0):
        self.years = years
        self.months = months
        self.days = days

    @classmethod
    def from_dates(cls, start, end, calendar=gregorian):
        start = Date(start, calendar=calendar)
        end = Date(end, calendar=calendar)
        if end < start:
            return -DateInterval.from_dates(end, start, calendar)
        years, months, days = 0, 0, 0
        current = start
        while Date(calendar.add_years(current.ymd, years + 1), calendar) <= end:
            years += 1
        current = Date(calendar.add_years(current.ymd, years), calendar)
        while Date(calendar.add_months(current.ymd, months + 1), calendar) <= end:
            months += 1
        current = Date(calendar.add_months(current.ymd, months), calendar)
        days = end - current
        return cls(years, months, days)

    def __repr__(self):
        attrs = [
            "{}={}".format(v, getattr(self, v))
            for v in ("years", "months", "days")
            if getattr(self, v) != 0
        ]
        return "DateInterval({})".format(",".join(attrs))

    def add_delta(self, date):
        ymd = date.ymd
        ymd = date.calendar.add_years(ymd, self.years)
        ymd = date.calendar.add_months(ymd, self.months)
        ymd = date.calendar.add_days(ymd, self.days)
        return Date(ymd, calendar=date.calendar, format=date.format)

    def subtract_delta(self, date):
        ymd = date.ymd
        ymd = date.calendar.add_years(ymd, -self.years)
        ymd = date.calendar.add_months(ymd, -self.months)
        ymd = date.calendar.add_days(ymd, -self.days)
        return Date(ymd, calendar=date.calendar, format=date.format)


class DateFilter(DateDelta):
    def __init__(self, filter_fn, max_date=None, min_date=None):
        self.filter = filter_fn
        self.max_date = None if max_date is None else Date(max_date)
        self.min_date = None if min_date is None else Date(min_date)

    def __repr__(self):
        return "DateFilter({})".format(self.filter)

    def add_delta(self, date):
        while self.max_date is None or date < self.max_date:
            date = Date(date.ordinal + 1, calendar=date.calendar, format=date.format)
            if self.filter(date):
                return date
        raise ValueError("Date out of bounds")

    def subtract_delta(self, date):
        while self.max_date is None or date > self.min_date:
            date = Date(date.ordinal - 1, calendar=date.calendar, format=date.format)
            if self.filter(date):
                return date
        raise ValueError("Date out of bounds")


# Date ranges


class DateRange(collections.abc.Sequence):
    def __init__(self, start, end, calendar=gregorian, format=None):
        self.start = Date(start, calendar=calendar, format=format)
        self.end = Date(end, calendar=calendar, format=format)
        if self.start > self.end:
            raise ValueError("Start date later than end date")
        self.calendar = calendar
        self.format = format

    def range_format(self, format_string):
        return format_string.format(
            D1=self.calendar.day_name(self.start.ymd),
            M1=self.calendar.month_name(self.start.ymd),
            Y1=self.calendar.year_name(self.start.ymd),
            W1=self.calendar.weekday_name(self.start.weekday),
            N1=self.calendar.date_name(self.start.ymd),
            d1=self.start.ymd.day,
            m1=self.start.ymd.month,
            y1=self.start.ymd.year,
            D2=self.calendar.day_name(self.end.ymd),
            M2=self.calendar.month_name(self.end.ymd),
            Y2=self.calendar.year_name(self.end.ymd),
            W2=self.calendar.weekday_name(self.end.weekday),
            N2=self.calendar.date_name(self.end.ymd),
            d2=self.end.ymd.day,
            m2=self.end.ymd.month,
            y2=self.end.ymd.year,
        )

    def __repr__(self):
        if self.format is not None:
            format_string = self.format
        elif self.start.ymd.year != self.end.ymd.year:
            format_string = "{D1} {M1} {Y1} - {D2} {M2} {Y2}"
        elif self.start.ymd.month != self.end.ymd.month:
            format_string = "{D1} {M1}-{D2} {M2} {Y2}"
        elif self.start.ymd.day != self.end.ymd.day:
            format_string = "{D1}-{D2} {M2} {Y2}"
        else:
            format_string = "{D2} {M2} {Y2}"
        return "{} [{}]".format(self.range_format(format_string), self.calendar.calendar_name)

    def __eq__(self, other):
        if isinstance(other, DateRange):
            return self.start == other.start and self.end == other.end
        elif isinstance(other, Date):
            return self == DateRange(other, other)
        else:
            return NotImplemented

    def __lt__(self, other):
        if isinstance(other, DateRange):
            return self.end < other.start
        elif isinstance(other, Date):
            return self < DateRange(other, other)
        else:
            return NotImplemented

    def __le__(self, other):
        if isinstance(other, DateRange):
            return self.end <= other.start
        elif isinstance(other, Date):
            return self <= DateRange(other, other)
        else:
            return NotImplemented

    def __gt__(self, other):
        if isinstance(other, DateRange):
            return self.start > other.end
        elif isinstance(other, Date):
            return self > DateRange(other, other)
        else:
            return NotImplemented

    def __ge__(self, other):
        if isinstance(other, DateRange):
            return self.start >= other.end
        elif isinstance(other, Date):
            return self >= DateRange(other, other)
        else:
            return NotImplemented

    def __contains__(self, other):
        if isinstance(other, DateRange):
            return self.start <= other.start <= other.end <= self.end
        elif isinstance(other, Date):
            return DateRange(other, other) in self
        else:
            return False

    def __len__(self):
        return self.end - self.start + 1

    def __getitem__(self, key):
        if not isinstance(key, Integral):
            raise TypeError
        date = self.start + key
        if not self.start <= date <= self.end:
            raise IndexError
        return date

    def __add__(self, other):
        if not non_string_sequence(other):
            other = (other, other)
        elif not len(other) == 2:
            return NotImplemented
        return DateRange(
            self.start + other[0], self.end + other[1], calendar=self.calendar, format=self.format
        )

    def __sub__(self, other):
        if isinstance(other, DateRange):
            other = (other.start, other.end)
        elif not non_string_sequence(other):
            other = (other, other)
        elif not len(other) == 2:
            return NotImplemented
        difference = (self.start - other[0], self.end - other[1])
        if non_string_sequence(difference, Date):
            return DateRange(
                difference[0], difference[1], calendar=self.calendar, format=self.format
            )
        else:
            return difference

    def to_calendar(self, calendar=None, format=None):
        return DateRange(self.start, self.end, calendar=calendar or self.calendar, format=format)


class DatePrecision(Enum):
    """Date precisions."""

    DAY, MONTH, YEAR, DECADE, CENTURY, MILLENNIUM = range(6)


class ApproximateDate(DateRange):

    CIRCA_RANGE = 2

    def __init__(self, date, precision, circa=False, calendar=gregorian):

        if isinstance(date, Integral):
            date = (date,)  # interpret numbers as years, not ordinals
        if non_string_sequence(date, Integral) and len(date) == 1:
            date = (date[0], calendar.months_in_year(date[0]) // 2 + 1, 1)
        elif non_string_sequence(date, Integral) and len(date) == 2:
            date = (date[0], date[1], calendar.days_in_month(date[0], date[1]) // 2 + 1)
        self.base_date = Date(date, calendar=calendar)
        self.precision = precision
        self.description = None
        self.circa = circa

        # initialise range
        year, month, _ = self.base_date.ymd
        circa_range = self.CIRCA_RANGE * int(circa)
        if precision == DatePrecision.DAY:
            delta = DateInterval(days=circa_range)
            start = self.base_date - delta
            end = self.base_date + delta
        elif precision == DatePrecision.MONTH:
            delta = DateInterval(months=circa_range)
            start = Date((year, month, 1), calendar=calendar) - delta
            end = (
                Date((year, month, calendar.days_in_month(year, month)), calendar=calendar) + delta
            )
        elif precision == DatePrecision.YEAR:
            delta = DateInterval(years=circa_range)
            last_month = calendar.months_in_year(year)
            start = Date((year, 1, 1), calendar=calendar) - delta
            end = (
                Date(
                    (year, last_month, calendar.days_in_month(year, last_month)), calendar=calendar
                )
                + delta
            )
        elif precision in (DatePrecision.DECADE, DatePrecision.CENTURY, DatePrecision.MILLENNIUM):
            n = (None, DatePrecision.DECADE, DatePrecision.CENTURY, DatePrecision.MILLENNIUM).index(
                precision
            )
            delta = DateInterval(years=circa_range * 10 ** n)
            first_year = (
                year - year % 10 ** n if year >= 0 else (year - 1) - (year - 1) % 10 ** n + 1
            )
            if not calendar.validate(YMD(first_year, 1, 1)):
                first_year += 1  # hack for missing year 0
            last_year = (
                first_year + 10 ** n - 1 - int(first_year == -(10 ** n) + 1)
            )  # count 0 as positive only
            last_month = calendar.months_in_year(last_year)
            last_day = calendar.days_in_month(last_year, last_month)
            start = Date((first_year, 1, 1), calendar=calendar) - delta
            end = Date((last_year, last_month, last_day), calendar=calendar) + delta
            self.description = "{}{}s".format(abs(year) // 10 ** n, "0" * n)
        super().__init__(start, end, calendar=calendar)

    def __repr__(self):
        if self.precision == DatePrecision.DAY:
            range = self.base_date.date_format("{D} {M} {Y}")
        elif self.precision == DatePrecision.MONTH:
            range = self.base_date.date_format("{M} {Y}")
        elif self.precision == DatePrecision.YEAR:
            range = self.base_date.date_format("{Y}")
        elif self.precision in (
            DatePrecision.DECADE,
            DatePrecision.CENTURY,
            DatePrecision.MILLENNIUM,
        ):
            range = self.base_date.date_format("{Y}").replace(
                str(abs(self.base_date.ymd.year)), self.description
            )
        else:
            return NotImplemented
        return "{}{} ({}) [{}]".format(
            "c. " * int(self.circa), range, self.precision.name.lower(), self.calendar.calendar_name
        )
# Date class supporting flexible calendars, deltas and ranges # Calendars (gregorian, julian, astronomical; with convertdate, also: iso week, hebrew, islamic, # french republican) Abstract base class for calendars. Supports YMD-type dates with weekdays.     Converts to and from a date ordinal, where 1 is 1st January AD1 in proleptic Gregorian.     Default implementations assume 1-based months and days and 0-based weekdays internally. # Formatting Calendar name User-friendly formatting of day. User-friendly formatting of month. User-friendly formatting of year. User-friendly formatting of week day. Optional user-friendly description of precise date. User-friendly formatting of a full date, used in repr. # Representation Number of days in a given month. Number of months in a given year. Whether a given YMD representation is valid. Convert YMD to day ordinal. Convert day ordinal to YMD tuple. Week day for a given date. # default to the standard 7-day week # Calculations Carry over invalid dates: e.g. Feb 30 > Mar 2. Add years to YMD tuple. Add months to YMD tuple. Add days to YMD tuple. Derived calendar that uses another calendar for its internal representation.     Used for zero-less era calendars and the civil Hebrew calendar. # Calendar classes Proleptic Western calendar that includes year 0. # based on years and months since 1 March BC1 # see http://www.tondering.dk/claus/cal/julperiod.php Proleptic Islamic Calendar that includes year 0. Proleptic Hebrew Calendar. Months are based on the ecclesiastical year, years on the     civil year. This means that the year increments between months 6 and 7! # skip past Adar A # no Adar A Proleptic French Republican Calendar # skip complementary month unless we start there ISO week date # Calendars # Dates # Date deltas Abstract base class for date deltas. Add delta to a Date. Subtract delta from a Date. # apply delta to date # combine deltas # Date ranges Date precisions. # interpret numbers as years, not ordinals # initialise range # hack for missing year 0 # count 0 as positive only