"""
run the test from the src/invesytoolbox directory:
pytest ../tests/test_date_time.py -v
"""
import datetime
from random import randint

import DateTime
import pendulum
from invesytoolbox.itb_date_time import (
    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_date,
    str_to_DT,
    str_to_dt,
    str_to_pendulum,
    unravel_duration,
)
from testdata.date_time import (
    dates_for_checking,
    durations,
    get_dateformat_error_str_part,
    iso8601_tests,
)


def test_get_dateformat():
    for d, f in dates_for_checking.items():
        fmt = f.get('convert_fmt')
        if not f.get('is_valid'):
            print(f'get_dateformat: ignoring {d} (is invalid)')
            continue

        try:
            df = get_dateformat(datestring=d)
        except ValueError as e:
            if get_dateformat_error_str_part in str(e):
                df = str(e)  # for assert check
            else:
                raise ValueError(e)
        try:
            assert df == fmt
            if get_dateformat_error_str_part in df:
                print(f'- {df} (test OK)')
            else:
                pass
        except AssertionError as e:
            print(f'get_dateformat: AssertionError on {d}: {e}')


def check_convert_from_to(func, convert_from, convert_to):
    for k, v in dates_for_checking.items():
        fmt = v.get('convert_fmt')

        if convert_from == 'string':
            value_to_convert = k
            fmts = [fmt]
        else:
            value_to_convert = v.get(convert_from)
            fmts = [None]

        if not value_to_convert:
            continue

        check_dt = v.get(convert_to)
        if convert_from == 'date':
            check_dt = remove_time(date_time=check_dt)

        for f in fmts:
            kwargs = {}
            if f:
                kwargs = {'fmt': f}
            try:
                dt = func(value_to_convert, **kwargs)
            except ValueError as e:
                if not check_dt:
                    continue  # there should be an error, so it's ok
                else:
                    raise ValueError(e)

            # if result is timezone-aware but reference is naive, make comparable
            if (
                isinstance(dt, datetime.datetime)
                and isinstance(check_dt, datetime.datetime)
                and dt.tzinfo is not None
                and check_dt.tzinfo is None
            ):
                check_dt = check_dt.replace(tzinfo=dt.tzinfo)

            # When converting date (no tz) to DateTime, the result timezone
            # depends on the system timezone. Compare only date/time parts.
            if (isinstance(dt, DateTime.DateTime)
                    and isinstance(check_dt, DateTime.DateTime)
                    and convert_from == 'date'):
                assert dt.parts()[:6] == check_dt.parts()[:6]
            else:
                try:
                    assert dt == check_dt
                except AssertionError as e:
                    raise AssertionError(e)


def test_str_to_dt():
    check_convert_from_to(
        func=str_to_dt,
        convert_from='string',
        convert_to='datetime'
    )


def test_str_to_date():
    check_convert_from_to(
        func=str_to_date,
        convert_from='string',
        convert_to='date'
    )


def test_str_to_DT():
    check_convert_from_to(
        func=str_to_DT,
        convert_from='string',
        convert_to='DateTime'
    )


def test_DT_to_dt():
    check_convert_from_to(
        func=DT_to_dt,
        convert_from='DateTime',
        convert_to='datetime'
    )


def test_DT_to_date():
    check_convert_from_to(
        func=DT_to_date,
        convert_from='DateTime',
        convert_to='date'
    )


def test_date_to_dt():
    check_convert_from_to(
        func=date_to_dt,
        convert_from='date',
        convert_to='datetime'
    )


def test_date_to_dt_with_zero_time():
    """Test that H=0 and M=0 are not treated as falsy."""
    d = datetime.date(2023, 5, 15)

    # H=0, M=30 → 00:30 (not midnight)
    r1 = date_to_dt(d, H=0, M=30)
    assert r1 == datetime.datetime(2023, 5, 15, 0, 30)

    # H=10, M=0 → 10:00 (not midnight)
    r2 = date_to_dt(d, H=10, M=0)
    assert r2 == datetime.datetime(2023, 5, 15, 10, 0)

    # H=0, M=0 → explicit midnight
    r3 = date_to_dt(d, H=0, M=0)
    assert r3 == datetime.datetime(2023, 5, 15, 0, 0)


def test_date_to_DT():
    check_convert_from_to(
        func=date_to_DT,
        convert_from='date',
        convert_to='DateTime'
    )


def test_convert_datetime():
    convert_tos = ('string', 'datetime', 'date', 'DateTime')
    converts = []

    for s, v in dates_for_checking.items():
        for k in convert_tos:
            if k == 'string':
                t = s
            else:
                t = v.get(k)

            if not t:
                continue

            converts.append((s, t))

    for date_string, conv_from in converts:
        dates_for_checking_val = dates_for_checking.get(date_string)

        for conv_to in convert_tos:
            check_obj = dates_for_checking_val.get(conv_to)
            convert_fmt = dates_for_checking_val.get('convert_fmt') if isinstance(conv_from, str) else None

            if check_obj is not None:
                for fmt in (convert_fmt, None):

                    try:
                        res_obj = convert_datetime(
                            date=conv_from,
                            convert_to=conv_to,
                            fmt=fmt
                        )
                    except ValueError as e:
                        if 'is not a valid date or date/time string.' in str(e):
                            # Exception in get_dateformat,
                            # validity is tested in the appropriate test function
                            continue
                        elif 'does not match format' in str(e):
                            continue
                        else:
                            raise ValueError(e)

                    if type(conv_from) is datetime.date:  # isinstance does not work
                        check_obj = remove_time(check_obj)

                    # Handle timezone-aware vs naive datetime comparison
                    if (isinstance(res_obj, datetime.datetime)
                            and isinstance(check_obj, datetime.datetime)
                            and res_obj.tzinfo is not None
                            and check_obj.tzinfo is None):
                        check_obj = check_obj.replace(tzinfo=res_obj.tzinfo)

                    # When converting naive datetime/date to DateTime, the
                    # result timezone depends on the system timezone. Compare
                    # only the date/time parts, not the timezone.
                    if (isinstance(res_obj, DateTime.DateTime)
                            and isinstance(check_obj, DateTime.DateTime)
                            and isinstance(conv_from, (datetime.datetime, datetime.date))
                            and not isinstance(conv_from, DateTime.DateTime)
                            and (not isinstance(conv_from, datetime.datetime)
                                 or conv_from.tzinfo is None)):
                        assert res_obj.parts()[:6] == check_obj.parts()[:6], \
                            f'convert from {conv_from} ({type(conv_from)}) to: {conv_to} with format {fmt}'
                    else:
                        assert res_obj == check_obj, f'convert from {conv_from} ({type(conv_from)}) to: {conv_to} with format {fmt}'


def test_get_calendar_week():
    for s, v in dates_for_checking.items():
        correct_week = v.get('week')

        if not correct_week:
            continue

        for some_date in (
            s,
            v.get('date'),
            v.get('datetime'),
            v.get('DateTime')
        ):
            week = get_calendar_week(some_date)

            assert week == correct_week


def test_get_dow_number():
    for s, v in dates_for_checking.items():
        correct_dow_number = v.get('dow_number')

        if not correct_dow_number:
            continue

        for some_date in (
            s,
            v.get('date'),
            v.get('datetime'),
            v.get('DateTime')
        ):
            dow_number = get_dow_number(some_date)

            assert dow_number == correct_dow_number


def test_get_isocalendar():
    for s, v in dates_for_checking.items():
        correct_isocalendar = v.get('isocalendar')

        if not correct_isocalendar:
            continue

        for some_date in (
            s,
            v.get('date'),
            v.get('datetime'),
            v.get('DateTime')
        ):
            iso_calendar = get_isocalendar(some_date)

            assert iso_calendar == correct_isocalendar


def test_is_valid_datetime_string():
    for s, v in dates_for_checking.items():
        is_valid = v.get('is_valid')
        is_not = '' if is_valid else 'not '

        assert is_valid_datetime_string(s) == is_valid, f'{s} is {is_not}a valid date/time!'

    for s, expected in iso8601_tests.items():
        result = is_valid_datetime_string(s)
        assert result == expected, f'ISO8601: {s} should be {"valid" if expected else "invalid"}'


def test_day_from_week():
    for s, v in dates_for_checking.items():
        correct_day = v.get('date')

        if not correct_day:
            continue

        year = correct_day.year
        week = v.get('week')
        dow = v.get('dow_number')

        day = day_from_week(
            year=year,
            week=week,
            weekday=dow
        )

        assert day == correct_day


def test_monday_from_week():
    for s, v in dates_for_checking.items():
        correct_monday = v.get('monday')

        if not correct_monday:
            continue

        year = correct_monday.year
        week = v.get('week')

        monday = monday_from_week(
            year=year,
            week=week
        )

        assert monday == correct_monday


def test_last_week_of_year():
    for s, v in dates_for_checking.items():
        correct_last_week = v.get('last_week')

        if not correct_last_week:
            continue

        last_week = last_week_of_year(v.get('datetime').year)

        assert last_week == correct_last_week


def test_daterange_from_week():
    for s, v in dates_for_checking.items():
        week = v.get('week')

        if not week:
            continue

        correct_monday = v.get('monday')
        correct_sunday = v.get('sunday')
        year = v.get('datetime').year

        daterange = daterange_from_week(
            year=year,
            week=week
        )

        assert daterange == (correct_monday, correct_sunday)


def test_dates_from_week():
    for s, v in dates_for_checking.items():
        week = v.get('week')

        if not week:
            continue

        year = v.get('datetime').year

        monday = v.get('monday')

        correct_dates = [monday]

        for i in range(1, 7):
            correct_dates.append(monday + datetime.timedelta(days=i))

        dates = dates_from_week(
            year=year,
            week=week
        )

        assert dates == correct_dates


def test_remove_time():
    for v in dates_for_checking.values():
        for dt in ('datetime', 'DateTime'):
            assert remove_time(v.get(dt)) == v.get(f'{dt}_notime')


def test_add_time():
    for _ in range(5):
        date_time = [
            randint(1920, 2023),
            randint(1, 12),
            randint(1, 28),
            randint(1, 11),
            randint(1, 29),
            randint(1, 29)
        ]
        add_time_list = [
            randint(1, 11),
            randint(1, 29),
            randint(1, 29)
        ]

        add_time_str = ':'.join([str(n).rjust(2, '0') for n in add_time_list])

        added_time = [
            *date_time[:3],
            date_time[3] + add_time_list[0],
            date_time[4] + add_time_list[1],
            date_time[5] + add_time_list[2]
        ]

        assert datetime.datetime(*added_time) == add_time(
            datetime.datetime(*date_time),
            add_time_str
        )
        assert DateTime.DateTime(*added_time) == add_time(
            DateTime.DateTime(*date_time),
            add_time_str
        )


def test_unravel_duration():
    for s, d in durations.items():
        for returning in ('list', 'dictionary'):
            assert unravel_duration(duration=s, returning=returning) == d.get(returning)


def test_str_to_dt_timezone():
    from zoneinfo import ZoneInfo

    # default timezone is Europe/Vienna
    result = str_to_dt('15.05.2023')
    assert result.tzinfo == ZoneInfo('Europe/Vienna')

    # explicit UTC
    result = str_to_dt('15.05.2023', tz='UTC')
    assert result.tzinfo == ZoneInfo('UTC')
    assert result.year == 2023
    assert result.month == 5
    assert result.day == 15

    # explicit timezone
    result = str_to_dt('15.05.2023 14:30', tz='US/Eastern')
    assert result.tzinfo == ZoneInfo('US/Eastern')
    assert result.hour == 14
    assert result.minute == 30


def test_str_to_DT_timezone():
    # default timezone Europe/Vienna
    result = str_to_DT('15.05.2023')
    assert result.year() == 2023
    assert result.month() == 5
    assert result.day() == 15
    # DateTime stores offset, not timezone name: CET=GMT+1, CEST=GMT+2
    assert result.timezone() in ('GMT+1', 'GMT+2', 'CET', 'CEST')

    # explicit UTC
    result = str_to_DT('15.05.2023', tz='UTC')
    assert result.timezone() in ('UTC', 'GMT+0', 'GMT')


def test_str_to_date_timezone():
    # date is timezone-independent for most cases, but at boundary times
    # the timezone can matter
    result = str_to_date('15.05.2023')
    assert result == datetime.date(2023, 5, 15)

    result = str_to_date('15.05.2023', tz='UTC')
    assert result == datetime.date(2023, 5, 15)


def test_str_to_pendulum_timezone():
    # default timezone Europe/Vienna
    result = str_to_pendulum('15.05.2023', fmt='DD.MM.YYYY')
    assert result.timezone_name == 'Europe/Vienna'

    # explicit UTC
    result = str_to_pendulum('15.05.2023', fmt='DD.MM.YYYY', tz='UTC')
    assert result.timezone_name == 'UTC'


def test_str_to_pendulum():
    # ISO8601 string without explicit format
    result = str_to_pendulum('2023-05-15T14:30:00')
    assert isinstance(result, pendulum.DateTime)
    assert result.year == 2023
    assert result.month == 5
    assert result.day == 15
    assert result.hour == 14
    assert result.minute == 30

    # with explicit format (pendulum uses DD.MM.YYYY, not %d.%m.%Y)
    result = str_to_pendulum('15.05.2023', fmt='DD.MM.YYYY')
    assert isinstance(result, pendulum.DateTime)
    assert result.year == 2023
    assert result.month == 5
    assert result.day == 15


def test_DT_to_pendulum():
    # use UTC timezone to avoid FixedTimezone issues
    DT = DateTime.DateTime('2023/06/15 10:30:00 UTC')
    result = DT_to_pendulum(DT)
    assert isinstance(result, pendulum.DateTime)
    assert result.year == 2023
    assert result.month == 6
    assert result.day == 15
    assert result.hour == 10
    assert result.minute == 30


def test_dt_to_pendulum():
    dt = datetime.datetime(2023, 6, 15, 10, 30)
    result = dt_to_pendulum(dt)
    assert isinstance(result, pendulum.DateTime)
    assert result.year == 2023
    assert result.month == 6
    assert result.day == 15
    assert result.hour == 10
    assert result.minute == 30


def test_pendulum_to_DT():
    pdt = pendulum.datetime(2023, 6, 15, 10, 30, tz='UTC')
    result = pendulum_to_DT(pdt)
    assert isinstance(result, DateTime.DateTime)
    assert result.year() == 2023
    assert result.month() == 6
    assert result.day() == 15
    # pendulum_to_DT converts to the pendulum timezone via .toZone()
    # so UTC input stays UTC
    assert result.minute() == 30


def test_get_monday():
    # week 1 of 2023: Monday is 2023-01-02
    monday = get_monday(2023, 1)
    assert monday == datetime.date(2023, 1, 2)

    # week 52 of 2022: Monday is 2022-12-26
    monday = get_monday(2022, 52)
    assert monday == datetime.date(2022, 12, 26)

    # returning as DateTime
    monday = get_monday(2023, 1, returning='DateTime')
    assert isinstance(monday, DateTime.DateTime)


def test_create_timedelta():
    # datetime timedelta without days
    td = create_timedelta('02:30:00', 'datetime')
    assert isinstance(td, datetime.timedelta)
    assert td == datetime.timedelta(hours=2, minutes=30)

    # pendulum duration
    td = create_timedelta('01:30:00', 'pendulum')
    assert isinstance(td, pendulum.Duration)

    # DateTime returns a number (fraction of day)
    td = create_timedelta('12:00:00', 'DateTime')
    assert td == 0.5  # 12 hours = 0.5 day

    # hours and minutes only
    td = create_timedelta('03:45', 'datetime')
    assert td == datetime.timedelta(hours=3, minutes=45)


if __name__ == '__main__':
    import pytest
    pytest.main([__file__, '-v'])
