"""
datetimelike delegation
"""

from __future__ import annotations

from typing import (
    TYPE_CHECKING,
    NoReturn,
    cast,
)
import warnings

import numpy as np

from pandas._libs import lib
from pandas.errors import Pandas4Warning
from pandas.util._exceptions import find_stack_level

from pandas.core.dtypes.common import (
    is_integer_dtype,
    is_list_like,
)
from pandas.core.dtypes.dtypes import (
    ArrowDtype,
    CategoricalDtype,
    DatetimeTZDtype,
    PeriodDtype,
)
from pandas.core.dtypes.generic import ABCSeries

from pandas.core.accessor import (
    PandasDelegate,
    delegate_names,
)
from pandas.core.arrays import (
    DatetimeArray,
    PeriodArray,
    TimedeltaArray,
)
from pandas.core.arrays.arrow.array import ArrowExtensionArray
from pandas.core.base import (
    NoNewAttributesMixin,
    PandasObject,
)
from pandas.core.indexes.datetimes import DatetimeIndex
from pandas.core.indexes.timedeltas import TimedeltaIndex

from pandas.tseries import frequencies

if TYPE_CHECKING:
    from pandas import (
        DataFrame,
        Series,
    )


class Properties(PandasDelegate, PandasObject, NoNewAttributesMixin):
    _hidden_attrs = PandasObject._hidden_attrs | {
        "orig",
        "name",
    }

    def __init__(self, data: Series, orig) -> None:
        if not isinstance(data, ABCSeries):
            raise TypeError(
                f"cannot convert an object of type {type(data)} to a datetimelike index"
            )

        self._parent = data
        self.orig = orig
        self.name = getattr(data, "name", None)
        self._freeze()

    def _get_values(self):
        data = self._parent
        if lib.is_np_dtype(data.dtype, "M"):
            return DatetimeIndex(data, copy=False, name=self.name)

        elif isinstance(data.dtype, DatetimeTZDtype):
            return DatetimeIndex(data, copy=False, name=self.name)

        elif lib.is_np_dtype(data.dtype, "m"):
            return TimedeltaIndex(data, copy=False, name=self.name)

        elif isinstance(data.dtype, PeriodDtype):
            return PeriodArray(data, copy=False)

        raise TypeError(
            f"cannot convert an object of type {type(data)} to a datetimelike index"
        )

    def _delegate_property_get(self, name: str):
        from pandas import Series

        values = self._get_values()

        result = getattr(values, name)

        # maybe need to upcast (ints)
        if isinstance(result, np.ndarray):
            if is_integer_dtype(result):
                result = result.astype("int64")
        elif not is_list_like(result):
            return result

        result = np.asarray(result)

        if self.orig is not None:
            index = self.orig.index
        else:
            index = self._parent.index
        # return the result as a Series
        return Series(result, index=index, name=self.name).__finalize__(self._parent)

    def _delegate_property_set(self, name: str, value, *args, **kwargs) -> NoReturn:
        raise ValueError(
            "modifications to a property of a datetimelike object are not supported. "
            "Change values on the original."
        )

    def _delegate_method(self, name: str, *args, **kwargs):
        from pandas import Series

        values = self._get_values()

        method = getattr(values, name)
        result = method(*args, **kwargs)

        if not is_list_like(result):
            return result

        return Series(result, index=self._parent.index, name=self.name).__finalize__(
            self._parent
        )


@delegate_names(
    delegate=ArrowExtensionArray,
    accessors=TimedeltaArray._datetimelike_ops,
    typ="property",
    accessor_mapping=lambda x: f"_dt_{x}",
    raise_on_missing=False,
)
@delegate_names(
    delegate=ArrowExtensionArray,
    accessors=TimedeltaArray._datetimelike_methods,
    typ="method",
    accessor_mapping=lambda x: f"_dt_{x}",
    raise_on_missing=False,
)
@delegate_names(
    delegate=ArrowExtensionArray,
    accessors=[
        *DatetimeArray._datetimelike_ops,
        # deprecated, but should still work
        "dayofweek",
        "dayofyear",
        "daysinmonth",
        "weekday",
    ],
    typ="property",
    accessor_mapping=lambda x: f"_dt_{x}",
    raise_on_missing=False,
)
@delegate_names(
    delegate=ArrowExtensionArray,
    accessors=DatetimeArray._datetimelike_methods,
    typ="method",
    accessor_mapping=lambda x: f"_dt_{x}",
    raise_on_missing=False,
)
class ArrowTemporalProperties(PandasDelegate, PandasObject, NoNewAttributesMixin):
    def __init__(self, data: Series, orig) -> None:
        if not isinstance(data, ABCSeries):
            raise TypeError(
                f"cannot convert an object of type {type(data)} to a datetimelike index"
            )

        self._parent = data
        self._orig = orig
        self._freeze()

    def _delegate_property_get(self, name: str):
        if not hasattr(self._parent.array, f"_dt_{name}"):
            raise NotImplementedError(
                f"dt.{name} is not supported for {self._parent.dtype}"
            )
        result = getattr(self._parent.array, f"_dt_{name}")

        if not is_list_like(result):
            return result

        if self._orig is not None:
            index = self._orig.index
        else:
            index = self._parent.index
        # return the result as a Series, which is by definition a copy
        result = type(self._parent)(
            result, index=index, name=self._parent.name
        ).__finalize__(self._parent)

        return result

    def _delegate_method(self, name: str, *args, **kwargs):
        if not hasattr(self._parent.array, f"_dt_{name}"):
            raise NotImplementedError(
                f"dt.{name} is not supported for {self._parent.dtype}"
            )

        result = getattr(self._parent.array, f"_dt_{name}")(*args, **kwargs)

        if self._orig is not None:
            index = self._orig.index
        else:
            index = self._parent.index
        # return the result as a Series, which is by definition a copy
        result = type(self._parent)(
            result, index=index, name=self._parent.name
        ).__finalize__(self._parent)

        return result

    def to_pytimedelta(self):
        # GH 57463
        warnings.warn(
            f"The behavior of {type(self).__name__}.to_pytimedelta is deprecated, "
            "in a future version this will return a Series containing python "
            "datetime.timedelta objects instead of an ndarray. To retain the "
            "old behavior, call `np.array` on the result",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return cast("ArrowExtensionArray", self._parent.array)._dt_to_pytimedelta()

    def to_pydatetime(self) -> Series:
        # GH#20306
        result = cast("ArrowExtensionArray", self._parent.array)._dt_to_pydatetime()
        result.index = self._parent.index
        result.name = self._parent.name
        return result

    def isocalendar(self) -> DataFrame:
        from pandas import DataFrame

        result = (
            cast("ArrowExtensionArray", self._parent.array)
            ._dt_isocalendar()
            ._pa_array.combine_chunks()
        )
        iso_calendar_df = DataFrame(
            {
                col: type(self._parent.array)(result.field(i))  # type: ignore[call-arg]
                for i, col in enumerate(["year", "week", "day"])
            },
            index=self._parent.index,
        )
        return iso_calendar_df

    @property
    def components(self) -> DataFrame:
        from pandas import DataFrame

        return (
            DataFrame(cast("ArrowExtensionArray", self._parent.array)._dt_components)
            .set_index(self._parent.index)
            .__finalize__(self._parent)
        )


@delegate_names(
    delegate=DatetimeArray,
    accessors=[*DatetimeArray._datetimelike_ops, "unit"],
    typ="property",
)
@delegate_names(
    delegate=DatetimeArray,
    accessors=[*DatetimeArray._datetimelike_methods, "as_unit"],
    typ="method",
)
class DatetimeProperties(Properties):
    """
    Accessor object for datetimelike properties of the Series values.

    Examples
    --------
    >>> seconds_series = pd.Series(pd.date_range("2000-01-01", periods=3, freq="s"))
    >>> seconds_series
    0   2000-01-01 00:00:00
    1   2000-01-01 00:00:01
    2   2000-01-01 00:00:02
    dtype: datetime64[us]
    >>> seconds_series.dt.second
    0    0
    1    1
    2    2
    dtype: int32

    >>> hours_series = pd.Series(pd.date_range("2000-01-01", periods=3, freq="h"))
    >>> hours_series
    0   2000-01-01 00:00:00
    1   2000-01-01 01:00:00
    2   2000-01-01 02:00:00
    dtype: datetime64[us]
    >>> hours_series.dt.hour
    0    0
    1    1
    2    2
    dtype: int32

    >>> quarters_series = pd.Series(pd.date_range("2000-01-01", periods=3, freq="QE"))
    >>> quarters_series
    0   2000-03-31
    1   2000-06-30
    2   2000-09-30
    dtype: datetime64[us]
    >>> quarters_series.dt.quarter
    0    1
    1    2
    2    3
    dtype: int32

    Returns a Series indexed like the original Series.
    Raises TypeError if the Series does not contain datetimelike values.
    """

    def to_pydatetime(self) -> Series:
        """
        Return the data as a Series of :class:`datetime.datetime` objects.

        Timezone information is retained if present.

        .. warning::

           Python's datetime uses microsecond resolution, which is lower than
           pandas (nanosecond). The values are truncated.

        Returns
        -------
        Series
            Series containing native Python datetime objects.

        See Also
        --------
        datetime.datetime : Standard library value for a datetime.

        Examples
        --------
        >>> s = pd.Series(pd.date_range("20180310", periods=2))
        >>> s
        0   2018-03-10
        1   2018-03-11
        dtype: datetime64[us]

        >>> s.dt.to_pydatetime()
        0    2018-03-10 00:00:00
        1    2018-03-11 00:00:00
        dtype: object

        pandas' nanosecond precision is truncated to microseconds.

        >>> s = pd.Series(pd.date_range("20180310", periods=2, freq="ns"))
        >>> s
        0   2018-03-10 00:00:00.000000000
        1   2018-03-10 00:00:00.000000001
        dtype: datetime64[ns]

        >>> s.dt.to_pydatetime()
        0    2018-03-10 00:00:00
        1    2018-03-10 00:00:00
        dtype: object
        """
        # GH#20306
        from pandas import Series

        return Series(
            self._get_values().to_pydatetime(),
            index=self._parent.index,
            name=self._parent.name,
            dtype=object,
        )

    @property
    def freq(self):
        """
        Return a string representing a frequency generated by infer_freq.

        .. deprecated:: 3.1.0
            A future version of pandas will return a :class:`BaseOffset`
            object (or ``None``) instead of a string. Use
            ``pd.set_option('future.infer_freq_returns_offset', True)`` to
            opt in to the future behavior.

        Returns None if it can't autodetect the frequency.

        See Also
        --------
        Series.dt.to_period : Cast to PeriodArray/PeriodIndex at a particular
            frequency.

        Examples
        --------
        >>> ser = pd.Series(["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04"])
        >>> ser = pd.to_datetime(ser)
        >>> ser.dt.freq
        'D'

        >>> ser = pd.Series(["2022-01-01", "2024-01-01", "2026-01-01", "2028-01-01"])
        >>> ser = pd.to_datetime(ser)
        >>> ser.dt.freq
        '2YS-JAN'
        """
        return frequencies.maybe_convert_inferred_freq(
            self._get_values()._inferred_freq_str, "Series.dt.freq"
        )

    def isocalendar(self) -> DataFrame:
        """
        Calculate year, week, and day according to the ISO 8601 standard.

        The ISO 8601 standard defines the first week of the year as the week
        containing the first Thursday. This method returns a DataFrame with
        columns for the ISO year, ISO week number, and ISO day of week.

        Returns
        -------
        DataFrame
            With columns year, week and day.

        See Also
        --------
        Timestamp.isocalendar : Function return a 3-tuple containing ISO year,
            week number, and weekday for the given Timestamp object.
        datetime.date.isocalendar : Return a named tuple object with
            three components: year, week and weekday.

        Examples
        --------
        >>> ser = pd.to_datetime(pd.Series(["2010-01-01", pd.NaT]))
        >>> ser.dt.isocalendar()
           year  week  day
        0  2009    53     5
        1  <NA>  <NA>  <NA>
        >>> ser.dt.isocalendar().week
        0      53
        1    <NA>
        Name: week, dtype: UInt32
        """
        return self._get_values().isocalendar().set_index(self._parent.index)

    @property
    def weekday(self) -> Series:
        """
        The day of the week with Monday=0, Sunday=6.

        .. deprecated:: 3.1.0
            Use :attr:`Series.dt.day_of_week` instead.
        """
        warnings.warn(
            "Series.dt.weekday is deprecated and will be removed "
            "in a future version. Use Series.dt.day_of_week instead.",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return self.day_of_week  # type: ignore[attr-defined]

    @property
    def dayofweek(self) -> Series:
        """
        The day of the week with Monday=0, Sunday=6.

        .. deprecated:: 3.1.0
            Use :attr:`Series.dt.day_of_week` instead.
        """
        warnings.warn(
            "Series.dt.dayofweek is deprecated and will be removed in a "
            "future version. Use Series.dt.day_of_week instead.",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return self.day_of_week  # type: ignore[attr-defined]

    @property
    def dayofyear(self) -> Series:
        """
        The ordinal day of the year.

        .. deprecated:: 3.1.0
            Use :attr:`Series.dt.day_of_year` instead.
        """
        warnings.warn(
            "Series.dt.dayofyear is deprecated and will be removed in a "
            "future version. Use Series.dt.day_of_year instead.",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return self.day_of_year  # type: ignore[attr-defined]

    @property
    def daysinmonth(self) -> Series:
        """
        The number of days in the month.

        .. deprecated:: 3.1.0
            Use :attr:`Series.dt.days_in_month` instead.
        """
        warnings.warn(
            "Series.dt.daysinmonth is deprecated and will be removed in a "
            "future version. Use Series.dt.days_in_month instead.",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return self.days_in_month  # type: ignore[attr-defined]


@delegate_names(
    delegate=TimedeltaArray, accessors=TimedeltaArray._datetimelike_ops, typ="property"
)
@delegate_names(
    delegate=TimedeltaArray,
    accessors=TimedeltaArray._datetimelike_methods,
    typ="method",
)
class TimedeltaProperties(Properties):
    """
    Accessor object for datetimelike properties of the Series values.

    Returns a Series indexed like the original Series.
    Raises TypeError if the Series does not contain datetimelike values.

    Examples
    --------
    >>> seconds_series = pd.Series(
    ...     pd.timedelta_range(start="1 second", periods=3, freq="s")
    ... )
    >>> seconds_series
    0   0 days 00:00:01
    1   0 days 00:00:02
    2   0 days 00:00:03
    dtype: timedelta64[us]
    >>> seconds_series.dt.seconds
    0    1
    1    2
    2    3
    dtype: int32
    """

    def to_pytimedelta(self) -> np.ndarray:
        """
        Return an array of native :class:`datetime.timedelta` objects.

        Python's standard `datetime` library uses a different representation
        timedelta's. This method converts a Series of pandas Timedeltas
        to `datetime.timedelta` format with the same length as the original
        Series.

        Returns
        -------
        numpy.ndarray
            Array of 1D containing data with `datetime.timedelta` type.

        See Also
        --------
        datetime.timedelta : A duration expressing the difference
            between two date, time, or datetime.

        Examples
        --------
        >>> s = pd.Series(pd.to_timedelta(np.arange(5), unit="D"))
        >>> s
        0   0 days
        1   1 days
        2   2 days
        3   3 days
        4   4 days
        dtype: timedelta64[us]

        >>> s.dt.to_pytimedelta()
        array([datetime.timedelta(0), datetime.timedelta(days=1),
        datetime.timedelta(days=2), datetime.timedelta(days=3),
        datetime.timedelta(days=4)], dtype=object)
        """
        # GH 57463
        warnings.warn(
            f"The behavior of {type(self).__name__}.to_pytimedelta is deprecated, "
            "in a future version this will return a Series containing python "
            "datetime.timedelta objects instead of an ndarray. To retain the "
            "old behavior, call `np.array` on the result",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return self._get_values().to_pytimedelta()

    @property
    def components(self) -> DataFrame:
        """
        Return a Dataframe of the components of the Timedeltas.

        Each row of the DataFrame corresponds to a Timedelta in the original
        Series and contains the individual components (days, hours, minutes,
        seconds, milliseconds, microseconds, nanoseconds) of the Timedelta.

        Returns
        -------
        DataFrame

        See Also
        --------
        TimedeltaIndex.components : Return a DataFrame of the individual resolution
            components of the Timedeltas.
        Series.dt.total_seconds : Return the total number of seconds in the duration.

        Examples
        --------
        >>> s = pd.Series(pd.to_timedelta(np.arange(5), unit="s"))
        >>> s
        0   0 days 00:00:00
        1   0 days 00:00:01
        2   0 days 00:00:02
        3   0 days 00:00:03
        4   0 days 00:00:04
        dtype: timedelta64[us]
        >>> s.dt.components
           days  hours  minutes  seconds  milliseconds  microseconds  nanoseconds
        0     0      0        0        0             0             0            0
        1     0      0        0        1             0             0            0
        2     0      0        0        2             0             0            0
        3     0      0        0        3             0             0            0
        4     0      0        0        4             0             0            0
        """
        return (
            self._get_values()
            .components.set_index(self._parent.index)
            .__finalize__(self._parent)
        )

    @property
    def freq(self):
        """
        Return a string representing a frequency generated by infer_freq.

        .. deprecated:: 3.1.0
            A future version of pandas will return a :class:`BaseOffset`
            object (or ``None``) instead of a string. Use
            ``pd.set_option('future.infer_freq_returns_offset', True)`` to
            opt in to the future behavior.

        Returns None if it can't autodetect the frequency.

        See Also
        --------
        Series.dt.components : Return a DataFrame of the components of the
            Timedeltas.

        Examples
        --------
        >>> ser = pd.Series(pd.timedelta_range("1 day", periods=4))
        >>> ser.dt.freq
        'D'

        >>> ser = pd.Series(pd.to_timedelta(["1 day", "3 days", "10 days"]))
        >>> ser.dt.freq is None
        True
        """
        return frequencies.maybe_convert_inferred_freq(
            self._get_values()._inferred_freq_str, "Series.dt.freq"
        )


@delegate_names(
    delegate=PeriodArray, accessors=PeriodArray._datetimelike_ops, typ="property"
)
@delegate_names(
    delegate=PeriodArray, accessors=PeriodArray._datetimelike_methods, typ="method"
)
class PeriodProperties(Properties):
    """
    Accessor object for datetimelike properties of the Series values.

    Returns a Series indexed like the original Series.
    Raises TypeError if the Series does not contain datetimelike values.

    Examples
    --------
    >>> seconds_series = pd.Series(
    ...     pd.period_range(
    ...         start="2000-01-01 00:00:00", end="2000-01-01 00:00:03", freq="s"
    ...     )
    ... )
    >>> seconds_series
    0    2000-01-01 00:00:00
    1    2000-01-01 00:00:01
    2    2000-01-01 00:00:02
    3    2000-01-01 00:00:03
    dtype: period[s]
    >>> seconds_series.dt.second
    0    0
    1    1
    2    2
    3    3
    dtype: int64

    >>> hours_series = pd.Series(
    ...     pd.period_range(start="2000-01-01 00:00", end="2000-01-01 03:00", freq="h")
    ... )
    >>> hours_series
    0    2000-01-01 00:00
    1    2000-01-01 01:00
    2    2000-01-01 02:00
    3    2000-01-01 03:00
    dtype: period[h]
    >>> hours_series.dt.hour
    0    0
    1    1
    2    2
    3    3
    dtype: int64

    >>> quarters_series = pd.Series(
    ...     pd.period_range(start="2000-01-01", end="2000-12-31", freq="Q-DEC")
    ... )
    >>> quarters_series
    0    2000Q1
    1    2000Q2
    2    2000Q3
    3    2000Q4
    dtype: period[Q-DEC]
    >>> quarters_series.dt.quarter
    0    1
    1    2
    2    3
    3    4
    dtype: int64
    """

    @property
    def weekday(self) -> Series:
        """
        The day of the week with Monday=0, Sunday=6.

        .. deprecated:: 3.1.0
            Use :attr:`Series.dt.day_of_week` instead.
        """
        warnings.warn(
            "Series.dt.weekday is deprecated and will be removed "
            "in a future version. Use Series.dt.day_of_week instead.",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return self.day_of_week  # type: ignore[attr-defined]

    @property
    def dayofweek(self):
        """
        The day of the week with Monday=0, Sunday=6.

        .. deprecated:: 3.1.0
            Use :attr:`Series.dt.day_of_week` instead.
        """
        warnings.warn(
            "Series.dt.dayofweek is deprecated and will be removed in a "
            "future version. Use Series.dt.day_of_week instead.",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return self.day_of_week  # type: ignore[attr-defined]

    @property
    def dayofyear(self):
        """
        The ordinal day of the year.

        .. deprecated:: 3.1.0
            Use :attr:`Series.dt.day_of_year` instead.
        """
        warnings.warn(
            "Series.dt.dayofyear is deprecated and will be removed in a "
            "future version. Use Series.dt.day_of_year instead.",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return self.day_of_year  # type: ignore[attr-defined]

    @property
    def daysinmonth(self):
        """
        The number of days in the month.

        .. deprecated:: 3.1.0
            Use :attr:`Series.dt.days_in_month` instead.
        """
        warnings.warn(
            "Series.dt.daysinmonth is deprecated and will be removed in a "
            "future version. Use Series.dt.days_in_month instead.",
            Pandas4Warning,
            stacklevel=find_stack_level(),
        )
        return self.days_in_month  # type: ignore[attr-defined]


class CombinedDatetimelikeProperties(
    DatetimeProperties, TimedeltaProperties, PeriodProperties
):
    """
    Accessor object for Series values' datetime-like, timedelta and period properties.

    This accessor provides access to properties and methods for datetime-like,
    timedelta, and period data types. It can be used with Series containing
    datetime64, timedelta64, or period data.

    Parameters
    ----------
    data : Series
        Series with datetime-like, timedelta, or period dtype.

    See Also
    --------
    DatetimeIndex : Index of datetime64 data.

    Examples
    --------
    >>> dates = pd.Series(
    ...     ["2024-01-01", "2024-01-15", "2024-02-5"], dtype="datetime64[ns]"
    ... )
    >>> dates.dt.day
    0     1
    1    15
    2     5
    dtype: int32
    >>> dates.dt.month
    0    1
    1    1
    2    2
    dtype: int32

    >>> dates = pd.Series(
    ...     ["2024-01-01", "2024-01-15", "2024-02-5"], dtype="datetime64[ns, UTC]"
    ... )
    >>> dates.dt.day
    0     1
    1    15
    2     5
    dtype: int32
    >>> dates.dt.month
    0    1
    1    1
    2    2
    dtype: int32
    """

    def __new__(cls, data: Series):  # pyright: ignore[reportInconsistentConstructor]
        # CombinedDatetimelikeProperties isn't really instantiated. Instead
        # we need to choose which parent (datetime or timedelta) is
        # appropriate. Since we're checking the dtypes anyway, we'll just
        # do all the validation here.

        if not isinstance(data, ABCSeries):
            raise TypeError(
                f"cannot convert an object of type {type(data)} to a datetimelike index"
            )

        orig = data if isinstance(data.dtype, CategoricalDtype) else None
        if orig is not None:
            data = data._constructor(
                orig.array,
                name=orig.name,
                copy=False,
                dtype=orig._values.categories.dtype,
                index=orig.index,
            )

        if isinstance(data.dtype, ArrowDtype) and data.dtype.kind in "Mm":
            return ArrowTemporalProperties(data, orig)
        if lib.is_np_dtype(data.dtype, "M"):
            return DatetimeProperties(data, orig)
        elif isinstance(data.dtype, DatetimeTZDtype):
            return DatetimeProperties(data, orig)
        elif lib.is_np_dtype(data.dtype, "m"):
            return TimedeltaProperties(data, orig)
        elif isinstance(data.dtype, PeriodDtype):
            return PeriodProperties(data, orig)

        raise AttributeError("Can only use .dt accessor with datetimelike values")
