# coding=utf-8
r"""
itb_date_time
=============

Timezone handling
-----------------

This module works with four datetime types: ``datetime.datetime``,
``datetime.date``, ``DateTime.DateTime`` (Zope) and ``pendulum.DateTime``.
Timezone handling follows these rules:

- **str_to_\* functions** (``str_to_dt``, ``str_to_DT``, ``str_to_date``,
  ``str_to_pendulum``):
  A plain date/time string carries no timezone information. These functions
  accept a ``tz`` parameter (default: ``'Europe/Vienna'``) so the caller
  can control which timezone is assumed.

- **\*_to_\* conversion functions** (``DT_to_dt``, ``DT_to_pendulum``,
  ``dt_to_pendulum``, ``pendulum_to_DT``):
  The timezone of the source object is preserved. No ``tz`` parameter is
  needed because the input already carries timezone information.

- **date_to_\* functions** (``date_to_dt``, ``date_to_DT``):
  A ``datetime.date`` has no timezone. These functions accept an optional
  ``tz`` parameter (default: ``None`` = naive). The caller can pass a
  timezone when one is needed for further processing.

- **Special case** -- ``dt_to_pendulum``:
  ``pendulum.instance()`` requires a timezone-aware datetime. If the input
  is naive, UTC is assumed.

The default timezone ``'Europe/Vienna'`` is consistent with the AT-centric
design of the invesytoolbox (see also ``itb_locales``).
"""

__all__ = [
    'DEFAULT_TIMEZONE',
    'DT_to_date',
    'DT_to_dt',
    'DT_to_pendulum',
    'add_time',
    'convert_datetime',
    'create_timedelta',
    'date_to_DT',
    'date_to_dt',
    'daterange_from_week',
    'dates_from_week',
    'day_from_week',
    'dt_to_pendulum',
    'get_calendar_week',
    'get_dateformat',
    'get_dow_number',
    'get_isocalendar',
    'get_monday',
    'is_valid_datetime_string',
    'last_week_of_year',
    'monday_from_week',
    'pendulum_to_DT',
    'remove_time',
    'str_to_DT',
    'str_to_date',
    'str_to_dt',
    'str_to_pendulum',
    'unravel_duration',
]

import datetime
from typing import List, Literal, Optional, Tuple, Union

import DateTime
import pendulum
try:
    from zoneinfo import ZoneInfo
except ImportError:
    from backports.zoneinfo import ZoneInfo

DEFAULT_TIMEZONE = 'Europe/Vienna'
"""Default timezone for ``str_to_*`` functions."""

DateTimeType = Union[datetime.date, datetime.datetime, datetime.time, DateTime.DateTime, pendulum.DateTime]

TIME_ELS = ('h', 'm', 's', 'ms')

time_dividers = {
    'h': 1/24,
    'm': 1/24/60,
    's': 1/24/60/60,
    'ms': 1/24/60/60/1000
}


def is_valid_datetime_string(
    datestring: str,
    returning: Optional[str] = None
) -> Union[bool, tuple]:
    """Check for the validity of a date/time string

    Fundamental assumptions:

    - dates with dots are day-first (dd.mm.yyyy)
    - dates with hyphens are year-first (yyyy-mm-dd)
    - dates with slashes: year-first if first part has 4 digits (yyyy/mm/dd),
      otherwise US format (mm/dd/yyyy)

    :param datestring: date or datetime string to validate
    :param returning: ``'tuple'`` returns ``(bool, error_or_None)``
    :return: True/False, or tuple ``(bool, error_message)`` if ``returning='tuple'``

    .. note:: ISO8601 strings (containing 'T') are validated via pendulum.
    """
    if not datestring or not isinstance(datestring, str):
        error = 'empty or not a string'
        return (False, error) if returning == 'tuple' else False

    datestring = datestring.strip()

    # ISO8601: contains 'T' separator (e.g. 2023-05-15T14:30:00)
    if 'T' in datestring:
        try:
            pendulum.parse(datestring)
        except pendulum.parsing.exceptions.ParserError as e:
            return (False, str(e)) if returning == 'tuple' else False
        return (True, None) if returning == 'tuple' else True

    naked_date = datestring.split()[0]

    fmt = None

    if '.' in naked_date:
        parts = naked_date.split('.')
        if len(parts) != 3:
            error = f'{datestring} is not a valid date or datetime string.'
            return (False, error) if returning == 'tuple' else False
        if len(parts[0]) > 2:
            error = f'{datestring} is not a valid date or datetime string.'
            return (False, error) if returning == 'tuple' else False
        fmt = '%d.%m.%Y'

    elif '-' in naked_date:
        parts = naked_date.split('-')
        if len(parts) != 3:
            error = f'{datestring} is not a valid date or datetime string.'
            return (False, error) if returning == 'tuple' else False
        if len(parts[-1]) > 2:
            error = f'{datestring} is not a valid date or datetime string.'
            return (False, error) if returning == 'tuple' else False
        fmt = '%Y-%m-%d'

    elif '/' in naked_date:
        parts = naked_date.split('/')
        if len(parts) != 3:
            error = f'{datestring} is not a valid date or datetime string.'
            return (False, error) if returning == 'tuple' else False
        if len(parts[0]) == 4:
            fmt = '%Y/%m/%d'
        else:
            fmt = '%m/%d/%Y'

    else:
        error = f'{datestring} is not a valid date or datetime string.'
        return (False, error) if returning == 'tuple' else False

    # Add time format if present
    time_parts = datestring.split(None, 1)
    if len(time_parts) > 1:
        colons = time_parts[1].count(':')
        if colons == 1:
            fmt += ' %H:%M'
        elif colons == 2:
            fmt += ' %H:%M:%S'
        else:
            error = f'{datestring} is not a valid date or datetime string.'
            return (False, error) if returning == 'tuple' else False

    try:
        datetime.datetime.strptime(datestring, fmt)
    except ValueError as e:
        return (False, str(e)) if returning == 'tuple' else False

    return (True, None) if returning == 'tuple' else True


PENDULUM_FMT = {
    '%d': 'DD',
    '%-d': 'D',
    '%m': 'MM',
    '%-m': 'M',
    '%Y': 'YYYY',
    '%H': 'HH',
    '%M': 'mm',
    '%S': 'ss',
}


def get_dateformat(
    datestring: str,
    checkonly: bool = False,
    leading_zeroes: bool = True,
    for_type: Literal['strptime', 'DateTime', 'pendulum'] = 'strptime',
    for_DateTime: bool = False,  # deprecated, use for_type='DateTime'
) -> str:
    """Detect the format of a datetime string and return the corresponding format string.

    For example: ``"15.05.2023"`` → ``"%d.%m.%Y"`` (strptime), ``"DD.MM.YYYY"`` (pendulum)

    :param checkonly: shortcut for :func:`is_valid_datetime_string` — returns True/False
    :param leading_zeroes: if False, format uses ``%-d`` and ``%-m`` (no leading zeroes).
        For pendulum: ``D`` and ``M`` instead of ``DD`` and ``MM``.
    :param for_type: ``'strptime'`` (default), ``'DateTime'`` (Zope), or ``'pendulum'``
    :param for_DateTime: deprecated, use ``for_type='DateTime'`` instead
    :param datestring: date or datetime string to analyze
    :raises ValueError: if the datestring is not a valid date or datetime string
    :return: format string, or bool if ``checkonly`` is True
    """
    # backwards compatibility
    if for_DateTime:
        for_type = 'DateTime'

    if checkonly:
        return is_valid_datetime_string(datestring)

    timefmt = ''
    if ' ' in datestring:
        try:
            datestring, timestring = datestring.split()
        except ValueError:
            raise ValueError(f'{datestring} is not a valid date or datetime string.')

        colon_count = timestring.count(':')
        timefmt = ' %H:%M'
        if colon_count == 2:
            timefmt += ':%S'
        elif colon_count > 2:
            raise ValueError(f'{datestring} is not a valid date or datetime string.')

    if '.' in datestring:
        datefmt = '%d.%m.%Y'

    elif '/' in datestring:
        if len(datestring.split('/')[0]) < 3:
            if for_type == 'DateTime':
                datefmt = 'us'
            else:
                datefmt = '%m/%d/%Y'  # US format
        else:
            if for_type == 'DateTime':
                datefmt = 'international'
            else:
                datefmt = '%Y/%m/%d'  # international format
    elif '-' in datestring:
        if for_type == 'DateTime':
            datefmt = 'international'
        else:
            datefmt = '%Y-%m-%d'
    else:  # default
        if for_type == 'DateTime':
            datefmt = 'international'
        else:
            datefmt = '%d.%m.%Y'

    if for_type == 'DateTime':
        try:
            DateTime.DateTime(datestring, datefmt=datefmt)
        except DateTime.interfaces.DateError:
            raise ValueError(f'{datestring} is not a valid date or datetime string.')
        return datefmt
    else:
        try:
            datetime.datetime.strptime(datestring, datefmt)
        except ValueError:
            raise ValueError(f'{datestring} is not a valid date or datetime string.')

        if not leading_zeroes:
            datefmt = datefmt.replace(
                '%d', '%-d'
            ).replace('%m', '%-m')

        fmt = datefmt + timefmt

        if for_type == 'pendulum':
            for strp, pend in PENDULUM_FMT.items():
                fmt = fmt.replace(strp, pend)

        return fmt


def str_to_dt(
    datestring: str,
    fmt: Optional[str] = None,
    tz: str = DEFAULT_TIMEZONE,
    checkonly: bool = False,  # deprecated, use is_valid_datetime_string
    returning: Optional[str] = None  # deprecated (only with checkonly)
) -> Union[datetime.datetime, bool]:
    """ Convert a string to timezone-aware datetime.datetime

    :param fmt: format string (e.g. ``'%d.%m.%Y'``). If not provided, the format is auto-detected.
    :param tz: timezone name (e.g. ``'Europe/Vienna'``, ``'UTC'``). Default: ``'Europe/Vienna'``.
    :param checkonly: deprecated, use :func:`is_valid_datetime_string` instead
    :param returning: deprecated (only with checkonly)
    :return: timezone-aware datetime.datetime
    """
    if checkonly:
        return is_valid_datetime_string(datestring, returning=returning)

    if not fmt:
        fmt = get_dateformat(datestring)

    naive_dt = datetime.datetime.strptime(datestring, fmt)
    return naive_dt.replace(tzinfo=ZoneInfo(tz))


def str_to_DT(
    datestring: str,
    fmt: Optional[str] = None,
    tz: str = DEFAULT_TIMEZONE
) -> DateTime.DateTime:
    """ Convert a string to DateTime.DateTime

    The conversion goes through datetime.datetime first, because
    DateTime.DateTime cannot parse a string with a given format-string.

    :param fmt: strptime format string (e.g. ``'%d.%m.%Y'``). Auto-detected if not provided.
    :param datestring: date or datetime string to convert
    :param tz: timezone name (e.g. ``'Europe/Vienna'``, ``'UTC'``). Default: ``'Europe/Vienna'``.
    :return: DateTime.DateTime object
    """
    return DateTime.DateTime(str_to_dt(datestring, fmt=fmt, tz=tz))


def str_to_pendulum(
    datestring: str,
    fmt: Optional[str] = None,
    tz: str = DEFAULT_TIMEZONE
) -> pendulum.DateTime:
    """ Convert a string to pendulum.DateTime

    :param fmt: pendulum format string (e.g. ``'DD.MM.YYYY'``).
        If not provided, the datestring is parsed as ISO8601.
    :param datestring: date or datetime string to convert
    :param tz: timezone name (e.g. ``'Europe/Vienna'``, ``'UTC'``). Default: ``'Europe/Vienna'``.
    :return: pendulum.DateTime object
    """
    if not fmt:
        return pendulum.parse(datestring, tz=pendulum.timezone(tz))
    else:
        return pendulum.from_format(datestring, fmt, tz=pendulum.timezone(tz))


def str_to_date(
    datestring: str,
    fmt: Optional[str] = None,
    tz: str = DEFAULT_TIMEZONE
) -> datetime.date:
    """ Convert a string to datetime.date

    :param fmt: strptime format string (e.g. ``'%d.%m.%Y'``). Auto-detected if not provided.
    :param datestring: date or datetime string to convert
    :param tz: timezone name — relevant because the timezone determines the date
        at boundary times. Default: ``'Europe/Vienna'``.
    :return: datetime.date object
    """
    return str_to_dt(datestring, fmt=fmt, tz=tz).date()


def DT_to_dt(
    DT: DateTime.DateTime
) -> datetime.datetime:
    """Convert DateTime.DateTime to datetime.datetime (timezone-aware)

    :param DT: DateTime.DateTime object
    :return: timezone-aware datetime.datetime
    """
    return DT.asdatetime()


def DT_to_date(
    DT: DateTime.DateTime
) -> datetime.date:
    """Convert DateTime.DateTime to datetime.date

    :param DT: DateTime.DateTime object
    :return: datetime.date
    """
    return datetime.date(
        DT.year(),
        DT.month(),
        DT.day()
    )


def DT_to_pendulum(
    DT: DateTime.DateTime
) -> pendulum.DateTime:
    """Convert DateTime.DateTime to pendulum.DateTime (preserves timezone and seconds)

    :param DT: DateTime.DateTime object
    :return: pendulum.DateTime
    """
    return pendulum.instance(DT.asdatetime())


def date_to_dt(
    date: datetime.date,
    H=None, M=None,
    tz: Optional[str] = None
) -> datetime.datetime:
    """ Convert datetime.date to datetime.datetime

    :param H: hour
    :param M: minute
    :param date: datetime.date to convert
    :param tz: timezone name (e.g. ``'Europe/Vienna'``, ``'UTC'``). Default: ``None`` (naive).
    :return: datetime.datetime (with time set to H:M or midnight)
    """
    tzinfo = ZoneInfo(tz) if tz else None

    if H is not None and M is not None:
        dt = datetime.datetime.combine(
            date,
            datetime.datetime(2000, 1, 1, int(H), int(M)).time(),
            tzinfo=tzinfo
        )
    else:
        dt = datetime.datetime.combine(
            date,
            datetime.datetime.min.time(),
            tzinfo=tzinfo
        )
    return dt


def date_to_DT(
    date: datetime.date,
    tz: Optional[str] = None
) -> DateTime.DateTime:
    """ Convert datetime.date to DateTime.DateTime

    :param date: datetime.date to convert
    :param tz: timezone name (e.g. ``'Europe/Vienna'``, ``'UTC'``). Default: ``None`` (naive).
    :return: DateTime.DateTime
    """
    return DateTime.DateTime(date_to_dt(date, tz=tz))


def dt_to_pendulum(
    dt: datetime.datetime
) -> pendulum.DateTime:
    """Convert datetime.datetime to pendulum.DateTime (preserves timezone and seconds)

    If the input datetime is naive (no timezone), UTC is assumed.

    :param dt: datetime.datetime to convert
    :return: pendulum.DateTime
    """
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=ZoneInfo('UTC'))
    return pendulum.instance(dt)


def pendulum_to_DT(
    pendulum_dt: pendulum.DateTime
) -> DateTime.DateTime:
    """Convert pendulum.DateTime to DateTime.DateTime (preserves timezone and seconds)

    :param pendulum_dt: pendulum.DateTime to convert
    :return: DateTime.DateTime
    """
    return DateTime.DateTime(
        pendulum_dt.year,
        pendulum_dt.month,
        pendulum_dt.day,
        pendulum_dt.hour,
        pendulum_dt.minute,
        pendulum_dt.second
    ).toZone(pendulum_dt.timezone.name)


def convert_datetime(
    date: Union[
        str,
        DateTimeType
    ],
    convert_to: Literal['date', 'datetime', 'DateTime', 'pendulum', 'str', 'string'],
    fmt: Optional[str] = None,
    tz: Optional[str] = None
) -> Union[DateTimeType, str]:
    """ Conversion of date and time formats

    :param convert_to:

        - date: datetime.date
        - datetime: datetime.datetime
        - DateTime: DateTime.DateTime
        - pendulum
        - str, string: string

    :param fmt: is used both for converting from and converting to
    :param date: date/datetime object or string to convert
    :param tz: timezone name. Only used when the source has no timezone
        (``str`` or ``datetime.date``). Default: ``None`` — for strings
        this falls back to ``DEFAULT_TIMEZONE``, for dates to naive.
    :return: converted date/datetime in the requested type
    """

    if isinstance(date, DateTime.DateTime):
        if convert_to == 'DateTime':
            return date
        elif convert_to == 'date':
            return DT_to_date(date)
        elif convert_to == 'datetime':
            return DT_to_dt(date)
        elif convert_to in ('str', 'string'):
            return date.strftime(fmt)
        elif convert_to == 'pendulum':
            return DT_to_pendulum(date)
    elif isinstance(date, pendulum.DateTime):
        if convert_to == 'pendulum':
            return date
        elif convert_to == 'datetime':
            return date
        elif convert_to == 'date':
            return date.date()
        elif convert_to == 'DateTime':
            return pendulum_to_DT(date)
        elif convert_to in ('str', 'string'):
            return date.strftime(fmt)
    elif isinstance(date, str):
        str_tz = tz or DEFAULT_TIMEZONE
        if convert_to in ('str', 'string'):
            if str_to_dt(date, checkonly=True, fmt=fmt):
                return date
            else:
                raise Exception(f'{date} is not a valid date/time string!')
        elif convert_to == 'date':
            return str_to_date(date, fmt=fmt, tz=str_tz)
        elif convert_to == 'datetime':
            return str_to_dt(date, fmt=fmt, tz=str_tz)
        elif convert_to == 'DateTime':
            return str_to_DT(date, fmt=fmt, tz=str_tz)
        elif convert_to == 'pendulum':
            return str_to_pendulum(date, tz=str_tz)
    elif isinstance(date, datetime.datetime):
        # datetime.datetime has to be checked BEFORE
        # datetime.date (isinstance(datetime.datetime, datetime.date) == True !)
        if convert_to == 'date':
            return date.date()
        elif convert_to == 'datetime':
            return date
        elif convert_to == 'DateTime':
            return DateTime.DateTime(date)
        elif convert_to in ('str', 'string'):
            if fmt:
                return date.strftime(fmt)
            else:
                return date.astimezone().isoformat()
        elif convert_to == 'pendulum':
            return dt_to_pendulum(date)
    elif type(date) is datetime.date:  # isinstance does not work
        if convert_to == 'date':
            return date
        elif convert_to == 'datetime':
            return date_to_dt(date, tz=tz)
        elif convert_to == 'DateTime':
            return date_to_DT(date, tz=tz)
        elif convert_to in ('str', 'string'):
            if fmt:
                return date.strftime(fmt)
            else:
                return date.isoformat()  # no timezone in datetime.date
        elif convert_to == 'pendulum':
            dt = date_to_dt(date, tz=tz or 'UTC')
            return dt_to_pendulum(dt)
    # type: ignore[return-value]
    raise RuntimeError("Unreachable code – all cases should be handled above")

def get_calendar_week(
    date: Union[
        str,
        datetime.datetime,
        datetime.date,
        DateTime.DateTime
    ]
) -> int:
    """ Get calendar week (week nb of year) from a date

    :param date: can be any date, datetime, DateTime or string
    :return: ISO calendar week number
    """
    date = convert_datetime(date, 'date')

    return date.isocalendar()[1]


def get_dow_number(
    date: Union[
        str,
        datetime.datetime,
        datetime.date,
        DateTime.DateTime
    ]
) -> int:
    """ Get day of week number from a date (1=Monday, 7=Sunday)

    :param date: can be any date, datetime, DateTime or string
    :return: day of week number (1=Monday, 7=Sunday)
    """
    date = convert_datetime(date, 'date')

    return date.isocalendar()[2]


def get_isocalendar(
    date: Union[
        str,
        datetime.datetime,
        datetime.date,
        DateTime.DateTime
    ]
) -> Tuple[int, int, int]:
    """ Returns the isocalendar tuple: (year, week, dow_number)

    :param date: can be any date, datetime, DateTime or string
    :return: ``datetime.IsoCalendarDate(year, week, weekday)`` — a named tuple
        compatible with ``Tuple[int, int, int]``
    """

    date = convert_datetime(date, 'date')

    return date.isocalendar()


def get_monday(
    year: int,
    week: int,
    returning: Literal['date', 'datetime', 'DateTime', 'pendulum'] = 'date'
) -> DateTimeType:
    """Returns the monday of a given ISO calendar week

    :param year: ISO year
    :param week: ISO week number
    :param returning: output type
    :return: monday as ``datetime.date`` by default, or converted
        to the type specified by ``returning``
    """
    monday = datetime.date.fromisocalendar(int(year), int(week), 1)

    if returning == 'date':
        return monday

    return convert_datetime(monday, returning)


def daterange_from_week(
    year: int,
    week: int,
    returning: Literal['date', 'datetime', 'DateTime', 'pendulum'] = 'date',
    endofweek: int = 7
) -> Tuple[DateTimeType, DateTimeType]:
    """Get a date range (monday, last_day) from a week

    :param year: ISO year
    :param week: ISO week number
    :param returning: 'date' (default), 'datetime', 'DateTime', 'pendulum'
    :param endofweek: last day of week (7=Sunday, 5=Friday, etc.)
    :return: tuple of (monday, last_day)
    """
    monday = get_monday(year=year, week=week)
    last_day = monday + datetime.timedelta(days=endofweek - 1)

    return convert_datetime(monday, returning), convert_datetime(last_day, returning)


def dates_from_week(
    year: int,
    week: int,
    returning: Literal['date', 'datetime', 'DateTime', 'pendulum'] = 'date',
    endofweek: int = 7
) -> List[DateTimeType]:
    """Get all dates of a week as a list

    :param year: ISO year
    :param week: ISO week number
    :param returning: 'date' (default), 'datetime', 'DateTime', 'pendulum'
    :param endofweek: 1 to 7 (monday to sunday)
    :return: list of dates
    """
    monday = get_monday(year=year, week=week)

    return_dates = [
        convert_datetime(monday + datetime.timedelta(days=i), returning)
        for i in range(endofweek)
    ]

    return return_dates


def day_from_week(
    year: int,
    week: int,
    weekday: int = 1,
    returning: Literal['date', 'datetime', 'DateTime', 'pendulum'] = 'date'
) -> Union[datetime.datetime, datetime.date, DateTime.DateTime, pendulum.DateTime]:
    """Get a specific day from a week

    :param year: ISO year
    :param week: ISO week number
    :param weekday: 1=Monday to 7=Sunday
    :param returning: 'date' (default), 'datetime', 'DateTime', 'pendulum'
    :return: the requested day
    """
    day = datetime.date.fromisocalendar(int(year), int(week), int(weekday))

    if returning == 'date':
        return day

    return convert_datetime(day, returning)


def monday_from_week(
    year: int,
    week: int,
    returning: Literal['date', 'datetime', 'DateTime', 'pendulum'] = 'date'
) -> Union[datetime.datetime, datetime.date, DateTime.DateTime, pendulum.DateTime]:
    """Get monday from a week

    :param year: ISO year
    :param week: ISO week number
    :param returning: 'date' (default), 'datetime', 'DateTime', 'pendulum'
    :return: monday of the given week

    .. note:: Convenience wrapper around :func:`day_from_week` / :func:`get_monday`.
    """
    return get_monday(year=year, week=week, returning=returning)


def last_week_of_year(
    year: int
) -> int:
    """Get the last week of a year

    :param year: the year
    :return: last ISO week number of the year
    """
    last_week = datetime.date(year, 12, 28)
    return last_week.isocalendar()[1]


def remove_time(
    date_time: Union[datetime.date, datetime.datetime, DateTime.DateTime, pendulum.DateTime],
    returning: Optional[str] = None
):
    """Remove time from a datetime, DateTime or pendulum object

    :param date_time: date, datetime, DateTime or pendulum object
    :param returning: if None, the same type as date_time is returned.
            Valid values: 'date', 'datetime', 'DateTime', 'pendulum'
    :return: date/datetime with time component removed
    """
    if date_time is None:
        return None

    if type(date_time) is datetime.date:
        if not returning or returning == 'date':
            return date_time
        return convert_datetime(date_time, returning)

    if not returning:
        # return same type as input
        if isinstance(date_time, DateTime.DateTime):
            returning = 'DateTime'
        elif isinstance(date_time, pendulum.DateTime):
            returning = 'pendulum'
        elif isinstance(date_time, datetime.datetime):
            returning = 'datetime'

    # For DateTime, preserve timezone by constructing directly from aware datetime
    if isinstance(date_time, DateTime.DateTime) and returning == 'DateTime':
        aware_dt = date_time.asdatetime().replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        return DateTime.DateTime(aware_dt)

    date_only = convert_datetime(date_time, 'date')

    if returning == 'date':
        return date_only

    return convert_datetime(date_only, returning)


def create_timedelta(
    timestr: str,
    returning: Optional[Literal['datetime', 'DateTime', 'pendulum']] = None,
    dt: str = None  # deprecated, use returning
):
    """Create a timedelta or a number from a time string

    :param timestr: H:M or H:M:S or H:M:S:ms, optionally with days: ``4T03:12:31``
    :param returning: 'datetime', 'DateTime' or 'pendulum'
    :param dt: deprecated, use ``returning``
    :return: timedelta, float (for DateTime), or pendulum.Duration
    """
    if not returning:
        returning = dt
    days_time = timestr.split('T')
    timestr = days_time[-1]
    days = int(days_time[0]) if len(days_time) > 1 else 0

    time_list = timestr.split(':')
    time_elements = {}

    for i, el in enumerate(TIME_ELS):
        try:
            time_elements[el] = int(time_list[i])
        except IndexError:
            time_elements[el] = 0

    if returning == 'DateTime':
        t_delta = days
        for el in TIME_ELS:
            t_delta += time_elements[el] * time_dividers[el]
        return t_delta

    kwargs = dict(
        days=days,
        hours=time_elements['h'],
        minutes=time_elements['m'],
        seconds=time_elements['s'],
        milliseconds=time_elements['ms'],
    )

    if returning == 'pendulum':
        return pendulum.duration(**kwargs)

    return datetime.timedelta(**kwargs)


def add_time(
    date_time: Union[datetime.date, datetime.datetime, DateTime.DateTime, pendulum.DateTime],
    timestr: str,
    subtract: bool = False,
    returning: Optional[str] = None
):
    """Add or subtract time from a date/datetime object

    :param date_time: date, datetime, DateTime or pendulum object
    :param timestr: H:M or H:M:S or H:M:S:ms, optionally with days (e.g. ``4T03:12:31``).
        A leading ``-`` triggers subtraction.
    :param subtract: if True, subtract instead of add
    :param returning: if None, the same type as the input is returned.
        Valid values: 'date', 'datetime', 'DateTime', 'pendulum'
    :return: date/datetime with time added or subtracted
    """
    if timestr.startswith('-'):
        subtract = True
        timestr = timestr[1:]

    if isinstance(date_time, DateTime.DateTime):
        dt = 'DateTime'
    elif isinstance(date_time, pendulum.DateTime):
        dt = 'pendulum'
    elif isinstance(date_time, datetime.datetime):
        dt = 'datetime'
    elif type(date_time) is datetime.date:
        date_time = date_to_dt(date_time)
        dt = 'datetime'
    else:
        raise TypeError(f'Unsupported type: {type(date_time)}')

    t_delta = create_timedelta(
        timestr=timestr,
        returning=dt
    )

    if subtract:
        result = date_time - t_delta
    else:
        result = date_time + t_delta

    if returning and returning != dt:
        return convert_datetime(result, returning)

    return result


def unravel_duration(
    duration: str,
    returning: Literal['list', 'tuple', 'dictionary'] = 'list'
):
    """Split a duration string into components (days, hours, minutes, seconds, milliseconds)

    :param duration: format ``nTH:M:S:ms`` (e.g. ``5T23:04:17``, ``45:3:18:1500``)
    :param returning: 'list' (default), 'tuple' or 'dictionary'
    :return: duration components as list, tuple, or dict
    """
    if 'T' in duration:
        days, rest = duration.split('T')
        days = int(days)
    else:
        days = 0
        rest = duration

    elements = rest.split(':')
    time_elements = [int(el) for el in elements[:4]]
    # pad to 4 elements (hours, minutes, seconds, milliseconds)
    time_elements += [0] * (4 - len(time_elements))
    time_elements.insert(0, days)

    # normalize overflow (ms→s→m→h→d)
    for i, divider in zip(range(4, 0, -1), (1000, 60, 60, 24)):
        carry, time_elements[i] = divmod(time_elements[i], divider)
        time_elements[i - 1] += carry

    if returning == 'list':
        return time_elements
    elif returning == 'tuple':
        return tuple(time_elements)
    elif returning in ('dict', 'dictionary'):
        names = ('days', 'hours', 'minutes', 'seconds', 'milliseconds')
        return dict(zip(names, time_elements))
    else:
        raise ValueError('argument returning must be list, tuple or dictionary.')
