# Source code for ubelt.util_time

    """
    This is util_time, it contains functions for handling time related code.

    The :func:`timestamp` function returns an iso8601 timestamp without much fuss.

    The :func:`timeparse` is the inverse of `timestamp`, and makes use of
    :mod:`dateutil` if it is available.

    The :class:`Timer` class is a context manager that times a block of indented
    code. It includes `tic` and `toc` methods a more matlab like feel.

    Timerit is gone! Use the standalone and separate module :py:mod:`timerit`.

    See Also:

        :mod:`tempora` - https://github.com/jaraco/tempora - time related utility functions from Jaraco

        :mod:`pendulum` - https://github.com/sdispater/pendulum - drop in replacement for datetime

        :mod:`arrow` - https://github.com/arrow-py/arrow

        :mod:`kwutil.util_time` - https://kwutil.readthedocs.io/en/latest/auto/kwutil.util_time.html
    """
    import time
    import sys
    from functools import lru_cache

    __all__ = ['timestamp', 'timeparse', 'Timer']

    @lru_cache(maxsize=None)
    def _needs_workaround39103():
        """
        Depending on the system C library, either %04Y or %Y wont work.
        This is an actual Python bug:
        https://bugs.python.org/issue13305

        singer-python also had a similar issue:
        https://github.com/singer-io/singer-python/issues/86

        See Also:
            https://github.com/jaraco/tempora/blob/main/tempora/__init__.py#L59
        """
        from datetime import datetime as datetime_cls
        return len(datetime_cls(1, 1, 1).strftime('%Y')) != 4

    def timestamp(datetime=None, precision=0, default_timezone='local',
                  allow_dateutil=True):
        """
        Make a concise iso8601 timestamp suitable for use in filenames.

        Args:
            datetime (datetime.datetime | datetime.date | None):
                A datetime to format into a timestamp. If unspecified, the current
                local time is used. If given as a date, the time 00:00 is used.

            precision (int):
                if non-zero, adds up to 6 digits of sub-second precision.

            default_timezone (str | datetime.timezone):
                if the input does not specify a timezone, assume this one.
                Can be "local" or "utc", or a standardized code if dateutil is
                installed.

            allow_dateutil (bool):
                if True, will use dateutil to lookup the default timezone if needed

        Returns:
            str:
                The timestamp, which will always contain a date, time, and
                timezone.

        Note:
            For more info see [WikiISO8601]_, [PyStrptime]_, [PyTime]_.

        References:
            .. [WikiISO8601] https://en.wikipedia.org/wiki/ISO_8601
            .. [PyStrptime] https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior
            .. [PyTime] https://docs.python.org/3/library/time.html

        Example:
            >>> import ubelt as ub
            >>> stamp = ub.timestamp()
            >>> print('stamp = {!r}'.format(stamp))
            stamp = ...-...-...T...

        Example:
            >>> import ubelt as ub
            >>> import datetime as datetime_mod
            >>> from datetime import datetime as datetime_cls
            >>> # Create a datetime object with timezone information
            >>> ast_tzinfo = datetime_mod.timezone(datetime_mod.timedelta(hours=-4), 'AST')
            >>> datetime = datetime_cls.utcfromtimestamp(123456789.123456789).replace(tzinfo=ast_tzinfo)
            >>> stamp = ub.timestamp(datetime, precision=2)
            >>> print('stamp = {!r}'.format(stamp))
            stamp = '1973-11-29T213309.12-4'

            >>> # Demo with a fractional hour timezone
            >>> act_tzinfo = datetime_mod.timezone(datetime_mod.timedelta(hours=+9.5), 'ACT')
            >>> datetime = datetime_cls.utcfromtimestamp(123456789.123456789).replace(tzinfo=act_tzinfo)
            >>> stamp = ub.timestamp(datetime, precision=2)
            >>> print('stamp = {!r}'.format(stamp))
            stamp = '1973-11-29T213309.12+0930'

            >>> # Can accept datetime or date objects with local, utc, or custom default timezones
            >>> act_tzinfo = datetime_mod.timezone(datetime_mod.timedelta(hours=+9.5), 'ACT')
            >>> datetime_utc = ub.timeparse('2020-03-05T112233', default_timezone='utc')
            >>> datetime_act = ub.timeparse('2020-03-05T112233', default_timezone=act_tzinfo)
            >>> datetime_notz = datetime_utc.replace(tzinfo=None)
            >>> date = datetime_utc.date()
            >>> stamp_utc = ub.timestamp(datetime_utc)
            >>> stamp_act = ub.timestamp(datetime_act)
            >>> stamp_date_utc = ub.timestamp(date, default_timezone='utc')
            >>> print(f'stamp_utc      = {stamp_utc}')
            >>> print(f'stamp_act      = {stamp_act}')
            >>> print(f'stamp_date_utc = {stamp_date_utc}')
            stamp_utc      = 2020-03-05T112233+0
            stamp_act      = 2020-03-05T112233+0930
            stamp_date_utc = 2020-03-05T000000+0

        Example:
            >>> # xdoctest: +REQUIRES(module:dateutil)
            >>> # Make sure we are compatible with dateutil
            >>> import ubelt as ub
            >>> from dateutil.tz import tzlocal
            >>> import datetime as datetime_mod
            >>> from datetime import datetime as datetime_cls
            >>> tz_act = datetime_mod.timezone(datetime_mod.timedelta(hours=+9.5), 'ACT')
            >>> tzinfo_list = [
            >>>     tz_act,
            >>>     datetime_mod.timezone(datetime_mod.timedelta(hours=-4), 'AST'),
            >>>     datetime_mod.timezone(datetime_mod.timedelta(hours=0), 'UTC'),
            >>>     datetime_mod.timezone.utc,
            >>>     None,
            >>>     tzlocal()
            >>> ]
            >>> # Note: there is a win32 bug here
            >>> # https://bugs.python.org/issue37 that means we cant use
            >>> # dates close to the epoch
            >>> datetime_list = [
            >>>     datetime_cls.utcfromtimestamp(123456789.123456789 + 315360000),
            >>>     datetime_cls.utcfromtimestamp(0 + 315360000),
            >>> ]
            >>> basis = {
            >>>     'precision': [0, 3, 9],
            >>>     'tzinfo': tzinfo_list,
            >>>     'datetime': datetime_list,
            >>>     'default_timezone': ['local', 'utc', tz_act],
            >>> }
            >>> for params in ub.named_product(basis):
            >>>     dtime = params['datetime'].replace(tzinfo=params['tzinfo'])
            >>>     precision = params.get('precision', 0)
            >>>     stamp = ub.timestamp(datetime=dtime, precision=precision)
            >>>     recon = ub.timeparse(stamp)
            >>>     alt = recon.strftime('%Y-%m-%dT%H%M%S.%f%z')
            >>>     print('---')
            >>>     print('params = {}'.format(ub.repr2(params, nl=1)))
            >>>     print(f'dtime={dtime}')
            >>>     print(f'stamp={stamp}')
            >>>     print(f'recon={recon}')
            >>>     print(f'alt  ={alt}')
            >>>     shift = 10 ** precision
            >>>     a = int(dtime.timestamp() * shift)
            >>>     b = int(recon.timestamp() * shift)
            >>>     assert a == b, f'{a} != {b}'
        """
        import datetime as datetime_mod
        from datetime import datetime as datetime_cls

        datetime_obj = datetime
        offset_seconds = None

        # datetime inherits from date. Strange, so we cant use this. See:
        # https://github.com/python/typeshed/issues/4802
        if isinstance(datetime_obj, datetime_mod.date) and not isinstance(datetime_obj, datetime_cls):
            # Coerce a date to datetime.datetime
            datetime_obj = datetime_cls.combine(datetime_obj, datetime_cls.min.time())

        if datetime_obj is None or datetime_obj.tzinfo is None:
            # In either case, we need to construct a timezone object
            tzinfo = _timezone_coerce(default_timezone,
                                      allow_dateutil=allow_dateutil)
            # If datetime_obj is unspecified, create a timezone aware now object
            if datetime_obj is None:
                datetime_obj = datetime_cls.now(tzinfo)
        else:
            tzinfo = datetime_obj.tzinfo

        # the arg to utcoffset is confusing
        offset_seconds = tzinfo.utcoffset(datetime_obj).total_seconds()
        # offset_seconds = tzinfo.utcoffset(None).total_seconds()

        seconds_per_hour = 3600
        tz_hour, tz_remain = divmod(offset_seconds, seconds_per_hour)
        tz_hour = int(tz_hour)
        if tz_remain:
            seconds_per_minute = 60
            tz_min = int(tz_remain // seconds_per_minute)
            utc_offset = '{:+03d}{:02d}'.format(tz_hour, tz_min)
        else:
            utc_offset = str(tz_hour) if tz_hour < 0 else '+' + str(tz_hour)
        if precision > 0:
            fprecision = 6  # microseconds are padded to 6 decimals
            # NOTE: The time.strftime and datetime.datetime.strftime methods
            # seem to work differently. The former does not support %f
            if _needs_workaround39103():  # nocover
                local_stamp = datetime_obj.strftime('%04Y-%m-%dT%H%M%S.%f')
            else:  # nocover
                local_stamp = datetime_obj.strftime('%Y-%m-%dT%H%M%S.%f')
            ms_offset = len(local_stamp) - max(0, fprecision - precision)
            local_stamp = local_stamp[:ms_offset]
        else:
            if _needs_workaround39103():  # nocover
                local_stamp = datetime_obj.strftime('%04Y-%m-%dT%H%M%S')
            else:  # nocover
                local_stamp = datetime_obj.strftime('%Y-%m-%dT%H%M%S')
        stamp = local_stamp + utc_offset
        return stamp

    def timeparse(stamp, default_timezone='local', allow_dateutil=True):
        """
        Create a :class:`datetime.datetime` object from a string timestamp.

        Without any extra dependencies this will parse the output of
        :func:`ubelt.util_time.timestamp` into a datetime object. In the case
        where the format differs, :func:`dateutil.parser.parse` will be used
        if the :py:mod:`python-dateutil` package is installed.

        Args:
            stamp (str):
                a string encoded timestamp

            default_timezone (str):
                if the input does not specify a timezone, assume this one.
                Can be "local" or "utc".

            allow_dateutil (bool):
                if False we only use the minimal parsing and do not allow a
                fallback to dateutil.

        Returns:
            datetime.datetime: the parsed datetime

        Raises:
            ValueError: if if parsing fails.

        TODO:
            - [ ] Allow defaulting to local or utm timezone (currently default is local)

        Example:
            >>> import ubelt as ub
            >>> # Demonstrate a round trip of timestamp and timeparse
            >>> stamp = ub.timestamp()
            >>> datetime = ub.timeparse(stamp)
            >>> assert ub.timestamp(datetime) == stamp
            >>> # Round trip with precision
            >>> stamp = ub.timestamp(precision=4)
            >>> datetime = ub.timeparse(stamp)
            >>> assert ub.timestamp(datetime, precision=4) == stamp

        Example:
            >>> import ubelt as ub
            >>> # We should always be able to parse these
            >>> good_stamps = [
            >>>     '2000-11-22',
            >>>     '2000-11-22T111111.44444Z',
            >>>     '2000-11-22T111111.44444+5',
            >>>     '2000-11-22T111111.44444-05',
            >>>     '2000-11-22T111111.44444-0500',
            >>>     '2000-11-22T111111.44444+0530',
            >>>     '2000-11-22T111111Z',
            >>>     '2000-11-22T111111+5',
            >>>     '2000-11-22T111111+0530',
            >>> ]
            >>> for stamp in good_stamps:
            >>>     print(f'----')
            >>>     print(f'stamp={stamp}')
            >>>     result = ub.timeparse(stamp, allow_dateutil=0)
            >>>     print(f'result={result!r}')
            >>>     recon = ub.timestamp(result)
            >>>     print(f'recon={recon}')

        Example:
            >>> import ubelt as ub
            >>> # We require dateutil to handle these types of stamps
            >>> import pytest
            >>> conditional_stamps = [
            >>>         '2000-01-02T11:23:58.12345+5:30',
            >>>         '09/25/2003',
            >>>         'Thu Sep 25 10:36:28 2003',
            >>> ]
            >>> for stamp in conditional_stamps:
            >>>     with pytest.raises(ValueError):
            >>>         result = ub.timeparse(stamp, allow_dateutil=False)
            >>> have_dateutil = bool(ub.modname_to_modpath('dateutil'))
            >>> if have_dateutil:
            >>>     for stamp in conditional_stamps:
            >>>         result = ub.timeparse(stamp)

        Ignore:
            import timerit
            ti = timerit.Timerit(1000, 10)
            ti.reset('non-standard dateutil.parse').call(lambda: dateutil.parser.parse('2000-01-02T112358.12345+5'))
            ti.reset('non-standard ubelt.timeparse').call(lambda: ub.timeparse('2000-01-02T112358.12345+5'))
            ti.reset('standard dateutil.parse').call(lambda: dateutil.parser.parse('2000-01-02T112358.12345+0500'))
            ti.reset('standard dateutil.isoparse').call(lambda: dateutil.parser.isoparse('2000-01-02T112358.12345+0500'))
            ti.reset('standard ubelt.timeparse').call(lambda: ub.timeparse('2000-01-02T112358.12345+0500'))
            ti.reset('standard datetime_cls.strptime').call(lambda: datetime_cls.strptime('2000-01-02T112358.12345+0500', '%Y-%m-%dT%H%M%S.%f%z'))
        """
        from datetime import datetime as datetime_cls
        datetime_obj = None
        # Check if we might have a minimal format
        maybe_minimal = (
            len(stamp) >= 17 and 'T' in stamp[10:]
        )
        fixed_stamp = stamp
        if maybe_minimal:
            # Note by default %z only handles the format `[+-]HHMM(SS(.ffffff))`
            # this means we have to handle the case where `[+-]HH` is given.
            # We do this by checking the offset and padding it to at least the
            # `[+-]HHMM` format
            date_part, timetz_part = stamp.split('T', 1)
            if '-' in timetz_part[6:]:
                time_part, sign, tz_part = timetz_part.partition('-')
            elif '+' in timetz_part[6:]:
                time_part, sign, tz_part = timetz_part.partition('+')
            else:
                # In 3.7 a Z suffix is handled correctly
                # For 3.6 compatibility, replace Z with +0000
                if timetz_part.endswith('Z'):
                    time_part = timetz_part[:-1]
                    sign = '+'
                    tz_part = '0000'
                else:
                    tz_part = None

            if tz_part is not None:
                if len(tz_part) == 1:
                    tz_part = '0{}00'.format(tz_part)
                elif len(tz_part) == 2:
                    tz_part = '{}00'.format(tz_part)
                fixed_stamp = ''.join([date_part, 'T', time_part, sign, tz_part])

        if len(stamp) == 10:
            try:
                fmt =  '%Y-%m-%d'
                datetime_obj = datetime_cls.strptime(fixed_stamp, fmt)
            except ValueError:
                pass

        if maybe_minimal and datetime_obj is None:
            minimal_formats = [
                '%Y-%m-%dT%H%M%S%z',
                '%Y-%m-%dT%H%M%S',
                '%Y-%m-%dT%H%M%S.%f%z',
                '%Y-%m-%dT%H%M%S.%f',
            ]
            for fmt in minimal_formats:
                try:
                    datetime_obj = datetime_cls.strptime(fixed_stamp, fmt)
                except ValueError:
                    pass
                else:
                    break

        if datetime_obj is None:
            # Our minimal logic did not work, can we use dateutil?
            if not allow_dateutil:
                raise ValueError((
                    'Cannot parse timestamp. '
                    'Unknown string format: {!r}, and '
                    'dateutil is not allowed').format(stamp))
            else:
                try:
                    from dateutil.parser import parse as du_parse
                except (ModuleNotFoundError, ImportError):  # nocover
                    raise ValueError((
                        'Cannot parse timestamp. '
                        'Unknown string format: {!r}, and '
                        'dateutil is not installed').format(stamp)) from None
                else:  # nocover
                    datetime_obj = du_parse(stamp)

        if datetime_obj.tzinfo is None:
            # Timezone is unspecified, need to construct the default one.
            tzinfo = _timezone_coerce(default_timezone,
                                      allow_dateutil=allow_dateutil)
            datetime_obj = datetime_obj.replace(tzinfo=tzinfo)

        return datetime_obj

    def _timezone_coerce(tzinfo, allow_dateutil=True):
        """
        Ensure output it a timezone instance.

        Example:
            >>> import pytest
            >>> from ubelt.util_time import *  # NOQA
            >>> from ubelt.util_time import _timezone_coerce
            >>> results = []
            >>> write = results.append
            >>> tzinfo = _timezone_coerce('utc', allow_dateutil=0)
            >>> print(f'tzinfo={tzinfo}')
            tzinfo=UTC

        Example:
            >>> # xdoctest: +REQUIRES(module:dateutil)
            >>> import pytest
            >>> results = []
            >>> write = results.append
            >>> write(_timezone_coerce('utc'))
            >>> write(_timezone_coerce('GMT'))
            >>> write(_timezone_coerce('EST'))
            >>> write(_timezone_coerce('HST'))
            >>> import datetime as datetime_mod
            >>> dt = datetime_mod.datetime.now()
            >>> for tzinfo in results:
            >>>     print(f'tzoffset={tzinfo.utcoffset(dt).total_seconds()}')
            tzoffset=0.0
            tzoffset=0.0
            tzoffset=-18000.0
            tzoffset=-36000.0

        Example:
            >>> import pytest
            >>> from ubelt.util_time import *  # NOQA
            >>> from ubelt.util_time import _timezone_coerce
            >>> with pytest.raises(ValueError):
            ...     _timezone_coerce('GMT', allow_dateutil=0)
            >>> with pytest.raises(TypeError):
            ...     _timezone_coerce(object(), allow_dateutil=0)
            >>> # xdoctest: +REQUIRES(module:dateutil)
            >>> with pytest.raises(KeyError):
            ...     _timezone_coerce('NotATimezone', allow_dateutil=1)

        Example:
            >>> import pytest
            >>> from ubelt.util_time import *  # NOQA
            >>> from ubelt.util_time import _timezone_coerce
            >>> import time
            >>> tz1 = _timezone_coerce('local', allow_dateutil=0)
            >>> tz2 = _timezone_coerce('local', allow_dateutil=1)
            >>> sec1 = tz1.utcoffset(None).total_seconds()
            >>> sec2 = tz2.utcoffset(None).total_seconds()
            >>> assert sec1 == sec2 == -time.timezone
        """
        import datetime as datetime_mod
        if isinstance(tzinfo, str):
            if tzinfo == 'local':
                # Note: the local timezone time.timezone is negated
                _delta = datetime_mod.timedelta(seconds=-time.timezone)
                out_tzinfo = datetime_mod.timezone(_delta)
            elif tzinfo == 'utc':
                out_tzinfo = datetime_mod.timezone.utc
            else:
                if allow_dateutil:
                    from dateutil import tz as tz_mod
                    out_tzinfo = tz_mod.gettz(tzinfo)
                    if out_tzinfo is None:
                        raise KeyError(tzinfo)
                else:
                    raise ValueError((
                        'Unrecognized timezone: {!r}, and '
                        'dateutil is not allowed').format(tzinfo))
        elif isinstance(tzinfo, datetime_mod.timezone):
            out_tzinfo = tzinfo
        else:
            raise TypeError(
                'Unknown type: {!r} for tzinfo'.format(
                    type(tzinfo))
            )
        return out_tzinfo

    class Timer:
        """
        Measures time elapsed between a start and end point. Can be used as a
        with-statement context manager, or using the tic/toc api.

        Attributes:
            elapsed (float): number of seconds measured by the context manager
            tstart (float): time of last `tic` reported by `self._time()`
            write (Callable): function used to write
            flush (Callable): function used to flush

        Example:
            >>> # Create and start the timer using the context manager
            >>> import math
            >>> import ubelt as ub
            >>> timer = ub.Timer('Timer test!', verbose=1)
            >>> with timer:
            >>>     math.factorial(10)
            >>> assert timer.elapsed > 0
            tic('Timer test!')
            ...toc('Timer test!')=...

        Example:
            >>> # Create and start the timer using the tic/toc interface
            >>> import ubelt as ub
            >>> timer = ub.Timer().tic()
            >>> elapsed1 = timer.toc()
            >>> elapsed2 = timer.toc()
            >>> elapsed3 = timer.toc()
            >>> assert elapsed1 <= elapsed2
            >>> assert elapsed2 <= elapsed3

        Example:
            >>> # In Python 3.7+ nanosecond resolution can be enabled
            >>> import ubelt as ub
            >>> import sys
            >>> if sys.version_info[0:2] <= (3, 6):
            >>>     import pytest
            >>>     pytest.skip()
            >>> # xdoctest +REQUIRES(Python>=3.7)  # fixme directive doesnt exist yet
            >>> timer = ub.Timer(label='perf_counter_ns', ns=True).tic()
            >>> elapsed1 = timer.toc()
            >>> elapsed2 = timer.toc()
            >>> assert elapsed1 <= elapsed2
            >>> assert isinstance(elapsed1, int)
        """
        _default_time = time.perf_counter

        def __init__(self, label='', verbose=None, newline=True, ns=False):
            """
            Args:
                label (str):
                    identifier for printing. Default to ''.

                verbose (int | None):
                    verbosity flag, defaults to True if label is given, otherwise 0.

                newline (bool):
                    if False and verbose, print tic and toc on the same line.
                    Defaults to True.

                ns (bool):
                    if True, a nano-second resolution timer to avoid precision loss
                    caused by the float type. Defaults to False.
            """
            if verbose is None:
                verbose = bool(label)
            self.label = label
            self.verbose = verbose
            self.newline = newline
            self.tstart = -1
            self.elapsed = -1
            self.write = sys.stdout.write
            self.flush = sys.stdout.flush
            self.ns = ns
            if self.ns:
                self._time = time.perf_counter_ns
            else:
                self._time = self._default_time

        def tic(self):
            """
            starts the timer

            Returns:
                Timer: self
            """
            if self.verbose:
                self.flush()
                self.write('\ntic(%r)' % self.label)
                if self.newline:
                    self.write('\n')
                self.flush()
            self.tstart = self._time()
            return self

        def toc(self):
            """
            stops the timer

            Returns:
                float | int: number of second or nanoseconds
            """
            elapsed = self._time() - self.tstart
            if self.verbose:
                if self.ns:
                    self.write('...toc(%r)=%.4fs\n' % (self.label, elapsed / 1e9))
                else:
                    self.write('...toc(%r)=%.4fs\n' % (self.label, elapsed))
                self.flush()
            return elapsed

        def __enter__(self):
            """
            Returns:
                Timer: self
            """
            self.tic()
            return self

        def __exit__(self, ex_type, ex_value, ex_traceback):
            """
            Args:
                ex_type (Type[BaseException] | None):
                ex_value (BaseException | None):
                ex_traceback (TracebackType | None):

            Returns:
                bool | None
            """
            self.elapsed = self.toc()
            if ex_traceback is not None:
                return False

    # class Time:
    #     """
    #     Stub for potential future time object
    #     """
    #     def __init__(cls, datetime):
    #         ...
    #     @classmethod
    #     def coerce(cls, data):
    #         ...
    #     @classmethod
    #     def parse(cls, stamp):
    #         ...
    #
    # class TimeDelta:
    #     ...