from __future__ import annotations

from datetime import (
    datetime,
    timedelta,
)
from typing import (
    TYPE_CHECKING,
    Literal,
    overload,
)
import warnings

from dateutil.relativedelta import (
    FR,
    MO,
    SA,
    SU,
    TH,
    TU,
    WE,
)
import numpy as np

from pandas._libs.tslibs.offsets import BaseOffset
from pandas.errors import PerformanceWarning

from pandas import (
    DateOffset,
    DatetimeIndex,
    Series,
    Timestamp,
    concat,
    date_range,
)

from pandas.tseries.offsets import (
    Day,
    Easter,
)

if TYPE_CHECKING:
    from collections.abc import Callable


def next_monday(dt: datetime) -> datetime:
    """
    Move Saturday and Sunday to the following Monday.

    A holiday on a weekday keeps its date.

    Parameters
    ----------
    dt : datetime
        The date the holiday falls on.

    Returns
    -------
    datetime
        The date the holiday is observed on.

    See Also
    --------
    tseries.holiday.weekend_to_monday : Move Saturday and Sunday to the
        following Monday.
    tseries.holiday.next_monday_or_tuesday : Move Saturday to Monday, and
        Sunday or Monday to Tuesday.
    tseries.holiday.Holiday : Class that defines a holiday with start/end
        dates and rules for observance.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import next_monday
    >>> next_monday(datetime(2022, 1, 1))  # Saturday
    datetime.datetime(2022, 1, 3, 0, 0)
    >>> next_monday(datetime(2022, 1, 2))  # Sunday
    datetime.datetime(2022, 1, 3, 0, 0)
    """
    if dt.weekday() == 5:
        return dt + timedelta(2)
    elif dt.weekday() == 6:
        return dt + timedelta(1)
    return dt


def next_monday_or_tuesday(dt: datetime) -> datetime:
    """
    Move Saturday to Monday, and Sunday or Monday to Tuesday.

    Use this for the second of two adjacent holidays, where the Monday is
    already taken by the holiday of the day before.

    Parameters
    ----------
    dt : datetime
        The date the holiday falls on.

    Returns
    -------
    datetime
        The date the holiday is observed on.

    See Also
    --------
    tseries.holiday.next_monday : Move Saturday and Sunday to the following
        Monday.
    tseries.holiday.after_nearest_workday : Move to the workday after the
        nearest workday.
    tseries.holiday.Holiday : Class that defines a holiday with start/end
        dates and rules for observance.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import next_monday_or_tuesday
    >>> next_monday_or_tuesday(datetime(2022, 1, 1))  # Saturday
    datetime.datetime(2022, 1, 3, 0, 0)
    >>> next_monday_or_tuesday(datetime(2022, 1, 2))  # Sunday
    datetime.datetime(2022, 1, 4, 0, 0)
    >>> next_monday_or_tuesday(datetime(2022, 1, 3))  # Monday
    datetime.datetime(2022, 1, 4, 0, 0)
    """
    dow = dt.weekday()
    if dow in (5, 6):
        return dt + timedelta(2)
    if dow == 0:
        return dt + timedelta(1)
    return dt


def previous_friday(dt: datetime) -> datetime:
    """
    Move Saturday and Sunday back to the previous Friday.

    A holiday on a weekday keeps its date.

    Parameters
    ----------
    dt : datetime
        The date the holiday falls on.

    Returns
    -------
    datetime
        The date the holiday is observed on.

    See Also
    --------
    tseries.holiday.next_monday : Move Saturday and Sunday to the following
        Monday.
    tseries.holiday.previous_workday : Move to the previous weekday.
    tseries.holiday.Holiday : Class that defines a holiday with start/end
        dates and rules for observance.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import previous_friday
    >>> previous_friday(datetime(2022, 1, 1))  # Saturday
    datetime.datetime(2021, 12, 31, 0, 0)
    >>> previous_friday(datetime(2022, 1, 2))  # Sunday
    datetime.datetime(2021, 12, 31, 0, 0)
    """
    if dt.weekday() == 5:
        return dt - timedelta(1)
    elif dt.weekday() == 6:
        return dt - timedelta(2)
    return dt


def sunday_to_monday(dt: datetime) -> datetime:
    """
    Move Sunday to the following Monday.

    A holiday on a Saturday stays where it is.

    Parameters
    ----------
    dt : datetime
        The date the holiday falls on.

    Returns
    -------
    datetime
        The date the holiday is observed on.

    See Also
    --------
    tseries.holiday.weekend_to_monday : Move Saturday and Sunday to the
        following Monday.
    tseries.holiday.nearest_workday : Move Saturday to Friday and Sunday to
        Monday.
    tseries.holiday.Holiday : Class that defines a holiday with start/end
        dates and rules for observance.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import sunday_to_monday
    >>> sunday_to_monday(datetime(2022, 1, 2))  # Sunday
    datetime.datetime(2022, 1, 3, 0, 0)
    >>> sunday_to_monday(datetime(2022, 1, 1))  # Saturday, unchanged
    datetime.datetime(2022, 1, 1, 0, 0)
    """
    if dt.weekday() == 6:
        return dt + timedelta(1)
    return dt


def weekend_to_monday(dt: datetime) -> datetime:
    """
    Move Saturday and Sunday to the following Monday.

    Same as ``next_monday``. Needed for holidays such as the Christmas
    observation in Europe.

    Parameters
    ----------
    dt : datetime
        The date the holiday falls on.

    Returns
    -------
    datetime
        The date the holiday is observed on.

    See Also
    --------
    tseries.holiday.next_monday : Move Saturday and Sunday to the following
        Monday.
    tseries.holiday.sunday_to_monday : Move Sunday to the following Monday.
    tseries.holiday.Holiday : Class that defines a holiday with start/end
        dates and rules for observance.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import weekend_to_monday
    >>> weekend_to_monday(datetime(2022, 1, 1))  # Saturday
    datetime.datetime(2022, 1, 3, 0, 0)
    >>> weekend_to_monday(datetime(2022, 1, 2))  # Sunday
    datetime.datetime(2022, 1, 3, 0, 0)
    """
    if dt.weekday() == 6:
        return dt + timedelta(1)
    elif dt.weekday() == 5:
        return dt + timedelta(2)
    return dt


def nearest_workday(dt: datetime) -> datetime:
    """
    Move Saturday to Friday and Sunday to Monday.

    Most US federal holidays follow this rule.

    Parameters
    ----------
    dt : datetime
        The date the holiday falls on.

    Returns
    -------
    datetime
        The date the holiday is observed on.

    See Also
    --------
    tseries.holiday.before_nearest_workday : Move to the workday before the
        nearest workday.
    tseries.holiday.after_nearest_workday : Move to the workday after the
        nearest workday.
    tseries.holiday.USFederalHolidayCalendar : US Federal Government holiday
        calendar.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import nearest_workday
    >>> nearest_workday(datetime(2022, 1, 1))  # Saturday
    datetime.datetime(2021, 12, 31, 0, 0)
    >>> nearest_workday(datetime(2022, 1, 2))  # Sunday
    datetime.datetime(2022, 1, 3, 0, 0)
    """
    if dt.weekday() == 5:
        return dt - timedelta(1)
    elif dt.weekday() == 6:
        return dt + timedelta(1)
    return dt


def next_workday(dt: datetime) -> datetime:
    """
    Move to the next weekday.

    The date is always advanced by at least one day, even when ``dt`` already
    falls on a weekday. To leave weekdays unchanged and move only weekend
    dates forward, use ``next_monday`` or ``weekend_to_monday``.

    Parameters
    ----------
    dt : datetime
        The date to move forward from.

    Returns
    -------
    datetime
        The next weekday after ``dt``.

    See Also
    --------
    tseries.holiday.previous_workday : Move to the previous weekday.
    tseries.holiday.next_monday : Move Saturday and Sunday to the following
        Monday.
    tseries.holiday.after_nearest_workday : Move to the workday after the
        nearest workday.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import next_workday
    >>> next_workday(datetime(2022, 1, 1))  # Saturday
    datetime.datetime(2022, 1, 3, 0, 0)
    >>> next_workday(datetime(2022, 1, 5))  # Wednesday, still advances
    datetime.datetime(2022, 1, 6, 0, 0)
    """
    dt += timedelta(days=1)
    while dt.weekday() > 4:
        # Mon-Fri are 0-4
        dt += timedelta(days=1)
    return dt


def previous_workday(dt: datetime) -> datetime:
    """
    Move to the previous weekday.

    The date is always moved back by at least one day, even when ``dt``
    already falls on a weekday. To leave weekdays unchanged and move only
    weekend dates back, use ``previous_friday``.

    Parameters
    ----------
    dt : datetime
        The date to move back from.

    Returns
    -------
    datetime
        The previous weekday before ``dt``.

    See Also
    --------
    tseries.holiday.next_workday : Move to the next weekday.
    tseries.holiday.previous_friday : Move Saturday and Sunday back to the
        previous Friday.
    tseries.holiday.before_nearest_workday : Move to the workday before the
        nearest workday.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import previous_workday
    >>> previous_workday(datetime(2022, 1, 2))  # Sunday
    datetime.datetime(2021, 12, 31, 0, 0)
    >>> previous_workday(datetime(2022, 1, 5))  # Wednesday, still moves back
    datetime.datetime(2022, 1, 4, 0, 0)
    """
    dt -= timedelta(days=1)
    while dt.weekday() > 4:
        # Mon-Fri are 0-4
        dt -= timedelta(days=1)
    return dt


def before_nearest_workday(dt: datetime) -> datetime:
    """
    Move to the workday before the nearest workday.

    ``nearest_workday`` runs first, so a Saturday holiday ends up on the
    Thursday before it.

    Parameters
    ----------
    dt : datetime
        The date the holiday falls on.

    Returns
    -------
    datetime
        The date the holiday is observed on.

    See Also
    --------
    tseries.holiday.nearest_workday : Move Saturday to Friday and Sunday to
        Monday.
    tseries.holiday.after_nearest_workday : Move to the workday after the
        nearest workday.
    tseries.holiday.previous_workday : Move to the previous weekday.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import before_nearest_workday
    >>> before_nearest_workday(datetime(2022, 1, 1))  # Saturday
    datetime.datetime(2021, 12, 30, 0, 0)
    >>> before_nearest_workday(datetime(2022, 1, 2))  # Sunday
    datetime.datetime(2021, 12, 31, 0, 0)
    """
    return previous_workday(nearest_workday(dt))


def after_nearest_workday(dt: datetime) -> datetime:
    """
    Move to the workday after the nearest workday.

    Needed for Boxing Day, or for several holidays in a row.

    Parameters
    ----------
    dt : datetime
        The date the holiday falls on.

    Returns
    -------
    datetime
        The date the holiday is observed on.

    See Also
    --------
    tseries.holiday.nearest_workday : Move Saturday to Friday and Sunday to
        Monday.
    tseries.holiday.before_nearest_workday : Move to the workday before the
        nearest workday.
    tseries.holiday.next_workday : Move to the next weekday.

    Examples
    --------
    >>> from datetime import datetime
    >>> from pandas.tseries.holiday import after_nearest_workday
    >>> after_nearest_workday(datetime(2022, 1, 1))  # Saturday
    datetime.datetime(2022, 1, 3, 0, 0)
    >>> after_nearest_workday(datetime(2022, 1, 2))  # Sunday
    datetime.datetime(2022, 1, 4, 0, 0)
    """
    return next_workday(nearest_workday(dt))


class Holiday:
    """
    Class that defines a holiday with start/end dates and rules for observance.

    A ``Holiday`` can either be defined by a fixed ``year``/``month``/``day``,
    or, more commonly, by a recurring ``month``/``day`` combined with an
    ``offset`` or ``observance`` rule that adjusts the date in each year it
    is calculated for. Instances of ``Holiday`` are typically collected in
    the ``rules`` of an :class:`~pandas.tseries.holiday.AbstractHolidayCalendar`.

    Parameters
    ----------
    name : str
        Name of the holiday, defaults to class name.
    year : int, default None
        Year of the holiday.
    month : int, default None
        Month of the holiday.
    day : int, default None
        Day of the holiday.
    offset : DateOffset or list of DateOffset, default None
        Computes offset from date. See :mod:`pandas.tseries.offsets` for the
        available offset classes.
    observance : function, default None
        Computes when holiday is given a pandas Timestamp.
    start_date : datetime-like, default None
        First date the holiday is observed.
    end_date : datetime-like, default None
        Last date the holiday is observed.
    days_of_week : tuple of int or dateutil.relativedelta weekday strs, default None
        Provide a tuple of days e.g  (0,1,2,3,) for Monday through Thursday
        Monday=0,..,Sunday=6
        Only instances of the holiday included in days_of_week will be computed.
    exclude_dates : DatetimeIndex or default None
        Specific dates to exclude e.g. skipping a specific year's holiday.

    See Also
    --------
    tseries.holiday.AbstractHolidayCalendar : Abstract interface to create
        holidays following certain rules.
    tseries.holiday.Holiday.dates : Calculate holidays observed between
        start date and end date.

    Examples
    --------
    >>> from dateutil.relativedelta import MO

    >>> USMemorialDay = pd.tseries.holiday.Holiday(
    ...     "Memorial Day", month=5, day=31, offset=pd.DateOffset(weekday=MO(-1))
    ... )
    >>> USMemorialDay
    Holiday: Memorial Day (month=5, day=31, offset=<DateOffset: weekday=MO(-1)>)

    >>> USLaborDay = pd.tseries.holiday.Holiday(
    ...     "Labor Day", month=9, day=1, offset=pd.DateOffset(weekday=MO(1))
    ... )
    >>> USLaborDay
    Holiday: Labor Day (month=9, day=1, offset=<DateOffset: weekday=MO(+1)>)

    >>> July3rd = pd.tseries.holiday.Holiday("July 3rd", month=7, day=3)
    >>> July3rd
    Holiday: July 3rd (month=7, day=3, )

    >>> NewYears = pd.tseries.holiday.Holiday(
    ...     "New Years Day",
    ...     month=1,
    ...     day=1,
    ...     observance=pd.tseries.holiday.nearest_workday,
    ... )
    >>> NewYears  # doctest: +SKIP
    Holiday: New Years Day (
        month=1, day=1, observance=<function nearest_workday at 0x66545e9bc440>
    )

    >>> July3rd = pd.tseries.holiday.Holiday(
    ...     "July 3rd", month=7, day=3, days_of_week=(0, 1, 2, 3)
    ... )
    >>> July3rd
    Holiday: July 3rd (month=7, day=3, )
    """

    start_date: Timestamp | None
    end_date: Timestamp | None
    days_of_week: tuple[int, ...] | None

    def __init__(
        self,
        name: str,
        year=None,
        month=None,
        day=None,
        offset: BaseOffset | list[BaseOffset] | None = None,
        observance: Callable[[datetime], datetime] | None = None,
        start_date=None,
        end_date=None,
        days_of_week: tuple[int, ...] | None = None,
        exclude_dates: DatetimeIndex | None = None,
    ) -> None:
        if offset is not None:
            if observance is not None:
                raise NotImplementedError("Cannot use both offset and observance.")
            if not (
                isinstance(offset, BaseOffset)
                or (
                    isinstance(offset, list)
                    and all(isinstance(off, BaseOffset) for off in offset)
                )
            ):
                raise ValueError(
                    "Only BaseOffsets and flat lists of them are supported for offset."
                )

        self.name = name
        self.year = year
        self.month = month
        self.day = day
        self.offset = offset
        self.start_date = (
            Timestamp(start_date) if start_date is not None else start_date
        )
        self.end_date = Timestamp(end_date) if end_date is not None else end_date
        self.observance = observance
        if not (days_of_week is None or isinstance(days_of_week, tuple)):
            raise ValueError("days_of_week must be None or tuple.")
        self.days_of_week = days_of_week
        if not (exclude_dates is None or isinstance(exclude_dates, DatetimeIndex)):
            raise ValueError("exclude_dates must be None or of type DatetimeIndex.")
        self.exclude_dates = exclude_dates

    def __repr__(self) -> str:
        info = ""
        if self.year is not None:
            info += f"year={self.year}, "
        info += f"month={self.month}, day={self.day}, "

        if self.offset is not None:
            info += f"offset={self.offset}"

        if self.observance is not None:
            info += f"observance={self.observance}"

        repr = f"Holiday: {self.name} ({info})"
        return repr

    @overload
    def dates(self, start_date, end_date, return_name: Literal[True]) -> Series: ...

    @overload
    def dates(
        self, start_date, end_date, return_name: Literal[False]
    ) -> DatetimeIndex: ...

    @overload
    def dates(self, start_date, end_date) -> DatetimeIndex: ...

    def dates(
        self, start_date, end_date, return_name: bool = False
    ) -> Series | DatetimeIndex:
        """
        Calculate holidays observed between start date and end date.

        Dates are computed from the rules defined for this holiday, i.e. its
        fixed ``year``/``month``/``day``, or its ``offset``/``observance``
        applied to each occurrence of ``month``/``day`` between the year
        before ``start_date`` and the year after ``end_date``.

        Parameters
        ----------
        start_date : starting date, datetime-like, optional
            The earliest date to consider in the search.
        end_date : ending date, datetime-like, optional
            The latest date to consider in the search.
        return_name : bool, optional, default=False
            If True, return a series that has dates and holiday names.
            False will only return dates.

        Returns
        -------
        Series or DatetimeIndex
            Series if return_name is True, otherwise a DatetimeIndex of
            the holiday's dates.

        See Also
        --------
        tseries.holiday.AbstractHolidayCalendar.holidays : Return a curve
            with holidays between start_date and end_date.

        Examples
        --------
        >>> from dateutil.relativedelta import MO
        >>> USMemorialDay = pd.tseries.holiday.Holiday(
        ...     "Memorial Day", month=5, day=31, offset=pd.DateOffset(weekday=MO(-1))
        ... )
        >>> USMemorialDay.dates("2020-01-01", "2021-12-31")
        DatetimeIndex(['2020-05-25', '2021-05-31'], dtype='datetime64[us]', freq=None)
        """
        start_date = Timestamp(start_date)
        end_date = Timestamp(end_date)

        filter_start_date = start_date
        filter_end_date = end_date

        if self.year is not None:
            dt = Timestamp(datetime(self.year, self.month, self.day))
            dti = DatetimeIndex([dt])
            if return_name:
                return Series(self.name, index=dti)
            else:
                return dti

        dates = self._reference_dates(start_date, end_date)
        holiday_dates = self._apply_rule(dates)
        if self.days_of_week is not None:
            holiday_dates = holiday_dates[
                np.isin(
                    holiday_dates.day_of_week,
                    self.days_of_week,
                ).ravel()
            ]

        if self.start_date is not None:
            filter_start_date = max(
                self.start_date.tz_localize(filter_start_date.tz), filter_start_date
            )
        if self.end_date is not None:
            filter_end_date = min(
                self.end_date.tz_localize(filter_end_date.tz), filter_end_date
            )
        holiday_dates = holiday_dates[
            (holiday_dates >= filter_start_date) & (holiday_dates <= filter_end_date)
        ]

        if self.exclude_dates is not None:
            holiday_dates = holiday_dates.difference(self.exclude_dates)
        if return_name:
            return Series(self.name, index=holiday_dates)
        return holiday_dates

    def _reference_dates(
        self, start_date: Timestamp, end_date: Timestamp
    ) -> DatetimeIndex:
        """
        Get reference dates for the holiday.

        Return reference dates for the holiday also returning the year
        prior to the start_date and year following the end_date.  This ensures
        that any offsets to be applied will yield the holidays within
        the passed in dates.
        """
        if self.start_date is not None:
            start_date = self.start_date.tz_localize(start_date.tz)

        if self.end_date is not None:
            end_date = self.end_date.tz_localize(start_date.tz)

        year_offset = DateOffset(years=1)
        reference_start_date = Timestamp(
            datetime(start_date.year - 1, self.month, self.day)
        )

        reference_end_date = Timestamp(
            datetime(end_date.year + 1, self.month, self.day)
        )
        # Don't process unnecessary holidays
        dates = date_range(
            start=reference_start_date,
            end=reference_end_date,
            freq=year_offset,
            tz=start_date.tz,
        )

        return dates

    def _apply_rule(self, dates: DatetimeIndex) -> DatetimeIndex:
        """
        Apply the given offset/observance to a DatetimeIndex of dates.

        Parameters
        ----------
        dates : DatetimeIndex
            Dates to apply the given offset/observance rule

        Returns
        -------
        Dates with rules applied
        """
        if dates.empty:
            return dates.copy()

        if self.observance is not None:
            return dates.map(lambda d: self.observance(d))  # type: ignore[return-value, misc]  # pyright: ignore[reportOptionalCall]

        if self.offset is not None:
            if not isinstance(self.offset, list):
                offsets = [self.offset]
            else:
                offsets = self.offset
            for offset in offsets:
                # if we are adding a non-vectorized value
                # ignore the PerformanceWarnings:
                with warnings.catch_warnings():
                    warnings.simplefilter("ignore", PerformanceWarning)
                    dates += offset
        return dates


holiday_calendars: dict[str, type[AbstractHolidayCalendar]] = {}


def register(cls) -> None:
    """
    Add a holiday calendar class to the registry of named calendars.

    Calendar classes register themselves as soon as they are defined, so this
    is rarely called directly. A class is registered under its ``name``
    attribute if it has one and under its class name otherwise, and
    registering a class under a name that is already taken replaces the
    earlier one.

    See Also
    --------
    tseries.holiday.get_calendar : Return an instance of a calendar based on
        its name.
    tseries.holiday.HolidayCalendarFactory : Create a holiday calendar class
        from two sets of rules.
    tseries.holiday.AbstractHolidayCalendar : Abstract interface to create
        holidays following certain rules.

    Examples
    --------
    >>> from pandas.tseries.holiday import (
    ...     AbstractHolidayCalendar,
    ...     USLaborDay,
    ...     get_calendar,
    ...     register,
    ... )
    >>> class MyCalendar(AbstractHolidayCalendar):
    ...     rules = [USLaborDay]
    >>> MyCalendar.name = "us_labor_only"
    >>> register(MyCalendar)
    >>> [rule.name for rule in get_calendar("us_labor_only").rules]
    ['Labor Day']
    """
    try:
        name = cls.name
    except AttributeError:
        name = cls.__name__
    holiday_calendars[name] = cls


def get_calendar(name: str) -> AbstractHolidayCalendar:
    """
    Return an instance of a calendar based on its name.

    The calendar class does not have to be kept around, since every calendar
    class is registered by name as soon as it is defined.

    Parameters
    ----------
    name : str
        Calendar name to return an instance of. This is the class name,
        unless the class sets a ``name`` attribute.

    Returns
    -------
    AbstractHolidayCalendar
        A new instance of the registered calendar class.

    Raises
    ------
    KeyError
        If no calendar is registered under ``name``.

    See Also
    --------
    tseries.holiday.register : Add a holiday calendar class to the registry
        of named calendars.
    tseries.holiday.HolidayCalendarFactory : Create a holiday calendar class
        from two sets of rules.
    tseries.holiday.AbstractHolidayCalendar : Abstract interface to create
        holidays following certain rules.

    Examples
    --------
    >>> from pandas.tseries.holiday import (
    ...     AbstractHolidayCalendar,
    ...     USLaborDay,
    ...     get_calendar,
    ... )
    >>> class MyCalendar(AbstractHolidayCalendar):
    ...     rules = [USLaborDay]
    >>> cal = get_calendar("MyCalendar")
    >>> cal.holidays(start="2020-01-01", end="2020-12-31")
    DatetimeIndex(['2020-09-07'], dtype='datetime64[us]', freq=None)
    """
    return holiday_calendars[name]()


class HolidayCalendarMetaClass(type):
    def __new__(cls, clsname: str, bases, attrs):
        calendar_class = super().__new__(cls, clsname, bases, attrs)
        register(calendar_class)
        return calendar_class


class AbstractHolidayCalendar(metaclass=HolidayCalendarMetaClass):
    """
    Abstract interface to create holidays following certain rules.

    A subclass normally defines its holidays by setting ``rules`` to a list
    of :class:`~pandas.tseries.holiday.Holiday` instances directly on the
    class body. Alternatively, a set of rules can be passed to the
    constructor. The calendar can then be used to compute the holiday
    dates falling within a given date range via
    :meth:`~pandas.tseries.holiday.AbstractHolidayCalendar.holidays`.

    Parameters
    ----------
    name : str
        Name of the holiday calendar, defaults to class name.
    rules : array of Holiday objects
        A set of rules used to create the holidays.

    See Also
    --------
    tseries.holiday.Holiday : Class that defines a holiday with start/end
        dates and rules for observance.
    tseries.offsets.CustomBusinessDay : DateOffset subclass representing
        possibly n business days excluding holidays.

    Examples
    --------
    >>> from pandas.tseries.holiday import AbstractHolidayCalendar, USLaborDay
    >>> class MyCalendar(AbstractHolidayCalendar):
    ...     rules = [USLaborDay]
    >>> MyCalendar().holidays(start="2020-01-01", end="2020-12-31")
    DatetimeIndex(['2020-09-07'], dtype='datetime64[us]', freq=None)
    """

    rules: list[Holiday] = []
    start_date = Timestamp(datetime(1970, 1, 1))
    end_date = Timestamp(datetime(2200, 12, 31))
    _cache: tuple[Timestamp, Timestamp, Series] | None = None

    def __init__(self, name: str = "", rules=None) -> None:
        super().__init__()
        if not name:
            name = type(self).__name__
        self.name = name

        if rules is not None:
            self.rules = rules

    def rule_from_name(self, name: str) -> Holiday | None:
        """
        Return the rule for the holiday with the given name.

        Searches ``self.rules`` for a :class:`~pandas.tseries.holiday.Holiday`
        whose ``name`` attribute matches exactly.

        Parameters
        ----------
        name : str
            Name of the holiday whose rule should be returned.

        Returns
        -------
        Holiday or None
            The matching rule from ``self.rules``, or None if no rule with
            the given name is found.

        See Also
        --------
        tseries.holiday.AbstractHolidayCalendar.holidays : Return a curve
            with holidays between start_date and end_date.

        Examples
        --------
        >>> from pandas.tseries.holiday import AbstractHolidayCalendar, USLaborDay
        >>> class MyCalendar(AbstractHolidayCalendar):
        ...     rules = [USLaborDay]
        >>> MyCalendar().rule_from_name("Labor Day")
        Holiday: Labor Day (month=9, day=1, offset=<DateOffset: weekday=MO(+1)>)
        """
        for rule in self.rules:
            if rule.name == name:
                return rule

        return None

    def holidays(
        self, start=None, end=None, return_name: bool = False
    ) -> DatetimeIndex | Series:
        """
        Return a curve with holidays between start_date and end_date.

        The holidays for the calendar are computed by evaluating the
        ``dates`` of each rule in ``self.rules`` and merging the results,
        caching them so that repeated calls covering a previously
        requested date range do not need to be recomputed.

        Parameters
        ----------
        start : starting date, datetime-like, optional
            The earliest date to consider, defaults to
            :attr:`AbstractHolidayCalendar.start_date`.
        end : ending date, datetime-like, optional
            The latest date to consider, defaults to
            :attr:`AbstractHolidayCalendar.end_date`.
        return_name : bool, optional
            If True, return a series that has dates and holiday names.
            False will only return a DatetimeIndex of dates.

        Returns
        -------
        DatetimeIndex or Series
            DatetimeIndex of holidays, or a Series mapping holiday dates to
            their names if ``return_name`` is True.

        See Also
        --------
        tseries.holiday.Holiday.dates : Calculate holidays observed between
            start date and end date.

        Examples
        --------
        >>> from pandas.tseries.holiday import AbstractHolidayCalendar, USLaborDay
        >>> class MyCalendar(AbstractHolidayCalendar):
        ...     rules = [USLaborDay]
        >>> MyCalendar().holidays(start="2020-01-01", end="2020-12-31")
        DatetimeIndex(['2020-09-07'], dtype='datetime64[us]', freq=None)
        """
        if self.rules is None:
            raise Exception(
                f"Holiday Calendar {self.name} does not have any rules specified"
            )

        if start is None:
            start = AbstractHolidayCalendar.start_date

        if end is None:
            end = AbstractHolidayCalendar.end_date

        start = Timestamp(start)
        end = Timestamp(end)

        # If we don't have a cache or the dates are outside the prior cache, we
        # get them again
        cache = self._cache
        if cache is None or start < cache[0] or end > cache[1]:
            pre_holidays = [
                rule.dates(start, end, return_name=True) for rule in self.rules
            ]
            if pre_holidays:
                holidays = concat(pre_holidays)
            else:
                holidays = Series(index=DatetimeIndex([]), dtype=object)

            cache = (start, end, holidays.sort_index())
            self._cache = cache

        holidays = cache[2]
        holidays = holidays[start:end]

        if return_name:
            return holidays
        else:
            return holidays.index

    @staticmethod
    def merge_class(base, other):
        """
        Merge holiday calendars together.

        The base calendar will take precedence to other. The merge will be
        done based on each holiday's name.

        Parameters
        ----------
        base : AbstractHolidayCalendar
            Instance/subclass or array of Holiday objects.
        other : AbstractHolidayCalendar
            Instance/subclass or array of Holiday objects.

        Returns
        -------
        list of Holiday
            Combined list of holidays from both calendars, with ``base``
            taking precedence over ``other`` for holidays sharing the
            same name.

        See Also
        --------
        tseries.holiday.AbstractHolidayCalendar.merge : Merge holiday
            calendars together.

        Examples
        --------
        >>> from pandas.tseries.holiday import (
        ...     AbstractHolidayCalendar,
        ...     USLaborDay,
        ...     USMemorialDay,
        ... )
        >>> class Calendar1(AbstractHolidayCalendar):
        ...     rules = [USMemorialDay]
        >>> class Calendar2(AbstractHolidayCalendar):
        ...     rules = [USLaborDay]
        >>> merged = AbstractHolidayCalendar.merge_class(Calendar1, Calendar2)
        >>> [holiday.name for holiday in merged]
        ['Labor Day', 'Memorial Day']
        """
        try:
            other = other.rules
        except AttributeError:
            pass

        if not isinstance(other, list):
            other = [other]
        other_holidays = {holiday.name: holiday for holiday in other}

        try:
            base = base.rules
        except AttributeError:
            pass

        if not isinstance(base, list):
            base = [base]
        base_holidays = {holiday.name: holiday for holiday in base}

        other_holidays.update(base_holidays)
        return list(other_holidays.values())

    def merge(self, other, inplace: bool = False):
        """
        Merge holiday calendars together.

        The caller's class rules take precedence. The merge will be done
        based on each holiday's name.

        Parameters
        ----------
        other : holiday calendar
            Calendar, instance/subclass, or array of Holiday objects to
            merge into this calendar's rules.
        inplace : bool (default=False)
            If True set rule_table to holidays, else return array of
            Holidays.

        Returns
        -------
        list of Holiday or None
            The combined list of holidays if ``inplace`` is False,
            otherwise None.

        See Also
        --------
        tseries.holiday.AbstractHolidayCalendar.merge_class : Merge holiday
            calendars together.

        Examples
        --------
        >>> from pandas.tseries.holiday import (
        ...     AbstractHolidayCalendar,
        ...     USLaborDay,
        ...     USMemorialDay,
        ... )
        >>> class Calendar1(AbstractHolidayCalendar):
        ...     rules = [USMemorialDay]
        >>> class Calendar2(AbstractHolidayCalendar):
        ...     rules = [USLaborDay]
        >>> merged = Calendar1().merge(Calendar2())
        >>> [holiday.name for holiday in merged]
        ['Labor Day', 'Memorial Day']
        """
        holidays = self.merge_class(self, other)
        if inplace:
            self.rules = holidays
        else:
            return holidays


USMemorialDay = Holiday(
    "Memorial Day", month=5, day=31, offset=DateOffset(weekday=MO(-1))
)
USLaborDay = Holiday("Labor Day", month=9, day=1, offset=DateOffset(weekday=MO(1)))
USColumbusDay = Holiday(
    "Columbus Day", month=10, day=1, offset=DateOffset(weekday=MO(2))
)
USThanksgivingDay = Holiday(
    "Thanksgiving Day", month=11, day=1, offset=DateOffset(weekday=TH(4))
)
USMartinLutherKingJr = Holiday(
    "Birthday of Martin Luther King, Jr.",
    start_date=datetime(1986, 1, 1),
    month=1,
    day=1,
    offset=DateOffset(weekday=MO(3)),
)
USPresidentsDay = Holiday(
    "Washington's Birthday", month=2, day=1, offset=DateOffset(weekday=MO(3))
)
GoodFriday = Holiday("Good Friday", month=1, day=1, offset=[Easter(), Day(-2)])

EasterMonday = Holiday("Easter Monday", month=1, day=1, offset=[Easter(), Day(1)])


class USFederalHolidayCalendar(AbstractHolidayCalendar):
    """
    US Federal Government holiday calendar.

    The rules follow the federal holiday schedule published by the US Office
    of Personnel Management:
    https://www.opm.gov/policy-data-oversight/pay-leave/federal-holidays/

    Holidays that land on a weekend are observed on the nearest weekday.
    Pass an instance as the ``calendar`` argument of
    :class:`~pandas.tseries.offsets.CustomBusinessDay` to skip these dates
    in business day arithmetic.

    Parameters
    ----------
    name : str
        Name of the holiday calendar, defaults to class name.
    rules : array of Holiday objects
        A set of rules used to create the holidays.

    See Also
    --------
    tseries.holiday.AbstractHolidayCalendar : Abstract interface to create
        holidays following certain rules.
    tseries.holiday.nearest_workday : Move Saturday to Friday and Sunday to
        Monday.
    tseries.holiday.get_calendar : Return an instance of a calendar based on
        its name.

    Examples
    --------
    >>> from pandas.tseries.holiday import USFederalHolidayCalendar
    >>> USFederalHolidayCalendar().holidays(start="2024-06-01", end="2024-07-31")
    DatetimeIndex(['2024-06-19', '2024-07-04'], dtype='datetime64[us]', freq=None)
    """

    rules = [
        Holiday("New Year's Day", month=1, day=1, observance=nearest_workday),
        USMartinLutherKingJr,
        USPresidentsDay,
        USMemorialDay,
        Holiday(
            "Juneteenth National Independence Day",
            month=6,
            day=19,
            start_date="2021-06-18",
            observance=nearest_workday,
        ),
        Holiday("Independence Day", month=7, day=4, observance=nearest_workday),
        USLaborDay,
        USColumbusDay,
        Holiday("Veterans Day", month=11, day=11, observance=nearest_workday),
        USThanksgivingDay,
        Holiday("Christmas Day", month=12, day=25, observance=nearest_workday),
    ]


def HolidayCalendarFactory(name: str, base, other, base_class=AbstractHolidayCalendar):
    """
    Create a holiday calendar class from two sets of rules.

    The rules of ``base`` and ``other`` are merged by holiday name, with
    ``base`` winning any clash, and the result becomes the ``rules`` of a new
    calendar class. That class registers itself under ``name``, so
    :func:`~pandas.tseries.holiday.get_calendar` can find it later.

    Parameters
    ----------
    name : str
        Name of the calendar class to create.
    base : AbstractHolidayCalendar
        Instance/subclass or array of Holiday objects whose rules take
        precedence.
    other : AbstractHolidayCalendar
        Instance/subclass or array of Holiday objects to merge into ``base``.
    base_class : type, default AbstractHolidayCalendar
        The class the new calendar class inherits from.

    Returns
    -------
    type
        A new subclass of ``base_class`` holding the merged rules.

    See Also
    --------
    tseries.holiday.AbstractHolidayCalendar.merge_class : Merge holiday
        calendars together.
    tseries.holiday.get_calendar : Return an instance of a calendar based on
        its name.
    tseries.holiday.register : Add a holiday calendar class to the registry
        of named calendars.

    Examples
    --------
    >>> from pandas.tseries.holiday import (
    ...     AbstractHolidayCalendar,
    ...     HolidayCalendarFactory,
    ...     USLaborDay,
    ...     USMemorialDay,
    ... )
    >>> class MyCalendar(AbstractHolidayCalendar):
    ...     rules = [USMemorialDay]
    >>> NewCalendar = HolidayCalendarFactory("NewCalendar", MyCalendar, USLaborDay)
    >>> [rule.name for rule in NewCalendar.rules]
    ['Labor Day', 'Memorial Day']
    """
    rules = AbstractHolidayCalendar.merge_class(base, other)
    calendar_class = type(name, (base_class,), {"rules": rules, "name": name})
    return calendar_class


__all__ = [
    "FR",
    "MO",
    "SA",
    "SU",
    "TH",
    "TU",
    "WE",
    "HolidayCalendarFactory",
    "after_nearest_workday",
    "before_nearest_workday",
    "get_calendar",
    "nearest_workday",
    "next_monday",
    "next_monday_or_tuesday",
    "next_workday",
    "previous_friday",
    "previous_workday",
    "register",
    "sunday_to_monday",
    "weekend_to_monday",
]
