from __future__ import annotations

import warnings
from collections.abc import Callable
from itertools import product
from typing import Literal

import numpy as np
import pandas as pd
import pytest

from xarray import CFTimeIndex
from xarray.coding.cftime_offsets import (
    _MONTH_ABBREVIATIONS,
    BaseCFTimeOffset,
    Day,
    Hour,
    Microsecond,
    Millisecond,
    Minute,
    MonthBegin,
    MonthEnd,
    QuarterBegin,
    QuarterEnd,
    Second,
    Tick,
    YearBegin,
    YearEnd,
    _legacy_to_new_freq,
    _new_to_legacy_freq,
    cftime_range,
    date_range,
    date_range_like,
    get_date_type,
    to_cftime_datetime,
    to_offset,
)
from xarray.coding.frequencies import infer_freq
from xarray.core.dataarray import DataArray
from xarray.tests import (
    _CFTIME_CALENDARS,
    assert_no_warnings,
    has_cftime,
    has_pandas_ge_2_2,
    requires_cftime,
    requires_pandas_3,
)

cftime = pytest.importorskip("cftime")


def _id_func(param):
    """Called on each parameter passed to pytest.mark.parametrize"""
    return str(param)


@pytest.fixture(params=_CFTIME_CALENDARS)
def calendar(request):
    return request.param


@pytest.mark.parametrize(
    ("offset", "expected_n"),
    [
        (BaseCFTimeOffset(), 1),
        (YearBegin(), 1),
        (YearEnd(), 1),
        (QuarterBegin(), 1),
        (QuarterEnd(), 1),
        (Tick(), 1),
        (Day(), 1),
        (Hour(), 1),
        (Minute(), 1),
        (Second(), 1),
        (Millisecond(), 1),
        (Microsecond(), 1),
        (BaseCFTimeOffset(n=2), 2),
        (YearBegin(n=2), 2),
        (YearEnd(n=2), 2),
        (QuarterBegin(n=2), 2),
        (QuarterEnd(n=2), 2),
        (Tick(n=2), 2),
        (Day(n=2), 2),
        (Hour(n=2), 2),
        (Minute(n=2), 2),
        (Second(n=2), 2),
        (Millisecond(n=2), 2),
        (Microsecond(n=2), 2),
    ],
    ids=_id_func,
)
def test_cftime_offset_constructor_valid_n(offset, expected_n):
    assert offset.n == expected_n


@pytest.mark.parametrize(
    ("offset", "invalid_n"),
    [
        (BaseCFTimeOffset, 1.5),
        (YearBegin, 1.5),
        (YearEnd, 1.5),
        (QuarterBegin, 1.5),
        (QuarterEnd, 1.5),
        (MonthBegin, 1.5),
        (MonthEnd, 1.5),
        (Tick, 1.5),
        (Day, 1.5),
        (Hour, 1.5),
        (Minute, 1.5),
        (Second, 1.5),
        (Millisecond, 1.5),
        (Microsecond, 1.5),
    ],
    ids=_id_func,
)
def test_cftime_offset_constructor_invalid_n(offset, invalid_n):
    with pytest.raises(TypeError):
        offset(n=invalid_n)


@pytest.mark.parametrize(
    ("offset", "expected_month"),
    [
        (YearBegin(), 1),
        (YearEnd(), 12),
        (YearBegin(month=5), 5),
        (YearEnd(month=5), 5),
        (QuarterBegin(), 3),
        (QuarterEnd(), 3),
        (QuarterBegin(month=5), 5),
        (QuarterEnd(month=5), 5),
    ],
    ids=_id_func,
)
def test_year_offset_constructor_valid_month(offset, expected_month):
    assert offset.month == expected_month


@pytest.mark.parametrize(
    ("offset", "invalid_month", "exception"),
    [
        (YearBegin, 0, ValueError),
        (YearEnd, 0, ValueError),
        (YearBegin, 13, ValueError),
        (YearEnd, 13, ValueError),
        (YearBegin, 1.5, TypeError),
        (YearEnd, 1.5, TypeError),
        (QuarterBegin, 0, ValueError),
        (QuarterEnd, 0, ValueError),
        (QuarterBegin, 1.5, TypeError),
        (QuarterEnd, 1.5, TypeError),
        (QuarterBegin, 13, ValueError),
        (QuarterEnd, 13, ValueError),
    ],
    ids=_id_func,
)
def test_year_offset_constructor_invalid_month(offset, invalid_month, exception):
    with pytest.raises(exception):
        offset(month=invalid_month)


@pytest.mark.parametrize(
    ("offset", "expected"),
    [
        (BaseCFTimeOffset(), None),
        (MonthBegin(), "MS"),
        (MonthEnd(), "ME"),
        (YearBegin(), "YS-JAN"),
        (YearEnd(), "YE-DEC"),
        (QuarterBegin(), "QS-MAR"),
        (QuarterEnd(), "QE-MAR"),
        (Day(), "D"),
        (Hour(), "h"),
        (Minute(), "min"),
        (Second(), "s"),
        (Millisecond(), "ms"),
        (Microsecond(), "us"),
    ],
    ids=_id_func,
)
def test_rule_code(offset, expected):
    assert offset.rule_code() == expected


@pytest.mark.parametrize(
    ("offset", "expected"),
    [
        (BaseCFTimeOffset(), "<BaseCFTimeOffset: n=1>"),
        (YearBegin(), "<YearBegin: n=1, month=1>"),
        (QuarterBegin(), "<QuarterBegin: n=1, month=3>"),
    ],
    ids=_id_func,
)
def test_str_and_repr(offset, expected):
    assert str(offset) == expected
    assert repr(offset) == expected


@pytest.mark.parametrize(
    "offset",
    [BaseCFTimeOffset(), MonthBegin(), QuarterBegin(), YearBegin()],
    ids=_id_func,
)
def test_to_offset_offset_input(offset):
    assert to_offset(offset) == offset


@pytest.mark.parametrize(
    ("freq", "expected"),
    [
        ("M", MonthEnd()),
        ("2M", MonthEnd(n=2)),
        ("ME", MonthEnd()),
        ("2ME", MonthEnd(n=2)),
        ("MS", MonthBegin()),
        ("2MS", MonthBegin(n=2)),
        ("D", Day()),
        ("2D", Day(n=2)),
        ("H", Hour()),
        ("2H", Hour(n=2)),
        ("h", Hour()),
        ("2h", Hour(n=2)),
        ("T", Minute()),
        ("2T", Minute(n=2)),
        ("min", Minute()),
        ("2min", Minute(n=2)),
        ("S", Second()),
        ("2S", Second(n=2)),
        ("L", Millisecond(n=1)),
        ("2L", Millisecond(n=2)),
        ("ms", Millisecond(n=1)),
        ("2ms", Millisecond(n=2)),
        ("U", Microsecond(n=1)),
        ("2U", Microsecond(n=2)),
        ("us", Microsecond(n=1)),
        ("2us", Microsecond(n=2)),
        # negative
        ("-2M", MonthEnd(n=-2)),
        ("-2ME", MonthEnd(n=-2)),
        ("-2MS", MonthBegin(n=-2)),
        ("-2D", Day(n=-2)),
        ("-2H", Hour(n=-2)),
        ("-2h", Hour(n=-2)),
        ("-2T", Minute(n=-2)),
        ("-2min", Minute(n=-2)),
        ("-2S", Second(n=-2)),
        ("-2L", Millisecond(n=-2)),
        ("-2ms", Millisecond(n=-2)),
        ("-2U", Microsecond(n=-2)),
        ("-2us", Microsecond(n=-2)),
    ],
    ids=_id_func,
)
@pytest.mark.filterwarnings("ignore::FutureWarning")  # Deprecation of "M" etc.
def test_to_offset_sub_annual(freq, expected):
    assert to_offset(freq) == expected


_ANNUAL_OFFSET_TYPES = {
    "A": YearEnd,
    "AS": YearBegin,
    "Y": YearEnd,
    "YS": YearBegin,
    "YE": YearEnd,
}


@pytest.mark.parametrize(
    ("month_int", "month_label"), list(_MONTH_ABBREVIATIONS.items()) + [(0, "")]
)
@pytest.mark.parametrize("multiple", [None, 2, -1])
@pytest.mark.parametrize("offset_str", ["AS", "A", "YS", "Y"])
@pytest.mark.filterwarnings("ignore::FutureWarning")  # Deprecation of "A" etc.
def test_to_offset_annual(month_label, month_int, multiple, offset_str):
    freq = offset_str
    offset_type = _ANNUAL_OFFSET_TYPES[offset_str]
    if month_label:
        freq = "-".join([freq, month_label])
    if multiple:
        freq = f"{multiple}{freq}"
    result = to_offset(freq)

    if multiple and month_int:
        expected = offset_type(n=multiple, month=month_int)
    elif multiple:
        expected = offset_type(n=multiple)
    elif month_int:
        expected = offset_type(month=month_int)
    else:
        expected = offset_type()
    assert result == expected


_QUARTER_OFFSET_TYPES = {"Q": QuarterEnd, "QS": QuarterBegin, "QE": QuarterEnd}


@pytest.mark.parametrize(
    ("month_int", "month_label"), list(_MONTH_ABBREVIATIONS.items()) + [(0, "")]
)
@pytest.mark.parametrize("multiple", [None, 2, -1])
@pytest.mark.parametrize("offset_str", ["QS", "Q", "QE"])
@pytest.mark.filterwarnings("ignore::FutureWarning")  # Deprecation of "Q" etc.
def test_to_offset_quarter(month_label, month_int, multiple, offset_str):
    freq = offset_str
    offset_type = _QUARTER_OFFSET_TYPES[offset_str]
    if month_label:
        freq = "-".join([freq, month_label])
    if multiple:
        freq = f"{multiple}{freq}"
    result = to_offset(freq)

    if multiple and month_int:
        expected = offset_type(n=multiple, month=month_int)
    elif multiple:
        if month_int:
            expected = offset_type(n=multiple)
        else:
            if offset_type == QuarterBegin:
                expected = offset_type(n=multiple, month=1)
            elif offset_type == QuarterEnd:
                expected = offset_type(n=multiple, month=12)
    elif month_int:
        expected = offset_type(month=month_int)
    else:
        if offset_type == QuarterBegin:
            expected = offset_type(month=1)
        elif offset_type == QuarterEnd:
            expected = offset_type(month=12)
    assert result == expected


@pytest.mark.parametrize("freq", ["Z", "7min2", "AM", "M-", "AS-", "QS-", "1H1min"])
def test_invalid_to_offset_str(freq):
    with pytest.raises(ValueError):
        to_offset(freq)


@pytest.mark.parametrize(
    ("argument", "expected_date_args"),
    [("2000-01-01", (2000, 1, 1)), ((2000, 1, 1), (2000, 1, 1))],
    ids=_id_func,
)
def test_to_cftime_datetime(calendar, argument, expected_date_args):
    date_type = get_date_type(calendar)
    expected = date_type(*expected_date_args)
    if isinstance(argument, tuple):
        argument = date_type(*argument)
    result = to_cftime_datetime(argument, calendar=calendar)
    assert result == expected


def test_to_cftime_datetime_error_no_calendar():
    with pytest.raises(ValueError):
        to_cftime_datetime("2000")


def test_to_cftime_datetime_error_type_error():
    with pytest.raises(TypeError):
        to_cftime_datetime(1)


_EQ_TESTS_A = [
    BaseCFTimeOffset(),
    YearBegin(),
    YearEnd(),
    YearBegin(month=2),
    YearEnd(month=2),
    QuarterBegin(),
    QuarterEnd(),
    QuarterBegin(month=2),
    QuarterEnd(month=2),
    MonthBegin(),
    MonthEnd(),
    Day(),
    Hour(),
    Minute(),
    Second(),
    Millisecond(),
    Microsecond(),
]
_EQ_TESTS_B = [
    BaseCFTimeOffset(n=2),
    YearBegin(n=2),
    YearEnd(n=2),
    YearBegin(n=2, month=2),
    YearEnd(n=2, month=2),
    QuarterBegin(n=2),
    QuarterEnd(n=2),
    QuarterBegin(n=2, month=2),
    QuarterEnd(n=2, month=2),
    MonthBegin(n=2),
    MonthEnd(n=2),
    Day(n=2),
    Hour(n=2),
    Minute(n=2),
    Second(n=2),
    Millisecond(n=2),
    Microsecond(n=2),
]


@pytest.mark.parametrize(("a", "b"), product(_EQ_TESTS_A, _EQ_TESTS_B), ids=_id_func)
def test_neq(a, b):
    assert a != b


_EQ_TESTS_B_COPY = [
    BaseCFTimeOffset(n=2),
    YearBegin(n=2),
    YearEnd(n=2),
    YearBegin(n=2, month=2),
    YearEnd(n=2, month=2),
    QuarterBegin(n=2),
    QuarterEnd(n=2),
    QuarterBegin(n=2, month=2),
    QuarterEnd(n=2, month=2),
    MonthBegin(n=2),
    MonthEnd(n=2),
    Day(n=2),
    Hour(n=2),
    Minute(n=2),
    Second(n=2),
    Millisecond(n=2),
    Microsecond(n=2),
]


@pytest.mark.parametrize(
    ("a", "b"), zip(_EQ_TESTS_B, _EQ_TESTS_B_COPY, strict=True), ids=_id_func
)
def test_eq(a, b):
    assert a == b


_MUL_TESTS = [
    (BaseCFTimeOffset(), 3, BaseCFTimeOffset(n=3)),
    (BaseCFTimeOffset(), -3, BaseCFTimeOffset(n=-3)),
    (YearEnd(), 3, YearEnd(n=3)),
    (YearBegin(), 3, YearBegin(n=3)),
    (QuarterEnd(), 3, QuarterEnd(n=3)),
    (QuarterBegin(), 3, QuarterBegin(n=3)),
    (MonthEnd(), 3, MonthEnd(n=3)),
    (MonthBegin(), 3, MonthBegin(n=3)),
    (Tick(), 3, Tick(n=3)),
    (Day(), 3, Day(n=3)),
    (Hour(), 3, Hour(n=3)),
    (Minute(), 3, Minute(n=3)),
    (Second(), 3, Second(n=3)),
    (Millisecond(), 3, Millisecond(n=3)),
    (Microsecond(), 3, Microsecond(n=3)),
    (Day(), 0.5, Hour(n=12)),
    (Hour(), 0.5, Minute(n=30)),
    (Hour(), -0.5, Minute(n=-30)),
    (Minute(), 0.5, Second(n=30)),
    (Second(), 0.5, Millisecond(n=500)),
    (Millisecond(), 0.5, Microsecond(n=500)),
]


@pytest.mark.parametrize(("offset", "multiple", "expected"), _MUL_TESTS, ids=_id_func)
def test_mul(offset, multiple, expected):
    assert offset * multiple == expected


@pytest.mark.parametrize(("offset", "multiple", "expected"), _MUL_TESTS, ids=_id_func)
def test_rmul(offset, multiple, expected):
    assert multiple * offset == expected


def test_mul_float_multiple_next_higher_resolution():
    """Test more than one iteration through _next_higher_resolution is required."""
    assert 1e-6 * Second() == Microsecond()
    assert 1e-6 / 60 * Minute() == Microsecond()


@pytest.mark.parametrize(
    "offset",
    [YearBegin(), YearEnd(), QuarterBegin(), QuarterEnd(), MonthBegin(), MonthEnd()],
    ids=_id_func,
)
def test_nonTick_offset_multiplied_float_error(offset):
    """Test that the appropriate error is raised if a non-Tick offset is
    multiplied by a float."""
    with pytest.raises(TypeError, match="unsupported operand type"):
        offset * 0.5


def test_Microsecond_multiplied_float_error():
    """Test that the appropriate error is raised if a Tick offset is multiplied
    by a float which causes it not to be representable by a
    microsecond-precision timedelta."""
    with pytest.raises(
        ValueError, match="Could not convert to integer offset at any resolution"
    ):
        Microsecond() * 0.5


@pytest.mark.parametrize(
    ("offset", "expected"),
    [
        (BaseCFTimeOffset(), BaseCFTimeOffset(n=-1)),
        (YearEnd(), YearEnd(n=-1)),
        (YearBegin(), YearBegin(n=-1)),
        (QuarterEnd(), QuarterEnd(n=-1)),
        (QuarterBegin(), QuarterBegin(n=-1)),
        (MonthEnd(), MonthEnd(n=-1)),
        (MonthBegin(), MonthBegin(n=-1)),
        (Day(), Day(n=-1)),
        (Hour(), Hour(n=-1)),
        (Minute(), Minute(n=-1)),
        (Second(), Second(n=-1)),
        (Millisecond(), Millisecond(n=-1)),
        (Microsecond(), Microsecond(n=-1)),
    ],
    ids=_id_func,
)
def test_neg(offset: BaseCFTimeOffset, expected: BaseCFTimeOffset) -> None:
    assert -offset == expected


_ADD_TESTS = [
    (Day(n=2), (1, 1, 3)),
    (Hour(n=2), (1, 1, 1, 2)),
    (Minute(n=2), (1, 1, 1, 0, 2)),
    (Second(n=2), (1, 1, 1, 0, 0, 2)),
    (Millisecond(n=2), (1, 1, 1, 0, 0, 0, 2000)),
    (Microsecond(n=2), (1, 1, 1, 0, 0, 0, 2)),
]


@pytest.mark.parametrize(("offset", "expected_date_args"), _ADD_TESTS, ids=_id_func)
def test_add_sub_monthly(offset, expected_date_args, calendar):
    date_type = get_date_type(calendar)
    initial = date_type(1, 1, 1)
    expected = date_type(*expected_date_args)
    result = offset + initial
    assert result == expected


@pytest.mark.parametrize(("offset", "expected_date_args"), _ADD_TESTS, ids=_id_func)
def test_radd_sub_monthly(offset, expected_date_args, calendar):
    date_type = get_date_type(calendar)
    initial = date_type(1, 1, 1)
    expected = date_type(*expected_date_args)
    result = initial + offset
    assert result == expected


@pytest.mark.parametrize(
    ("offset", "expected_date_args"),
    [
        (Day(n=2), (1, 1, 1)),
        (Hour(n=2), (1, 1, 2, 22)),
        (Minute(n=2), (1, 1, 2, 23, 58)),
        (Second(n=2), (1, 1, 2, 23, 59, 58)),
        (Millisecond(n=2), (1, 1, 2, 23, 59, 59, 998000)),
        (Microsecond(n=2), (1, 1, 2, 23, 59, 59, 999998)),
    ],
    ids=_id_func,
)
def test_rsub_sub_monthly(offset, expected_date_args, calendar):
    date_type = get_date_type(calendar)
    initial = date_type(1, 1, 3)
    expected = date_type(*expected_date_args)
    result = initial - offset
    assert result == expected


@pytest.mark.parametrize("offset", _EQ_TESTS_A, ids=_id_func)
def test_sub_error(offset, calendar):
    date_type = get_date_type(calendar)
    initial = date_type(1, 1, 1)
    with pytest.raises(TypeError):
        offset - initial


@pytest.mark.parametrize(
    ("a", "b"), zip(_EQ_TESTS_A, _EQ_TESTS_B, strict=True), ids=_id_func
)
def test_minus_offset(a, b):
    result = b - a
    expected = a
    assert result == expected


@pytest.mark.parametrize(
    ("a", "b"),
    list(zip(np.roll(_EQ_TESTS_A, 1), _EQ_TESTS_B, strict=True))  # type: ignore[arg-type]
    + [(YearEnd(month=1), YearEnd(month=2))],
    ids=_id_func,
)
def test_minus_offset_error(a, b):
    with pytest.raises(TypeError):
        b - a


@pytest.mark.parametrize(
    ("initial_date_args", "offset", "expected_date_args"),
    [
        ((1, 1, 1), MonthBegin(), (1, 2, 1)),
        ((1, 1, 1), MonthBegin(n=2), (1, 3, 1)),
        ((1, 1, 7), MonthBegin(), (1, 2, 1)),
        ((1, 1, 7), MonthBegin(n=2), (1, 3, 1)),
        ((1, 3, 1), MonthBegin(n=-1), (1, 2, 1)),
        ((1, 3, 1), MonthBegin(n=-2), (1, 1, 1)),
        ((1, 3, 3), MonthBegin(n=-1), (1, 3, 1)),
        ((1, 3, 3), MonthBegin(n=-2), (1, 2, 1)),
        ((1, 2, 1), MonthBegin(n=14), (2, 4, 1)),
        ((2, 4, 1), MonthBegin(n=-14), (1, 2, 1)),
        ((1, 1, 1, 5, 5, 5, 5), MonthBegin(), (1, 2, 1, 5, 5, 5, 5)),
        ((1, 1, 3, 5, 5, 5, 5), MonthBegin(), (1, 2, 1, 5, 5, 5, 5)),
        ((1, 1, 3, 5, 5, 5, 5), MonthBegin(n=-1), (1, 1, 1, 5, 5, 5, 5)),
    ],
    ids=_id_func,
)
def test_add_month_begin(calendar, initial_date_args, offset, expected_date_args):
    date_type = get_date_type(calendar)
    initial = date_type(*initial_date_args)
    result = initial + offset
    expected = date_type(*expected_date_args)
    assert result == expected


@pytest.mark.parametrize(
    ("initial_date_args", "offset", "expected_year_month", "expected_sub_day"),
    [
        ((1, 1, 1), MonthEnd(), (1, 1), ()),
        ((1, 1, 1), MonthEnd(n=2), (1, 2), ()),
        ((1, 3, 1), MonthEnd(n=-1), (1, 2), ()),
        ((1, 3, 1), MonthEnd(n=-2), (1, 1), ()),
        ((1, 2, 1), MonthEnd(n=14), (2, 3), ()),
        ((2, 4, 1), MonthEnd(n=-14), (1, 2), ()),
        ((1, 1, 1, 5, 5, 5, 5), MonthEnd(), (1, 1), (5, 5, 5, 5)),
        ((1, 2, 1, 5, 5, 5, 5), MonthEnd(n=-1), (1, 1), (5, 5, 5, 5)),
    ],
    ids=_id_func,
)
def test_add_month_end(
    calendar, initial_date_args, offset, expected_year_month, expected_sub_day
):
    date_type = get_date_type(calendar)
    initial = date_type(*initial_date_args)
    result = initial + offset
    reference_args = expected_year_month + (1,)
    reference = date_type(*reference_args)

    # Here the days at the end of each month varies based on the calendar used
    expected_date_args = (
        expected_year_month + (reference.daysinmonth,) + expected_sub_day
    )
    expected = date_type(*expected_date_args)
    assert result == expected


@pytest.mark.parametrize(
    (
        "initial_year_month",
        "initial_sub_day",
        "offset",
        "expected_year_month",
        "expected_sub_day",
    ),
    [
        ((1, 1), (), MonthEnd(), (1, 2), ()),
        ((1, 1), (), MonthEnd(n=2), (1, 3), ()),
        ((1, 3), (), MonthEnd(n=-1), (1, 2), ()),
        ((1, 3), (), MonthEnd(n=-2), (1, 1), ()),
        ((1, 2), (), MonthEnd(n=14), (2, 4), ()),
        ((2, 4), (), MonthEnd(n=-14), (1, 2), ()),
        ((1, 1), (5, 5, 5, 5), MonthEnd(), (1, 2), (5, 5, 5, 5)),
        ((1, 2), (5, 5, 5, 5), MonthEnd(n=-1), (1, 1), (5, 5, 5, 5)),
    ],
    ids=_id_func,
)
def test_add_month_end_onOffset(
    calendar,
    initial_year_month,
    initial_sub_day,
    offset,
    expected_year_month,
    expected_sub_day,
):
    date_type = get_date_type(calendar)
    reference_args = initial_year_month + (1,)
    reference = date_type(*reference_args)
    initial_date_args = initial_year_month + (reference.daysinmonth,) + initial_sub_day
    initial = date_type(*initial_date_args)
    result = initial + offset
    reference_args = expected_year_month + (1,)
    reference = date_type(*reference_args)

    # Here the days at the end of each month varies based on the calendar used
    expected_date_args = (
        expected_year_month + (reference.daysinmonth,) + expected_sub_day
    )
    expected = date_type(*expected_date_args)
    assert result == expected


@pytest.mark.parametrize(
    ("initial_date_args", "offset", "expected_date_args"),
    [
        ((1, 1, 1), YearBegin(), (2, 1, 1)),
        ((1, 1, 1), YearBegin(n=2), (3, 1, 1)),
        ((1, 1, 1), YearBegin(month=2), (1, 2, 1)),
        ((1, 1, 7), YearBegin(n=2), (3, 1, 1)),
        ((2, 2, 1), YearBegin(n=-1), (2, 1, 1)),
        ((1, 1, 2), YearBegin(n=-1), (1, 1, 1)),
        ((1, 1, 1, 5, 5, 5, 5), YearBegin(), (2, 1, 1, 5, 5, 5, 5)),
        ((2, 1, 1, 5, 5, 5, 5), YearBegin(n=-1), (1, 1, 1, 5, 5, 5, 5)),
    ],
    ids=_id_func,
)
def test_add_year_begin(calendar, initial_date_args, offset, expected_date_args):
    date_type = get_date_type(calendar)
    initial = date_type(*initial_date_args)
    result = initial + offset
    expected = date_type(*expected_date_args)
    assert result == expected


@pytest.mark.parametrize(
    ("initial_date_args", "offset", "expected_year_month", "expected_sub_day"),
    [
        ((1, 1, 1), YearEnd(), (1, 12), ()),
        ((1, 1, 1), YearEnd(n=2), (2, 12), ()),
        ((1, 1, 1), YearEnd(month=1), (1, 1), ()),
        ((2, 3, 1), YearEnd(n=-1), (1, 12), ()),
        ((1, 3, 1), YearEnd(n=-1, month=2), (1, 2), ()),
        ((1, 1, 1, 5, 5, 5, 5), YearEnd(), (1, 12), (5, 5, 5, 5)),
        ((1, 1, 1, 5, 5, 5, 5), YearEnd(n=2), (2, 12), (5, 5, 5, 5)),
    ],
    ids=_id_func,
)
def test_add_year_end(
    calendar, initial_date_args, offset, expected_year_month, expected_sub_day
):
    date_type = get_date_type(calendar)
    initial = date_type(*initial_date_args)
    result = initial + offset
    reference_args = expected_year_month + (1,)
    reference = date_type(*reference_args)

    # Here the days at the end of each month varies based on the calendar used
    expected_date_args = (
        expected_year_month + (reference.daysinmonth,) + expected_sub_day
    )
    expected = date_type(*expected_date_args)
    assert result == expected


@pytest.mark.parametrize(
    (
        "initial_year_month",
        "initial_sub_day",
        "offset",
        "expected_year_month",
        "expected_sub_day",
    ),
    [
        ((1, 12), (), YearEnd(), (2, 12), ()),
        ((1, 12), (), YearEnd(n=2), (3, 12), ()),
        ((2, 12), (), YearEnd(n=-1), (1, 12), ()),
        ((3, 12), (), YearEnd(n=-2), (1, 12), ()),
        ((1, 1), (), YearEnd(month=2), (1, 2), ()),
        ((1, 12), (5, 5, 5, 5), YearEnd(), (2, 12), (5, 5, 5, 5)),
        ((2, 12), (5, 5, 5, 5), YearEnd(n=-1), (1, 12), (5, 5, 5, 5)),
    ],
    ids=_id_func,
)
def test_add_year_end_onOffset(
    calendar,
    initial_year_month,
    initial_sub_day,
    offset,
    expected_year_month,
    expected_sub_day,
):
    date_type = get_date_type(calendar)
    reference_args = initial_year_month + (1,)
    reference = date_type(*reference_args)
    initial_date_args = initial_year_month + (reference.daysinmonth,) + initial_sub_day
    initial = date_type(*initial_date_args)
    result = initial + offset
    reference_args = expected_year_month + (1,)
    reference = date_type(*reference_args)

    # Here the days at the end of each month varies based on the calendar used
    expected_date_args = (
        expected_year_month + (reference.daysinmonth,) + expected_sub_day
    )
    expected = date_type(*expected_date_args)
    assert result == expected


@pytest.mark.parametrize(
    ("initial_date_args", "offset", "expected_date_args"),
    [
        ((1, 1, 1), QuarterBegin(), (1, 3, 1)),
        ((1, 1, 1), QuarterBegin(n=2), (1, 6, 1)),
        ((1, 1, 1), QuarterBegin(month=2), (1, 2, 1)),
        ((1, 1, 7), QuarterBegin(n=2), (1, 6, 1)),
        ((2, 2, 1), QuarterBegin(n=-1), (1, 12, 1)),
        ((1, 3, 2), QuarterBegin(n=-1), (1, 3, 1)),
        ((1, 1, 1, 5, 5, 5, 5), QuarterBegin(), (1, 3, 1, 5, 5, 5, 5)),
        ((2, 1, 1, 5, 5, 5, 5), QuarterBegin(n=-1), (1, 12, 1, 5, 5, 5, 5)),
    ],
    ids=_id_func,
)
def test_add_quarter_begin(calendar, initial_date_args, offset, expected_date_args):
    date_type = get_date_type(calendar)
    initial = date_type(*initial_date_args)
    result = initial + offset
    expected = date_type(*expected_date_args)
    assert result == expected


@pytest.mark.parametrize(
    ("initial_date_args", "offset", "expected_year_month", "expected_sub_day"),
    [
        ((1, 1, 1), QuarterEnd(), (1, 3), ()),
        ((1, 1, 1), QuarterEnd(n=2), (1, 6), ()),
        ((1, 1, 1), QuarterEnd(month=1), (1, 1), ()),
        ((2, 3, 1), QuarterEnd(n=-1), (1, 12), ()),
        ((1, 3, 1), QuarterEnd(n=-1, month=2), (1, 2), ()),
        ((1, 1, 1, 5, 5, 5, 5), QuarterEnd(), (1, 3), (5, 5, 5, 5)),
        ((1, 1, 1, 5, 5, 5, 5), QuarterEnd(n=2), (1, 6), (5, 5, 5, 5)),
    ],
    ids=_id_func,
)
def test_add_quarter_end(
    calendar, initial_date_args, offset, expected_year_month, expected_sub_day
):
    date_type = get_date_type(calendar)
    initial = date_type(*initial_date_args)
    result = initial + offset
    reference_args = expected_year_month + (1,)
    reference = date_type(*reference_args)

    # Here the days at the end of each month varies based on the calendar used
    expected_date_args = (
        expected_year_month + (reference.daysinmonth,) + expected_sub_day
    )
    expected = date_type(*expected_date_args)
    assert result == expected


@pytest.mark.parametrize(
    (
        "initial_year_month",
        "initial_sub_day",
        "offset",
        "expected_year_month",
        "expected_sub_day",
    ),
    [
        ((1, 12), (), QuarterEnd(), (2, 3), ()),
        ((1, 12), (), QuarterEnd(n=2), (2, 6), ()),
        ((1, 12), (), QuarterEnd(n=-1), (1, 9), ()),
        ((1, 12), (), QuarterEnd(n=-2), (1, 6), ()),
        ((1, 1), (), QuarterEnd(month=2), (1, 2), ()),
        ((1, 12), (5, 5, 5, 5), QuarterEnd(), (2, 3), (5, 5, 5, 5)),
        ((1, 12), (5, 5, 5, 5), QuarterEnd(n=-1), (1, 9), (5, 5, 5, 5)),
    ],
    ids=_id_func,
)
def test_add_quarter_end_onOffset(
    calendar,
    initial_year_month,
    initial_sub_day,
    offset,
    expected_year_month,
    expected_sub_day,
):
    date_type = get_date_type(calendar)
    reference_args = initial_year_month + (1,)
    reference = date_type(*reference_args)
    initial_date_args = initial_year_month + (reference.daysinmonth,) + initial_sub_day
    initial = date_type(*initial_date_args)
    result = initial + offset
    reference_args = expected_year_month + (1,)
    reference = date_type(*reference_args)

    # Here the days at the end of each month varies based on the calendar used
    expected_date_args = (
        expected_year_month + (reference.daysinmonth,) + expected_sub_day
    )
    expected = date_type(*expected_date_args)
    assert result == expected


# Note for all sub-monthly offsets, pandas always returns True for onOffset
@pytest.mark.parametrize(
    ("date_args", "offset", "expected"),
    [
        ((1, 1, 1), MonthBegin(), True),
        ((1, 1, 1, 1), MonthBegin(), True),
        ((1, 1, 5), MonthBegin(), False),
        ((1, 1, 5), MonthEnd(), False),
        ((1, 3, 1), QuarterBegin(), True),
        ((1, 3, 1, 1), QuarterBegin(), True),
        ((1, 3, 5), QuarterBegin(), False),
        ((1, 12, 1), QuarterEnd(), False),
        ((1, 1, 1), YearBegin(), True),
        ((1, 1, 1, 1), YearBegin(), True),
        ((1, 1, 5), YearBegin(), False),
        ((1, 12, 1), YearEnd(), False),
        ((1, 1, 1), Day(), True),
        ((1, 1, 1, 1), Day(), True),
        ((1, 1, 1), Hour(), True),
        ((1, 1, 1), Minute(), True),
        ((1, 1, 1), Second(), True),
        ((1, 1, 1), Millisecond(), True),
        ((1, 1, 1), Microsecond(), True),
    ],
    ids=_id_func,
)
def test_onOffset(calendar, date_args, offset, expected):
    date_type = get_date_type(calendar)
    date = date_type(*date_args)
    result = offset.onOffset(date)
    assert result == expected


@pytest.mark.parametrize(
    ("year_month_args", "sub_day_args", "offset"),
    [
        ((1, 1), (), MonthEnd()),
        ((1, 1), (1,), MonthEnd()),
        ((1, 12), (), QuarterEnd()),
        ((1, 1), (), QuarterEnd(month=1)),
        ((1, 12), (), YearEnd()),
        ((1, 1), (), YearEnd(month=1)),
    ],
    ids=_id_func,
)
def test_onOffset_month_or_quarter_or_year_end(
    calendar, year_month_args, sub_day_args, offset
):
    date_type = get_date_type(calendar)
    reference_args = year_month_args + (1,)
    reference = date_type(*reference_args)
    date_args = year_month_args + (reference.daysinmonth,) + sub_day_args
    date = date_type(*date_args)
    result = offset.onOffset(date)
    assert result


@pytest.mark.parametrize(
    ("offset", "initial_date_args", "partial_expected_date_args"),
    [
        (YearBegin(), (1, 3, 1), (2, 1)),
        (YearBegin(), (1, 1, 1), (1, 1)),
        (YearBegin(n=2), (1, 3, 1), (2, 1)),
        (YearBegin(n=2, month=2), (1, 3, 1), (2, 2)),
        (YearEnd(), (1, 3, 1), (1, 12)),
        (YearEnd(n=2), (1, 3, 1), (1, 12)),
        (YearEnd(n=2, month=2), (1, 3, 1), (2, 2)),
        (YearEnd(n=2, month=4), (1, 4, 30), (1, 4)),
        (QuarterBegin(), (1, 3, 2), (1, 6)),
        (QuarterBegin(), (1, 4, 1), (1, 6)),
        (QuarterBegin(n=2), (1, 4, 1), (1, 6)),
        (QuarterBegin(n=2, month=2), (1, 4, 1), (1, 5)),
        (QuarterEnd(), (1, 3, 1), (1, 3)),
        (QuarterEnd(n=2), (1, 3, 1), (1, 3)),
        (QuarterEnd(n=2, month=2), (1, 3, 1), (1, 5)),
        (QuarterEnd(n=2, month=4), (1, 4, 30), (1, 4)),
        (MonthBegin(), (1, 3, 2), (1, 4)),
        (MonthBegin(), (1, 3, 1), (1, 3)),
        (MonthBegin(n=2), (1, 3, 2), (1, 4)),
        (MonthEnd(), (1, 3, 2), (1, 3)),
        (MonthEnd(), (1, 4, 30), (1, 4)),
        (MonthEnd(n=2), (1, 3, 2), (1, 3)),
        (Day(), (1, 3, 2, 1), (1, 3, 2, 1)),
        (Hour(), (1, 3, 2, 1, 1), (1, 3, 2, 1, 1)),
        (Minute(), (1, 3, 2, 1, 1, 1), (1, 3, 2, 1, 1, 1)),
        (Second(), (1, 3, 2, 1, 1, 1, 1), (1, 3, 2, 1, 1, 1, 1)),
        (Millisecond(), (1, 3, 2, 1, 1, 1, 1000), (1, 3, 2, 1, 1, 1, 1000)),
        (Microsecond(), (1, 3, 2, 1, 1, 1, 1), (1, 3, 2, 1, 1, 1, 1)),
    ],
    ids=_id_func,
)
def test_rollforward(calendar, offset, initial_date_args, partial_expected_date_args):
    date_type = get_date_type(calendar)
    initial = date_type(*initial_date_args)
    if isinstance(offset, MonthBegin | QuarterBegin | YearBegin):
        expected_date_args = partial_expected_date_args + (1,)
    elif isinstance(offset, MonthEnd | QuarterEnd | YearEnd):
        reference_args = partial_expected_date_args + (1,)
        reference = date_type(*reference_args)
        expected_date_args = partial_expected_date_args + (reference.daysinmonth,)
    else:
        expected_date_args = partial_expected_date_args
    expected = date_type(*expected_date_args)
    result = offset.rollforward(initial)
    assert result == expected


@pytest.mark.parametrize(
    ("offset", "initial_date_args", "partial_expected_date_args"),
    [
        (YearBegin(), (1, 3, 1), (1, 1)),
        (YearBegin(n=2), (1, 3, 1), (1, 1)),
        (YearBegin(n=2, month=2), (1, 3, 1), (1, 2)),
        (YearBegin(), (1, 1, 1), (1, 1)),
        (YearBegin(n=2, month=2), (1, 2, 1), (1, 2)),
        (YearEnd(), (2, 3, 1), (1, 12)),
        (YearEnd(n=2), (2, 3, 1), (1, 12)),
        (YearEnd(n=2, month=2), (2, 3, 1), (2, 2)),
        (YearEnd(month=4), (1, 4, 30), (1, 4)),
        (QuarterBegin(), (1, 3, 2), (1, 3)),
        (QuarterBegin(), (1, 4, 1), (1, 3)),
        (QuarterBegin(n=2), (1, 4, 1), (1, 3)),
        (QuarterBegin(n=2, month=2), (1, 4, 1), (1, 2)),
        (QuarterEnd(), (2, 3, 1), (1, 12)),
        (QuarterEnd(n=2), (2, 3, 1), (1, 12)),
        (QuarterEnd(n=2, month=2), (2, 3, 1), (2, 2)),
        (QuarterEnd(n=2, month=4), (1, 4, 30), (1, 4)),
        (MonthBegin(), (1, 3, 2), (1, 3)),
        (MonthBegin(n=2), (1, 3, 2), (1, 3)),
        (MonthBegin(), (1, 3, 1), (1, 3)),
        (MonthEnd(), (1, 3, 2), (1, 2)),
        (MonthEnd(n=2), (1, 3, 2), (1, 2)),
        (MonthEnd(), (1, 4, 30), (1, 4)),
        (Day(), (1, 3, 2, 1), (1, 3, 2, 1)),
        (Hour(), (1, 3, 2, 1, 1), (1, 3, 2, 1, 1)),
        (Minute(), (1, 3, 2, 1, 1, 1), (1, 3, 2, 1, 1, 1)),
        (Second(), (1, 3, 2, 1, 1, 1, 1), (1, 3, 2, 1, 1, 1, 1)),
        (Millisecond(), (1, 3, 2, 1, 1, 1, 1000), (1, 3, 2, 1, 1, 1, 1000)),
        (Microsecond(), (1, 3, 2, 1, 1, 1, 1), (1, 3, 2, 1, 1, 1, 1)),
    ],
    ids=_id_func,
)
def test_rollback(calendar, offset, initial_date_args, partial_expected_date_args):
    date_type = get_date_type(calendar)
    initial = date_type(*initial_date_args)
    if isinstance(offset, MonthBegin | QuarterBegin | YearBegin):
        expected_date_args = partial_expected_date_args + (1,)
    elif isinstance(offset, MonthEnd | QuarterEnd | YearEnd):
        reference_args = partial_expected_date_args + (1,)
        reference = date_type(*reference_args)
        expected_date_args = partial_expected_date_args + (reference.daysinmonth,)
    else:
        expected_date_args = partial_expected_date_args
    expected = date_type(*expected_date_args)
    result = offset.rollback(initial)
    assert result == expected


_CFTIME_RANGE_TESTS = [
    (
        "0001-01-01",
        "0001-01-04",
        None,
        "D",
        "neither",
        False,
        [(1, 1, 2), (1, 1, 3)],
    ),
    (
        "0001-01-01",
        "0001-01-04",
        None,
        "D",
        "both",
        False,
        [(1, 1, 1), (1, 1, 2), (1, 1, 3), (1, 1, 4)],
    ),
    (
        "0001-01-01",
        "0001-01-04",
        None,
        "D",
        "left",
        False,
        [(1, 1, 1), (1, 1, 2), (1, 1, 3)],
    ),
    (
        "0001-01-01",
        "0001-01-04",
        None,
        "D",
        "right",
        False,
        [(1, 1, 2), (1, 1, 3), (1, 1, 4)],
    ),
    (
        "0001-01-01T01:00:00",
        "0001-01-04",
        None,
        "D",
        "both",
        False,
        [(1, 1, 1, 1), (1, 1, 2, 1), (1, 1, 3, 1)],
    ),
    (
        "0001-01-01 01:00:00",
        "0001-01-04",
        None,
        "D",
        "both",
        False,
        [(1, 1, 1, 1), (1, 1, 2, 1), (1, 1, 3, 1)],
    ),
    (
        "0001-01-01T01:00:00",
        "0001-01-04",
        None,
        "D",
        "both",
        True,
        [(1, 1, 1), (1, 1, 2), (1, 1, 3), (1, 1, 4)],
    ),
    (
        "0001-01-01",
        None,
        4,
        "D",
        "both",
        False,
        [(1, 1, 1), (1, 1, 2), (1, 1, 3), (1, 1, 4)],
    ),
    (
        None,
        "0001-01-04",
        4,
        "D",
        "both",
        False,
        [(1, 1, 1), (1, 1, 2), (1, 1, 3), (1, 1, 4)],
    ),
    (
        (1, 1, 1),
        "0001-01-04",
        None,
        "D",
        "both",
        False,
        [(1, 1, 1), (1, 1, 2), (1, 1, 3), (1, 1, 4)],
    ),
    (
        (1, 1, 1),
        (1, 1, 4),
        None,
        "D",
        "both",
        False,
        [(1, 1, 1), (1, 1, 2), (1, 1, 3), (1, 1, 4)],
    ),
    (
        "0001-01-30",
        "0011-02-01",
        None,
        "3YS-JUN",
        "both",
        False,
        [(1, 6, 1), (4, 6, 1), (7, 6, 1), (10, 6, 1)],
    ),
    ("0001-01-04", "0001-01-01", None, "D", "both", False, []),
    (
        "0010",
        None,
        4,
        YearBegin(n=-2),
        "both",
        False,
        [(10, 1, 1), (8, 1, 1), (6, 1, 1), (4, 1, 1)],
    ),
    (
        "0010",
        None,
        4,
        "-2YS",
        "both",
        False,
        [(10, 1, 1), (8, 1, 1), (6, 1, 1), (4, 1, 1)],
    ),
    (
        "0001-01-01",
        "0001-01-04",
        4,
        None,
        "both",
        False,
        [(1, 1, 1), (1, 1, 2), (1, 1, 3), (1, 1, 4)],
    ),
    (
        "0001-06-01",
        None,
        4,
        "3QS-JUN",
        "both",
        False,
        [(1, 6, 1), (2, 3, 1), (2, 12, 1), (3, 9, 1)],
    ),
    (
        "0001-06-01",
        None,
        4,
        "-1MS",
        "both",
        False,
        [(1, 6, 1), (1, 5, 1), (1, 4, 1), (1, 3, 1)],
    ),
    (
        "0001-01-30",
        None,
        4,
        "-1D",
        "both",
        False,
        [(1, 1, 30), (1, 1, 29), (1, 1, 28), (1, 1, 27)],
    ),
]


@pytest.mark.parametrize(
    ("start", "end", "periods", "freq", "inclusive", "normalize", "expected_date_args"),
    _CFTIME_RANGE_TESTS,
    ids=_id_func,
)
def test_cftime_range(
    start, end, periods, freq, inclusive, normalize, calendar, expected_date_args
):
    date_type = get_date_type(calendar)
    expected_dates = [date_type(*args) for args in expected_date_args]

    if isinstance(start, tuple):
        start = date_type(*start)
    if isinstance(end, tuple):
        end = date_type(*end)

    result = cftime_range(
        start=start,
        end=end,
        periods=periods,
        freq=freq,
        inclusive=inclusive,
        normalize=normalize,
        calendar=calendar,
    )
    resulting_dates = result.values

    assert isinstance(result, CFTimeIndex)

    if freq is not None:
        np.testing.assert_equal(resulting_dates, expected_dates)
    else:
        # If we create a linear range of dates using cftime.num2date
        # we will not get exact round number dates.  This is because
        # datetime arithmetic in cftime is accurate approximately to
        # 1 millisecond (see https://unidata.github.io/cftime/api.html).
        deltas = resulting_dates - expected_dates
        deltas = np.array([delta.total_seconds() for delta in deltas])
        assert np.max(np.abs(deltas)) < 0.001


def test_cftime_range_name():
    result = cftime_range(start="2000", periods=4, name="foo")
    assert result.name == "foo"

    result = cftime_range(start="2000", periods=4)
    assert result.name is None


@pytest.mark.parametrize(
    ("start", "end", "periods", "freq", "inclusive"),
    [
        (None, None, 5, "YE", None),
        ("2000", None, None, "YE", None),
        (None, "2000", None, "YE", None),
        (None, None, None, None, None),
        ("2000", "2001", None, "YE", "up"),
        ("2000", "2001", 5, "YE", None),
    ],
)
def test_invalid_cftime_range_inputs(
    start: str | None,
    end: str | None,
    periods: int | None,
    freq: str | None,
    inclusive: Literal["up", None],
) -> None:
    with pytest.raises(ValueError):
        cftime_range(start, end, periods, freq, inclusive=inclusive)  # type: ignore[arg-type]


_CALENDAR_SPECIFIC_MONTH_END_TESTS = [
    ("noleap", [(2, 28), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
    ("all_leap", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
    ("360_day", [(2, 30), (4, 30), (6, 30), (8, 30), (10, 30), (12, 30)]),
    ("standard", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
    ("gregorian", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
    ("julian", [(2, 29), (4, 30), (6, 30), (8, 31), (10, 31), (12, 31)]),
]


@pytest.mark.parametrize(
    ("calendar", "expected_month_day"),
    _CALENDAR_SPECIFIC_MONTH_END_TESTS,
    ids=_id_func,
)
def test_calendar_specific_month_end(
    calendar: str, expected_month_day: list[tuple[int, int]]
) -> None:
    year = 2000  # Use a leap-year to highlight calendar differences
    result = cftime_range(
        start="2000-02", end="2001", freq="2ME", calendar=calendar
    ).values
    date_type = get_date_type(calendar)
    expected = [date_type(year, *args) for args in expected_month_day]
    np.testing.assert_equal(result, expected)


@pytest.mark.parametrize(
    ("calendar", "expected_month_day"),
    _CALENDAR_SPECIFIC_MONTH_END_TESTS,
    ids=_id_func,
)
def test_calendar_specific_month_end_negative_freq(
    calendar: str, expected_month_day: list[tuple[int, int]]
) -> None:
    year = 2000  # Use a leap-year to highlight calendar differences
    result = cftime_range(
        start="2001",
        end="2000",
        freq="-2ME",
        calendar=calendar,
    ).values
    date_type = get_date_type(calendar)
    expected = [date_type(year, *args) for args in expected_month_day[::-1]]
    np.testing.assert_equal(result, expected)


@pytest.mark.parametrize(
    ("calendar", "start", "end", "expected_number_of_days"),
    [
        ("noleap", "2000", "2001", 365),
        ("all_leap", "2000", "2001", 366),
        ("360_day", "2000", "2001", 360),
        ("standard", "2000", "2001", 366),
        ("gregorian", "2000", "2001", 366),
        ("julian", "2000", "2001", 366),
        ("noleap", "2001", "2002", 365),
        ("all_leap", "2001", "2002", 366),
        ("360_day", "2001", "2002", 360),
        ("standard", "2001", "2002", 365),
        ("gregorian", "2001", "2002", 365),
        ("julian", "2001", "2002", 365),
    ],
)
def test_calendar_year_length(
    calendar: str, start: str, end: str, expected_number_of_days: int
) -> None:
    result = cftime_range(start, end, freq="D", inclusive="left", calendar=calendar)
    assert len(result) == expected_number_of_days


@pytest.mark.parametrize("freq", ["YE", "ME", "D"])
def test_dayofweek_after_cftime_range(freq: str) -> None:
    result = cftime_range("2000-02-01", periods=3, freq=freq).dayofweek
    # TODO: remove once requiring pandas 2.2+
    freq = _new_to_legacy_freq(freq)
    expected = pd.date_range("2000-02-01", periods=3, freq=freq).dayofweek
    np.testing.assert_array_equal(result, expected)


@pytest.mark.parametrize("freq", ["YE", "ME", "D"])
def test_dayofyear_after_cftime_range(freq: str) -> None:
    result = cftime_range("2000-02-01", periods=3, freq=freq).dayofyear
    # TODO: remove once requiring pandas 2.2+
    freq = _new_to_legacy_freq(freq)
    expected = pd.date_range("2000-02-01", periods=3, freq=freq).dayofyear
    np.testing.assert_array_equal(result, expected)


def test_cftime_range_standard_calendar_refers_to_gregorian() -> None:
    from cftime import DatetimeGregorian

    (result,) = cftime_range("2000", periods=1)
    assert isinstance(result, DatetimeGregorian)


@pytest.mark.parametrize(
    "start,calendar,use_cftime,expected_type",
    [
        ("1990-01-01", "standard", None, pd.DatetimeIndex),
        ("1990-01-01", "proleptic_gregorian", True, CFTimeIndex),
        ("1990-01-01", "noleap", None, CFTimeIndex),
        ("1990-01-01", "gregorian", False, pd.DatetimeIndex),
        ("1400-01-01", "standard", None, CFTimeIndex),
        ("3400-01-01", "standard", None, CFTimeIndex),
    ],
)
def test_date_range(
    start: str, calendar: str, use_cftime: bool | None, expected_type
) -> None:
    dr = date_range(
        start, periods=14, freq="D", calendar=calendar, use_cftime=use_cftime
    )

    assert isinstance(dr, expected_type)


def test_date_range_errors() -> None:
    with pytest.raises(ValueError, match="Date range is invalid"):
        date_range(
            "1400-01-01", periods=1, freq="D", calendar="standard", use_cftime=False
        )

    with pytest.raises(ValueError, match="Date range is invalid"):
        date_range(
            "2480-01-01",
            periods=1,
            freq="D",
            calendar="proleptic_gregorian",
            use_cftime=False,
        )

    with pytest.raises(ValueError, match="Invalid calendar "):
        date_range(
            "1900-01-01", periods=1, freq="D", calendar="noleap", use_cftime=False
        )


@requires_cftime
@pytest.mark.parametrize(
    "start,freq,cal_src,cal_tgt,use_cftime,exp0,exp_pd",
    [
        ("2020-02-01", "4ME", "standard", "noleap", None, "2020-02-28", False),
        ("2020-02-01", "ME", "noleap", "gregorian", True, "2020-02-29", True),
        ("2020-02-01", "QE-DEC", "noleap", "gregorian", True, "2020-03-31", True),
        ("2020-02-01", "YS-FEB", "noleap", "gregorian", True, "2020-02-01", True),
        ("2020-02-01", "YE-FEB", "noleap", "gregorian", True, "2020-02-29", True),
        ("2020-02-01", "-1YE-FEB", "noleap", "gregorian", True, "2019-02-28", True),
        ("2020-02-28", "3h", "all_leap", "gregorian", False, "2020-02-28", True),
        ("2020-03-30", "ME", "360_day", "gregorian", False, "2020-03-31", True),
        ("2020-03-31", "ME", "gregorian", "360_day", None, "2020-03-30", False),
        ("2020-03-31", "-1ME", "gregorian", "360_day", None, "2020-03-30", False),
    ],
)
def test_date_range_like(start, freq, cal_src, cal_tgt, use_cftime, exp0, exp_pd):
    expected_freq = freq

    source = date_range(start, periods=12, freq=freq, calendar=cal_src)

    out = date_range_like(source, cal_tgt, use_cftime=use_cftime)

    assert len(out) == 12

    assert infer_freq(out) == expected_freq

    assert out[0].isoformat().startswith(exp0)

    if exp_pd:
        assert isinstance(out, pd.DatetimeIndex)
    else:
        assert isinstance(out, CFTimeIndex)
        assert out.calendar == cal_tgt


@requires_cftime
@pytest.mark.parametrize(
    "freq", ("YE", "YS", "YE-MAY", "MS", "ME", "QS", "h", "min", "s")
)
@pytest.mark.parametrize("use_cftime", (True, False))
def test_date_range_like_no_deprecation(freq, use_cftime):
    # ensure no internal warnings
    # TODO: remove once freq string deprecation is finished

    source = date_range("2000", periods=3, freq=freq, use_cftime=False)

    with assert_no_warnings():
        date_range_like(source, "standard", use_cftime=use_cftime)


def test_date_range_like_same_calendar():
    src = date_range("2000-01-01", periods=12, freq="6h", use_cftime=False)
    out = date_range_like(src, "standard", use_cftime=False)
    assert src is out


@pytest.mark.filterwarnings("ignore:Converting non-default")
def test_date_range_like_errors():
    src = date_range("1899-02-03", periods=20, freq="D", use_cftime=False)
    src = src[np.arange(20) != 10]  # Remove 1 day so the frequency is not inferable.

    with pytest.raises(
        ValueError,
        match="`date_range_like` was unable to generate a range as the source frequency was not inferable.",
    ):
        date_range_like(src, "gregorian")

    src = DataArray(
        np.array(
            [["1999-01-01", "1999-01-02"], ["1999-01-03", "1999-01-04"]],
            dtype=np.datetime64,
        ),
        dims=("x", "y"),
    )
    with pytest.raises(
        ValueError,
        match="'source' must be a 1D array of datetime objects for inferring its range.",
    ):
        date_range_like(src, "noleap")

    da = DataArray([1, 2, 3, 4], dims=("time",))
    with pytest.raises(
        ValueError,
        match="'source' must be a 1D array of datetime objects for inferring its range.",
    ):
        date_range_like(da, "noleap")


def as_timedelta_not_implemented_error():
    tick = Tick()
    with pytest.raises(NotImplementedError):
        tick.as_timedelta()


@pytest.mark.parametrize("function", [cftime_range, date_range])
def test_cftime_or_date_range_invalid_inclusive_value(function: Callable) -> None:
    if function == cftime_range and not has_cftime:
        pytest.skip("requires cftime")

    with pytest.raises(ValueError, match="nclusive"):
        function("2000", periods=3, inclusive="foo")


@pytest.mark.parametrize("function", [cftime_range, date_range])
def test_cftime_or_date_range_inclusive_None(function) -> None:
    if function == cftime_range and not has_cftime:
        pytest.skip("requires cftime")

    result_None = function("2000-01-01", "2000-01-04")
    result_both = function("2000-01-01", "2000-01-04", inclusive="both")
    np.testing.assert_equal(result_None.values, result_both.values)


@pytest.mark.parametrize(
    "freq", ["A", "AS", "Q", "M", "H", "T", "S", "L", "U", "Y", "A-MAY"]
)
def test_to_offset_deprecation_warning(freq):
    # Test for deprecations outlined in GitHub issue #8394
    with pytest.warns(FutureWarning, match="is deprecated"):
        to_offset(freq)


@pytest.mark.skipif(has_pandas_ge_2_2, reason="only relevant for pandas lt 2.2")
@pytest.mark.parametrize(
    "freq, expected",
    (
        ["Y", "YE"],
        ["A", "YE"],
        ["Q", "QE"],
        ["M", "ME"],
        ["AS", "YS"],
        ["YE", "YE"],
        ["QE", "QE"],
        ["ME", "ME"],
        ["YS", "YS"],
    ),
)
@pytest.mark.parametrize("n", ("", "2"))
def test_legacy_to_new_freq(freq, expected, n):
    freq = f"{n}{freq}"
    result = _legacy_to_new_freq(freq)

    expected = f"{n}{expected}"

    assert result == expected


@pytest.mark.skipif(has_pandas_ge_2_2, reason="only relevant for pandas lt 2.2")
@pytest.mark.parametrize("year_alias", ("YE", "Y", "A"))
@pytest.mark.parametrize("n", ("", "2"))
def test_legacy_to_new_freq_anchored(year_alias, n):
    for month in _MONTH_ABBREVIATIONS.values():
        freq = f"{n}{year_alias}-{month}"
        result = _legacy_to_new_freq(freq)

        expected = f"{n}YE-{month}"

        assert result == expected


@pytest.mark.skipif(has_pandas_ge_2_2, reason="only relevant for pandas lt 2.2")
@pytest.mark.filterwarnings("ignore:'[AY]' is deprecated")
@pytest.mark.parametrize(
    "freq, expected",
    (["A", "A"], ["YE", "A"], ["Y", "A"], ["QE", "Q"], ["ME", "M"], ["YS", "AS"]),
)
@pytest.mark.parametrize("n", ("", "2"))
def test_new_to_legacy_freq(freq, expected, n):
    freq = f"{n}{freq}"
    result = _new_to_legacy_freq(freq)

    expected = f"{n}{expected}"

    assert result == expected


@pytest.mark.skipif(has_pandas_ge_2_2, reason="only relevant for pandas lt 2.2")
@pytest.mark.filterwarnings("ignore:'[AY]-.{3}' is deprecated")
@pytest.mark.parametrize("year_alias", ("A", "Y", "YE"))
@pytest.mark.parametrize("n", ("", "2"))
def test_new_to_legacy_freq_anchored(year_alias, n):
    for month in _MONTH_ABBREVIATIONS.values():
        freq = f"{n}{year_alias}-{month}"
        result = _new_to_legacy_freq(freq)

        expected = f"{n}A-{month}"

        assert result == expected


@pytest.mark.skipif(has_pandas_ge_2_2, reason="only for pandas lt 2.2")
@pytest.mark.parametrize(
    "freq, expected",
    (
        # pandas-only freq strings are passed through
        ("BH", "BH"),
        ("CBH", "CBH"),
        ("N", "N"),
    ),
)
def test_legacy_to_new_freq_pd_freq_passthrough(freq, expected):
    result = _legacy_to_new_freq(freq)
    assert result == expected


@pytest.mark.filterwarnings("ignore:'.' is deprecated ")
@pytest.mark.skipif(has_pandas_ge_2_2, reason="only for pandas lt 2.2")
@pytest.mark.parametrize(
    "freq, expected",
    (
        # these are each valid in pandas lt 2.2
        ("T", "T"),
        ("min", "min"),
        ("S", "S"),
        ("s", "s"),
        ("L", "L"),
        ("ms", "ms"),
        ("U", "U"),
        ("us", "us"),
        # pandas-only freq strings are passed through
        ("bh", "bh"),
        ("cbh", "cbh"),
        ("ns", "ns"),
    ),
)
def test_new_to_legacy_freq_pd_freq_passthrough(freq, expected):
    result = _new_to_legacy_freq(freq)
    assert result == expected


@pytest.mark.filterwarnings("ignore:Converting a CFTimeIndex with:")
@pytest.mark.parametrize("start", ("2000", "2001"))
@pytest.mark.parametrize("end", ("2000", "2001"))
@pytest.mark.parametrize(
    "freq",
    (
        "MS",
        pytest.param("-1MS", marks=requires_pandas_3),
        "YS",
        pytest.param("-1YS", marks=requires_pandas_3),
        "ME",
        pytest.param("-1ME", marks=requires_pandas_3),
        "YE",
        pytest.param("-1YE", marks=requires_pandas_3),
    ),
)
def test_cftime_range_same_as_pandas(start, end, freq) -> None:
    result = date_range(start, end, freq=freq, calendar="standard", use_cftime=True)
    result = result.to_datetimeindex(time_unit="ns")
    expected = date_range(start, end, freq=freq, use_cftime=False)

    np.testing.assert_array_equal(result, expected)


@pytest.mark.filterwarnings("ignore:Converting a CFTimeIndex with:")
@pytest.mark.parametrize(
    "start, end, periods",
    [
        ("2022-01-01", "2022-01-10", 2),
        ("2022-03-01", "2022-03-31", 2),
        ("2022-01-01", "2022-01-10", None),
        ("2022-03-01", "2022-03-31", None),
    ],
)
def test_cftime_range_no_freq(start, end, periods):
    """
    Test whether cftime_range produces the same result as Pandas
    when freq is not provided, but start, end and periods are.
    """
    # Generate date ranges using cftime_range
    cftimeindex = cftime_range(start=start, end=end, periods=periods)
    result = cftimeindex.to_datetimeindex(time_unit="ns")
    expected = pd.date_range(start=start, end=end, periods=periods)

    np.testing.assert_array_equal(result, expected)


@pytest.mark.parametrize(
    "start, end, periods",
    [
        ("2022-01-01", "2022-01-10", 2),
        ("2022-03-01", "2022-03-31", 2),
        ("2022-01-01", "2022-01-10", None),
        ("2022-03-01", "2022-03-31", None),
    ],
)
def test_date_range_no_freq(start, end, periods):
    """
    Test whether date_range produces the same result as Pandas
    when freq is not provided, but start, end and periods are.
    """
    # Generate date ranges using date_range
    result = date_range(start=start, end=end, periods=periods)
    expected = pd.date_range(start=start, end=end, periods=periods)

    np.testing.assert_array_equal(result, expected)


@pytest.mark.parametrize(
    "offset",
    [
        MonthBegin(n=1),
        MonthEnd(n=1),
        QuarterBegin(n=1),
        QuarterEnd(n=1),
        YearBegin(n=1),
        YearEnd(n=1),
    ],
    ids=lambda x: f"{x}",
)
@pytest.mark.parametrize("has_year_zero", [False, True])
def test_offset_addition_preserves_has_year_zero(offset, has_year_zero):
    with warnings.catch_warnings():
        warnings.filterwarnings("ignore", message="this date/calendar/year zero")
        datetime = cftime.DatetimeGregorian(-1, 12, 31, has_year_zero=has_year_zero)

    result = datetime + offset
    assert result.has_year_zero == datetime.has_year_zero
    if has_year_zero:
        assert result.year == 0
    else:
        assert result.year == 1


@pytest.mark.parametrize(
    "offset",
    [
        MonthBegin(n=1),
        MonthEnd(n=1),
        QuarterBegin(n=1),
        QuarterEnd(n=1),
        YearBegin(n=1),
        YearEnd(n=1),
    ],
    ids=lambda x: f"{x}",
)
@pytest.mark.parametrize("has_year_zero", [False, True])
def test_offset_subtraction_preserves_has_year_zero(offset, has_year_zero):
    datetime = cftime.DatetimeGregorian(1, 1, 1, has_year_zero=has_year_zero)
    result = datetime - offset
    assert result.has_year_zero == datetime.has_year_zero
    if has_year_zero:
        assert result.year == 0
    else:
        assert result.year == -1


@pytest.mark.parametrize("has_year_zero", [False, True])
def test_offset_day_option_end_accounts_for_has_year_zero(has_year_zero):
    offset = MonthEnd(n=1)

    with warnings.catch_warnings():
        warnings.filterwarnings("ignore", message="this date/calendar/year zero")
        datetime = cftime.DatetimeGregorian(-1, 1, 31, has_year_zero=has_year_zero)

    result = datetime + offset
    assert result.has_year_zero == datetime.has_year_zero
    if has_year_zero:
        assert result.day == 28
    else:
        assert result.day == 29
