from __future__ import annotations

import functools
import itertools
from typing import (
    TYPE_CHECKING,
    Any,
    Literal,
    cast,
)
import warnings

import numpy as np

from pandas._config.config import _global_config as config

from pandas._libs import (
    NaT,
    NaTType,
    iNaT,
    lib,
)
import pandas._libs.algos as libalgos
from pandas._libs.tslibs import OutOfBoundsTimedelta
from pandas.compat._optional import import_optional_dependency

from pandas.core.dtypes.common import (
    ensure_float64,
    is_float,
    is_float_dtype,
    is_integer,
    is_numeric_dtype,
    is_object_dtype,
    needs_i8_conversion,
    pandas_dtype,
)
from pandas.core.dtypes.missing import (
    isna,
    na_value_for_dtype,
    notna,
)

if TYPE_CHECKING:
    from collections.abc import Callable

    from pandas._typing import (
        ArrayLike,
        AxisInt,
        CorrelationMethod,
        Dtype,
        DtypeObj,
        F,
        Scalar,
        Shape,
        npt,
    )

bn = import_optional_dependency("bottleneck", errors="warn")
_BOTTLENECK_INSTALLED = bn is not None
_USE_BOTTLENECK = False


def set_use_bottleneck(v: bool = True) -> None:
    # set/unset to use bottleneck
    global _USE_BOTTLENECK
    if _BOTTLENECK_INSTALLED:
        _USE_BOTTLENECK = v


set_use_bottleneck(config["compute"]["use_bottleneck"])


class disallow:
    def __init__(self, *dtypes: Dtype) -> None:
        super().__init__()
        self.dtypes = tuple(pandas_dtype(dtype).type for dtype in dtypes)

    def check(self, obj) -> bool:
        return hasattr(obj, "dtype") and issubclass(obj.dtype.type, self.dtypes)

    def __call__(self, f: F) -> F:
        @functools.wraps(f)
        def _f(*args, **kwargs):
            obj_iter = itertools.chain(args, kwargs.values())
            if any(self.check(obj) for obj in obj_iter):
                f_name = f.__name__.replace("nan", "")
                raise TypeError(
                    f"reduction operation '{f_name}' not allowed for this dtype"
                )
            try:
                return f(*args, **kwargs)
            except ValueError as e:
                # we want to transform an object array
                # ValueError message to the more typical TypeError
                # e.g. this is normally a disallowed function on
                # object arrays that contain strings
                if is_object_dtype(args[0]):
                    raise TypeError(e) from e
                raise

        return cast("F", _f)


class bottleneck_switch:
    def __init__(self, name=None, **kwargs) -> None:
        self.name = name
        self.kwargs = kwargs

    def __call__(self, alt: F) -> F:
        bn_name = self.name or alt.__name__

        try:
            bn_func = getattr(bn, bn_name)
        except (AttributeError, NameError):  # pragma: no cover
            bn_func = None

        @functools.wraps(alt)
        def f(
            values: np.ndarray,
            *,
            axis: AxisInt | None = None,
            skipna: bool = True,
            **kwds,
        ):
            if len(self.kwargs) > 0:
                for k, v in self.kwargs.items():
                    if k not in kwds:
                        kwds[k] = v

            # GH#18976 bottleneck's nanmin/nanmax raise on empty input; skip it
            #  and let each nanops function return the NA for its own dtype.
            if (
                _USE_BOTTLENECK
                and skipna
                and values.size > 0
                and _bn_ok_dtype(values.dtype, bn_name)
            ):
                if kwds.get("mask", None) is None:
                    # `mask` is not recognised by bottleneck, would raise
                    #  TypeError if called
                    kwds.pop("mask", None)
                    result = bn_func(values, axis=axis, **kwds)  # pyright: ignore[reportOptionalCall]

                    # prefer to treat inf/-inf as NA, but must compute the func
                    # twice :(
                    if _has_infs(result):
                        result = alt(values, axis=axis, skipna=skipna, **kwds)
                else:
                    result = alt(values, axis=axis, skipna=skipna, **kwds)
            else:
                result = alt(values, axis=axis, skipna=skipna, **kwds)

            return result

        return cast("F", f)


def _bn_ok_dtype(dtype: DtypeObj, name: str) -> bool:
    # Bottleneck chokes on datetime64, PeriodDtype (or an EA)
    if dtype != object and not needs_i8_conversion(dtype):
        # GH 42878
        # Bottleneck uses naive summation leading to O(n) loss of precision
        # unlike numpy which implements pairwise summation, which has O(log(n)) loss
        # crossref: https://github.com/pydata/bottleneck/issues/379

        # GH 15507
        # bottleneck does not properly upcast during the sum
        # so can overflow

        # GH 9422
        # further we also want to preserve NaN when all elements
        # are NaN, unlike bottleneck/numpy which consider this
        # to be 0
        return name not in ["nansum", "nanprod", "nanmean"]
    return False


def _has_infs(result) -> bool:
    if isinstance(result, np.ndarray):
        if result.dtype in ("f8", "f4"):
            # Note: outside of a nanops-specific test, we always have
            #  result.ndim == 1, so there is no risk of this ravel making a copy.
            return lib.has_infs(result.ravel("K"))
    try:
        return np.isinf(result).any()
    except (TypeError, NotImplementedError):
        # if it doesn't support infs, then it can't have infs
        return False


def _get_fill_value(
    dtype: DtypeObj, fill_value: Scalar | None = None, fill_value_typ=None
):
    """return the correct fill value for the dtype of the values"""
    if fill_value is not None:
        return fill_value
    if _na_ok_dtype(dtype):
        if fill_value_typ is None:
            return np.nan
        elif fill_value_typ == "+inf":
            return np.inf
        else:
            return -np.inf
    elif dtype.kind == "u":
        # Unsigned ints: use a uint64 sentinel so np.where keeps the values
        # unsigned.  An int64 fill would force a uint64/int64 mix to promote to
        # float64, losing precision for values above 2**53 and giving the wrong
        # idxmin/idxmax/min/max (GH#64478).  u8max/0 also bracket the full
        # uint64 range, unlike i8max which real values can exceed.
        if fill_value_typ == "+inf":
            return np.uint64(lib.u8max)
        return np.uint64(0)
    elif fill_value_typ == "+inf":
        # need the max int here
        # Return as np.int64 so that np.where promotes the dtype
        # instead of raising OverflowError (numpy 2.5+) when the
        # value doesn't fit in the array's dtype (e.g. int8).
        return np.int64(lib.i8max)
    else:
        return np.int64(iNaT)


def _maybe_get_mask(
    values: np.ndarray, skipna: bool, mask: npt.NDArray[np.bool_] | None
) -> npt.NDArray[np.bool_] | None:
    """
    Compute a mask if and only if necessary.

    An explicit `mask` is returned unchanged; the values it marks need not be
    NaN (a masked array stores fill values there).  Otherwise one is computed
    with isna(), but only where it is needed: never for boolean or integer
    values, which cannot store NaN, and under skipna=False only for
    datetime64/timedelta64, whose NaT stops being detectable once `_get_values`
    views it as i8 (GH#37392).

    A caller that already holds that i8 view must pass its own mask.

    Parameters
    ----------
    values : ndarray
        input array to potentially compute mask for
    skipna : bool
        boolean for whether NaNs should be skipped
    mask : Optional[ndarray]
        nan-mask if known

    Returns
    -------
    Optional[np.ndarray[bool]]
    """
    if mask is None:
        if values.dtype.kind in "biu":
            # Boolean data cannot contain nulls, so signal via mask being None
            return None

        if skipna or values.dtype.kind in "mM":
            mask = isna(values)

    return mask


def _get_values(
    values: np.ndarray,
    skipna: bool,
    fill_value: Any = None,
    fill_value_typ: str | None = None,
    mask: npt.NDArray[np.bool_] | None = None,
) -> tuple[np.ndarray, npt.NDArray[np.bool_] | None]:
    """
    Utility to get the values view, mask, dtype, dtype_max, and fill_value.

    If both mask and fill_value/fill_value_typ are not None and skipna is True,
    the values array will be copied.

    For input arrays of boolean or integer dtypes, copies will only occur if a
    precomputed mask, a fill_value/fill_value_typ, and skipna=True are
    provided.

    Parameters
    ----------
    values : ndarray
        input array to potentially compute mask for
    skipna : bool
        boolean for whether NaNs should be skipped
    fill_value : Any
        value to fill NaNs with
    fill_value_typ : str
        Set to '+inf' or '-inf' to handle dtype-specific infinities
    mask : Optional[np.ndarray[bool]]
        nan-mask if known

    Returns
    -------
    values : ndarray
        Potential copy of input value array
    mask : Optional[ndarray[bool]]
        Mask for values, if deemed necessary to compute
    """
    # In _get_values is only called from within nanops, and in all cases
    #  with scalar fill_value.  This guarantee is important for the
    #  np.where call below

    mask = _maybe_get_mask(values, skipna, mask)

    dtype = values.dtype

    datetimelike = False
    if values.dtype.kind in "mM":
        # changing timedelta64/datetime64 to int64 needs to happen after
        #  finding `mask` above
        values = np.asarray(values.view("i8"))
        datetimelike = True

    if skipna and (mask is not None):
        # get our fill value (in case we need to provide an alternative
        # dtype for it)
        fill_value = _get_fill_value(
            dtype, fill_value=fill_value, fill_value_typ=fill_value_typ
        )

        if fill_value is not None:
            if mask.any():
                if datetimelike or _na_ok_dtype(dtype):
                    values = values.copy()
                    np.putmask(values, mask, fill_value)
                else:
                    # np.where will promote if needed
                    values = np.where(~mask, values, fill_value)

    return values, mask


def _get_dtype_max(dtype: np.dtype) -> np.dtype:
    # return a platform independent precision dtype
    dtype_max = dtype
    if dtype.kind in "bi":
        dtype_max = np.dtype(np.int64)
    elif dtype.kind == "u":
        dtype_max = np.dtype(np.uint64)
    elif dtype.kind == "f":
        dtype_max = np.dtype(np.float64)
    return dtype_max


def _na_ok_dtype(dtype: DtypeObj) -> bool:
    if needs_i8_conversion(dtype):
        return False
    return not issubclass(dtype.type, np.integer)


def _wrap_results(result, dtype: np.dtype, fill_value=None, result_mask=None):
    """wrap our results if needed"""
    if result is NaT:
        pass

    elif dtype.kind == "M":
        if fill_value is None:
            # GH#24293
            fill_value = iNaT
        if not isinstance(result, np.ndarray):
            assert not isna(fill_value), "Expected non-null fill_value"
            if result == fill_value:
                result = np.nan

            if isna(result):
                result = np.datetime64("NaT", "ns").astype(dtype)
            else:
                result = np.int64(result).view(dtype)
            # retain original unit
            result = result.astype(dtype, copy=False)
        else:
            # If we have float dtype, taking a view will give the wrong result
            result = result.astype(dtype)
    elif dtype.kind == "m":
        if not isinstance(result, np.ndarray):
            if result_mask or result == fill_value or np.isnan(result):
                unit = np.datetime_data(dtype)[0]
                result = np.timedelta64("NaT", unit)  # type: ignore[call-overload]

            elif np.fabs(result) > lib.i8max:
                # GH#43178: raise if the result is too large for the dtype's unit
                raise OutOfBoundsTimedelta("overflow in timedelta operation")
            else:
                # return a timedelta64 with the original unit
                result = np.int64(result).astype(dtype, copy=False)

        else:
            overflow = np.abs(result) > lib.i8max
            if result_mask is not None:
                # GH#43178: positions that will be set to NaT (skipna=False) are
                #  exempt from the overflow check and must not trip the cast below
                overflow &= ~result_mask
                result = np.where(result_mask, 0, result)
            if overflow.any():
                # GH#43178: raise if any result is too large for the dtype's unit
                raise OutOfBoundsTimedelta("overflow in timedelta operation")
            result = result.astype("m8[ns]").view(dtype)

    return result


def _datetimelike_compat(func: F) -> F:
    """
    If we have datetime64 or timedelta64 values, ensure we have a correct
    mask before calling the wrapped function, then cast back afterwards.
    """

    @functools.wraps(func)
    def new_func(
        values: np.ndarray,
        *,
        axis: AxisInt | None = None,
        skipna: bool = True,
        mask: npt.NDArray[np.bool_] | None = None,
        **kwargs,
    ):
        orig_values = values

        datetimelike = values.dtype.kind in "mM"
        if datetimelike and mask is None:
            mask = isna(values)

        result = func(values, axis=axis, skipna=skipna, mask=mask, **kwargs)

        if datetimelike:
            if getattr(result, "dtype", None) == orig_values.dtype:
                # GH#18976 empty input short-circuits to NA of the original
                #  dtype; _wrap_results expects an i8 result and would compare
                #  this against iNaT.
                return result

            result_mask = None
            if not skipna:
                assert mask is not None  # checked above
                # positions with any NA reduce to NaT, so exempt them from the
                # timedelta overflow guard in _wrap_results
                if isinstance(result, np.ndarray):
                    result_mask = mask.any(axis=axis)
                else:
                    result_mask = mask.any()
            result = _wrap_results(
                result, orig_values.dtype, fill_value=iNaT, result_mask=result_mask
            )
            if not skipna:
                assert mask is not None  # checked above
                result = _mask_datetimelike_result(result, axis, mask, orig_values)

        return result

    return cast("F", new_func)


def _na_for_min_count(values: np.ndarray, axis: AxisInt | None) -> Scalar | np.ndarray:
    """
    Return the missing value for `values`.

    Parameters
    ----------
    values : ndarray
    axis : int or None
        axis for the reduction, required if values.ndim > 1.

    Returns
    -------
    result : scalar or ndarray
        For 1-D values, returns a scalar of the correct missing type.
        For 2-D values, returns a 1-D array where each element is missing.
    """
    # we either return np.nan or pd.NaT
    if values.dtype.kind in "iufcb":
        values = values.astype("float64")
    fill_value = na_value_for_dtype(values.dtype)

    if values.ndim == 1:
        return fill_value
    elif axis is None:
        return fill_value
    else:
        result_shape = values.shape[:axis] + values.shape[axis + 1 :]

        return np.full(result_shape, fill_value, dtype=values.dtype)


def maybe_operate_rowwise(func: F) -> F:
    """
    NumPy operations on C-contiguous ndarrays with axis=1 can be
    very slow if axis 1 >> axis 0.
    Operate row-by-row and concatenate the results.
    """

    @functools.wraps(func)
    def newfunc(values: np.ndarray, *, axis: AxisInt | None = None, **kwargs):
        if (
            axis == 1
            and values.ndim == 2
            and values.flags["C_CONTIGUOUS"]
            # only takes this path for wide arrays (long dataframes), for threshold see
            # https://github.com/pandas-dev/pandas/pull/43311#issuecomment-974891737
            and (values.shape[1] / 1000) > values.shape[0]
            and values.dtype not in (object, bool)
        ):
            arrs = list(values)
            if kwargs.get("mask") is not None:
                mask = kwargs.pop("mask")
                results = [
                    func(arrs[i], mask=mask[i], **kwargs) for i in range(len(arrs))
                ]
            else:
                results = [func(x, **kwargs) for x in arrs]
            return np.array(results)

        return func(values, axis=axis, **kwargs)

    return cast("F", newfunc)


def _ensure_numeric(values: np.ndarray) -> np.ndarray:
    """
    Convert an object-dtype ndarray to the numeric dtype it represents.

    Object-dtype input is normalized once, up front, so that every statistic
    below can treat it exactly as it treats the equivalent float64/complex128
    array.  Whatever a reduction does for ``np.float64`` input it now also does
    for an object array of floats, and any object array that does not hold
    numbers raises ``TypeError`` from here instead of from whichever ufunc
    happened to touch it first.

    Parameters
    ----------
    values : np.ndarray

    Returns
    -------
    np.ndarray
        `values` unchanged if it is not object-dtype, else the equivalent
        float64 or complex128 array.

    Raises
    ------
    TypeError
        If the values are not numbers.
    """
    if values.dtype.kind in "SUT":
        raise TypeError(f"Could not convert {values.dtype} values to numeric")
    if values.dtype != object:
        return values

    # GH#44008, GH#36703 strings (and datetime64s) convert to a number without
    #  complaint, so reject them before numpy gets the chance
    non_numeric, has_nat = lib.first_non_numeric(values)
    if non_numeric is not None:
        raise TypeError(f"Could not convert {non_numeric!r} to numeric")

    if has_nat:
        # A datetime64/timedelta64 NaT is an NA, but numpy casts it to the
        #  int64 sentinel rather than to NaN.  Swap in None, which every cast
        #  below maps to the same NaN it maps pd.NaT to.  Any *non*-NaT
        #  datetime64 was already rejected above, so every one left is an NA.
        nat_mask = np.array(
            [isinstance(val, (np.datetime64, np.timedelta64)) for val in values.ravel()]
        )
        values = values.copy()
        values[nat_mask.reshape(values.shape)] = None

    try:
        with warnings.catch_warnings():
            # numpy *scalars* have __float__ and so cast to float64 while
            #  discarding the imaginary part; only Python complex refuses
            #  outright.  Treat the warning as a failed cast either way, so a
            #  complex payload falls through to complex128 below (GH#34671).
            warnings.simplefilter("error", np.exceptions.ComplexWarning)
            return values.astype(np.float64)
    except (TypeError, ValueError, np.exceptions.ComplexWarning):
        pass

    mask = isna(values)
    filled = values
    if mask.any():
        # None/NaT/pd.NA have no float(); retry with the NaN sentinel
        filled = values.copy()
        filled[mask] = np.nan
        try:
            with warnings.catch_warnings():
                warnings.simplefilter("error", np.exceptions.ComplexWarning)
                return filled.astype(np.float64)
        except (TypeError, ValueError, np.exceptions.ComplexWarning):
            pass

    if mask.any():
        # e.g. complex, which has no __float__.  Convert each NA on its own so
        #  it lands where an object->complex128 cast would have put it: None
        #  and the NAs numpy refuses outright on nan+nanj, np.nan on nan+0j.
        filled[mask] = [_to_complex(val) for val in values[mask]]

    try:
        return filled.astype(np.complex128)
    except (TypeError, ValueError) as err:
        # GH#29941 e.g. Timestamps, or elements that are themselves list-like
        raise TypeError(
            f"Could not convert {_first_unconvertible(filled)!r} to numeric"
        ) from err


def _to_complex(val: Any) -> complex:
    """
    Convert `val` to a complex, mapping an NA that has no complex() to nan+nanj.
    """
    try:
        return complex(val)
    except (TypeError, ValueError):
        return complex(np.nan, np.nan)


def _complex_castable(val: Any) -> bool:
    """
    Whether numpy's object->complex128 cast can handle `val` on its own.
    """
    try:
        complex(val)
    except (TypeError, ValueError):
        return False
    return True


def _first_unconvertible(values: np.ndarray) -> object:
    """
    Return the element responsible for a failed `_ensure_numeric` conversion.
    """
    for val in values.ravel():
        if not _complex_castable(val):
            return val
    # numpy balked at something complex() accepts; report the values as a whole
    return values


def _ensure_numeric_input(func: F) -> F:
    """
    Normalize object-dtype values before the wrapped reduction sees them.
    """

    @functools.wraps(func)
    def new_func(values: np.ndarray, **kwargs):
        return func(_ensure_numeric(values), **kwargs)

    return cast("F", new_func)


def dt64_any_all_msg(how: str) -> str:
    """
    Message for the GH#34479 removal of any/all on datetime64 data.

    Shared so that the reduction, groupby and sparse paths cannot drift apart.
    """
    return (
        f"'{how}' with datetime64 dtypes is not supported. "
        f"Use (obj != pd.Timestamp(0)).{how}() instead."
    )


def nanany(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    mask: npt.NDArray[np.bool_] | None = None,
) -> bool:
    """
    Check if any elements along an axis evaluate to True.

    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    result : bool

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, 2])
    >>> nanops.nanany(s.values)
    np.True_

    >>> from pandas.core import nanops
    >>> s = pd.Series([np.nan])
    >>> nanops.nanany(s.values)
    np.False_
    """
    if values.dtype.kind in "iub" and mask is None:
        # GH#26032 fastpath
        # error: Incompatible return value type (got "Union[bool_, ndarray]",
        # expected "bool")
        return values.any(axis)  # type: ignore[return-value]

    if values.dtype.kind == "M":
        # GH#34479
        raise TypeError(dt64_any_all_msg("any"))

    values, _ = _get_values(values, skipna, fill_value=False, mask=mask)

    # For object type, any won't necessarily return
    # boolean values (numpy/numpy#4352)
    if values.dtype == object:
        values = values.astype(bool)

    # error: Incompatible return value type (got "Union[bool_, ndarray]", expected
    # "bool")
    return values.any(axis)  # type: ignore[return-value]


def nanall(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    mask: npt.NDArray[np.bool_] | None = None,
) -> bool:
    """
    Check if all elements along an axis evaluate to True.

    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    result : bool

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, 2, np.nan])
    >>> nanops.nanall(s.values)
    np.True_

    >>> from pandas.core import nanops
    >>> s = pd.Series([1, 0])
    >>> nanops.nanall(s.values)
    np.False_
    """
    if values.dtype.kind in "iub" and mask is None:
        # GH#26032 fastpath
        # error: Incompatible return value type (got "Union[bool_, ndarray]",
        # expected "bool")
        return values.all(axis)  # type: ignore[return-value]

    if values.dtype.kind == "M":
        # GH#34479
        raise TypeError(dt64_any_all_msg("all"))

    values, _ = _get_values(values, skipna, fill_value=True, mask=mask)

    # For object type, all won't necessarily return
    # boolean values (numpy/numpy#4352)
    if values.dtype == object:
        values = values.astype(bool)

    # error: Incompatible return value type (got "Union[bool_, ndarray]", expected
    # "bool")
    return values.all(axis)  # type: ignore[return-value]


@disallow("M8")
@_datetimelike_compat
@maybe_operate_rowwise
def nansum(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    min_count: int = 0,
    mask: npt.NDArray[np.bool_] | None = None,
) -> np.ndarray | np.int64 | float | NaTType:
    """
    Sum the elements along an axis ignoring NaNs

    Parameters
    ----------
    values : ndarray[dtype]
    axis : int, optional
    skipna : bool, default True
    min_count: int, default 0
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    result : dtype

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, 2, np.nan])
    >>> nanops.nansum(s.values)
    np.float64(3.0)
    """
    dtype = values.dtype
    if dtype == object and not skipna:
        if mask is None:
            mask = isna(values)
        if mask.any():
            # GH#4147: an object NA that supports addition propagates on its
            # own and carries more type information than a bare nan would
            # (pd.NA, Decimal("NaN"), NaT), so prefer it.  Step in only when it
            # does not, e.g. None, or values that cannot be added at all.
            try:
                the_sum = values.sum(axis, dtype=_get_dtype_max(dtype))
            except TypeError:
                pass
            else:
                return _maybe_null_out(
                    the_sum, axis, mask, values.shape, min_count=min_count
                )
            if values.ndim == 1 or axis is None:
                # error: Incompatible return value type (got "Union[Scalar,
                # ndarray]", expected "Union[ndarray, float, NaTType]")
                return _na_for_min_count(values, axis)  # type: ignore[return-value]
            values = _blank_object_na_slices(values, mask, axis, 0)
            min_count = _na_min_count(values, axis, min_count)
    else:
        values, mask = _get_values(values, skipna, fill_value=0, mask=mask)
    dtype_sum = _get_dtype_max(dtype)
    if dtype.kind == "f":
        # GH#43929 float16 sum overflows easily; upcast like numpy does
        dtype_sum = np.dtype(np.float64) if dtype == np.float16 else dtype
    elif dtype.kind == "m":
        # GH#66551 float64 has a 53-bit mantissa, so accumulating i8 values there
        #  silently rounds the result; sum in int64 instead.
        the_sum = _sum_timedelta_i8(values, axis, mask, skipna, min_count)
        return _maybe_null_out(
            the_sum, axis, mask, values.shape, min_count=min_count, datetimelike=True
        )

    the_sum = values.sum(axis, dtype=dtype_sum)
    the_sum = _maybe_null_out(the_sum, axis, mask, values.shape, min_count=min_count)

    return the_sum


def _sum_timedelta_i8(
    values: np.ndarray,
    axis: AxisInt | None,
    mask: npt.NDArray[np.bool_] | None,
    skipna: bool,
    min_count: int,
) -> np.ndarray | np.int64:
    """
    Sum i8-viewed timedelta64 values exactly, raising on a result that does not
    fit in int64.

    int64 addition is modular, so an intermediate that leaves the range does not
    corrupt the total; only the total itself has to be checked.  Values are
    summed in blocks short enough that each block sum provably stays in range,
    and the block sums are then combined in Python ints.

    Parameters
    ----------
    values : ndarray[int64]
    axis : int, optional
    mask : ndarray[bool], optional
    skipna : bool
    min_count : int

    Returns
    -------
    int64 scalar or ndarray[int64]

    Raises
    ------
    OutOfBoundsTimedelta
        If the exact sum is not representable as a timedelta64, i.e. it lands
        outside (iNaT, i8max].
    """
    # Results that get discarded downstream are exempt from the bounds check:
    #  positions that reduce to NaT under skipna=False, as in _wrap_results, and
    #  positions that min_count nulls out in _maybe_null_out.
    exempt: np.ndarray | np.bool_ = np.False_
    if not skipna and mask is not None:
        exempt = mask.any(axis=axis)
    if min_count > 0:
        if axis is not None and values.ndim > 1:
            counts = values.shape[axis] - (0 if mask is None else mask.sum(axis))
            exempt = exempt | (counts < min_count)
        else:
            exempt = exempt | check_below_min_count(values.shape, mask, min_count)

    if not skipna and mask is not None and mask.any():
        # These positions reduce to NaT regardless of what they sum to, so keep
        #  their raw iNaT out of the accumulator.
        values = np.where(mask, 0, values)

    if values.size == 0:
        return values.sum(axis, dtype=np.int64)

    reduce_axis: AxisInt = 0
    if axis is None:
        values = values.ravel()
    else:
        reduce_axis = axis

    length = values.shape[reduce_axis]
    largest = max(abs(int(values.min())), abs(int(values.max())))
    block = lib.i8max // largest if largest else length

    if block >= length:
        # Every partial sum is bounded by length * largest <= i8max, so the
        #  int64 sum is exact and in range.
        return values.sum(reduce_axis, dtype=np.int64)

    starts = np.arange(0, length, block)
    block_sums = np.add.reduceat(values, starts, axis=reduce_axis)
    total = block_sums.sum(reduce_axis, dtype=object)

    out_of_range = (total <= iNaT) | (total > lib.i8max)
    if np.any(out_of_range & ~exempt):
        raise OutOfBoundsTimedelta("overflow in timedelta operation")

    if isinstance(total, np.ndarray):
        # anything still out of range here is exempt, i.e. is discarded anyway
        return np.where(out_of_range, 0, total).astype(np.int64)
    return np.int64(0 if out_of_range else total)


def _mask_datetimelike_result(
    result: np.ndarray | np.datetime64 | np.timedelta64,
    axis: AxisInt | None,
    mask: npt.NDArray[np.bool_],
    orig_values: np.ndarray,
) -> np.ndarray | np.datetime64 | np.timedelta64 | NaTType:
    if isinstance(result, np.ndarray):
        # we need to apply the mask
        result = result.astype("i8").view(orig_values.dtype)
        axis_mask = mask.any(axis=axis)
        result[axis_mask] = iNaT
    elif mask.any():
        return np.int64(iNaT).view(orig_values.dtype)
    return result


@_ensure_numeric_input
@bottleneck_switch()
@_datetimelike_compat
def nanmean(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    mask: npt.NDArray[np.bool_] | None = None,
) -> float:
    """
    Compute the mean of the element along an axis ignoring NaNs

    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    float
        Unless input is a float array, in which case use the same
        precision as the input array.

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, 2, np.nan])
    >>> nanops.nanmean(s.values)
    np.float64(1.5)
    """
    if values.size == 0:
        # GH#18976
        return cast("float", _na_for_min_count(values, axis))

    dtype = values.dtype
    values, mask = _get_values(values, skipna, fill_value=0, mask=mask)
    dtype_sum = _get_dtype_max(dtype)
    dtype_count = np.dtype(np.float64)

    # not using needs_i8_conversion because that includes period
    if dtype.kind in "mM":
        dtype_sum = np.dtype(np.float64)
    elif dtype.kind in "iu":
        dtype_sum = np.dtype(np.float64)
    elif dtype.kind == "f":
        # GH#43929 upcast float16 sum and count, which lose precision or overflow
        dtype_sum = np.dtype(np.float64) if dtype == np.float16 else dtype
        dtype_count = dtype_sum

    count = _get_counts(values.shape, mask, axis, dtype=dtype_count)
    the_sum = values.sum(axis, dtype=dtype_sum)

    if axis is not None and getattr(the_sum, "ndim", False):
        count = cast("np.ndarray", count)
        with np.errstate(all="ignore"):
            # suppress division by zero warnings
            the_mean = the_sum / count
        ct_mask = count == 0
        if ct_mask.any():
            the_mean[ct_mask] = np.nan
    else:
        the_mean = the_sum / count if count > 0 else np.nan

    return the_mean


@_ensure_numeric_input
@bottleneck_switch()
def nanmedian(
    values: np.ndarray, *, axis: AxisInt | None = None, skipna: bool = True, mask=None
) -> float | np.ndarray:
    """
    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    result : float | ndarray
        Unless input is a float or complex array, in which case use the same
        precision as the input array.

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, np.nan, 2, 2])
    >>> nanops.nanmedian(s.values)
    2.0

    >>> s = pd.Series([np.nan, np.nan, np.nan])
    >>> nanops.nanmedian(s.values)
    nan
    """
    # for floats without mask, the data already uses NaN as missing value
    # indicator, and `mask` will be calculated from that below -> in those
    # cases we never need to set NaN to the masked values
    using_nan_sentinel = values.dtype.kind == "f" and mask is None

    def get_median(x: np.ndarray, _mask=None):
        if _mask is None:
            _mask = notna(x)
        else:
            _mask = ~_mask
        if not skipna and not _mask.all():
            # x's own NaN: np.apply_along_axis below takes the result dtype
            #  from the first slice, so a bare float would cast a later
            #  complex slice to real
            return x.dtype.type(np.nan)
        with warnings.catch_warnings():
            # Suppress RuntimeWarning about All-NaN slice
            warnings.filterwarnings(
                "ignore", "All-NaN slice encountered", RuntimeWarning
            )
            warnings.filterwarnings("ignore", "Mean of empty slice", RuntimeWarning)
            res = np.nanmedian(x[_mask])
        return res

    dtype = values.dtype
    values, mask = _get_values(values, skipna, mask=mask, fill_value=None)
    if values.dtype.kind not in "fc":
        # complex is left alone; np.nanmedian handles it, and casting it to f8
        #  would silently drop the imaginary part
        values = values.astype("f8")
    if not using_nan_sentinel and mask is not None:
        if not values.flags.writeable:
            values = values.copy()
        values[mask] = np.nan

    notempty = values.size

    res: float | np.ndarray

    # an array from a frame
    if values.ndim > 1 and axis is not None:
        # there's a non-empty array to apply over otherwise numpy raises
        if notempty:
            if not skipna:
                res = np.apply_along_axis(get_median, axis, values)

            else:
                # fastpath for the skipna case
                with warnings.catch_warnings():
                    # Suppress RuntimeWarning about All-NaN slice
                    warnings.filterwarnings(
                        "ignore", "All-NaN slice encountered", RuntimeWarning
                    )
                    if (values.shape[1] == 1 and axis == 0) or (
                        values.shape[0] == 1 and axis == 1
                    ):
                        # GH52788: fastpath when squeezable, nanmedian for 2D array slow
                        # atleast_1d: see test_nanmedian_2d_matches_numpy (GH#68191)
                        res = np.nanmedian(
                            np.atleast_1d(np.squeeze(values)), keepdims=True
                        )
                    else:
                        res = np.nanmedian(values, axis=axis)

        else:
            # must return the correct shape, but median is not defined for the
            # empty set so return nans of shape "everything but the passed axis"
            # since "axis" is where the reduction would occur if we had a nonempty
            # array
            res = _get_empty_reduction_result(values.shape, axis)

    else:
        # otherwise return a scalar value
        res = get_median(values, mask) if notempty else np.nan
    return _wrap_results(res, dtype)


def _get_empty_reduction_result(
    shape: Shape,
    axis: AxisInt,
) -> np.ndarray:
    """
    The result from a reduction on an empty ndarray.

    Parameters
    ----------
    shape : Tuple[int, ...]
    axis : int

    Returns
    -------
    np.ndarray
    """
    shp = np.array(shape)
    dims = np.arange(len(shape))
    ret = np.empty(shp[dims != axis], dtype=np.float64)
    ret.fill(np.nan)
    return ret


def _get_counts_nanvar(
    values_shape: Shape,
    mask: npt.NDArray[np.bool_] | None,
    axis: AxisInt | None,
    ddof: int,
    dtype: np.dtype = np.dtype(np.float64),
) -> tuple[float | np.ndarray, float | np.ndarray]:
    """
    Get the count of non-null values along an axis, accounting
    for degrees of freedom.

    Parameters
    ----------
    values_shape : Tuple[int, ...]
        shape tuple from values ndarray, used if mask is None
    mask : Optional[ndarray[bool]]
        locations in values that should be considered missing
    axis : Optional[int]
        axis to count along
    ddof : int
        degrees of freedom
    dtype : type, optional
        type to use for count

    Returns
    -------
    count : np.floating or np.ndarray
    d : np.floating or np.ndarray
    """
    count = _get_counts(values_shape, mask, axis, dtype=dtype)
    d = count - dtype.type(ddof)

    # always return NaN, never inf
    if is_float(count):
        if count <= ddof:
            # dtype's own NaN, not a bare float: nansem divides by sqrt(count),
            #  which would widen a float32 result to float64
            count = d = dtype.type(np.nan)
    else:
        # count is not narrowed by is_float check
        count = cast("np.ndarray", count)
        mask = count <= ddof
        if mask.any():
            np.putmask(d, mask, np.nan)
            np.putmask(count, mask, np.nan)
    return count, d


@_ensure_numeric_input
@bottleneck_switch(ddof=1)
def nanstd(
    values,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    ddof: int = 1,
    mask=None,
):
    """
    Compute the standard deviation along given axis while ignoring NaNs

    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    ddof : int, default 1
        Delta Degrees of Freedom. The divisor used in calculations is N - ddof,
        where N represents the number of elements.
    mask : ndarray[bool], optional
        NA-mask if known

    Returns
    -------
    result : float
        Unless input is a float array, in which case use the same
        precision as the input array.

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, np.nan, 2, 3])
    >>> nanops.nanstd(s.values)
    1.0
    """
    if values.dtype.kind == "M":
        unit = np.datetime_data(values.dtype)[0]
        values = values.view(f"m8[{unit}]")

    if values.size == 0:
        # GH#18976
        return cast("float", _na_for_min_count(values, axis))

    orig_dtype = values.dtype
    values, mask = _get_values(values, skipna, mask=mask)

    result = np.sqrt(nanvar(values, axis=axis, skipna=skipna, ddof=ddof, mask=mask))
    return _wrap_results(result, orig_dtype)


@_ensure_numeric_input
@disallow("M8", "m8")
@bottleneck_switch(ddof=1)
def nanvar(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    ddof: int = 1,
    mask=None,
):
    """
    Compute the variance along given axis while ignoring NaNs

    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    ddof : int, default 1
        Delta Degrees of Freedom. The divisor used in calculations is N - ddof,
        where N represents the number of elements.
    mask : ndarray[bool], optional
        NA-mask if known

    Returns
    -------
    result : float
        Unless input is a float array, in which case use the same
        precision as the input array.

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, np.nan, 2, 3])
    >>> nanops.nanvar(s.values)
    1.0
    """
    if values.size == 0:
        # GH#18976
        return cast("float", _na_for_min_count(values, axis))
    dtype = values.dtype
    mask = _maybe_get_mask(values, skipna, mask)
    if dtype.kind in "biu":
        # bool: the np.nan putmask below would write True into a bool array
        values = values.astype("f8")
    elif dtype.kind == "c":
        # https://en.wikipedia.org/wiki/Complex_random_variable#Variance_and_pseudo-variance
        # The variance is equal to the sum of
        # the variances of the real and imaginary part of the complex random variable.
        return nanvar(
            values.real, axis=axis, skipna=skipna, ddof=ddof, mask=mask
        ) + nanvar(values.imag, axis=axis, skipna=skipna, ddof=ddof, mask=mask)

    if values.dtype.kind == "f":
        count, d = _get_counts_nanvar(values.shape, mask, axis, ddof, values.dtype)
    else:
        count, d = _get_counts_nanvar(values.shape, mask, axis, ddof)

    if mask is not None:
        values = values.copy()
        # GH#65373 an explicit mask marks NA, so skipna=False propagates
        np.putmask(values, mask, 0 if skipna else np.nan)

    # xref GH10242
    # Compute variance via two-pass algorithm, which is stable against
    # cancellation errors and relatively accurate for small numbers of
    # observations.
    #
    # See https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance
    avg = values.sum(axis=axis, dtype=np.float64) / count
    if axis is not None:
        avg = np.expand_dims(avg, axis)

    sqr = (avg - values) ** 2
    if mask is not None:
        np.putmask(sqr, mask, 0)
    # numpy's stubs allow the division to produce a plain float, which it never
    #  does here; d is an ndarray or an np.float64 and so is the sum
    result: np.ndarray | np.float64 = cast(
        "np.ndarray | np.float64", sqr.sum(axis=axis, dtype=np.float64) / d
    )

    # Return variance as np.float64 (the datatype used in the accumulator),
    # unless we were dealing with a float array, in which case use the same
    # precision as the original values array.
    if dtype.kind == "f":
        result = result.astype(dtype, copy=False)
    return result


@_ensure_numeric_input
@disallow("M8", "m8")
def nansem(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    ddof: int = 1,
    mask: npt.NDArray[np.bool_] | None = None,
) -> float:
    """
    Compute the standard error in the mean along given axis while ignoring NaNs

    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    ddof : int, default 1
        Delta Degrees of Freedom. The divisor used in calculations is N - ddof,
        where N represents the number of elements.
    mask : ndarray[bool], optional
        NA-mask if known

    Returns
    -------
    result : float64
        Unless input is a float array, in which case use the same
        precision as the input array.

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, np.nan, 2, 3])
    >>> nanops.nansem(s.values)
     np.float64(0.5773502691896258)
    """
    # This checks if non-numeric-like data is passed with numeric_only=False
    # and raises a TypeError otherwise
    nanvar(values, axis=axis, skipna=skipna, ddof=ddof, mask=mask)

    mask = _maybe_get_mask(values, skipna, mask)
    # Convert to bottleneck return a float
    if values.dtype.kind not in "fc":
        values = values.astype("f8")

    dtype_count = np.dtype(np.float64)
    if values.dtype.kind == "f":
        dtype_count = values.dtype
    count, _ = _get_counts_nanvar(values.shape, mask, axis, ddof, dtype_count)
    var = nanvar(values, axis=axis, skipna=skipna, ddof=ddof, mask=mask)

    return np.sqrt(var) / np.sqrt(count)


def _blank_object_na_slices(
    values: np.ndarray,
    mask: npt.NDArray[np.bool_],
    axis: AxisInt,
    fill_value: Any,
) -> np.ndarray:
    """
    Replace every reduction slice of an object-dtype array that holds an NA
    with ``fill_value``.

    For ``skipna=False`` those slices are nulled out afterwards, so their values
    never reach the result and need not even support the operation being applied
    (GH#4147).  A float NaN propagates through arithmetic and comparison on its
    own; an object NA does not, and raises instead.

    Only for reductions over an axis of a 2-D array.  When the reduction covers
    the whole array the result is simply NA, and callers short-circuit instead.
    """
    any_na = np.expand_dims(mask.any(axis=axis), axis)
    return np.where(any_na, fill_value, values)


def _na_min_count(values: np.ndarray, axis: AxisInt, min_count: int) -> int:
    """
    ``min_count`` that nulls out any slice holding an NA, without weakening an
    explicit larger ``min_count`` from the caller (GH#4147).
    """
    return max(min_count, values.shape[axis])


def _fill_object_na(
    values: np.ndarray,
    mask: npt.NDArray[np.bool_],
    axis: AxisInt | None,
    fill_value: Any,
) -> np.ndarray:
    """
    Replace NA entries in an object-dtype array with a valid value taken from
    the same reduction slice.

    GH#65500: the +/-inf fill used for other dtypes is not comparable with
    arbitrary objects (Timestamps, strings, tuples) and raises.  Substituting a
    value already present in the slice cannot change a min/max, and for
    argmin/argmax a result landing on a filled position is corrected by
    ``_maybe_fix_arg_at_na``.  Entirely-NA slices get ``fill_value``, since
    their result is discarded by the caller either way.
    """
    if mask.all():
        return np.full(values.shape, fill_value, dtype=object)
    if values.ndim == 1 or axis is None:
        fill = np.empty(1, dtype=object)
        # wrap in an array so that e.g. tuple fill values
        # are not broadcast by np.where
        fill[0] = values[~mask][0]
        return np.where(mask, fill, values)
    indexer = np.expand_dims((~mask).argmax(axis=axis), axis)
    fill = np.take_along_axis(values, indexer, axis=axis)
    # argmax selects an NA entry for entirely-NA slices; fill
    # those with +/-inf instead so the reduction does not warn
    all_na = np.expand_dims(mask.all(axis=axis), axis)
    fill = np.where(all_na, fill_value, fill)
    return np.where(mask, fill, values)


def _get_arg_values(
    values: np.ndarray,
    *,
    fill_value_typ: str,
    mask: npt.NDArray[np.bool_] | None,
    skipna: bool,
    axis: AxisInt | None = None,
) -> tuple[np.ndarray, npt.NDArray[np.bool_] | None]:
    """
    Prepare values for nanargmin/nanargmax by filling NA entries so that the
    comparison does not see them, and raise for ``skipna=False``.

    Mirrors ``_get_values(values, True, ...)`` except for object dtype, where
    the +/-inf fill is not comparable with arbitrary objects (GH#4147).  The
    object fill is a same-slice value, which *can* win the comparison; a result
    landing on it is corrected by ``_maybe_fix_arg_at_na``.
    """
    if values.dtype == object:
        if mask is None:
            mask = isna(values)
        if not skipna and mask.any():
            # _maybe_arg_null_out would raise this once the argmin/argmax is
            # known, but the comparison must not run first: values in a slice
            # holding an NA need not be comparable with each other (GH#4147)
            raise ValueError("Encountered an NA value with skipna=False")
        if mask.any():
            fill_value = _get_fill_value(values.dtype, fill_value_typ=fill_value_typ)
            values = _fill_object_na(values, mask, axis, fill_value)
        return values, mask
    return _get_values(values, True, fill_value_typ=fill_value_typ, mask=mask)


def _nanminmax(meth, fill_value_typ):
    @bottleneck_switch(name=f"nan{meth}")
    @_datetimelike_compat
    def reduction(
        values: np.ndarray,
        *,
        axis: AxisInt | None = None,
        skipna: bool = True,
        mask: npt.NDArray[np.bool_] | None = None,
    ):
        if values.size == 0:
            return _na_for_min_count(values, axis)

        dtype = values.dtype
        min_count = 1
        if dtype == object:
            # GH#65500: _get_values' +/-inf fill isn't comparable with arbitrary
            # objects (Timestamps, strings) and raises; see _fill_object_na.
            if mask is None:
                mask = isna(values)
            if mask.any():
                fill_value = _get_fill_value(dtype, fill_value_typ=fill_value_typ)
                if skipna:
                    values = _fill_object_na(values, mask, axis, fill_value)
                elif values.ndim == 1 or axis is None:
                    # GH#4147: an object NA does not propagate through the
                    # comparison the way a float NaN does, so null out every
                    # slice that holds one, matching float64/str/dt64.
                    return _na_for_min_count(values, axis)
                else:
                    values = _blank_object_na_slices(values, mask, axis, fill_value)
                    min_count = _na_min_count(values, axis, min_count)
        else:
            values, mask = _get_values(
                values, skipna, fill_value_typ=fill_value_typ, mask=mask
            )
        result = getattr(values, meth)(axis)
        result = _maybe_null_out(
            result,
            axis,
            mask,
            values.shape,
            min_count=min_count,
            datetimelike=dtype.kind in "mM",
        )
        return result

    return reduction


nanmin = _nanminmax("min", fill_value_typ="+inf")
nanmax = _nanminmax("max", fill_value_typ="-inf")


def nanargmax(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    mask: npt.NDArray[np.bool_] | None = None,
) -> int | np.ndarray:
    """
    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    result : int or ndarray[int]
        The index/indices  of max value in specified axis or -1 in the NA case

    Examples
    --------
    >>> from pandas.core import nanops
    >>> arr = np.array([1, 2, 3, np.nan, 4])
    >>> nanops.nanargmax(arr)
    np.int64(4)

    >>> arr = np.array(range(12), dtype=np.float64).reshape(4, 3)
    >>> arr[2:, 2] = np.nan
    >>> arr
    array([[ 0.,  1.,  2.],
           [ 3.,  4.,  5.],
           [ 6.,  7., nan],
           [ 9., 10., nan]])
    >>> nanops.nanargmax(arr, axis=1)
    array([2, 2, 1, 1])
    """
    values, mask = _get_arg_values(
        values, fill_value_typ="-inf", mask=mask, skipna=skipna, axis=axis
    )
    result = values.argmax(axis)
    # error: Argument 1 to "_maybe_fix_arg_at_na" has incompatible type "Any |
    # signedinteger[Any]"; expected "ndarray[Any, Any]"
    result = _maybe_fix_arg_at_na(result, mask, axis)  # type: ignore[arg-type]
    result = _maybe_arg_null_out(result, axis, mask, skipna)  # type: ignore[assignment]
    return result


def nanargmin(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    mask: npt.NDArray[np.bool_] | None = None,
) -> int | np.ndarray:
    """
    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    result : int or ndarray[int]
        The index/indices of min value in specified axis or -1 in the NA case

    Examples
    --------
    >>> from pandas.core import nanops
    >>> arr = np.array([1, 2, 3, np.nan, 4])
    >>> nanops.nanargmin(arr)
    np.int64(0)

    >>> arr = np.array(range(12), dtype=np.float64).reshape(4, 3)
    >>> arr[2:, 0] = np.nan
    >>> arr
    array([[ 0.,  1.,  2.],
           [ 3.,  4.,  5.],
           [nan,  7.,  8.],
           [nan, 10., 11.]])
    >>> nanops.nanargmin(arr, axis=1)
    array([0, 0, 1, 1])
    """
    values, mask = _get_arg_values(
        values, fill_value_typ="+inf", mask=mask, skipna=skipna, axis=axis
    )
    result = values.argmin(axis)
    # error: Argument 1 to "_maybe_fix_arg_at_na" has incompatible type "Any |
    # signedinteger[Any]"; expected "ndarray[Any, Any]"
    result = _maybe_fix_arg_at_na(result, mask, axis)  # type: ignore[arg-type]
    result = _maybe_arg_null_out(result, axis, mask, skipna)  # type: ignore[assignment]
    return result


@_ensure_numeric_input
@disallow("M8", "m8")
@maybe_operate_rowwise
def nanskew(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    mask: npt.NDArray[np.bool_] | None = None,
) -> np.ndarray | float:
    """
    Compute the sample skewness.

    The statistic computed here is the adjusted Fisher-Pearson standardized
    moment coefficient G1. The algorithm computes this coefficient directly
    from the second and third central moment.

    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    result : float64
        Unless input is a float array, in which case use the same
        precision as the input array.

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, np.nan, 1, 2])
    >>> round(nanops.nanskew(s.values), 6)
    np.float64(1.732051)
    """
    dtype = values.dtype
    values = ensure_float64(values)

    result: npt.NDArray[np.floating] | np.floating
    if axis is None or (values.ndim == 1 and axis == 0):
        order: Literal["F", "C"] = "F" if values.flags.f_contiguous else "C"
        result_float = libalgos.scalar_skew(
            values.ravel(order), skipna, mask.ravel(order) if mask is not None else None
        )
        result = np.float64(result_float)
    elif axis in {0, 1}:
        result = libalgos.axis_skew(values, axis, skipna, mask)
    else:
        raise ValueError("axis must be 0, 1 or None")

    if dtype.kind == "f":
        result = result.astype(dtype, copy=False)

    return result


@_ensure_numeric_input
@disallow("M8", "m8")
@maybe_operate_rowwise
def nankurt(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    mask: npt.NDArray[np.bool_] | None = None,
) -> np.ndarray | float:
    """
    Compute the sample excess kurtosis

    The statistic computed here is the adjusted Fisher-Pearson standardized
    moment coefficient G2, computed directly from the second and fourth
    central moment.

    Parameters
    ----------
    values : ndarray
    axis : int, optional
    skipna : bool, default True
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    result : float64
        Unless input is a float array, in which case use the same
        precision as the input array.

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, np.nan, 1, 3, 2])
    >>> round(nanops.nankurt(s.values), 6)
    np.float64(-1.289256)
    """
    dtype = values.dtype
    values = ensure_float64(values)

    result: npt.NDArray[np.floating] | np.floating
    if axis is None or (values.ndim == 1 and axis == 0):
        order: Literal["F", "C"] = "F" if values.flags.f_contiguous else "C"
        result_float = libalgos.scalar_kurt(
            values.ravel(order), skipna, mask.ravel(order) if mask is not None else None
        )
        result = np.float64(result_float)
    elif axis in {0, 1}:
        result = libalgos.axis_kurt(values, axis, skipna, mask)
    else:
        raise ValueError("axis must be 0, 1 or None")

    if dtype.kind == "f":
        result = result.astype(dtype, copy=False)

    return result


@disallow("M8", "m8")
@maybe_operate_rowwise
def nanprod(
    values: np.ndarray,
    *,
    axis: AxisInt | None = None,
    skipna: bool = True,
    min_count: int = 0,
    mask: npt.NDArray[np.bool_] | None = None,
) -> float:
    """
    Parameters
    ----------
    values : ndarray[dtype]
    axis : int, optional
    skipna : bool, default True
    min_count: int, default 0
    mask : ndarray[bool], optional
        nan-mask if known

    Returns
    -------
    Dtype
        The product of all elements on a given axis. ( NaNs are treated as 1)

    Examples
    --------
    >>> from pandas.core import nanops
    >>> s = pd.Series([1, 2, 3, np.nan])
    >>> nanops.nanprod(s.values)
    np.float64(6.0)
    """
    mask = _maybe_get_mask(values, skipna, mask)

    if values.dtype == object and not skipna:
        # GH#4147: see nansum
        if mask is None:
            mask = isna(values)
        if mask.any():
            try:
                result = values.prod(axis)
            except TypeError:
                pass
            else:
                return _maybe_null_out(  # type: ignore[return-value]
                    result, axis, mask, values.shape, min_count=min_count
                )
            if values.ndim == 1 or axis is None:
                # error: Incompatible return value type (got "Union[Scalar,
                # ndarray]", expected "float")
                return _na_for_min_count(values, axis)  # type: ignore[return-value]
            values = _blank_object_na_slices(values, mask, axis, 1)
            min_count = _na_min_count(values, axis, min_count)
    elif skipna and mask is not None:
        values = values.copy()
        values[mask] = 1
    result = values.prod(axis)
    # error: Incompatible return value type (got "Union[ndarray, float]", expected
    # "float")
    return _maybe_null_out(  # type: ignore[return-value]
        result, axis, mask, values.shape, min_count=min_count
    )


def _maybe_fix_arg_at_na(
    result: np.ndarray,
    mask: npt.NDArray[np.bool_] | None,
    axis: AxisInt | None,
) -> np.ndarray:
    # helper function for nanargmin/nanargmax.  Masked positions carry either
    # the +/-inf sentinel or, for object dtype, the value at the first unmasked
    # position (GH#4147).  Either way an argmin/argmax landing on a masked
    # position means the fill is the extremum, so the first unmasked position
    # holds it and is the correct result (GH#64478)
    if mask is None or not mask.any():
        return result
    if axis is None or mask.ndim == 1:
        if mask.ravel()[result] and not mask.all():
            # error: Incompatible return value type (got "signedinteger[_32Bit
            # | _64Bit]", expected "ndarray[tuple[Any, ...], dtype[Any]]")
            return (~mask).ravel().argmax()  # type: ignore[return-value]
    else:
        indexer = np.expand_dims(result, axis)
        arg_is_na = np.take_along_axis(mask, indexer, axis).squeeze(axis)
        if arg_is_na.any():
            result = np.where(arg_is_na, (~mask).argmax(axis), result)
    return result


def _maybe_arg_null_out(
    result: np.ndarray,
    axis: AxisInt | None,
    mask: npt.NDArray[np.bool_] | None,
    skipna: bool,
) -> np.ndarray | int:
    # helper function for nanargmin/nanargmax
    if mask is None:
        return result

    if axis is None or not getattr(result, "ndim", False):
        if skipna and mask.all():
            raise ValueError("Encountered all NA values")
        elif not skipna and mask.any():
            raise ValueError("Encountered an NA value with skipna=False")
    elif skipna and mask.all(axis).any():
        raise ValueError("Encountered all NA values")
    elif not skipna and mask.any(axis).any():
        raise ValueError("Encountered an NA value with skipna=False")
    return result


def _get_counts(
    values_shape: Shape,
    mask: npt.NDArray[np.bool_] | None,
    axis: AxisInt | None,
    dtype: np.dtype[np.floating] = np.dtype(np.float64),
) -> np.floating | npt.NDArray[np.floating]:
    """
    Get the count of non-null values along an axis

    Parameters
    ----------
    values_shape : tuple of int
        shape tuple from values ndarray, used if mask is None
    mask : Optional[ndarray[bool]]
        locations in values that should be considered missing
    axis : Optional[int]
        axis to count along
    dtype : type, optional
        type to use for count

    Returns
    -------
    count : scalar or array
    """
    if axis is None:
        if mask is not None:
            n = mask.size - mask.sum()
        else:
            n = np.prod(values_shape)
        return dtype.type(n)

    if mask is not None:
        count = mask.shape[axis] - mask.sum(axis)
    else:
        count = values_shape[axis]

    if is_integer(count):
        return dtype.type(count)
    return count.astype(dtype, copy=False)


def _maybe_null_out(
    result: np.ndarray | np.int64 | float | NaTType,
    axis: AxisInt | None,
    mask: npt.NDArray[np.bool_] | None,
    shape: tuple[int, ...],
    min_count: int = 1,
    datetimelike: bool = False,
) -> np.ndarray | np.int64 | float | NaTType:
    """
    Returns
    -------
    Dtype
        The product of all elements on a given axis. ( NaNs are treated as 1)
    """
    if min_count <= 0:
        # min_count <= 0 never nulls out; short-circuit
        return result

    if axis is not None and isinstance(result, np.ndarray):
        if mask is not None:
            null_mask = (mask.shape[axis] - mask.sum(axis) - min_count) < 0
        else:
            # we have no nulls, kept mask=None in _maybe_get_mask
            below_count = shape[axis] - min_count < 0
            new_shape = shape[:axis] + shape[axis + 1 :]
            null_mask = np.broadcast_to(below_count, new_shape)

        if np.any(null_mask):
            if datetimelike:
                # GH#60646 For datetimelike, no need to cast to float
                result[null_mask] = iNaT
            elif is_numeric_dtype(result):
                if np.iscomplexobj(result):
                    result = result.astype("c16")
                elif not is_float_dtype(result):
                    result = result.astype("f8", copy=False)
                result[null_mask] = np.nan
            else:
                # GH12941, use None to auto cast null
                result[null_mask] = None
    elif result is not NaT:
        if check_below_min_count(shape, mask, min_count):
            result_dtype = getattr(result, "dtype", None)
            if is_float_dtype(result_dtype):
                # error: Item "None" of "Optional[Any]" has no attribute "type"
                result = result_dtype.type("nan")  # type: ignore[union-attr]
            else:
                result = np.nan

    return result


def check_below_min_count(
    shape: tuple[int, ...], mask: npt.NDArray[np.bool_] | None, min_count: int
) -> bool:
    """
    Check for the `min_count` keyword. Returns True if below `min_count` (when
    missing value should be returned from the reduction).

    Parameters
    ----------
    shape : tuple
        The shape of the values (`values.shape`).
    mask : ndarray[bool] or None
        Boolean numpy array (typically of same shape as `shape`) or None.
    min_count : int
        Keyword passed through from sum/prod call.

    Returns
    -------
    bool
    """
    if min_count > 0:
        if mask is None:
            # no missing values, only check size
            non_nulls = np.prod(shape)
        else:
            non_nulls = mask.size - mask.sum()
        if non_nulls < min_count:
            return True
    return False


@disallow("M8", "m8")
def nancorr(
    a: np.ndarray,
    b: np.ndarray,
    *,
    method: CorrelationMethod = "pearson",
    min_periods: int | None = None,
) -> float:
    """
    a, b: ndarrays
    """
    if len(a) != len(b):
        raise AssertionError("Operands to nancorr must have same size")

    if min_periods is None:
        min_periods = 1

    valid = notna(a) & notna(b)
    if not valid.all():
        a = a[valid]
        b = b[valid]

    if len(a) < min_periods:
        return np.nan

    a = _ensure_numeric(a)
    b = _ensure_numeric(b)

    f = get_corr_func(method)
    return f(a, b)


def get_corr_func(
    method: CorrelationMethod,
) -> Callable[[np.ndarray, np.ndarray], float]:
    if method == "kendall":
        from scipy.stats import kendalltau

        def func(a, b):  # pyright: ignore[reportRedeclaration]
            return kendalltau(a, b)[0]

        return func
    elif method == "spearman":
        from scipy.stats import spearmanr

        def func(a, b):  # pyright: ignore[reportRedeclaration]
            return spearmanr(a, b)[0]

        return func
    elif method == "pearson":

        def func(a, b):
            return _pearson_corr(a, b)

        return func
    elif callable(method):
        return method

    raise ValueError(
        f"Unknown method '{method}', expected one of "
        "'kendall', 'spearman', 'pearson', or callable"
    )


def _pearson_corr(a: np.ndarray, b: np.ndarray) -> float:
    if a.ndim != 1 or b.ndim != 1 or np.iscomplexobj(a) or np.iscomplexobj(b):
        return np.corrcoef(a, b)[0, 1]

    if len(a) < 2:
        return np.nan

    a = a.astype(np.float64, copy=False)
    b = b.astype(np.float64, copy=False)

    a = a - a.mean()
    b = b - b.mean()
    a_scale = np.max(np.abs(a))
    b_scale = np.max(np.abs(b))

    if a_scale == 0 or b_scale == 0:
        return np.nan

    a = a / a_scale
    b = b / b_scale
    fact = len(a) - 1
    divisor = np.sqrt(np.dot(a, a) / fact)

    if divisor == 0:
        return np.nan

    result = np.dot(a, b) / fact / divisor
    divisor = np.sqrt(np.dot(b, b) / fact)

    if divisor == 0:
        return np.nan

    return np.clip(result / divisor, -1.0, 1.0)


@disallow("M8", "m8")
def nancov(
    a: np.ndarray,
    b: np.ndarray,
    *,
    min_periods: int | None = None,
    ddof: int | None = 1,
) -> float:
    if len(a) != len(b):
        raise AssertionError("Operands to nancov must have same size")

    if min_periods is None:
        min_periods = 1

    valid = notna(a) & notna(b)
    if not valid.all():
        a = a[valid]
        b = b[valid]

    if len(a) < min_periods:
        return np.nan

    a = _ensure_numeric(a)
    b = _ensure_numeric(b)

    return np.cov(a, b, ddof=ddof)[0, 1]


def na_accum_func(values: ArrayLike, accum_func, *, skipna: bool) -> ArrayLike:
    """
    Cumulative function with skipna support.

    Parameters
    ----------
    values : np.ndarray or ExtensionArray
    accum_func : {np.cumprod, np.maximum.accumulate, np.cumsum, np.minimum.accumulate}
    skipna : bool

    Returns
    -------
    np.ndarray or ExtensionArray
    """
    mask_a, mask_b = {
        np.cumprod: (1.0, np.nan),
        np.maximum.accumulate: (-np.inf, np.nan),
        np.cumsum: (0.0, np.nan),
        np.minimum.accumulate: (np.inf, np.nan),
    }[accum_func]

    # This should go through ea interface
    assert values.dtype.kind not in "mM"

    # We will be applying this function to block values
    if skipna and not issubclass(values.dtype.type, (np.integer, np.bool_)):
        vals = values.copy()
        mask = isna(vals)
        vals[mask] = mask_a
        result = accum_func(vals, axis=0)
        result[mask] = mask_b
    else:
        result = accum_func(values, axis=0)

    return result
