# -*- coding: utf-8 -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8
import i_need_py23

"""Des fonctions pour gérer les dates.
"""

__all__ = ('pydate', 'pydatetime',
           'datef',
           'YEAR_DATEF', 'ISO_DATEF', 'NUM_DATEF',
           'FR_DATEF', 'FRHM_DATEF',
           'TW_DATEF',
           'Date', 'isdate', 'isanydate',
           'parse_date', 'ensure_date',
           'rfc2822',
           'DateSpec', 'DateSpecs',
           )

import re
import time as time_mod
from time import time, localtime, gmtime, asctime
from datetime import date as pydate, datetime as pydatetime, timedelta

from base import sorted, isstr, isnum, isseq
from uio import _s, _u

dateformat_map = {'%Y': '%(y)04i',
                  '%m': '%(m)02i',
                  '%d': '%(d)02i',
                  '%H': '%(H)02i',
                  '%M': '%(M)02i',
                  '%S': '%(S)02i',
                  }
YEAR_DATEF = '%Y'
ISO_DATEF = '%Y-%m-%d'
NUM_DATEF = '%Y%m%d'
FR_DATEF = '%d/%m/%Y'
FRHM_DATEF = '%d/%m/%Y-%H:%M'
TW_DATEF = '%Y%m%d%H%M'

def datef(format=None, t=None):
    """Retourner la date avec le format indiqué.

    On peut utiliser les formats suivants:

    %Y année (4 digits)
    %m mois (2 digits)
    %d jour (2 digits)
    %H heure (2 digits)
    %M minutes (2 digits)
    %S secondes (2 digits)
    """
    if format is None: format = FR_DATEF
    if t is None: t = time()

    y, m, d, H, M, S, W, J, dst = localtime(t)
    for fr, to in dateformat_map.items():
        format = format.replace(fr, to)
    return format % locals()

def _fix_year(year, thisyear=None):
    if year < 100:
        if thisyear is None: thisyear = Date().year
        year = year + thisyear - thisyear % 100
    elif year < 1000:
        if thisyear is None: thisyear = Date().year
        year = year + thisyear - thisyear % 1000
    return year
def _isleap(year):
    return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def _fix_month(year, month):
    month -= 1
    while month > 11:
        month -= 12
        year += 1
    while month < 0:
        month += 12
        year -= 1
    month += 1
    return year, month
MONTHDAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
def _monthdays(year, month, offset=0):
    year, month = _fix_month(year, month + offset)
    if month == 2 and _isleap(year): leapday = 1
    else: leapday = 0
    return MONTHDAYS[month] + leapday
def _fix_day(year, month, day):
    # on assume que month est déjà "fixé"
    day -= 1
    while day > _monthdays(year, month) - 1:
        day -= _monthdays(year, month)
        year, month = _fix_month(year, month + 1)
    while day < 0:
        year, month = _fix_month(year, month - 1)
        day += _monthdays(year, month)
    day += 1
    return year, month, day
def _fix_date(day, month, year):
    year, month = _fix_month(year, month)
    year, month, day = _fix_day(year, month, day)
    return day, month, year

MONTHNAMES = [u"Janvier", u"Février", u"Mars", u"Avril", u"Mai", u"Juin",
              u"Juillet", u"Août", u"Septembre", u"Octobre", u"Novembre", u"Décembre",
              ]
MONTHNAMES3 = [u"Jan", u"Fév", u"Mar", u"Avr", u"Mai", u"Jun",
              u"Jul", u"Aoû", u"Sep", u"Oct", u"Nov", u"Déc",
              ]
MONTHNAMES1 = [u"J", u"F", u"M", u"A", u"M", u"J",
              u"J", u"A", u"S", u"O", u"N", u"D",
              ]

class Date(object):
    """Un wrapper pour 'datetime.date'.

    Attention! Cet objet est mutable, il ne faut donc pas l'utiliser comme clé
    dans un dictionnaire.
    """
    _d = None

    def __init__(self, day=None, month=None, year=None, t=None):
        """Initialiser l'objet.

        Dans l'ordre, les champs considérés sont:
        - day si c'est une instance de Date ou datetime.date
        - t le nombre de secondes depuis l'epoch, comme retourné par
          time.time(). Cette valeur est fusionnée avec les valeurs numériques
          day, month, year.
        """
        if day is not None and not isnum(day) and month is None and year is None and t is None:
            if isinstance(day, pydate): self._d = day
            elif isinstance(day, Date): self._d = day._d
        if self._d is None:
            if t is None: t = time()
            y, m, d = localtime(t)[:3]
            if year is None: year = y
            if month is None: month = m
            if day is None: day = d
            day, month, year = _fix_date(day, month, year)
            self._d = pydate(year, month, day)

    date = property(lambda self: self._d)
    year = property(lambda self: self._d.year)
    month = property(lambda self: self._d.month)
    day = property(lambda self: self._d.day)

    # nombre de jours du mois
    monthdays = property(lambda self: MONTHDAYS[self.month])

    def weekday(self):
        """Retourner le jour de la semaine, de 0 (lundi) à 6 (dimanche)
        """
        return self._d.weekday()
    def isoweekday(self):
        """Retourner le jour de la semaine, de 1 (lundi) à 7 (dimanche)
        """
        return self._d.isoweekday()
    def is_today(self):
        """Tester si cette date est le jour d'aujourd'hui
        """
        now = self.__class__()._d
        date = self._d
        return now.year == date.year and now.month == date.month and now.day == date.day

    def calday(self, show_month=False, show_year=False):
        """Retourner 'day' si day != 1 and not show_month and not show_year,
        'day/month' si month != 1 and not show_year,
        'day/month/year' sinon
        """
        day, month, year = self.day, self.month, self.year
        if day != 1 and not show_month and not show_year: return _u(day)
        elif month != 1 and not show_year: return u"%i/%i" % (day, month)
        else: return u"%i/%i/%i" % (day, month, year)

    def monthname(self, format=None):
        """Obtenir le nom du mois.
        Si format est dans (1, 't', 'tiny'), retourner le nom sur 1 lettre.
        Si format est dans (3, 's', 'small'), retourner le nom sur 3 lettres.
        Sinon, retourner le nom complet.
        """
        if format in (1, 't', 'tiny'): names = MONTHNAMES1
        elif format in (3, 's', 'small'): names = MONTHNAMES3
        else: names = MONTHNAMES
        return names[self.month - 1]

    __monthname1 = lambda self: self.monthname(1)
    __monthname3 = lambda self: self.monthname(3)
    FORMAT_MAP = {'%Y': '%(y)04i', '%m': '%(m)02i', '%d': '%(d)02i',
                  '%H': '%(H)02i', '%M': '%(M)02i', '%S': '%(S)02i',
                  '%1m': __monthname1, '%3m': __monthname3, '%fm': monthname,
                  '%C': calday,
                  }
    def format(self, format=None):
        """Formater la date pour affichage.

        Les champs valides sont %Y, %m, %d qui correspondent à la date de cet
        objet, %H, %M, %S qui valent toujours 0, et %1m, %3m, %fm, %C, qui
        correspondent respectivement à self.monthname(1), self.monthname(3),
        self.monthname(), self.calday().
        """
        if format is None: format = FR_DATEF
        y, m, d, H, M, S = self.year, self.month, self.day, 0, 0, 0
        for fr, to in self.FORMAT_MAP.items():
            if callable(to): to = to(self)
            format = format.replace(fr, to)
        return format % locals()

    def set(self, day=None, month=None, year=None):
        kw = {}
        for name, value in [('day', day), ('month', month), ('year', year)]:
            if value is not None: kw[name] = value
        self._d = self._d.replace(**kw)
        return self

    def set_weekday(self, weekday=0):
        if self.weekday() != weekday:
            day = self.day + weekday - self.weekday()
            self.set(*_fix_date(day, self.month, self.year))
        return self

    def set_isoweekday(self, isoweekday=1):
        if self.isoweekday() != isoweekday:
            day = self.day + isoweekday - self.isoweekday()
            self.set(*_fix_date(day, self.month, self.year))
        return self

    def __repr__(self):
        return '%s(%i, %i, %i)' % (self.__class__.__name__, self.year, self.month, self.day)
    def __str__(self):
        return '%02i/%02i/%04i' % (self.day, self.month, self.year)
    def __unicode__(self):
        return u'%02i/%02i/%04i' % (self.day, self.month, self.year)

    def __eq__(self, other): return self._d == self._date(other, False)
    def __ne__(self, other): return self._d != self._date(other, False)
    def __lt__(self, other):
        if other is None: return False
        else: return self._d < self._date(other)
    def __le__(self, other):
        if other is None: return False
        else: return self._d <= self._date(other)
    def __gt__(self, other):
        if other is None: return True
        else: return self._d > self._date(other)
    def __ge__(self, other):
        if other is None: return True
        else: return self._d >= self._date(other)
    def __cmp__(self, other):
        if other is None: return 1
        else: return cmp(self._d, self._date(other))
    def __hash__(self): return hash(self._d)

    def _date(self, d, required=True):
        """Retourner l'instance de datetime.date correspondant à l'objet d.
        """
        if isinstance(d, pydate): return d
        elif isinstance(d, Date): return d._d
        elif required: raise ValueError("Expected datetime.date or Date instance, got %s" % repr(d))
        else: return None

    def _delta(self, td):
        """Retourner l'instance de datetime.timedelta correspondant à l'objet td
        """
        if isinstance(td, timedelta): return td
        elif isnum(td): return timedelta(td)
        else: raise ValueError("Expected number or datetime.delta instance got %s" % repr(td))

    def _new(cls, d=None, t=None):
        """Constructeur. d est une instance de Date ou datetime.date. t est un
        nombre de secondes depuis l'epoch.
        """
        if d is not None:
            if isinstance(d, pydate): return cls(d.day, d.month, d.year)
            elif isinstance(d, Date): return cls(d.day, d.month, d.year)
            else: raise ValueError("Expected datetime.date or Date instance, got %s" % repr(d))
        elif t is not None: return cls(t=t)
        else: return cls()
    _new = classmethod(_new)

    def copy(self):
        """Retourner une nouvelle instance, copie de cet objet
        """
        return self._new(self._d)

    def replace(self, day=None, month=None, year=None):
        """Retourner une nouvelle instance avec les champs spécifiés modifiés.
        """
        kw = {}
        for name, value in [('day', day), ('month', month), ('year', year)]:
            if value is not None: kw[name] = value
        return self._new(self._d.replace(**kw))

    def __add__(self, other): return self._new(self._d + self._delta(other))
    __radd__ = __add__
    def add(self, days=1): return self + days

    def __sub__(self, other): return self._new(self._d - self._delta(other))
    __rsub__ = __sub__
    def sub(self, days=1): return self - days

    def diff(self, other):
        """Retourner le nombre de jours de différences entre cette date et other
        """
        delta = self._d - self._date(other)
        return delta.days

    def __fix_weekday(self, date):
        """Si date est après jeudi, retourner le début de la semaine
        suivante, sinon retourner le début de la semaine courante.
        """
        date = date.copy()
        if date.weekday() > 3:
            date = date.set_weekday(0)
            date += 7
        else:
            date.set_weekday(0)
        return date

    def get_monthweeks(self, complete=True, only_debut=None):
        """Retourner une liste de dates (debut, fin) correspondant aux débuts
        et aux fins des semaine du mois de cet objet.

        Si only_debut==True, ne retourner que la liste de valeurs debut au lieu
        des tuples (debut, fin). Par défaut only_debut==complete

        Si complete==True, on ne retourne que des semaines complètes: les dates
        au début et à la fin du mois sont corrigées pour inclure les jours du
        mois précédent et du mois suivant s'il y a au moins 4 jours dans le mois
        courant.

        Sinon, les semaines du début et de la fin du mois peuvent être tronquées
        et ne contiennent que les jours du mois.
        """
        if only_debut is None: only_debut = complete

        first = self.copy().set(1)
        monthdays = first.monthdays
        last = first + monthdays
        weeks = []
        if complete:
            first = self.__fix_weekday(first)
            last = self.__fix_weekday(last)
            debut = first
            while debut < last:
                fin = debut + 6
                if only_debut: weeks.append(debut)
                else: weeks.append((debut, fin))
                debut = fin + 1
        else:
            last -= 1
            debut = first
            while debut <= last:
                fin = debut.copy().set_weekday(6)
                if fin > last: fin = last
                if only_debut: weeks.append(debut)
                else: weeks.append((debut, fin))
                debut = fin + 1
        return weeks

def isdate(d):
    """Tester si d est une instance de Date
    """
    return isinstance(d, Date)
def isanydate(d):
    """Tester si d est une instance de Date ou datetime.date
    """
    return isinstance(d, Date) or isinstance(d, pydate)

RE_DATE_FR = re.compile(r'(\d+)(?:/(\d+)(?:/(\d+))?)?$')
RE_DATE_ISO = re.compile(r'(\d+)-(\d+)-(\d+)$')
def parse_date(s):
    """Parser une chaine et retourner une instance de Date
    """
    mof = RE_DATE_FR.match(s)
    moi = RE_DATE_ISO.match(s)
    if mof is not None:
        year = mof.group(3)
        month = mof.group(2)
        day = mof.group(1)
    elif moi is not None:
        year = moi.group(1)
        month = moi.group(2)
        day = moi.group(3)
    else:
        raise ValueError("Invalid date format: %s" % _s(s))
    if year is not None: year = _fix_year(int(year))
    if month is not None: month = int(month)
    if day is not None: day = int(day)
    return Date(day, month, year)

def ensure_date(d):
    """Retourner une instance de Date, ou None si d==None.

    d peut être une intance de datetime.date, Date ou une chaine.
    """
    if d is None: return None
    elif isinstance(d, Date): return d
    elif isinstance(d, pydate): return Date._new(d)
    if not isstr(d): d = _s(d)
    return parse_date(d)

def _tzname():
    tz = time_mod.timezone
    if tz > 0: s = "-"
    else: s = "+"
    tz = abs(tz) / 60
    h = tz / 60
    m = tz % 60
    return "%s%02i%02i" % (s, h, m)

def rfc2822(time=None, gmt=True):
    """Retourner la date au format rfc 2822.

    time est une date au format de time.time()
    """
    if time is None: time = time_mod.time()
    if gmt:
        time = gmtime(time)
        tzname = "+0000"
    else:
        time = localtime(time)
        tzname = _tzname()
    return "%s %s" % (asctime(time), tzname)

class _DateSpecConstants:
    """Constantes utilisées par les classes DateSpec et ses filles
    """

    # Contrainte
    C = r'(?:!(w|n)(\d+))'
    C_COUNT = 2 # nombre de groupes pour l'expression régulière C
    C_OP = 0 # numéro relatif du groupe pour la valeur OP
    C_WD = 1 # numéro relatif du groupe pour la valeur WEEKDAY

    # Spécification
    I = r'(\d+)'
    I_COUNT = 1 # nombre de groupes pour l'expression régulière I
    I_VALUE = 0 # numéro relatif du groupe pour la valeur VALUE

    R = r'(?:(\d+)(?:\s*-\s*(\d+))?)' # Range
    R_COUNT = 2 # nombre de groupes pour l'expression régulière R
    R_FROM = 0 # numéro relatif du groupe pour la valeur FROM
    R_TO = 1 # numéro relatif du groupe pour la valeur TO

    AOR = r'(?:(\*)|%s)' % R          # AnyOrRange
    AOR_COUNT = 1 + R_COUNT # nombre de groupes pour l'expression régulière AOR
    AOR_R_POS = 1 # position du premier groupe de l'expression R dans AOR
    AOR_ANY = 0
    AOR_FROM = AOR_R_POS + R_FROM # numéro relatif du groupe pour la valeur FROM
    AOR_TO = AOR_R_POS + R_TO # numéro relatif du groupe pour la valeur TO

    S = r'(?:\+%s|w%s|%s)(?:\s*/\s*%s(?:\s*/\s*%s)?)?' % (I, R, AOR, AOR, AOR)
    S_COUNT = I_COUNT + R_COUNT + 3 * AOR_COUNT # nombre de groupes pour l'expression régulière S
    S_I_POS = 0 # position du premier groupe de l'expression I dans S
    S_R_POS = S_I_POS + I_COUNT # position du premier groupe de l'expression R dans S
    S_DAOR_POS = S_R_POS + R_COUNT # position du premier groupe de l'expression DAOR dans S
    S_MAOR_POS = S_DAOR_POS + AOR_COUNT # position du premier groupe de l'expression DAOR dans S
    S_YAOR_POS = S_MAOR_POS + AOR_COUNT # position du premier groupe de l'expression DAOR dans S
    S_OFFSET = S_I_POS + I_VALUE # numéro relatif du groupe pour la valeur OFFSET
    S_WD_FROM = S_R_POS + R_FROM # numéro relatif du groupe pour la valeur FROM de WD
    S_WD_TO = S_R_POS + R_TO # numéro relatif du groupe pour la valeur TO de WD
    S_D_ANY = S_DAOR_POS + AOR_ANY # numéro relatif du groupe pour la valeur ANY de D
    S_D_FROM = S_DAOR_POS + AOR_FROM # numéro relatif du groupe pour la valeur FROM de D
    S_D_TO = S_DAOR_POS + AOR_TO # numéro relatif du groupe pour la valeur TO de D
    S_M_ANY = S_MAOR_POS + AOR_ANY # numéro relatif du groupe pour la valeur ANY de M
    S_M_FROM = S_MAOR_POS + AOR_FROM # numéro relatif du groupe pour la valeur FROM de M
    S_M_TO = S_MAOR_POS + AOR_TO # numéro relatif du groupe pour la valeur TO de M
    S_Y_ANY = S_YAOR_POS + AOR_ANY # numéro relatif du groupe pour la valeur ANY de Y
    S_Y_FROM = S_YAOR_POS + AOR_FROM # numéro relatif du groupe pour la valeur FROM de Y
    S_Y_TO = S_YAOR_POS + AOR_TO # numéro relatif du groupe pour la valeur TO de Y

    RE_SPEC = re.compile(r'(?:(?:%s)|(?:%s))$' % (C, S))
    # offsets des positions des groupes dans l'expression RE_SPEC
    SPEC_C_POS = 0
    SPEC_S_POS = SPEC_C_POS + C_COUNT
    # position des groupes dans l'expression RE_SPEC
    SPEC_C_OFF = 1 + SPEC_C_POS
    CONS_OP = SPEC_C_OFF + C_OP
    CONS_WD = SPEC_C_OFF + C_WD
    SPEC_S_OFF = 1 + SPEC_S_POS
    SPEC_OFFSET = SPEC_S_OFF + S_OFFSET
    SPEC_WD_FROM = SPEC_S_OFF + S_WD_FROM
    SPEC_WD_TO = SPEC_S_OFF + S_WD_TO
    SPEC_D_ANY = SPEC_S_OFF + S_D_ANY
    SPEC_D_FROM = SPEC_S_OFF + S_D_FROM
    SPEC_D_TO = SPEC_S_OFF + S_D_TO
    SPEC_M_ANY = SPEC_S_OFF + S_M_ANY
    SPEC_M_FROM = SPEC_S_OFF + S_M_FROM
    SPEC_M_TO = SPEC_S_OFF + S_M_TO
    SPEC_Y_ANY = SPEC_S_OFF + S_Y_ANY
    SPEC_Y_FROM = SPEC_S_OFF + S_Y_FROM
    SPEC_Y_TO = SPEC_S_OFF + S_Y_TO

    def _range(f, t=None):
        f = int(f)
        if t is None: t = f
        else: t = int(t)
        if t < f: t, f = f, t
        return (f, t)
    _range = staticmethod(_range)
    def _isw(vs): return vs == '*'
    _isw = staticmethod(_isw)
    def _isr(vs): return isseq(vs)
    _isr = staticmethod(_isr)
    def _matches(cls, vs, v):
        if cls._isw(vs): return True
        elif cls._isr(vs): return v >= vs[0] and v <= vs[1]
        else: raise ValueError("Invalid format: %s" % _s(vs))
    _matches = classmethod(_matches)
    def _tostr(cls, vs):
        if cls._isw(vs):
            return "*"
        elif cls._isr(vs):
            if vs[0] == vs[1]: return "%i" % vs[0]
            else: return "%i-%i" % vs
        else: raise ValueError("Invalid format: %s" % _s(vs))
    _tostr = classmethod(_tostr)
    def _check_range(cls, name, vs, min, max):
        if (min is not None and (vs[0] < min or vs[1] < min)) or \
            (max is not None and (vs[0] > max or vs[1] > max)):
            if min is None: min = u"-INF"
            else: min = str(min)
            if max is None: max = u"+INF"
            else: max = str(max)
            raise ValueError("%s values must be in the [%s, %s] range, got %s" % (name, min, max, cls._tostr(vs)))
    _check_range = classmethod(_check_range)
    def _check_value(cls, name, v, min, max):
        if (min is not None and v < min) or (max is not None and v > max):
            if min is None: min = u"-INF"
            else: min = str(min)
            if max is None: max = u"+INF"
            else: max = str(max)
            raise ValueError("%s value must be in the [%s, %s] range, got %i" % (name, min, max, v))
    _check_value = classmethod(_check_value)

class DateSpec(_DateSpecConstants):
    """Une spécification de dates de la forme D[/M[/Y]], ou une spécification
    de contrainte de date de la forme !W.

    - D peut prendre l'une des formes suivantes:
        - soit des jours du moins sous la forme *, DAY ou FROM-TO.
        - soit des jours de la semaine sous la forme "w"WEEKDAY ou "w"FROM-TO
          avec 1=Lundi, ..., 7=Dimanche
        - soit une expression relative de la forme "+"DAYS, qui représente
          DAYS jours après une date de référence.
    - M représente des mois sous la forme *, MONTH ou FROM-TO.
    - Y représente des années sous la forme *, YEAR ou FROM-TO.
    - W représente des jours de la semaine sous la forme "w"WEEKDAY ou
      "n"WEEKDAY avec 1=Lundi, ..., 7=Dimanche

    Exemples:

    w1-5
        Les jours de la semaine
    15/1-6
        Les 15 des mois de janvier à juin
    */1
        N'importe quel jour du mois de janvier
    !w4
        Spécifier que le jour DOIT être un Jeudi.
    !n4
        Spécifier que le jour DOIT être le Jeudi *suivant* la date de référence
    """

    class Strategy(_DateSpecConstants):
        def matches(self, date):
            u"""Tester si la date correspond à cette spécification de date
            """
            raise NotImplementedError

        def fix(self, date, now=None, refdate=None):
            u"""Corriger date, refdate étant la date de référence
            """
            raise NotImplementedError

        def is_obsolete(self, now=None):
            u"""Tester si cette spécification de date est obsolète, c'est à
            dire si elle désigne une date passée.
            """
            raise NotImplementedError

    class ConstraintStrategy(Strategy):
        """Une contrainte de date:

        "!wWEEKDAY" signifie que le jour DOIT être celui spécifié, en restant
        dans la semaine en cours.

        "!nWEEKDAY" signifie que le jour DOIT être celui spécifié, mais en
        prenant toujours une date future. Il est alors possible de passer sur
        la semaine suivante pour arriver au bon jour.
        """
        _op = None # op: w ou n
        _ws = None # weekdays

        def __init__(self, mo):
            self._op = mo.group(self.CONS_OP)
            ws = mo.group(self.CONS_WD)
            if ws is not None: self._ws = self._range(ws)
            if self._ws is not None:
                self._check_range("WEEKDAYS", self._ws, 0, 7)

        def __str__(self):
            s = "!"
            if self._ws is not None:
                s += self._op
                s += self._tostr(self._ws)
            return s

        def matches(self, date):
            return True

        def fix(self, date, now=None, refdate=None):
            date = ensure_date(date)
            expected_wd = self._ws[0]
            actual_wd = date.isoweekday()
            if expected_wd != actual_wd:
                date += expected_wd - actual_wd
                if self._op == 'n' and actual_wd > expected_wd:
                    date += 7
            return date

        def is_obsolete(self, now=None):
            return False

    class DateStrategy(Strategy):
        """Une spécification de date
        """
        _offset = None # offset
        _ws = None # weekdays
        _ds = None # days
        _ms = None # months
        _ys = None # years

        def __init__(self, mo):
            # offset
            o = mo.group(self.SPEC_OFFSET)
            if o is None: pass
            else: self._offset = self._range(o)[0]
            if self._offset is not None:
                self._check_value("OFFSET", self._offset, 1, None)
            # weekdays
            wf, wt = mo.group(self.SPEC_WD_FROM), mo.group(self.SPEC_WD_TO)
            if wf is None and wt is None: pass
            elif wt is not None: self._ws = self._range(wf, wt)
            else: self._ws = self._range(wf)
            if self._ws is not None:
                self._check_range("WEEKDAYS", self._ws, 0, 7)
            # days
            dw, df, dt = mo.group(self.SPEC_D_ANY), mo.group(self.SPEC_D_FROM), mo.group(self.SPEC_D_TO)
            if dw is None and df is None and dt is None: pass
            elif dw is not None: self._ds = '*'
            elif dt is not None: self._ds = self._range(df, dt)
            else: self._ds = self._range(df)
            # months
            mw, mf, mt = mo.group(self.SPEC_M_ANY), mo.group(self.SPEC_M_FROM), mo.group(self.SPEC_M_TO)
            if mw is None and mf is None and mt is None: self._ms = '*'
            elif mw is not None: self._ms = '*'
            elif mt is not None: self._ms = self._range(mf, mt)
            else: self._ms = self._range(mf)
            # years
            yw, yf, yt = mo.group(self.SPEC_Y_ANY), mo.group(self.SPEC_Y_FROM), mo.group(self.SPEC_Y_TO)
            if yw is None and yf is None and yt is None: self._ys = '*'
            elif yw is not None: self._ys = '*'
            elif yt is not None: self._ys = self._range(yf, yt)
            else: self._ys = self._range(yf)
            if self._isr(self._ys):
                self._ys = map(_fix_year, self._ys)

        def __str__(self):
            s = ""
            if self._offset is not None:
                s += "+%i" % self._offset
            if self._ws is not None:
                s += "w"
                s += self._tostr(self._ws)
            elif self._ds is not None:
                s += self._tostr(self._ds)
            s += "/"
            s += self._tostr(self._ms)
            s += "/"
            s += self._tostr(self._ys)
            return s

        def fill_ranges(self, yrs = None, mrs = None, drs = None, wrs = None):
            if yrs is None: yrs = []
            yrs.append(self._ys)
            if mrs is None: mrs = []
            mrs.append(self._ms)
            if self._ws is not None:
                if wrs is None: wrs = []
                wrs.append(self._ws)
            elif self._ds is not None:
                if drs is None: drs = []
                drs.append(self._ds)
            return yrs, mrs, drs, wrs

        def matches(self, date):
            date = ensure_date(date)
            # tester l'année
            if not self._matches(self._ys, date.year): return False
            # tester le mois
            if not self._matches(self._ms, date.month): return False
            # tester weekday ou day
            if self._ws is not None:
                if not self._matches(self._ws, date.isoweekday()): return False
            elif self._ds is not None:
                if not self._matches(self._ds, date.day): return False
            return True

        def fix(self, date, now=None, refdate=None):
            if self._offset is not None:
                if now is None: now = Date()
                if refdate is None: refdate = now
                date = refdate + self._offset
            return date

        def is_obsolete(self, now=None):
            if self._offset is not None: return False
            elif self._ws is not None: return False
            elif self._isw(self._ds): return False
            elif self._isw(self._ms): return False
            elif self._isw(self._ys): return False
            if now is None: now = Date()
            y = now.year; ys = self._ys
            if y > ys[0] and y > ys[1]: return True
            elif y < ys[0] and y < ys[1]: return False
            m = now.month; ms = self._ms
            if m > ms[0] and m > ms[1]: return True
            elif m < ms[0] and m < ms[1]: return False
            d = now.day; ds = self._ds
            if d > ds[0] and d > ds[1]: return True
            return False

    _strategy = None
    strategy = property(lambda self: self._strategy)

    def is_constraint_spec(self):
        """Retourner True s'il s'agit d'une spécification de contrainte de date
        """
        return isinstance(self._strategy, self.ConstraintStrategy)
    def is_date_spec(self):
        """Retourner True s'il s'agit d'une spécification de date
        """
        return isinstance(self._strategy, self.DateStrategy)

    def __init__(self, spec):
        mo = self.RE_SPEC.match(spec)
        if mo is None:
            raise ValueError("Invalid DateSpec format: %s" % _s(spec))

        if mo.group(self.CONS_WD) is None: strategy = self.DateStrategy(mo)
        else: strategy = self.ConstraintStrategy(mo)
        self._strategy = strategy

    def __str__(self):
        return self._strategy.__str__()

    def __repr__(self):
        return "%s(\"%s\")" % (self.__class__.__name__, self)

    def matches(self, date):
        return self._strategy.matches(date)

    def fix(self, date, now=None, refdate=None):
        return self._strategy.fix(date, now, refdate)

    def matches_fix(self, date, now=None, refdate=None):
        if self.matches(date): return True, self.fix(date, now, refdate)
        else: return False, date

    def is_obsolete(self):
        return self._strategy.is_obsolete()

class DateSpecs:
    """Une suite de spécifications de date, séparées par des virgules.

    Attention! l'ordre est important, car les calculs et l'évaluation des
    contraintes se fait dans l'ordre des spécifications.
    """
    RE_COMMA = re.compile(r'\s*,\s*')

    _specs = None
    def __constraint_specs(self):
        return [spec for spec in self._specs if spec.is_constraint_spec()]
    def __date_specs(self):
        return [spec for spec in self._specs if spec.is_date_spec()]

    def __init__(self, specs):
        specs = _s(specs).strip()
        self._specs = [DateSpec(spec) for spec in self.RE_COMMA.split(specs)]

    def __str__(self):
        return ",".join([str(spec) for spec in self._specs])

    def __repr__(self):
        return "%s(\"%s\")" % (self.__class__.__name__, self)

    def matches(self, date):
        for spec in self._specs:
            if spec.matches(date): return True
        return False

    def matches_fix(self, date, now=None, refdate=None):
        if now is None: now = Date()
        if refdate is None: refdate = now
        for spec in self.__date_specs():
            if spec.matches(date):
                for spec in self._specs:
                    date = spec.fix(date, now, refdate)
                return True, date
        return False, date

    _now = None
    _refdate = None
    _candidates = None

    def _reset_candidates(self):
        self._now = None
        self._refdate = None
        self._candidates = None

    def _get_candidates(self, now=None, refdate=None):
        if now is None: now = Date()
        if refdate is None: refdate = now
        if self._candidates is not None and \
            now == self._now and refdate == self._refdate:
            return self._candidates

        isw = DateSpec._isw
        # Enumérer les candidats de weekdays, days, months, years
        yrs = None
        mrs = None
        drs = None
        wrs = None
        for spec in self.__date_specs():
            yrs, mrs, drs, wrs = spec.strategy.fill_ranges(yrs, mrs, drs, wrs)
        # Calculer les dates candidates
        # ...years
        candidates = {}
        if yrs is None: yrs = ['*']
        for ys in yrs:
            if ys == '*':
                candidates[now.year] = {}
                candidates[now.year + 1] = {}
            else:
                for y in range(ys[0], ys[1] + 1):
                    candidates[y] = {}
        years = candidates.keys()
        # ...months
        for year in years:
            if mrs is None: mrs = ['*']
            for ms in mrs:
                if ms == '*':
                    candidates[year][now.month] = {}
                    candidates[year][now.month + 1] = {}
                else:
                    for m in range(ms[0], ms[1] + 1):
                        candidates[year][m] = {}
        # ...weekdays or days
        for year in years:
            for month in candidates[year].keys():
                monthdays = range(1, _monthdays(year, month) + 1)
                #candidates[year][month]['ws'] = None
                candidates[year][month]['ds'] = None
                if wrs is not None:
                    # si on précise des jours de semaine,
                    # inclure tous les jours du mois
                    #ws = []
                    #for wr in wrs:
                    #    ws.extend(range(wr[0], wr[1] + 1))
                    #candidates[year][month]['ws'] = ws
                    candidates[year][month]['ds'] = monthdays
                elif drs is not None:
                    ds = []
                    for dr in drs:
                        if isw(dr): ds.extend(monthdays)
                        else: ds.extend(range(dr[0], dr[1] + 1))
                    candidates[year][month]['ds'] = ds
                else:
                    # ni weekdays, ni days, prendre tous les jours du mois
                    # à configurer ci-dessous quand on saura quel mois prendre
                    candidates[year][month]['ds'] = monthdays
        # fin
        self._now = now
        self._refdate = refdate
        self._candidates = candidates
        return candidates

    def get_next_date(self, now=None, refdate=None):
        if now is None: now = Date()
        if refdate is None: refdate = now
        candidates = self._get_candidates(now, refdate)
        for year in [year for year in sorted(candidates.keys())
                     if year >= now.year]:
            for month in [month for month in sorted(candidates[year].keys())
                          if Date(0, month + 1, year) >= now]:
                days = [day for day in candidates[year][month]['ds']
                        if Date(day, month, year) > now]
                #weekdays = candidates[year][month]['ws']
                for day in days:
                    next = Date(day, month, year)
                    matches, next = self.matches_fix(next, now, refdate)
                    if matches: return next
        return None

    def remove_obsoletes(self):
        specs = [spec for spec in self._specs if not spec.is_obsolete()]
        if len(specs) != len(self._specs):
            self._specs = specs
            self._reset_candidates()
            return True
        else:
            return False
