#!/usr/bin/env python
# -*- encoding: utf-8 -*-
# ---------------------------------------------------------------------
# xoutil.datetime
# ---------------------------------------------------------------------
# Copyright (c) 2013-2017 Merchise Autrement [~°/~] and Contributors
# Copyright (c) 2012 Medardo Rodríguez
# All rights reserved.
#
# This is free software; you can redistribute it and/or modify it under the
# terms of the LICENCE attached (see LICENCE file) in the distribution
# package.
#
# Based on code submitted to comp.lang.python by Andrew Dalke, copied from
# Django and generalized.
#
# Created on Feb 15, 2012

'''Extends the standard `datetime` module.

- Python's ``datetime.strftime`` doesn't handle dates previous to 1900.
  This module define classes to override `date` and `datetime` to support the
  formatting of a date through its full proleptic Gregorian date range.

Based on code submitted to comp.lang.python by Andrew Dalke, copied from
Django and generalized.

You may use this module as a drop-in replacement of the standard library
`datetime` module.

'''

# TODO: Consider use IoC to extend python datetime module


from __future__ import (division as _py3_division,
                        print_function as _py3_print,
                        unicode_literals as _py3_unicode,
                        absolute_import as _py3_abs_imports)

import sys

from datetime import *    # noqa
from xoutil.deprecation import deprecated

from re import compile as _regex_compile
from time import strftime as _time_strftime


#: Simple constants for .weekday() method
class WEEKDAY:
    MONDAY = 0
    TUESDAY = 1
    WEDNESDAY = 2
    THURSDAY = 3
    FRIDAY = 4
    SATURDAY = 5
    SUNDAY = 6


class ISOWEEKDAY:
    MONDAY = 1
    TUESDAY = 2
    WEDNESDAY = 3
    THURSDAY = 4
    FRIDAY = 5
    SATURDAY = 6
    SUNDAY = 7


try:
    date(1800, 1, 1).strftime("%Y")
except ValueError:
    # This happens in Pytnon 2.7, I was considering to replace `strftime`
    # function from `time` module, that is used for all `strftime` methods;
    # but (WTF), Python double checks the year (in each method and then again
    # in `time.strftime` function).

    class date(date):
        __doc__ = date.__doc__

        def strftime(self, fmt):
            return strftime(self, fmt)

        def __sub__(self, other):
            return assure(super(date, self).__sub__(other))

    class datetime(datetime):
        __doc__ = datetime.__doc__

        def strftime(self, fmt):
            return strftime(self, fmt)

        def __sub__(self, other):
            return assure(super(datetime, self).__sub__(other))

        def combine(self, date, time):
            return assure(super(datetime, self).combine(date, time))

        def date(self):
            return assure(super(datetime, self).date())

        @staticmethod
        def now(tz=None):
            return assure(super(datetime, datetime).now(tz=tz))

    def assure(obj):
        '''Make sure that a `date` or `datetime` instance is a safe version.

        With safe it's meant that will use the adapted subclass on this module
        or the standard if these weren't generated.

        Classes that could be assured are: `date`, `datetime`, `time` and
        `timedelta`.

        '''
        t = type(obj)
        name = t.__name__
        if name == date.__name__:
            return obj if t is date else date(*obj.timetuple()[:3])
        elif name == datetime.__name__:
            if t is datetime:
                return obj
            else:
                args = obj.timetuple()[:6] + (obj.microsecond, obj.tzinfo)
                return datetime(*args)
        elif isinstance(obj, (time, timedelta)):
            return obj
        else:
            raise TypeError('Not valid type for datetime assuring: %s' % name)
else:
    def assure(obj):
        '''Make sure that a `date` or `datetime` instance is a safe version.

        This is only a type checker alternative to standard library.

        '''
        if isinstance(obj, (date, datetime, time, timedelta)):
            return obj
        else:
            raise TypeError('Not valid type for datetime assuring: %s' % name)


@deprecated(assure)
def new_date(d):
    '''Generate a safe date from a legacy datetime date object.'''
    return date(d.year, d.month, d.day)


@deprecated(assure)
def new_datetime(d):
    '''Generate a safe "datetime" from a "datetime.date" or "datetime.datetime"
    object.

    '''
    args = [d.year, d.month, d.day]
    if isinstance(d, datetime.__base__):    # legacy datetime
        args.extend([d.hour, d.minute, d.second, d.microsecond, d.tzinfo])
    return datetime(*args)


# This library does not support strftime's "%s" or "%y" format strings.
# Allowed if there's an even number of "%"s because they are escaped.
_illegal_formatting = _regex_compile(br"((^|[^%])(%%)*%[sy])")


def _year_find_all(fmt, year, no_year_tuple):
    text = _time_strftime(fmt, (year,) + no_year_tuple)
    regex = _regex_compile(str(year))
    return {match.start() for match in regex.finditer(text)}


_TD_LABELS = 'dhms'    # days, hours, minutes, seconds


def strfdelta(delta):
    '''
    Format a timedelta using a smart pretty algorithm.

    Only two levels of values will be printed.

    ::

        >>> def t(h, m):
        ...     return timedelta(hours=h, minutes=m)

        >>> strfdelta(t(4, 56)) == '4h 56m'
        True

    '''
    from xoutil.string import strfnumber
    ss, sss = str('%s%s'), str(' %s%s')
    if delta.days:
        days = delta.days
        delta -= timedelta(days=days)
        hours = delta.total_seconds() / 60 / 60
        res = ss % (days, _TD_LABELS[0])
        if hours >= 0.01:
            res += sss % (strfnumber(hours), _TD_LABELS[1])
    else:
        seconds = delta.total_seconds()
        if seconds > 60:
            minutes = seconds / 60
            if minutes > 60:
                hours = int(minutes / 60)
                minutes -= hours * 60
                res = ss % (hours, _TD_LABELS[1])
                if minutes >= 0.01:
                    res += sss % (strfnumber(minutes), _TD_LABELS[2])
            else:
                minutes = int(minutes)
                seconds -= 60 * minutes
                res = ss % (minutes, _TD_LABELS[2])
                if seconds >= 0.01:
                    res += sss % (strfnumber(seconds), _TD_LABELS[3])
        else:
            res = ss % (strfnumber(seconds, '%0.3f'), _TD_LABELS[3])
    return res


def strftime(dt, fmt):
    if dt.year >= 1900:
        bases = type(dt).mro()
        i = 0
        base = _strftime = type(dt).strftime
        while _strftime == base:
            aux = getattr(bases[i], 'strftime', base)
            if aux != base:
                _strftime = aux
            else:
                i += 1
        return _strftime(dt, fmt)
    else:
        illegal_formatting = _illegal_formatting.search(fmt)
        if illegal_formatting is None:
            year = dt.year
            # For every non-leap year century, advance by 6 years to get into
            # the 28-year repeat cycle
            delta = 2000 - year
            year += 6 * (delta // 100 + delta // 400)
            year += ((2000 - year) // 28) * 28   # Move to around the year 2000
            no_year_tuple = dt.timetuple()[1:]
            sites = _year_find_all(fmt, year, no_year_tuple)
            sites &= _year_find_all(fmt, year + 28, no_year_tuple)
            res = _time_strftime(fmt, (year,) + no_year_tuple)
            syear = "%04d" % dt.year
            for site in sites:
                res = res[:site] + syear + res[site + 4:]
            return res
        else:
            msg = ('strftime of dates before 1900 does not handle'
                   ' %s') % illegal_formatting.group(0)
            raise TypeError(msg)


def parse_date(value=None):
    if value:
        y, m, d = value.split('-')
        return date(int(y), int(m), int(d))
    else:
        return date.today()


def get_month_first(ref=None):
    '''Given a reference date, returns the first date of the same month. If
    `ref` is not given, then uses current date as the reference.
    '''
    aux = ref or date.today()
    y, m = aux.year, aux.month
    return date(y, m, 1)


def get_month_last(ref=None):
    '''Given a reference date, returns the last date of the same month. If
    `ref` is not given, then uses current date as the reference.
    '''
    aux = ref or date.today()
    y, m = aux.year, aux.month
    if m == 12:
        m = 1
        y += 1
    else:
        m += 1
    return date(y, m, 1) - timedelta(1)


def get_next_month(ref=None, lastday=False):
    '''Get the first or last day of the *next month*.

    If `lastday` is False return the first date of the `next month`.
    Otherwise, return the last date.

    The *next month* is computed with regards to a reference date.  If `ref`
    is None, take the current date as the reference.

    Examples:

      >>> get_next_month(date(2017, 1, 23))
      date(2017, 2, 1)

      >>> get_next_month(date(2017, 1, 23), lastday=True)
      date(2017, 2, 28)

    .. versionadded:: 1.7.3

    '''
    result = get_month_last(ref) + timedelta(days=1)
    if lastday:
        return get_month_last(result)
    else:
        return result


def is_full_month(start, end):
    '''Returns true if the arguments comprises a whole month.
    '''
    sd, sm, sy = start.day, start.month, start.year
    em, ey = end.month, end.year
    return ((sd == 1) and (sm == em) and (sy == ey) and
            (em != (end + timedelta(1)).month))


class flextime(timedelta):
    @classmethod
    def parse_simple_timeformat(cls, which):
        if 'h' in which:
            hour, rest = which.split('h')
        else:
            hour, rest = 0, which
        return int(hour), int(rest), 0

    def __new__(cls, *args, **kwargs):
        first = None
        if args:
            first, rest = args[0], args[1:]
        _super = super(flextime, cls).__new__
        if first and not rest and not kwargs:
            hour, minutes, seconds = cls.parse_simple_timeformat(first)
            return _super(cls, hours=hour, minutes=minutes, seconds=seconds)
        else:
            return _super(cls, *args, **kwargs)


# TODO: Merge this with the new time span.
def daterange(*args):
    '''Returns an iterator that yields each date in the range of ``[start,
    stop)``, not including the stop.

    If `start` is given, it must be a date (or `datetime`) value; and in this
    case only `stop` may be an integer meaning the numbers of days to look
    ahead (or back if `stop` is negative).

    If only `stop` is given, `start` will be the first day of stop's month.

    `step`, if given, should be a non-zero integer meaning the numbers of days
    to jump from one date to the next. It defaults to ``1``. If it's positive
    then `stop` should happen after `start`, otherwise no dates will be
    yielded. If it's negative `stop` should be before `start`.

    As with `range`, `stop` is never included in the yielded dates.

    .. note:: In a future release this will be merged with
              `ClosedDateRange`:class:

    '''
    import operator
    # Use base classes to allow broader argument values
    from datetime import date, datetime
    if len(args) == 1:
        start, stop, step = None, args[0], None
    elif len(args) == 2:
        start, stop = args
        step = None
    else:
        start, stop, step = args
    if not step and step is not None:
        raise ValueError('Invalid step value %r' % step)
    if not start:
        if not isinstance(stop, (date, datetime)):
            raise TypeError('stop must a date if start is None')
        else:
            start = get_month_first(stop)
    else:
        if stop is not None and not isinstance(stop, (date, datetime)):
            stop = start + timedelta(days=stop)
    if step is None or step > 0:
        compare = operator.lt
    else:
        compare = operator.gt
    step = timedelta(days=(step if step else 1))

    # Encloses the generator so that signature validation exceptions happen
    # without needing to call next().
    def _generator():
        current = start
        while stop is None or compare(current, stop):
            yield current
            current += step
    return _generator()


if sys.version_info < (3, 0):
    class infinity_extended_date(date):
        'A date that compares to Infinity'
        def operator(name, T=True):
            def result(self, other):
                from .infinity import Infinity
                if other is Infinity:
                    return T
                elif other is -Infinity:
                    return not T
                else:
                    return getattr(date, name)(self, other)
            return result

        # It seems that @total_ordering is worthless because date implements
        # this operators
        __le__ = operator('__le__')
        __ge__ = operator('__ge__', T=False)
        __lt__ = operator('__lt__')
        __gt__ = operator('__gt__', T=False)
        del operator

        def __eq__(self, other):
            from .infinity import Infinity
            # I have to put this because when doing ``timespan != date``
            # Python 2 may chose to call date's __ne__ instead of
            # TimeSpan.__ne__.  I assume the same applies to __eq__.
            if isinstance(other, _EmptyTimeSpan):
                return False
            if isinstance(other, TimeSpan):
                return TimeSpan.from_date(self) == other
            elif other is Infinity or other is -Infinity:
                return False
            else:
                return date.__eq__(self, other)

        def __ne__(self, other):
            return not (self == other)
else:
    infinity_extended_date = date


del sys


class DateField(object):
    '''A simple descriptor for dates.

    Ensures that assigned values must be parseable dates and parses them.

    '''
    def __init__(self, name, nullable=False):
        self.name = name
        self.nullable = nullable

    def __get__(self, instance, owner):
        if instance is not None:
            res = instance.__dict__[self.name]
            if res:
                return infinity_extended_date(res.year, res.month, res.day)
            else:
                return res
        else:
            return self

    def __set__(self, instance, value):
        from datetime import date  # all dates, not just xoutil's
        if value in (None, False):
            # We regard False as None, so that working with Odoo is easier:
            # missing values in Odoo, often come as False instead of None.
            if not self.nullable:
                raise ValueError('Setting None to a required field')
            else:
                value = None
        elif not isinstance(value, date):
            value = parse_date(value)
        instance.__dict__[self.name] = value


class TimeSpan(object):
    '''A *continuous* span of time.

    Time spans objects are iterable.  They yield exactly two times: first the
    start date, and then the end date::

       >>> ts = TimeSpan('2017-08-01', '2017-09-01')
       >>> tuple(ts)
       (date(2017, 8, 1), date(2017, 9, 1))

    Time spans objects have two items::

       >>> ts[0]
       date(2017, 8, 1)

       >>> ts[1]
       date(2017, 9, 1)

       >>> ts[:]
       (date(2017, 8, 1), date(2017, 9, 1))

    Two time spans are equal if their start_date and end_date are equal.  When
    comparing a time span with a date, the date is coerced to a time span
    (`from_date`:meth:).

    A time span with its `start` set to None is unbound to the past.  A time
    span with its `end` set to None is unbound to the future.  A time span
    that is both unbound to the past and the future contains all possible
    dates.  A time span that is not unbound in any direction is
    `bound <bound>`:attr:.

    A bound time span is `valid`:attr: if its start date comes before its end
    date.

    Time spans can `intersect <__mul__>`:meth:, be `merged <__add__>`:meth:
    and compared for containment and subset/superset order.  In this regard,
    they represent the *set* of dates between `start` and `end`, inclusively.

    .. warning:: Time spans don't implement the difference operation expected
       in sets because the difference of two span is not necessarily
       *continuous*.

    '''
    start_date = DateField('start_date', nullable=True)
    end_date = DateField('end_date', nullable=True)

    def __init__(self, start_date=None, end_date=None):
        self.start_date = start_date
        self.end_date = end_date

    @classmethod
    def from_date(self, date):
        '''Return a new time span that covers a single `date`.'''
        return self(start_date=date, end_date=date)

    @property
    def past_unbound(self):
        'True if the time span is not bound into the past.'
        return self.start_date is None

    @property
    def future_unbound(self):
        'True if the time span is not bound into the future.'
        return self.end_date is None

    @property
    def unbound(self):
        '''True if the time span is `unbound into the past <past_unbound>`:attr: or
        `unbount into the future <future_unbound>`:attr: or both.

        '''
        return self.future_unbound or self.past_unbound

    @property
    def bound(self):
        'True if the time span is not `unbound <unbound>`:attr:.'
        return not self.unbound

    @property
    def valid(self):
        '''A bound time span is valid if it starts before it ends.

        Unbound time spans are always valid.

        '''
        if self.bound:
            return self.start_date <= self.end_date
        else:
            return True

    def __contains__(self, other):
        '''Test if we completely cover `other` time span.

        A time span completely cover another one if every day contained by
        `other` is also contained by `self`.

        Another way to define it is that ``self & other == other``.

        '''
        from datetime import date
        if isinstance(other, date):
            if self.start_date and self.end_date:
                return self.start_date <= other <= self.end_date
            elif self.start_date:
                return self.start_date <= other
            elif self.end_date:
                return other <= self.end_date
            else:
                return True
        else:
            return False

    def overlaps(self, other):
        '''Test if the time spans overlaps.'''
        return self <= other or other <= self

    def isdisjoint(self, other):
        return not self.overlaps(other)

    def __le__(self, other):
        'True if `other` is a subset.'
        return (self & other) == self

    issubset = __le__

    def __lt__(self, other):
        'True if `other` is a proper subset.'
        return self != other and self <= other

    def __gt__(self, other):
        'True if `other` is a proper superset.'
        return self != other and self >= other

    def __ge__(self, other):
        'True if `other` is a superset.'
        # Notice that ge is not the opposite of lt.
        return (self & other) == other

    issuperset = covers = __ge__

    def __iter__(self):
        yield self.start_date
        yield self.end_date

    def __getitem__(self, index):
        this = tuple(self)
        return this[index]

    def __eq__(self, other):
        import datetime
        from xoutil.objects import validate_attrs
        if isinstance(other, datetime.date):
            other = type(self).from_date(other)
        if not isinstance(other, TimeSpan):
            other = type(self)(other)
        return validate_attrs(self, other, ('start_date', 'end_date'))

    def __ne__(self, other):
        return not (self == other)

    def __and__(self, other):
        '''Get the time span that is the intersection with another time span.

        If two time spans don't overlap, return the `empty time span
        <EmptyTimeSpan>`:obj:.

        If `other` is not a TimeSpan we try to create one.  If `other` is a
        date, we create the TimeSpan that starts and end that very day. Other
        types are passed unchanged to the constructor.

        '''
        import datetime
        from .infinity import Infinity
        if isinstance(other, _EmptyTimeSpan):
            return other
        elif isinstance(other, datetime.date):
            other = TimeSpan.from_date(other)
        elif not isinstance(other, TimeSpan):
            raise TypeError
        start = max(self.start_date or -Infinity, other.start_date or -Infinity)
        end = min(self.end_date or Infinity, other.end_date or Infinity)
        if start <= end:
            if start is -Infinity:
                start = None
            if end is Infinity:
                end = None
            return type(self)(start, end)
        else:
            return EmptyTimeSpan
    __mul__ = __and__

    def intersection(self, *others):
        'Return ``self [& other1 & ...]``.'
        import operator
        from functools import reduce
        return reduce(operator.mul, others, self)

    def __or__(self, other):
        'Return the union of both time spans.'
        import datetime
        from .infinity import Infinity
        if isinstance(other, _EmptyTimeSpan):
            return self
        elif isinstance(other, datetime.date):
            other = TimeSpan.from_date(other)
        elif not isinstance(other, TimeSpan):
            raise TypeError
        start = min(self.start_date or -Infinity, other.start_date or -Infinity)
        end = max(self.end_date or Infinity, other.end_date or Infinity)
        if start <= end:
            if start is -Infinity:
                start = None
            if end is Infinity:
                end = None
            return type(self)(start, end)
        else:
            return EmptyTimeSpan
    __add__ = __or__

    def union(self, *others):
        'Return ``self [| other1 | ...]``.'
        return sum(self, *others)

    def __repr__(self):
        start, end = self
        return 'TimeSpan(%r, %r)' % (start.isoformat() if start else None,
                                     end.isoformat() if end else None)


class _EmptyTimeSpan(object):
    def __bool__(self):
        return False

    __nonzero__ = __bool__

    def __contains__(self, which):
        return False  # I don't contain noone

    # The empty is equal only to itself
    def __eq__(self, which):
        from datetime import date
        if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)):
            # We expect `self` to be a singleton
            return self is which
        else:
            raise TypeError

    def __ne__(self, which):
        return not (self == which)

    # The empty set is a subset of any other set.  dates are regarded as the
    # set that contains that
    def __le__(self, which):
        from datetime import date
        if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)):
            return True
        else:
            raise TypeError

    # The empty set is only a superset of itself.
    __ge__ = covers = __eq__

    # The empty set is a *proper* subset of any set but itself.  The empty
    # set is disjoint with any other set but itself.
    __lt__ = isdisjoint = __ne__

    # The empty set is a *proper* superset of no one
    def __gt__(self, which):
        from datetime import date
        if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)):
            return True
        else:
            raise TypeError

    # `empty | x == empty + x == x`
    def __add__(self, which):
        from datetime import date
        if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)):
            return which
        else:
            raise TypeError

    __or__ = __add__

    # `empty & x == empty * x == empty`
    def __mul__(self, other):
        from datetime import date
        if isinstance(other, (TimeSpan, date, _EmptyTimeSpan)):
            return self
        else:
            raise TypeError

    __and__ = __mul__

    def __repr__(self):
        return 'EmptyTimeSpan'


EmptyTimeSpan = _EmptyTimeSpan()

_EmptyTimeSpan.__new__ = None  # Disallow creating more instances
