from collections.abc import (
    Iterator,
    Sequence,
)
from datetime import timedelta
from typing import (
    Any,
    Generic,
    Literal,
    Protocol,
    Self,
    TypeAlias,
    TypeVar,
    overload,
    type_check_only,
)

import numpy as np
import numpy.typing as npt
from pandas._stubs_only import T_contra
from pandas.core.accessor import DirNamesMixin
from pandas.core.arraylike import OpsMixin
from pandas.core.arrays import ExtensionArray
from pandas.core.arrays.categorical import Categorical
from pandas.core.arrays.floating import FloatingArray
from pandas.core.arrays.integer import IntegerArray
from pandas.core.arrays.timedeltas import TimedeltaArray
from pandas.core.indexes.accessors import ArrayDescriptor
from pandas.core.indexes.base import Index
from pandas.core.indexes.timedeltas import TimedeltaIndex
from pandas.core.series import Series

from pandas._libs.tslibs.timedeltas import Timedelta
from pandas._typing import (
    S1,
    S2,
    AxisIndex,
    DropKeep,
    DTypeLike,
    GenericT,
    GenericT_co,
    Just,
    ListLike,
    Scalar,
    SupportsDType,
    np_1darray,
    np_1darray_intp,
    np_ndarray,
    np_ndarray_anyint,
    np_ndarray_bool,
    np_ndarray_complex,
    np_ndarray_float,
    np_ndarray_td,
)

T_INTERVAL_NP = TypeVar("T_INTERVAL_NP", bound=np.bytes_ | np.str_)

class NoNewAttributesMixin:
    def __setattr__(self, key: str, value: Any) -> None: ...

class PandasObject(DirNamesMixin): ...

class IndexOpsMixin(OpsMixin, Generic[S1, GenericT_co]):
    __array_priority__: int = ...
    @property
    def T(self) -> Self: ...
    @property
    def shape(self) -> tuple[int, ...]: ...
    @property
    def ndim(self) -> int: ...
    def item(self) -> S1: ...
    @property
    def nbytes(self) -> int: ...
    @property
    def size(self) -> int: ...
    array = ArrayDescriptor()
    @overload
    def to_numpy(
        self,
        dtype: None = None,
        copy: bool = False,
        na_value: Scalar = ...,
        **kwargs: Any,
    ) -> np_1darray[GenericT_co]: ...
    @overload
    def to_numpy(
        self,
        dtype: np.dtype[GenericT] | SupportsDType[GenericT] | type[GenericT],
        copy: bool = False,
        na_value: Scalar = ...,
        **kwargs: Any,
    ) -> np_1darray[GenericT]: ...
    @overload
    def to_numpy(
        self,
        dtype: DTypeLike,
        copy: bool = False,
        na_value: Scalar = ...,
        **kwargs: Any,
    ) -> np_1darray: ...
    @property
    def empty(self) -> bool: ...
    def max(
        self, axis: AxisIndex | None = ..., skipna: bool = ..., **kwargs: Any
    ) -> S1: ...
    def min(
        self, axis: AxisIndex | None = ..., skipna: bool = ..., **kwargs: Any
    ) -> S1: ...
    def argmax(
        self,
        axis: AxisIndex | None = ...,
        skipna: bool = True,
        *args: Any,
        **kwargs: Any,
    ) -> np.int64:
        """
Return int position of the largest value in the Series.

If the maximum is achieved in multiple locations,
the first row position is returned.

Parameters
----------
axis : {None}
    Unused. Parameter needed for compatibility with DataFrame.
skipna : bool, default True
    Exclude NA/null values when showing the result.
*args, **kwargs
    Additional arguments and keywords for compatibility with NumPy.

Returns
-------
int
    Row position of the maximum value.

See Also
--------
Series.argmax : Return position of the maximum value.
Series.argmin : Return position of the minimum value.
numpy.ndarray.argmax : Equivalent method for numpy arrays.
Series.idxmax : Return index label of the maximum values.
Series.idxmin : Return index label of the minimum values.

Examples
--------
Consider dataset containing cereal calories

>>> s = pd.Series({'Corn Flakes': 100.0, 'Almond Delight': 110.0,
...                'Cinnamon Toast Crunch': 120.0, 'Cocoa Puff': 110.0})
>>> s
Corn Flakes              100.0
Almond Delight           110.0
Cinnamon Toast Crunch    120.0
Cocoa Puff               110.0
dtype: float64

>>> s.argmax()
2
>>> s.argmin()
0

The maximum cereal calories is the third element and
the minimum cereal calories is the first element,
since series is zero-indexed.
        """
        pass
    def argmin(
        self,
        axis: AxisIndex | None = ...,
        skipna: bool = True,
        *args: Any,
        **kwargs: Any,
    ) -> np.int64:
        """
Return int position of the smallest value in the Series.

If the minimum is achieved in multiple locations,
the first row position is returned.

Parameters
----------
axis : {None}
    Unused. Parameter needed for compatibility with DataFrame.
skipna : bool, default True
    Exclude NA/null values when showing the result.
*args, **kwargs
    Additional arguments and keywords for compatibility with NumPy.

Returns
-------
int
    Row position of the minimum value.

See Also
--------
Series.argmin : Return position of the minimum value.
Series.argmax : Return position of the maximum value.
numpy.ndarray.argmin : Equivalent method for numpy arrays.
Series.idxmax : Return index label of the maximum values.
Series.idxmin : Return index label of the minimum values.

Examples
--------
Consider dataset containing cereal calories

>>> s = pd.Series({'Corn Flakes': 100.0, 'Almond Delight': 110.0,
...                'Cinnamon Toast Crunch': 120.0, 'Cocoa Puff': 110.0})
>>> s
Corn Flakes              100.0
Almond Delight           110.0
Cinnamon Toast Crunch    120.0
Cocoa Puff               110.0
dtype: float64

>>> s.argmax()
2
>>> s.argmin()
0

The maximum cereal calories is the third element and
the minimum cereal calories is the first element,
since series is zero-indexed.
        """
        pass
    def tolist(self) -> list[S1]: ...
    def to_list(self) -> list[S1]: ...
    def __iter__(self) -> Iterator[S1]: ...
    @property
    def hasnans(self) -> bool: ...
    @overload
    def value_counts(
        self,
        normalize: Literal[False] = False,
        sort: bool = ...,
        ascending: bool = ...,
        bins: int | None = ...,
        dropna: bool = ...,
    ) -> Series[int]: ...
    @overload
    def value_counts(
        self,
        normalize: Literal[True],
        sort: bool = ...,
        ascending: bool = ...,
        bins: int | None = ...,
        dropna: bool = ...,
    ) -> Series[float]: ...
    def nunique(self, dropna: bool = True) -> int: ...
    @property
    def is_unique(self) -> bool: ...
    @property
    def is_monotonic_decreasing(self) -> bool: ...
    @property
    def is_monotonic_increasing(self) -> bool: ...
    def factorize(
        self, sort: bool = False, use_na_sentinel: bool = True
    ) -> tuple[np_1darray, np_1darray | Index | Categorical]:
        """
Encode the object as an enumerated type or categorical variable.

This method is useful for obtaining a numeric representation of an
array when all that matters is identifying distinct values. `factorize`
is available as both a top-level function :func:`pandas.factorize`,
and as a method :meth:`Series.factorize` and :meth:`Index.factorize`.

Parameters
----------
sort : bool, default False
    Sort `uniques` and shuffle `codes` to maintain the
    relationship.

use_na_sentinel : bool, default True
    If True, the sentinel -1 will be used for NaN values. If False,
    NaN values will be encoded as non-negative integers and will not drop the
    NaN from the uniques of the values.

    .. versionadded:: 1.5.0

Returns
-------
codes : ndarray
    An integer ndarray that's an indexer into `uniques`.
    ``uniques.take(codes)`` will have the same values as `values`.
uniques : ndarray, Index, or Categorical
    The unique valid values. When `values` is Categorical, `uniques`
    is a Categorical. When `values` is some other pandas object, an
    `Index` is returned. Otherwise, a 1-D ndarray is returned.

    .. note::

       Even if there's a missing value in `values`, `uniques` will
       *not* contain an entry for it.

See Also
--------
cut : Discretize continuous-valued array.
unique : Find the unique value in an array.

Notes
-----
Reference :ref:`the user guide <reshaping.factorize>` for more examples.

Examples
--------
These examples all show factorize as a top-level method like
``pd.factorize(values)``. The results are identical for methods like
:meth:`Series.factorize`.

>>> codes, uniques = pd.factorize(np.array(['b', 'b', 'a', 'c', 'b'], dtype="O"))
>>> codes
array([0, 0, 1, 2, 0])
>>> uniques
array(['b', 'a', 'c'], dtype=object)

With ``sort=True``, the `uniques` will be sorted, and `codes` will be
shuffled so that the relationship is the maintained.

>>> codes, uniques = pd.factorize(np.array(['b', 'b', 'a', 'c', 'b'], dtype="O"),
...                               sort=True)
>>> codes
array([1, 1, 0, 2, 1])
>>> uniques
array(['a', 'b', 'c'], dtype=object)

When ``use_na_sentinel=True`` (the default), missing values are indicated in
the `codes` with the sentinel value ``-1`` and missing values are not
included in `uniques`.

>>> codes, uniques = pd.factorize(np.array(['b', None, 'a', 'c', 'b'], dtype="O"))
>>> codes
array([ 0, -1,  1,  2,  0])
>>> uniques
array(['b', 'a', 'c'], dtype=object)

Thus far, we've only factorized lists (which are internally coerced to
NumPy arrays). When factorizing pandas objects, the type of `uniques`
will differ. For Categoricals, a `Categorical` is returned.

>>> cat = pd.Categorical(['a', 'a', 'c'], categories=['a', 'b', 'c'])
>>> codes, uniques = pd.factorize(cat)
>>> codes
array([0, 0, 1])
>>> uniques
['a', 'c']
Categories (3, object): ['a', 'b', 'c']

Notice that ``'b'`` is in ``uniques.categories``, despite not being
present in ``cat.values``.

For all other pandas objects, an Index of the appropriate type is
returned.

>>> cat = pd.Series(['a', 'a', 'c'])
>>> codes, uniques = pd.factorize(cat)
>>> codes
array([0, 0, 1])
>>> uniques
Index(['a', 'c'], dtype='object')

If NaN is in the values, and we want to include NaN in the uniques of the
values, it can be achieved by setting ``use_na_sentinel=False``.

>>> values = np.array([1, 2, 1, np.nan])
>>> codes, uniques = pd.factorize(values)  # default: use_na_sentinel=True
>>> codes
array([ 0,  1,  0, -1])
>>> uniques
array([1., 2.])

>>> codes, uniques = pd.factorize(values, use_na_sentinel=False)
>>> codes
array([0, 1, 0, 2])
>>> uniques
array([ 1.,  2., nan])
        """
        pass
    @overload
    def searchsorted(
        self,
        value: ListLike,
        side: Literal["left", "right"] = ...,
        sorter: ListLike | None = None,
    ) -> np_1darray_intp:
        """
Find indices where elements should be inserted to maintain order.

Find the indices into a sorted Index `self` such that, if the
corresponding elements in `value` were inserted before the indices,
the order of `self` would be preserved.

.. note::

    The Index *must* be monotonically sorted, otherwise
    wrong locations will likely be returned. Pandas does *not*
    check this for you.

Parameters
----------
value : array-like or scalar
    Values to insert into `self`.
side : {'left', 'right'}, optional
    If 'left', the index of the first suitable location found is given.
    If 'right', return the last such index.  If there is no suitable
    index, return either 0 or N (where N is the length of `self`).
sorter : 1-D array-like, optional
    Optional array of integer indices that sort `self` into ascending
    order. They are typically the result of ``np.argsort``.

Returns
-------
int or array of int
    A scalar or array of insertion points with the
    same shape as `value`.

See Also
--------
sort_values : Sort by the values along either axis.
numpy.searchsorted : Similar method from NumPy.

Notes
-----
Binary search is used to find the required insertion points.

Examples
--------
>>> ser = pd.Series([1, 2, 3])
>>> ser
0    1
1    2
2    3
dtype: int64

>>> ser.searchsorted(4)
3

>>> ser.searchsorted([0, 4])
array([0, 3])

>>> ser.searchsorted([1, 3], side='left')
array([0, 2])

>>> ser.searchsorted([1, 3], side='right')
array([1, 3])

>>> ser = pd.Series(pd.to_datetime(['3/11/2000', '3/12/2000', '3/13/2000']))
>>> ser
0   2000-03-11
1   2000-03-12
2   2000-03-13
dtype: datetime64[ns]

>>> ser.searchsorted('3/14/2000')
3

>>> ser = pd.Categorical(
...     ['apple', 'bread', 'bread', 'cheese', 'milk'], ordered=True
... )
>>> ser
['apple', 'bread', 'bread', 'cheese', 'milk']
Categories (4, object): ['apple' < 'bread' < 'cheese' < 'milk']

>>> ser.searchsorted('bread')
1

>>> ser.searchsorted(['bread'], side='right')
array([3])

If the values are not monotonically sorted, wrong locations
may be returned:

>>> ser = pd.Series([2, 1, 3])
>>> ser
0    2
1    1
2    3
dtype: int64

>>> ser.searchsorted(1)  # doctest: +SKIP
0  # wrong result, correct would be 1
        """
        pass
    @overload
    def searchsorted(
        self,
        value: Scalar,
        side: Literal["left", "right"] = ...,
        sorter: ListLike | None = None,
    ) -> np.intp: ...
    def drop_duplicates(self, *, keep: DropKeep = ...) -> Self: ...

ScalarArrayIndexJustInt: TypeAlias = (
    Just[int]
    | np.integer
    | Sequence[Just[int] | np.integer]
    | np_ndarray_anyint
    | IntegerArray
    | Index[int]
)
ScalarArrayIndexSeriesJustInt: TypeAlias = ScalarArrayIndexJustInt | Series[int]
ScalarArrayIndexJustFloat: TypeAlias = (
    Just[float]
    | np.floating
    | Sequence[Just[float] | np.floating]
    | np_ndarray_float
    | FloatingArray
    | Index[float]
)
ScalarArrayIndexSeriesJustFloat: TypeAlias = ScalarArrayIndexJustFloat | Series[float]
ScalarArrayIndexJustComplex: TypeAlias = (
    Just[complex]
    | np.complexfloating
    | Sequence[Just[complex] | np.complexfloating]
    | np_ndarray_complex
    | Index[complex]
)
ScalarArrayIndexSeriesJustComplex: TypeAlias = (
    ScalarArrayIndexJustComplex | Series[complex]
)

NumpyRealScalar: TypeAlias = np.bool | np.integer | np.floating
IndexReal: TypeAlias = Index[bool] | Index[int] | Index[float]
ScalarArrayIndexReal: TypeAlias = (
    float
    | Sequence[float | NumpyRealScalar]
    | NumpyRealScalar
    | npt.NDArray[NumpyRealScalar]
    | ExtensionArray
    | IndexReal
)
SeriesReal: TypeAlias = Series[bool] | Series[int] | Series[float]
ScalarArrayIndexSeriesReal: TypeAlias = ScalarArrayIndexReal | SeriesReal

NumpyComplexScalar: TypeAlias = NumpyRealScalar | np.complexfloating
IndexComplex: TypeAlias = IndexReal | Index[complex]
ScalarArrayIndexComplex: TypeAlias = (
    complex
    | Sequence[complex | NumpyComplexScalar]
    | NumpyComplexScalar
    | npt.NDArray[NumpyComplexScalar]
    | ExtensionArray
    | IndexComplex
)
SeriesComplex: TypeAlias = SeriesReal | Series[complex]
ScalarArrayIndexSeriesComplex: TypeAlias = ScalarArrayIndexComplex | SeriesComplex

ArrayIndexTimedeltaNoSeq: TypeAlias = np_ndarray_td | TimedeltaArray | TimedeltaIndex
ScalarArrayIndexTimedelta: TypeAlias = (
    timedelta
    | np.timedelta64
    | Sequence[timedelta | np.timedelta64]
    | ArrayIndexTimedeltaNoSeq
)
ArrayIndexSeriesTimedeltaNoSeq: TypeAlias = ArrayIndexTimedeltaNoSeq | Series[Timedelta]
ScalarArrayIndexSeriesTimedelta: TypeAlias = (
    ScalarArrayIndexTimedelta | Series[Timedelta]
)

NumListLike: TypeAlias = (  # TODO: pandas-dev/pandas-stubs#1474 deprecated, do not use
    ExtensionArray
    | np_ndarray_bool
    | np_ndarray_anyint
    | np_ndarray_float
    | np_ndarray_complex
    | dict[str, np_ndarray]
    | Sequence[complex]
)

@type_check_only
class ElementOpsMixin(Generic[S2]):
    @overload
    def _proto_add(
        self: ElementOpsMixin[bool], other: bool | np.bool_
    ) -> ElementOpsMixin[bool]: ...
    @overload
    def _proto_add(
        self: ElementOpsMixin[int], other: int | np.integer
    ) -> ElementOpsMixin[int]: ...
    @overload
    def _proto_add(
        self: ElementOpsMixin[float], other: float | np.floating
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_add(
        self: ElementOpsMixin[complex], other: complex | np.complexfloating
    ) -> ElementOpsMixin[complex]: ...
    @overload
    def _proto_add(self: ElementOpsMixin[str], other: str) -> ElementOpsMixin[str]: ...
    @overload
    def _proto_radd(
        self: ElementOpsMixin[bool], other: bool | np.bool_
    ) -> ElementOpsMixin[bool]: ...
    @overload
    def _proto_radd(
        self: ElementOpsMixin[int], other: int | np.integer
    ) -> ElementOpsMixin[int]: ...
    @overload
    def _proto_radd(
        self: ElementOpsMixin[float], other: float | np.floating
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_radd(
        self: ElementOpsMixin[complex], other: complex | np.complexfloating
    ) -> ElementOpsMixin[complex]: ...
    @overload
    def _proto_radd(self: ElementOpsMixin[str], other: str) -> ElementOpsMixin[str]: ...
    @overload
    def _proto_mul(
        self: ElementOpsMixin[bool], other: bool | np.bool_
    ) -> ElementOpsMixin[bool]: ...
    @overload
    def _proto_mul(
        self: ElementOpsMixin[int], other: int | np.integer
    ) -> ElementOpsMixin[int]: ...
    @overload
    def _proto_mul(
        self: ElementOpsMixin[float], other: float | np.floating
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_mul(
        self: ElementOpsMixin[complex], other: complex | np.complexfloating
    ) -> ElementOpsMixin[complex]: ...
    @overload
    def _proto_mul(
        self: ElementOpsMixin[Timedelta],
        other: Just[int] | Just[float] | np.integer | np.floating,
    ) -> ElementOpsMixin[Timedelta]: ...
    @overload
    def _proto_mul(
        self: ElementOpsMixin[str], other: Just[int] | np.integer
    ) -> ElementOpsMixin[str]: ...
    @overload
    def _proto_rmul(
        self: ElementOpsMixin[bool], other: bool | np.bool_
    ) -> ElementOpsMixin[bool]: ...
    @overload
    def _proto_rmul(
        self: ElementOpsMixin[int], other: int | np.integer
    ) -> ElementOpsMixin[int]: ...
    @overload
    def _proto_rmul(
        self: ElementOpsMixin[float], other: float | np.floating
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_rmul(
        self: ElementOpsMixin[complex], other: complex | np.complexfloating
    ) -> ElementOpsMixin[complex]: ...
    @overload
    def _proto_rmul(
        self: ElementOpsMixin[Timedelta],
        other: Just[int] | Just[float] | np.integer | np.floating,
    ) -> ElementOpsMixin[Timedelta]: ...
    @overload
    def _proto_rmul(
        self: ElementOpsMixin[str], other: Just[int] | np.integer
    ) -> ElementOpsMixin[str]: ...
    @overload
    def _proto_truediv(
        self: ElementOpsMixin[int], other: int | np.integer
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_truediv(
        self: ElementOpsMixin[float], other: float | np.floating
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_truediv(
        self: ElementOpsMixin[complex], other: complex | np.complexfloating
    ) -> ElementOpsMixin[complex]: ...
    @overload
    def _proto_truediv(
        self: ElementOpsMixin[Timedelta], other: timedelta | np.timedelta64 | Timedelta
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_rtruediv(
        self: ElementOpsMixin[int], other: int | np.integer
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_rtruediv(
        self: ElementOpsMixin[float], other: float | np.floating
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_rtruediv(
        self: ElementOpsMixin[complex], other: complex | np.complexfloating
    ) -> ElementOpsMixin[complex]: ...
    @overload
    def _proto_rtruediv(
        self: ElementOpsMixin[Timedelta], other: timedelta | np.timedelta64 | Timedelta
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_floordiv(
        self: ElementOpsMixin[int], other: int | np.integer
    ) -> ElementOpsMixin[int]: ...
    @overload
    def _proto_floordiv(
        self: ElementOpsMixin[float], other: float | np.floating
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_floordiv(
        self: ElementOpsMixin[Timedelta], other: timedelta | np.timedelta64 | Timedelta
    ) -> ElementOpsMixin[int]: ...
    @overload
    def _proto_rfloordiv(
        self: ElementOpsMixin[int], other: int | np.integer
    ) -> ElementOpsMixin[int]: ...
    @overload
    def _proto_rfloordiv(
        self: ElementOpsMixin[float], other: float | np.floating
    ) -> ElementOpsMixin[float]: ...
    @overload
    def _proto_rfloordiv(
        self: ElementOpsMixin[Timedelta], other: timedelta | np.timedelta64 | Timedelta
    ) -> ElementOpsMixin[int]: ...

@type_check_only
class Supports_ProtoAdd(Protocol[T_contra, S2]):
    def _proto_add(self, other: T_contra, /) -> ElementOpsMixin[S2]: ...

@type_check_only
class Supports_ProtoRAdd(Protocol[T_contra, S2]):
    def _proto_radd(self, other: T_contra, /) -> ElementOpsMixin[S2]: ...

@type_check_only
class Supports_ProtoMul(Protocol[T_contra, S2]):
    def _proto_mul(self, other: T_contra, /) -> ElementOpsMixin[S2]: ...

@type_check_only
class Supports_ProtoRMul(Protocol[T_contra, S2]):
    def _proto_rmul(self, other: T_contra, /) -> ElementOpsMixin[S2]: ...

@type_check_only
class Supports_ProtoTrueDiv(Protocol[T_contra, S2]):
    def _proto_truediv(self, other: T_contra, /) -> ElementOpsMixin[S2]: ...

@type_check_only
class Supports_ProtoRTrueDiv(Protocol[T_contra, S2]):
    def _proto_rtruediv(self, other: T_contra, /) -> ElementOpsMixin[S2]: ...

@type_check_only
class Supports_ProtoFloorDiv(Protocol[T_contra, S2]):
    def _proto_floordiv(self, other: T_contra, /) -> ElementOpsMixin[S2]: ...

@type_check_only
class Supports_ProtoRFloorDiv(Protocol[T_contra, S2]):
    def _proto_rfloordiv(self, other: T_contra, /) -> ElementOpsMixin[S2]: ...
