# Licensed to Modin Development Team under one or more contributor license agreements.
# See the NOTICE file distributed with this work for additional information regarding
# copyright ownership.  The Modin Development Team licenses this file to you under the
# Apache License, Version 2.0 (the "License"); you may not use this file except in
# compliance with the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software distributed under
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific language
# governing permissions and limitations under the License.

"""Implement GroupBy public API as pandas does."""

import warnings
from collections.abc import Iterable
from types import BuiltinFunctionType

import numpy as np
import pandas
import pandas.core.common as com
import pandas.core.groupby
from pandas._libs import lib
from pandas.api.types import is_scalar
from pandas.core.apply import reconstruct_func
from pandas.core.dtypes.common import (
    is_datetime64_any_dtype,
    is_integer,
    is_list_like,
    is_numeric_dtype,
)
from pandas.errors import SpecificationError

from modin.core.dataframe.algebra.default2pandas.groupby import GroupBy
from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler
from modin.error_message import ErrorMessage
from modin.logging import ClassLogger, disable_logging
from modin.pandas.utils import cast_function_modin2pandas
from modin.utils import (
    MODIN_UNNAMED_SERIES_LABEL,
    _inherit_docstrings,
    hashable,
    try_cast_to_pandas,
    wrap_into_list,
    wrap_udf_function,
)

from .series import Series
from .utils import is_label
from .window import RollingGroupby

_DEFAULT_BEHAVIOUR = {
    "__class__",
    "__getitem__",
    "__init__",
    "__iter__",
    "_as_index",
    "_axis",
    "_by",
    "_check_index",
    "_columns",
    "_compute_index_grouped",
    "_default_to_pandas",
    "_df",
    "_drop",
    "_groups_cache",
    "_idx_name",
    "_index",
    "_indices_cache",
    "_internal_by",
    "_internal_by_cache",
    "_is_multi_by",
    "_iter",
    "_kwargs",
    "_level",
    "_pandas_class",
    "_query_compiler",
    "_sort",
    "_wrap_aggregation",
}


@_inherit_docstrings(pandas.core.groupby.DataFrameGroupBy)
class DataFrameGroupBy(ClassLogger):
    _pandas_class = pandas.core.groupby.DataFrameGroupBy
    _return_tuple_when_iterating = False

    def __init__(
        self,
        df,
        by,
        axis,
        level,
        as_index,
        sort,
        group_keys,
        idx_name,
        drop,
        **kwargs,
    ):
        self._axis = axis
        self._idx_name = idx_name
        self._df = df
        self._query_compiler = self._df._query_compiler
        self._columns = self._query_compiler.columns
        self._by = by
        self._drop = drop
        # When providing a list of columns of length one to DataFrame.groupby(),
        # the keys that are returned by iterating over the resulting DataFrameGroupBy
        # object will now be tuples of length one (pandas#GH47761)
        self._return_tuple_when_iterating = kwargs.pop(
            "return_tuple_when_iterating", False
        )

        if (
            level is None
            and is_list_like(by)
            or isinstance(by, type(self._query_compiler))
        ):
            # This tells us whether or not there are multiple columns/rows in the groupby
            self._is_multi_by = (
                isinstance(by, type(self._query_compiler)) and len(by.columns) > 1
            ) or (
                not isinstance(by, type(self._query_compiler))
                and axis == 0
                and all(
                    (hashable(obj) and obj in self._query_compiler.columns)
                    or isinstance(obj, type(self._query_compiler))
                    or is_list_like(obj)
                    for obj in self._by
                )
            )
        else:
            self._is_multi_by = False
        self._level = level
        self._kwargs = {
            "level": level,
            "sort": sort,
            "as_index": as_index,
            "group_keys": group_keys,
        }
        self._kwargs.update(kwargs)

    def _override(self, **kwargs):
        """
        Override groupby parameters.

        Parameters
        ----------
        **kwargs : dict
            Parameters to override.

        Returns
        -------
        DataFrameGroupBy
            A groupby object with new parameters.
        """
        new_kw = dict(
            df=self._df,
            by=self._by,
            axis=self._axis,
            idx_name=self._idx_name,
            drop=self._drop,
            **self._kwargs,
        )
        new_kw.update(kwargs)
        return type(self)(**new_kw)

    @disable_logging
    def __getattr__(self, key):
        """
        Alter regular attribute access, looks up the name in the columns.

        Parameters
        ----------
        key : str
            Attribute name.

        Returns
        -------
        The value of the attribute.
        """
        try:
            return object.__getattribute__(self, key)
        except AttributeError as err:
            if key in self._columns:
                return self.__getitem__(key)
            raise err

    def __getattribute__(self, item):
        attr = super().__getattribute__(item)
        if item not in _DEFAULT_BEHAVIOUR and not self._query_compiler.lazy_execution:
            # We default to pandas on empty DataFrames. This avoids a large amount of
            # pain in underlying implementation and returns a result immediately rather
            # than dealing with the edge cases that empty DataFrames have.
            if callable(attr) and self._df.empty and hasattr(self._pandas_class, item):

                def default_handler(*args, **kwargs):
                    return self._default_to_pandas(item, *args, **kwargs)

                return default_handler
        return attr

    @property
    def ngroups(self):
        return len(self)

    def skew(self, axis=lib.no_default, skipna=True, numeric_only=False, **kwargs):
        # default behaviour for aggregations; for the reference see
        # `_op_via_apply` func in pandas==2.0.2
        if axis is None or axis is lib.no_default:
            axis = self._axis

        if axis != 0 or not skipna:
            return self._default_to_pandas(
                lambda df: df.skew(
                    axis=axis, skipna=skipna, numeric_only=numeric_only, **kwargs
                )
            )

        return self._wrap_aggregation(
            type(self._query_compiler).groupby_skew,
            agg_kwargs=kwargs,
            numeric_only=numeric_only,
        )

    def ffill(self, limit=None):
        ErrorMessage.single_warning(
            ".ffill() is implemented using .fillna() in Modin, "
            + "which can be impacted by pandas bug https://github.com/pandas-dev/pandas/issues/43412 "
            + "on dataframes with duplicated indices"
        )
        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore",
                message=".*fillna with 'method' is deprecated.*",
                category=FutureWarning,
            )
            return self.fillna(limit=limit, method="ffill")

    def sem(self, ddof=1, numeric_only=False):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_sem,
            agg_kwargs=dict(ddof=ddof),
            numeric_only=numeric_only,
        )

    def sample(self, n=None, frac=None, replace=False, weights=None, random_state=None):
        return self._default_to_pandas(
            lambda df: df.sample(
                n=n,
                frac=frac,
                replace=replace,
                weights=weights,
                random_state=random_state,
            )
        )

    def ewm(self, *args, **kwargs):
        return self._default_to_pandas(lambda df: df.ewm(*args, **kwargs))

    def value_counts(
        self,
        subset=None,
        normalize: bool = False,
        sort: bool = True,
        ascending: bool = False,
        dropna: bool = True,
    ):
        return self._default_to_pandas(
            lambda df: df.value_counts(
                subset=subset,
                normalize=normalize,
                sort=sort,
                ascending=ascending,
                dropna=dropna,
            )
        )

    def mean(self, numeric_only=False, engine=None, engine_kwargs=None):
        if engine not in ("cython", None) and engine_kwargs is not None:
            return self._default_to_pandas(
                lambda df: df.mean(
                    numeric_only=numeric_only,
                    engine=engine,
                    engine_kwargs=engine_kwargs,
                )
            )
        return self._check_index(
            self._wrap_aggregation(
                type(self._query_compiler).groupby_mean,
                agg_kwargs=dict(numeric_only=numeric_only),
                numeric_only=numeric_only,
            )
        )

    def any(self, skipna=True):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_any,
            numeric_only=False,
            agg_kwargs=dict(skipna=skipna),
        )

    @property
    def plot(self):  # pragma: no cover
        return self._default_to_pandas(lambda df: df.plot)

    def ohlc(self):
        from .dataframe import DataFrame

        return DataFrame(
            query_compiler=self._query_compiler.groupby_ohlc(
                by=self._by,
                axis=self._axis,
                groupby_kwargs=self._kwargs,
                agg_args=[],
                agg_kwargs={},
                is_df=isinstance(self._df, DataFrame),
            ),
        )

    def __bytes__(self):
        """
        Convert DataFrameGroupBy object into a python2-style byte string.

        Returns
        -------
        bytearray
            Byte array representation of `self`.

        Notes
        -----
        Deprecated and removed in pandas and will be likely removed in Modin.
        """
        return self._default_to_pandas(lambda df: df.__bytes__())

    _groups_cache = lib.no_default

    # TODO: since python 3.9:
    # @cached_property
    @property
    def groups(self):
        if self._groups_cache is not lib.no_default:
            return self._groups_cache

        self._groups_cache = self._compute_index_grouped(numerical=False)
        return self._groups_cache

    def min(self, numeric_only=False, min_count=-1, engine=None, engine_kwargs=None):
        if engine not in ("cython", None) and engine_kwargs is not None:
            return self._default_to_pandas(
                lambda df: df.min(
                    numeric_only=numeric_only,
                    min_count=min_count,
                    engine=engine,
                    engine_kwargs=engine_kwargs,
                )
            )
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_min,
            agg_kwargs=dict(min_count=min_count),
            numeric_only=numeric_only,
        )

    def max(self, numeric_only=False, min_count=-1, engine=None, engine_kwargs=None):
        if engine not in ("cython", None) and engine_kwargs is not None:
            return self._default_to_pandas(
                lambda df: df.max(
                    numeric_only=numeric_only,
                    min_count=min_count,
                    engine=engine,
                    engine_kwargs=engine_kwargs,
                )
            )
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_max,
            agg_kwargs=dict(min_count=min_count),
            numeric_only=numeric_only,
        )

    def idxmax(self, axis=lib.no_default, skipna=True, numeric_only=False):
        if axis is not lib.no_default:
            self._deprecate_axis(axis, "idxmax")
        # default behaviour for aggregations; for the reference see
        # `_op_via_apply` func in pandas==2.0.2
        if axis is None or axis is lib.no_default:
            axis = self._axis
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_idxmax,
            agg_kwargs=dict(axis=axis, skipna=skipna),
            numeric_only=numeric_only,
        )

    def idxmin(self, axis=lib.no_default, skipna=True, numeric_only=False):
        if axis is not lib.no_default:
            self._deprecate_axis(axis, "idxmin")
        # default behaviour for aggregations; for the reference see
        # `_op_via_apply` func in pandas==2.0.2
        if axis is None or axis is lib.no_default:
            axis = self._axis
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_idxmin,
            agg_kwargs=dict(axis=axis, skipna=skipna),
            numeric_only=numeric_only,
        )

    @property
    def ndim(self):
        """
        Return 2.

        Returns
        -------
        int
            Returns 2.

        Notes
        -----
        Deprecated and removed in pandas and will be likely removed in Modin.
        """
        return 2  # ndim is always 2 for DataFrames

    def shift(
        self,
        periods=1,
        freq=None,
        axis=lib.no_default,
        fill_value=lib.no_default,
        suffix=None,
    ):
        if suffix:
            return self._default_to_pandas(
                lambda df: df.shift(
                    periods=periods,
                    freq=freq,
                    axis=axis,
                    fill_value=fill_value,
                    suffix=suffix,
                )
            )
        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "shift")
        else:
            axis = 0

        def _shift(data, periods, freq, axis, fill_value, is_set_nan_rows=True):
            from .dataframe import DataFrame

            result = data.shift(periods, freq, axis, fill_value)

            if (
                is_set_nan_rows
                and isinstance(self._by, BaseQueryCompiler)
                and (
                    # Check using `issubset` is effective only in case of MultiIndex
                    set(self._by.columns).issubset(list(data.columns))
                    if isinstance(self._by.columns, pandas.MultiIndex)
                    else len(
                        self._by.columns.unique()
                        .sort_values()
                        .difference(data.columns.unique().sort_values())
                    )
                    == 0
                )
                and DataFrame(query_compiler=self._by.isna()).any(axis=None)
            ):
                mask_nan_rows = data[self._by.columns].isna().any(axis=1)
                result = result.loc[~mask_nan_rows]
            return result

        if freq is None and axis == 1 and self._axis == 0:
            result = _shift(self._df, periods, freq, axis, fill_value)
        elif (
            freq is not None
            and axis == 0
            and self._axis == 0
            and isinstance(self._by, BaseQueryCompiler)
        ):
            result = _shift(
                self._df, periods, freq, axis, fill_value, is_set_nan_rows=False
            )
            result = result.dropna(subset=self._by.columns)
            if self._sort:
                result = result.sort_values(list(self._by.columns), axis=axis)
            else:
                result = result.sort_index()
        else:
            result = self._wrap_aggregation(
                type(self._query_compiler).groupby_shift,
                numeric_only=False,
                agg_kwargs=dict(
                    periods=periods, freq=freq, axis=axis, fill_value=fill_value
                ),
            )
        return result

    def nth(self, n, dropna=None):
        # TODO: what we really should do is create a GroupByNthSelector to mimic
        # pandas behavior and then implement some of these methods there.
        # Adapted error checking from pandas
        if dropna:
            if not is_integer(n):
                raise ValueError("dropna option only supported for an integer argument")

            if dropna not in ("any", "all"):
                # Note: when agg-ing picker doesn't raise this, just returns NaN
                raise ValueError(
                    "For a DataFrame or Series groupby.nth, dropna must be "
                    + "either None, 'any' or 'all', "
                    + f"(was passed {dropna})."
                )

        return self._check_index(
            self._wrap_aggregation(
                type(self._query_compiler).groupby_nth,
                numeric_only=False,
                agg_kwargs=dict(n=n, dropna=dropna),
            )
        )

    def cumsum(self, axis=lib.no_default, *args, **kwargs):
        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "cumsum")
        else:
            axis = 0
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_cumsum,
            agg_args=args,
            agg_kwargs=dict(axis=axis, **kwargs),
        )

    _indices_cache = lib.no_default

    # TODO: since python 3.9:
    # @cached_property
    @property
    def indices(self):
        if self._indices_cache is not lib.no_default:
            return self._indices_cache

        self._indices_cache = self._compute_index_grouped(numerical=True)
        return self._indices_cache

    @_inherit_docstrings(pandas.core.groupby.DataFrameGroupBy.pct_change)
    def pct_change(
        self,
        periods=1,
        fill_method=lib.no_default,
        limit=lib.no_default,
        freq=None,
        axis=lib.no_default,
    ):
        from .dataframe import DataFrame

        if fill_method not in (lib.no_default, None) or limit is not lib.no_default:
            warnings.warn(
                "The 'fill_method' keyword being not None and the 'limit' keyword in "
                + f"{type(self).__name__}.pct_change are deprecated and will be removed "
                + "in a future version. Either fill in any non-leading NA values prior "
                + "to calling pct_change or specify 'fill_method=None' to not fill NA "
                + "values.",
                FutureWarning,
            )
        if fill_method is lib.no_default:
            if any(grp.isna().values.any() for _, grp in self):
                warnings.warn(
                    "The default fill_method='ffill' in "
                    + f"{type(self).__name__}.pct_change is deprecated and will be "
                    + "removed in a future version. Call ffill before calling "
                    + "pct_change to retain current behavior and silence this warning.",
                    FutureWarning,
                )
            fill_method = "ffill"
        if limit is lib.no_default:
            limit = None

        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "pct_change")
        else:
            axis = 0

        # Should check for API level errors
        # Attempting to match pandas error behavior here
        if not isinstance(periods, int):
            raise TypeError(f"periods must be an int. got {type(periods)} instead")

        if isinstance(self._df, Series):
            if not is_numeric_dtype(self._df.dtypes):
                raise TypeError(
                    f"unsupported operand type for -: got {self._df.dtypes}"
                )
        elif isinstance(self._df, DataFrame) and axis == 0:
            for col, dtype in self._df.dtypes.items():
                # can't calculate change on non-numeric columns, so check for
                # non-numeric columns that are not included in the `by`
                if not is_numeric_dtype(dtype) and not (
                    isinstance(self._by, BaseQueryCompiler) and col in self._by.columns
                ):
                    raise TypeError(f"unsupported operand type for -: got {dtype}")

        return self._wrap_aggregation(
            type(self._query_compiler).groupby_pct_change,
            agg_kwargs=dict(
                periods=periods,
                fill_method=fill_method,
                limit=limit,
                freq=freq,
                axis=axis,
            ),
        )

    def filter(self, func, dropna=True, *args, **kwargs):
        return self._default_to_pandas(
            lambda df: df.filter(func, dropna=dropna, *args, **kwargs)
        )

    def _deprecate_axis(self, axis: int, name: str) -> None:
        if axis == 1:
            warnings.warn(
                f"{type(self).__name__}.{name} with axis=1 is deprecated and "
                + "will be removed in a future version. Operate on the un-grouped "
                + "DataFrame instead",
                FutureWarning,
            )
        else:
            warnings.warn(
                f"The 'axis' keyword in {type(self).__name__}.{name} is deprecated "
                + "and will be removed in a future version. "
                + "Call without passing 'axis' instead.",
                FutureWarning,
            )

    def cummax(self, axis=lib.no_default, numeric_only=False, **kwargs):
        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "cummax")
        else:
            axis = 0
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_cummax,
            agg_kwargs=dict(axis=axis, **kwargs),
            numeric_only=numeric_only,
        )

    def apply(self, func, *args, include_groups=True, **kwargs):
        func = cast_function_modin2pandas(func)
        if not isinstance(func, BuiltinFunctionType):
            func = wrap_udf_function(func)

        apply_res = self._wrap_aggregation(
            qc_method=type(self._query_compiler).groupby_agg,
            numeric_only=False,
            agg_func=func,
            agg_args=args,
            agg_kwargs={**kwargs, "include_groups": include_groups},
            how="group_wise",
        )
        reduced_index = pandas.Index([MODIN_UNNAMED_SERIES_LABEL])
        if not isinstance(apply_res, Series) and apply_res.columns.equals(
            reduced_index
        ):
            apply_res = apply_res.squeeze(axis=1)
        return self._check_index(apply_res)

    @property
    def dtypes(self):
        if self._axis == 1:
            raise ValueError("Cannot call dtypes on groupby with axis=1")
        warnings.warn(
            f"{type(self).__name__}.dtypes is deprecated and will be removed in "
            + "a future version. Check the dtypes on the base object instead",
            FutureWarning,
        )
        return self._check_index(
            self._wrap_aggregation(
                type(self._query_compiler).groupby_dtypes,
                numeric_only=False,
            )
        )

    def first(self, numeric_only=False, min_count=-1, skipna=True):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_first,
            agg_kwargs=dict(min_count=min_count, skipna=skipna),
            numeric_only=numeric_only,
        )

    def last(self, numeric_only=False, min_count=-1, skipna=True):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_last,
            agg_kwargs=dict(min_count=min_count, skipna=skipna),
            numeric_only=numeric_only,
        )

    _internal_by_cache = lib.no_default

    # TODO: since python 3.9:
    # @cached_property
    @property
    def _internal_by(self):
        """
        Get only those components of 'by' that are column labels of the source frame.

        Returns
        -------
        tuple of labels
        """
        if self._internal_by_cache is not lib.no_default:
            return self._internal_by_cache

        internal_by = tuple()
        if self._drop:
            if is_list_like(self._by):
                internal_by_list = []
                for by in self._by:
                    if isinstance(by, str):
                        internal_by_list.append(by)
                    elif isinstance(by, pandas.Grouper):
                        internal_by_list.append(by.key)
                internal_by = tuple(internal_by_list)
            elif isinstance(self._by, pandas.Grouper):
                internal_by = tuple([self._by.key])
            else:
                ErrorMessage.catch_bugs_and_request_email(
                    failure_condition=not isinstance(self._by, BaseQueryCompiler),
                    extra_log=f"When 'drop' is True, 'by' must be either list-like, Grouper, or a QueryCompiler, met: {type(self._by)}.",
                )
                internal_by = tuple(self._by.columns)

        self._internal_by_cache = internal_by
        return internal_by

    def __getitem__(self, key):
        """
        Implement indexing operation on a DataFrameGroupBy object.

        Parameters
        ----------
        key : list or str
            Names of columns to use as subset of original object.

        Returns
        -------
        DataFrameGroupBy or SeriesGroupBy
            Result of indexing operation.

        Raises
        ------
        NotImplementedError
            Column lookups on GroupBy with arbitrary Series in by is not yet supported.
        """
        # These parameters are common for building the resulted Series or DataFrame groupby object
        kwargs = {
            **self._kwargs.copy(),
            "by": self._by,
            "axis": self._axis,
            "idx_name": self._idx_name,
        }
        # The rules of type deduction for the resulted object is the following:
        #   1. If `key` is a list-like or `as_index is False`, then the resulted object is a DataFrameGroupBy
        #   2. Otherwise, the resulted object is SeriesGroupBy
        #   3. Result type does not depend on the `by` origin
        # Examples:
        #   - drop: any, as_index: any, __getitem__(key: list_like) -> DataFrameGroupBy
        #   - drop: any, as_index: False, __getitem__(key: any) -> DataFrameGroupBy
        #   - drop: any, as_index: True, __getitem__(key: label) -> SeriesGroupBy
        if is_list_like(key):
            make_dataframe = True
        else:
            if self._as_index:
                make_dataframe = False
            else:
                make_dataframe = True
                key = [key]
        if make_dataframe:
            internal_by = frozenset(self._internal_by)
            if len(internal_by.intersection(key)) != 0:
                ErrorMessage.mismatch_with_pandas(
                    operation="GroupBy.__getitem__",
                    message=(
                        "intersection of the selection and 'by' columns is not yet supported, "
                        + "to achieve the desired result rewrite the original code from:\n"
                        + "df.groupby('by_column')['by_column']\n"
                        + "to the:\n"
                        + "df.groupby(df['by_column'].copy())['by_column']"
                    ),
                )
            # We need to maintain order of the columns in key, using a set doesn't
            # maintain order.
            # We use dictionaries since they maintain insertion order as of 3.7,
            # and its faster to call dict.update than it is to loop through `key`
            # and select only the elements which aren't in `cols_to_grab`.
            cols_to_grab = dict.fromkeys(self._internal_by)
            cols_to_grab.update(dict.fromkeys(key))
            key = [col for col in cols_to_grab.keys() if col in self._df.columns]
            return DataFrameGroupBy(
                self._df[key],
                drop=self._drop,
                **kwargs,
            )
        if (
            self._is_multi_by
            and isinstance(self._by, list)
            and not all(hashable(o) and o in self._df for o in self._by)
        ):
            raise NotImplementedError(
                "Column lookups on GroupBy with arbitrary Series in by"
                + " is not yet supported."
            )
        return SeriesGroupBy(
            self._df[key],
            drop=False,
            **kwargs,
        )

    def cummin(self, axis=lib.no_default, numeric_only=False, **kwargs):
        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "cummin")
        else:
            axis = 0
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_cummin,
            agg_kwargs=dict(axis=axis, **kwargs),
            numeric_only=numeric_only,
        )

    def bfill(self, limit=None):
        ErrorMessage.single_warning(
            ".bfill() is implemented using .fillna() in Modin, "
            + "which can be impacted by pandas bug https://github.com/pandas-dev/pandas/issues/43412 "
            + "on dataframes with duplicated indices"
        )
        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore",
                message=".*fillna with 'method' is deprecated.*",
                category=FutureWarning,
            )
            return self.fillna(limit=limit, method="bfill")

    def prod(self, numeric_only=False, min_count=0):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_prod,
            agg_kwargs=dict(min_count=min_count),
            numeric_only=numeric_only,
        )

    def std(self, ddof=1, engine=None, engine_kwargs=None, numeric_only=False):
        if engine not in ("cython", None) and engine_kwargs is not None:
            return self._default_to_pandas(
                lambda df: df.std(
                    ddof=ddof,
                    engine=engine,
                    engine_kwargs=engine_kwargs,
                    numeric_only=numeric_only,
                )
            )
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_std,
            agg_kwargs=dict(ddof=ddof),
            numeric_only=numeric_only,
        )

    def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs):
        if engine not in ("cython", None) and engine_kwargs is not None:
            return self._default_to_pandas(
                lambda df: df.aggregate(
                    func, *args, engine=engine, engine_kwargs=engine_kwargs, **kwargs
                )
            )
        if self._axis != 0:
            # This is not implemented in pandas,
            # so we throw a different message
            raise NotImplementedError("axis other than 0 is not supported")

        if (
            callable(func)
            and isinstance(func, BuiltinFunctionType)
            and func.__name__ in dir(self)
        ):
            func_name = func.__name__
            warnings.warn(
                f"The provided callable {func} is currently using "
                + f"{type(self).__name__}.{func_name}. In a future version of pandas, "
                + "the provided callable will be used directly. To keep current "
                + f"behavior pass the string {func_name} instead.",
                category=FutureWarning,
            )
            func = func_name

        do_relabel = None
        if isinstance(func, dict) or func is None:
            # the order from `reconstruct_func` cannot be used correctly if there
            # is more than one columnar partition, since for correct use all columns
            # must be available within one partition.
            old_kwargs = dict(kwargs)
            relabeling_required, func_dict, new_columns, _ = reconstruct_func(
                func, **kwargs
            )

            if relabeling_required:

                def do_relabel(obj_to_relabel):  # noqa: F811
                    # unwrap nested labels into one level tuple
                    result_labels = [None] * len(old_kwargs)
                    for idx, labels in enumerate(old_kwargs.values()):
                        if is_scalar(labels) or callable(labels):
                            result_labels[idx] = (
                                labels if not callable(labels) else labels.__name__
                            )
                            continue
                        new_elem = []
                        for label in labels:
                            if is_scalar(label) or callable(label):
                                new_elem.append(
                                    label if not callable(label) else label.__name__
                                )
                            else:
                                new_elem.extend(label)
                        result_labels[idx] = tuple(new_elem)

                    new_order = obj_to_relabel.columns.get_indexer(result_labels)
                    new_columns_idx = pandas.Index(new_columns)
                    if not self._as_index:
                        nby_cols = len(obj_to_relabel.columns) - len(new_columns_idx)
                        new_order = np.concatenate([np.arange(nby_cols), new_order])
                        by_cols = obj_to_relabel.columns[:nby_cols]
                        if by_cols.nlevels != new_columns_idx.nlevels:
                            by_cols = by_cols.remove_unused_levels()
                            empty_levels = [
                                i
                                for i, level in enumerate(by_cols.levels)
                                if len(level) == 1 and level[0] == ""
                            ]
                            by_cols = by_cols.droplevel(empty_levels)
                        new_columns_idx = by_cols.append(new_columns_idx)
                    result = obj_to_relabel.iloc[:, new_order]
                    result.columns = new_columns_idx
                    return result

            if any(isinstance(fn, list) for fn in func_dict.values()):
                # multicolumn case
                # putting functions in a `list` allows to achieve multicolumn in each partition
                func_dict = {
                    col: fn if isinstance(fn, list) else [fn]
                    for col, fn in func_dict.items()
                }
            if (
                relabeling_required
                and not self._as_index
                and any(col in func_dict for col in self._internal_by)
            ):
                ErrorMessage.mismatch_with_pandas(
                    operation="GroupBy.aggregate(**dictionary_renaming_aggregation)",
                    message=(
                        "intersection of the columns to aggregate and 'by' is not yet supported when 'as_index=False', "
                        + "columns with group names of the intersection will not be presented in the result. "
                        + "To achieve the desired result rewrite the original code from:\n"
                        + "df.groupby('by_column', as_index=False).agg(agg_func=('by_column', agg_func))\n"
                        + "to the:\n"
                        + "df.groupby('by_column').agg(agg_func=('by_column', agg_func)).reset_index()"
                    ),
                )

            if any(i not in self._df.columns for i in func_dict.keys()):
                raise SpecificationError("nested renamer is not supported")
            if func is None:
                kwargs = {}
            func = func_dict
        elif is_list_like(func):
            # for list-list aggregation pandas always puts
            # groups as index in the result, ignoring as_index,
            # so we have to reset it to default value
            res = self._override(as_index=True)._wrap_aggregation(
                qc_method=type(self._query_compiler).groupby_agg,
                numeric_only=False,
                agg_func=func,
                agg_args=args,
                agg_kwargs=kwargs,
                how="axis_wise",
            )
            if not self._kwargs["as_index"]:
                res.reset_index(inplace=True)
            return res
        elif callable(func):
            return self._check_index(
                self._wrap_aggregation(
                    qc_method=type(self._query_compiler).groupby_agg,
                    numeric_only=False,
                    agg_func=func,
                    agg_args=args,
                    agg_kwargs=kwargs,
                    how="axis_wise",
                )
            )
        elif isinstance(func, str):
            # Using "getattr" here masks possible AttributeError which we throw
            # in __getattr__, so we should call __getattr__ directly instead.
            agg_func = self.__getattr__(func)
            if callable(agg_func):
                return agg_func(*args, **kwargs)

        result = self._wrap_aggregation(
            qc_method=type(self._query_compiler).groupby_agg,
            numeric_only=False,
            agg_func=func,
            agg_args=args,
            agg_kwargs=kwargs,
            how="axis_wise",
        )
        return do_relabel(result) if do_relabel else result

    agg = aggregate

    def rank(
        self,
        method="average",
        ascending=True,
        na_option="keep",
        pct=False,
        axis=lib.no_default,
    ):
        if na_option not in {"keep", "top", "bottom"}:
            raise ValueError("na_option must be one of 'keep', 'top', or 'bottom'")

        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "rank")
        else:
            axis = 0

        result = self._wrap_aggregation(
            type(self._query_compiler).groupby_rank,
            agg_kwargs=dict(
                method=method,
                ascending=ascending,
                na_option=na_option,
                pct=pct,
                axis=axis,
            ),
            numeric_only=False,
        )
        return result

    @property
    def corrwith(self):
        return self._default_to_pandas(lambda df: df.corrwith)

    def var(self, ddof=1, engine=None, engine_kwargs=None, numeric_only=False):
        if engine not in ("cython", None) and engine_kwargs is not None:
            return self._default_to_pandas(
                lambda df: df.var(
                    ddof=ddof,
                    engine=engine,
                    engine_kwargs=engine_kwargs,
                    numeric_only=numeric_only,
                )
            )
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_var,
            agg_kwargs=dict(ddof=ddof),
            numeric_only=numeric_only,
        )

    def get_group(self, name, obj=None):
        work_object = self._override(
            df=obj if obj is not None else self._df, as_index=True
        )

        return work_object._check_index(
            work_object._wrap_aggregation(
                qc_method=type(work_object._query_compiler).groupby_get_group,
                numeric_only=False,
                agg_kwargs=dict(name=name),
            )
        )

    def __len__(self):
        return len(self.indices)

    def all(self, skipna=True):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_all,
            numeric_only=False,
            agg_kwargs=dict(skipna=skipna),
        )

    def size(self):
        if self._axis == 1:
            return DataFrameGroupBy(
                self._df.T.iloc[:, [0]],
                self._by,
                0,
                drop=self._drop,
                idx_name=self._idx_name,
                **self._kwargs,
            ).size()
        result = self._wrap_aggregation(
            type(self._query_compiler).groupby_size,
            numeric_only=False,
        )
        if not isinstance(result, Series):
            result = result.squeeze(axis=1)
        if not self._kwargs.get("as_index") and not isinstance(result, Series):
            result = (
                result.rename(columns={MODIN_UNNAMED_SERIES_LABEL: "index"})
                if MODIN_UNNAMED_SERIES_LABEL in result.columns
                else result
            )
        elif isinstance(self._df, Series):
            result.name = self._df.name
        return result

    def sum(self, numeric_only=False, min_count=0, engine=None, engine_kwargs=None):
        if engine not in ("cython", None) and engine_kwargs is not None:
            return self._default_to_pandas(
                lambda df: df.sum(
                    numeric_only=numeric_only,
                    min_count=min_count,
                    engine=engine,
                    engine_kwargs=engine_kwargs,
                )
            )

        return self._wrap_aggregation(
            type(self._query_compiler).groupby_sum,
            agg_kwargs=dict(min_count=min_count),
            numeric_only=numeric_only,
        )

    def describe(self, percentiles=None, include=None, exclude=None):
        return self._default_to_pandas(
            lambda df: df.describe(
                percentiles=percentiles, include=include, exclude=exclude
            )
        )

    def boxplot(
        self,
        grouped,
        subplots=True,
        column=None,
        fontsize=None,
        rot=0,
        grid=True,
        ax=None,
        figsize=None,
        layout=None,
        sharex=False,
        sharey=True,
        backend=None,
        **kwargs,
    ):
        return self._default_to_pandas(
            lambda df: df.boxplot(
                grouped,
                subplots=subplots,
                column=column,
                fontsize=fontsize,
                rot=rot,
                grid=grid,
                ax=ax,
                figsize=figsize,
                layout=layout,
                sharex=sharex,
                sharey=sharey,
                backend=backend,
                **kwargs,
            )
        )

    def ngroup(self, ascending=True):
        result = self._wrap_aggregation(
            type(self._query_compiler).groupby_ngroup,
            numeric_only=False,
            agg_kwargs=dict(ascending=ascending),
        )
        if not isinstance(result, Series):
            # The result should always be a Series with name None and type int64
            result = result.squeeze(axis=1)
        return result

    def nunique(self, dropna=True):
        return self._check_index(
            self._wrap_aggregation(
                type(self._query_compiler).groupby_nunique,
                numeric_only=False,
                agg_kwargs=dict(dropna=dropna),
            )
        )

    def resample(self, rule, *args, include_groups=True, **kwargs):
        return self._default_to_pandas(
            lambda df: df.resample(rule, *args, include_groups=include_groups, **kwargs)
        )

    def median(self, numeric_only=False):
        return self._check_index(
            self._wrap_aggregation(
                type(self._query_compiler).groupby_median,
                numeric_only=numeric_only,
            )
        )

    def head(self, n=5):
        # groupby().head()/.tail() ignore as_index, so override it to True
        work_object = self._override(as_index=True)

        return work_object._check_index(
            work_object._wrap_aggregation(
                type(work_object._query_compiler).groupby_head,
                agg_kwargs=dict(n=n),
                numeric_only=False,
            )
        )

    def cumprod(self, axis=lib.no_default, *args, **kwargs):
        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "cumprod")
        else:
            axis = 0
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_cumprod,
            agg_args=args,
            agg_kwargs=dict(axis=axis, **kwargs),
        )

    def __iter__(self):
        return self._iter.__iter__()

    def cov(self, min_periods=None, ddof=1, numeric_only=False):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_cov,
            agg_kwargs=dict(min_periods=min_periods, ddof=ddof),
            numeric_only=numeric_only,
        )

    def transform(self, func, *args, engine=None, engine_kwargs=None, **kwargs):
        if engine not in ("cython", None) and engine_kwargs is not None:
            return self._default_to_pandas(
                lambda df: df.transform(
                    func, *args, engine=engine, engine_kwargs=engine_kwargs, **kwargs
                )
            )

        return self._wrap_aggregation(
            qc_method=type(self._query_compiler).groupby_agg,
            numeric_only=False,
            agg_func=func,
            agg_args=args,
            agg_kwargs=kwargs,
            how="transform",
        )

    def corr(self, method="pearson", min_periods=1, numeric_only=False):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_corr,
            agg_kwargs=dict(method=method, min_periods=min_periods),
            numeric_only=numeric_only,
        )

    def fillna(
        self,
        value=None,
        method=None,
        axis=lib.no_default,
        inplace=False,
        limit=None,
        downcast=lib.no_default,
    ):
        if axis is not lib.no_default:
            self._deprecate_axis(axis, "fillna")

        warnings.warn(
            f"{type(self).__name__}.fillna is deprecated and will be removed "
            + "in a future version. Use obj.ffill(), obj.bfill(), "
            + "or obj.nearest() instead.",
            FutureWarning,
        )

        # default behaviour for aggregations; for the reference see
        # `_op_via_apply` func in pandas==2.0.2
        if axis is None or axis is lib.no_default:
            axis = self._axis

        new_groupby_kwargs = self._kwargs.copy()
        new_groupby_kwargs["as_index"] = True
        work_object = type(self)(
            df=self._df,
            by=self._by,
            axis=self._axis,
            idx_name=self._idx_name,
            drop=self._drop,
            **new_groupby_kwargs,
        )
        return work_object._wrap_aggregation(
            type(self._query_compiler).groupby_fillna,
            agg_kwargs=dict(
                value=value,
                method=method,
                axis=axis,
                inplace=inplace,
                limit=limit,
                downcast=downcast,
            ),
            numeric_only=False,
        )

    def count(self):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_count,
            numeric_only=False,
        )

    def pipe(self, func, *args, **kwargs):
        return com.pipe(self, func, *args, **kwargs)

    def cumcount(self, ascending=True):
        result = self._wrap_aggregation(
            type(self._query_compiler).groupby_cumcount,
            numeric_only=False,
            agg_kwargs=dict(ascending=ascending),
        )
        if not isinstance(result, Series):
            # The result should always be a Series with name None and type int64
            result = result.squeeze(axis=1)
        return result

    def tail(self, n=5):
        # groupby().head()/.tail() ignore as_index, so override it to True
        work_object = self._override(as_index=True)
        return work_object._check_index(
            work_object._wrap_aggregation(
                type(work_object._query_compiler).groupby_tail,
                agg_kwargs=dict(n=n),
                numeric_only=False,
            )
        )

    # expanding and rolling are unique cases and need to likely be handled
    # separately. They do not appear to be commonly used.
    def expanding(self, *args, **kwargs):
        return self._default_to_pandas(lambda df: df.expanding(*args, **kwargs))

    def rolling(self, *args, **kwargs):
        return RollingGroupby(self, *args, **kwargs)

    def hist(
        self,
        column=None,
        by=None,
        grid=True,
        xlabelsize=None,
        xrot=None,
        ylabelsize=None,
        yrot=None,
        ax=None,
        sharex=False,
        sharey=False,
        figsize=None,
        layout=None,
        bins=10,
        backend=None,
        legend=False,
        **kwargs,
    ):
        return self._default_to_pandas(
            lambda df: df.hist(
                column=column,
                by=by,
                grid=grid,
                xlabelsize=xlabelsize,
                xrot=xrot,
                ylabelsize=ylabelsize,
                yrot=yrot,
                ax=ax,
                sharex=sharex,
                sharey=sharey,
                figsize=figsize,
                layout=layout,
                bins=bins,
                backend=backend,
                legend=legend,
                **kwargs,
            )
        )

    def quantile(self, q=0.5, interpolation="linear", numeric_only=False):
        # TODO: handle list-like cases properly
        if is_list_like(q):
            return self._default_to_pandas(
                lambda df: df.quantile(q=q, interpolation=interpolation)
            )

        return self._check_index(
            self._wrap_aggregation(
                type(self._query_compiler).groupby_quantile,
                numeric_only=numeric_only,
                agg_kwargs=dict(q=q, interpolation=interpolation),
            )
        )

    def diff(self, periods=1, axis=lib.no_default):
        from .dataframe import DataFrame

        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "diff")
        else:
            axis = 0

        # Should check for API level errors
        # Attempting to match pandas error behavior here
        if not isinstance(periods, int):
            raise TypeError(f"periods must be an int. got {type(periods)} instead")

        if isinstance(self._df, Series):
            if not is_numeric_dtype(self._df.dtypes):
                raise TypeError(
                    f"unsupported operand type for -: got {self._df.dtypes}"
                )
        elif isinstance(self._df, DataFrame) and axis == 0:
            for col, dtype in self._df.dtypes.items():
                # can't calculate diff on non-numeric columns, so check for non-numeric
                # columns that are not included in the `by`
                if not (
                    is_numeric_dtype(dtype) or is_datetime64_any_dtype(dtype)
                ) and not (
                    isinstance(self._by, BaseQueryCompiler) and col in self._by.columns
                ):
                    raise TypeError(f"unsupported operand type for -: got {dtype}")

        return self._wrap_aggregation(
            type(self._query_compiler).groupby_diff,
            agg_kwargs=dict(
                periods=periods,
                axis=axis,
            ),
        )

    def take(self, indices, axis=lib.no_default, **kwargs):
        return self._default_to_pandas(lambda df: df.take(indices, axis=axis, **kwargs))

    @property
    def _index(self):
        """
        Get index value.

        Returns
        -------
        pandas.Index
            Index value.
        """
        return self._query_compiler.index

    @property
    def _sort(self):
        """
        Get sort parameter value.

        Returns
        -------
        bool
            Value of sort parameter used to create DataFrameGroupBy object.
        """
        return self._kwargs.get("sort")

    @property
    def _as_index(self):
        """
        Get as_index parameter value.

        Returns
        -------
        bool
            Value of as_index parameter used to create DataFrameGroupBy object.
        """
        return self._kwargs.get("as_index")

    @property
    def _iter(self):
        """
        Construct a tuple of (group_id, DataFrame) tuples to allow iteration over groups.

        Returns
        -------
        generator
            Generator expression of GroupBy object broken down into tuples for iteration.
        """
        from .dataframe import DataFrame

        indices = self.indices
        group_ids = indices.keys()
        if self._axis == 0:
            return (
                (
                    (k,) if self._return_tuple_when_iterating else k,
                    DataFrame(
                        query_compiler=self._query_compiler.getitem_row_array(
                            indices[k]
                        )
                    ),
                )
                for k in (sorted(group_ids) if self._sort else group_ids)
            )
        else:
            return (
                (
                    (k,) if self._return_tuple_when_iterating else k,
                    DataFrame(
                        query_compiler=self._query_compiler.getitem_column_array(
                            indices[k], numeric=True
                        )
                    ),
                )
                for k in (sorted(group_ids) if self._sort else group_ids)
            )

    def _compute_index_grouped(self, numerical=False):
        """
        Construct an index of group IDs.

        Parameters
        ----------
        numerical : bool, default: False
            Whether a group indices should be positional (True) or label-based (False).

        Returns
        -------
        dict
            A dict of {group name -> group indices} values.

        See Also
        --------
        pandas.core.groupby.GroupBy.groups
        """
        # We end up using pure pandas to compute group indices, so raising a warning
        ErrorMessage.default_to_pandas("Group indices computation")

        # Splitting level-by and column-by since we serialize them in a different ways
        by = None
        level = []
        if self._level is not None:
            level = self._level
            if not isinstance(level, list):
                level = [level]
        elif isinstance(self._by, list):
            by = []
            for o in self._by:
                if hashable(o) and o in self._query_compiler.get_index_names(
                    self._axis
                ):
                    level.append(o)
                else:
                    by.append(o)
        else:
            by = self._by

        is_multi_by = self._is_multi_by or (by is not None and len(level) > 0)
        # `dropna` param is the only one that matters for the group indices result
        dropna = self._kwargs.get("dropna", True)

        if isinstance(self._by, BaseQueryCompiler) and is_multi_by:
            by = list(self._by.columns)

        if is_multi_by:
            # Because we are doing a collect (to_pandas) here and then groupby, we
            # end up using pandas implementation. Add the warning so the user is
            # aware.
            ErrorMessage.catch_bugs_and_request_email(self._axis == 1)
            if isinstance(by, list) and all(
                is_label(self._df, o, self._axis) for o in by
            ):
                pandas_df = self._df._query_compiler.getitem_column_array(
                    by
                ).to_pandas()
            else:
                by = try_cast_to_pandas(by, squeeze=True)
                pandas_df = self._df._to_pandas()
            by = wrap_into_list(by, level)
            groupby_obj = pandas_df.groupby(by=by, dropna=dropna)
            return groupby_obj.indices if numerical else groupby_obj.groups
        else:
            if isinstance(self._by, type(self._query_compiler)):
                by = self._by.to_pandas().squeeze().values
            elif self._by is None:
                index = self._query_compiler.get_axis(self._axis)
                levels_to_drop = [
                    i
                    for i, name in enumerate(index.names)
                    if name not in level and i not in level
                ]
                by = index.droplevel(levels_to_drop)
                if isinstance(by, pandas.MultiIndex):
                    by = by.reorder_levels(level)
            else:
                by = self._by
            axis_labels = self._query_compiler.get_axis(self._axis)
            if numerical:
                # Since we want positional indices of the groups, we want to group
                # on a `RangeIndex`, not on the actual index labels
                axis_labels = pandas.RangeIndex(len(axis_labels))
            # `pandas.Index.groupby` doesn't take any parameters except `by`.
            # Have to convert an Index to a Series to be able to process `dropna=False`:
            if dropna:
                return axis_labels.groupby(by)
            else:
                groupby_obj = axis_labels.to_series().groupby(by, dropna=dropna)
                return groupby_obj.indices if numerical else groupby_obj.groups

    def _wrap_aggregation(
        self,
        qc_method,
        numeric_only=False,
        agg_args=None,
        agg_kwargs=None,
        **kwargs,
    ):
        """
        Perform common metadata transformations and apply groupby functions.

        Parameters
        ----------
        qc_method : callable
            The query compiler method to call.
        numeric_only : {None, True, False}, default: None
            Specifies whether to aggregate non numeric columns:
                - True: include only numeric columns (including categories that holds a numeric dtype)
                - False: include all columns
                - None: infer the parameter, ``False`` if there are no numeric types in the frame,
                  ``True`` otherwise.
        agg_args : list-like, optional
            Positional arguments to pass to the aggregation function.
        agg_kwargs : dict-like, optional
            Keyword arguments to pass to the aggregation function.
        **kwargs : dict
            Keyword arguments to pass to the specified query compiler's method.

        Returns
        -------
        DataFrame or Series
            Returns the same type as `self._df`.
        """
        agg_args = tuple() if agg_args is None else agg_args
        agg_kwargs = dict() if agg_kwargs is None else agg_kwargs

        if numeric_only and self.ndim == 2:
            by_cols = self._internal_by
            mask_cols = [
                col
                for col, dtype in self._query_compiler.dtypes.items()
                if (is_numeric_dtype(dtype) or col in by_cols)
            ]
            groupby_qc = self._query_compiler.getitem_column_array(mask_cols)
        else:
            groupby_qc = self._query_compiler

        return type(self._df)(
            query_compiler=qc_method(
                groupby_qc,
                by=self._by,
                axis=self._axis,
                groupby_kwargs=self._kwargs,
                agg_args=agg_args,
                agg_kwargs=agg_kwargs,
                drop=self._drop,
                **kwargs,
            )
        )

    def _check_index(self, result):
        """
        Check the result of groupby aggregation on the need of resetting index.

        Parameters
        ----------
        result : DataFrame
            Group by aggregation result.

        Returns
        -------
        DataFrame
        """
        if self._by is None and not self._as_index:
            # This is a workaround to align behavior with pandas. In this case pandas
            # resets index, but Modin doesn't do that. More details are in https://github.com/modin-project/modin/issues/3716.
            result.reset_index(drop=True, inplace=True)

        return result

    def _default_to_pandas(self, f, *args, **kwargs):
        """
        Execute function `f` in default-to-pandas way.

        Parameters
        ----------
        f : callable or str
            The function to apply to each group.
        *args : list
            Extra positional arguments to pass to `f`.
        **kwargs : dict
            Extra keyword arguments to pass to `f`.

        Returns
        -------
        modin.pandas.DataFrame
            A new Modin DataFrame with the result of the pandas function.
        """
        if (
            isinstance(self._by, type(self._query_compiler))
            and len(self._by.columns) == 1
        ):
            by = self._by.columns[0] if self._drop else self._by.to_pandas().squeeze()
        # converting QC 'by' to a list of column labels only if this 'by' comes from the self (if drop is True)
        elif self._drop and isinstance(self._by, type(self._query_compiler)):
            by = list(self._by.columns)
        else:
            by = self._by

        by = try_cast_to_pandas(by, squeeze=True)
        # Since 'by' may be a 2D query compiler holding columns to group by,
        # to_pandas will also produce a pandas DataFrame containing them.
        # So splitting 2D 'by' into a list of 1D Series using 'GroupBy.validate_by':
        by = GroupBy.validate_by(by)

        def groupby_on_multiple_columns(df, *args, **kwargs):
            groupby_obj = df.groupby(by=by, axis=self._axis, **self._kwargs)

            if callable(f):
                return f(groupby_obj, *args, **kwargs)
            else:
                ErrorMessage.catch_bugs_and_request_email(
                    failure_condition=not isinstance(f, str)
                )
                attribute = getattr(groupby_obj, f)
                if callable(attribute):
                    return attribute(*args, **kwargs)
                return attribute

        return self._df._default_to_pandas(groupby_on_multiple_columns, *args, **kwargs)


@_inherit_docstrings(pandas.core.groupby.SeriesGroupBy)
class SeriesGroupBy(DataFrameGroupBy):
    _pandas_class = pandas.core.groupby.SeriesGroupBy

    @property
    def ndim(self):
        """
        Return 1.

        Returns
        -------
        int
            Returns 1.

        Notes
        -----
        Deprecated and removed in pandas and will be likely removed in Modin.
        """
        return 1  # ndim is always 1 for Series

    @property
    def _iter(self):
        """
        Construct a tuple of (group_id, Series) tuples to allow iteration over groups.

        Returns
        -------
        generator
            Generator expression of GroupBy object broken down into tuples for iteration.
        """
        indices = self.indices
        group_ids = indices.keys()
        if self._axis == 0:
            return (
                (
                    k,
                    Series(
                        query_compiler=self._query_compiler.getitem_row_array(
                            indices[k]
                        )
                    ),
                )
                for k in (sorted(group_ids) if self._sort else group_ids)
            )
        else:
            return (
                (
                    k,
                    Series(
                        query_compiler=self._query_compiler.getitem_column_array(
                            indices[k], numeric=True
                        )
                    ),
                )
                for k in (sorted(group_ids) if self._sort else group_ids)
            )

    def _try_get_str_func(self, fn):
        """
        Try to convert a groupby aggregation function to a string or list of such.

        Parameters
        ----------
        fn : callable, str, or Iterable

        Returns
        -------
        str, list
            If `fn` is a callable, return its name, otherwise return `fn` itself.
            If `fn` is a string, return it. If `fn` is an Iterable, return a list
            of _try_get_str_func applied to each element of `fn`.
        """
        if not isinstance(fn, str) and isinstance(fn, Iterable):
            return [self._try_get_str_func(f) for f in fn]
        return fn.__name__ if callable(fn) else fn

    def value_counts(
        self,
        normalize: bool = False,
        sort: bool = True,
        ascending: bool = False,
        bins=None,
        dropna: bool = True,
    ):
        return self._default_to_pandas(
            lambda ser: ser.value_counts(
                normalize=normalize,
                sort=sort,
                ascending=ascending,
                bins=bins,
                dropna=dropna,
            )
        )

    def corr(self, other, method="pearson", min_periods=None):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_corr,
            agg_kwargs=dict(other=other, method=method, min_periods=min_periods),
        )

    def cov(self, other, min_periods=None, ddof=1):
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_cov,
            agg_kwargs=dict(other=other, min_periods=min_periods, ddof=ddof),
        )

    def describe(self, percentiles=None, include=None, exclude=None):
        return self._default_to_pandas(
            lambda df: df.describe(
                percentiles=percentiles, include=include, exclude=exclude
            )
        )

    def apply(self, func, *args, **kwargs):
        return super().apply(func, *args, **kwargs)

    def idxmax(self, axis=lib.no_default, skipna=True):
        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "idxmax")
        else:
            axis = 0
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_idxmax,
            agg_kwargs=dict(axis=axis, skipna=skipna),
        )

    def idxmin(self, axis=lib.no_default, skipna=True):
        if axis is not lib.no_default:
            axis = self._df._get_axis_number(axis)
            self._deprecate_axis(axis, "idxmin")
        else:
            axis = 0
        return self._wrap_aggregation(
            type(self._query_compiler).groupby_idxmin,
            agg_kwargs=dict(axis=axis, skipna=skipna),
        )

    def hist(
        self,
        by=None,
        ax=None,
        grid=True,
        xlabelsize=None,
        xrot=None,
        ylabelsize=None,
        yrot=None,
        figsize=None,
        bins=10,
        backend=None,
        legend=False,
        **kwargs,
    ):
        return self._default_to_pandas(
            lambda df: df.hist(
                by=by,
                ax=ax,
                grid=grid,
                xlabelsize=xlabelsize,
                xrot=xrot,
                ylabelsize=ylabelsize,
                yrot=yrot,
                figsize=figsize,
                bins=bins,
                backend=backend,
                legend=legend,
                **kwargs,
            )
        )

    @property
    def is_monotonic_decreasing(self):
        return self._default_to_pandas(lambda ser: ser.is_monotonic_decreasing)

    @property
    def is_monotonic_increasing(self):
        return self._default_to_pandas(lambda ser: ser.is_monotonic_increasing)

    @property
    def dtype(self):
        return self._default_to_pandas(lambda ser: ser.dtype)

    def unique(self):
        return self._check_index(
            self._wrap_aggregation(
                type(self._query_compiler).groupby_unique,
                numeric_only=False,
            )
        )

    def nlargest(self, n=5, keep="first"):
        return self._check_index(
            self._wrap_aggregation(
                type(self._query_compiler).groupby_nlargest,
                agg_kwargs=dict(n=n, keep=keep),
                numeric_only=True,
            )
        )

    def nsmallest(self, n=5, keep="first"):
        return self._check_index(
            self._wrap_aggregation(
                type(self._query_compiler).groupby_nsmallest,
                agg_kwargs=dict(n=n, keep=keep),
                numeric_only=True,
            )
        )

    def _validate_func_kwargs(self, kwargs: dict):
        """
        Validate types of user-provided "named aggregation" kwargs.

        Parameters
        ----------
        kwargs : dict

        Returns
        -------
        columns : List[str]
            List of user-provided keys.
        funcs : List[Union[str, callable[...,Any]]]
            List of user-provided aggfuncs.

        Raises
        ------
        `TypeError` is raised if aggfunc is not `str` or callable.

        Notes
        -----
        Copied from pandas.
        """
        columns = list(kwargs)
        funcs = []
        for col_func in kwargs.values():
            if not (isinstance(col_func, str) or callable(col_func)):
                raise TypeError(
                    f"func is expected but received {type(col_func).__name__} in **kwargs."
                )
            funcs.append(col_func)
        if not columns:
            raise TypeError("Must provide 'func' or named aggregation **kwargs.")
        return columns, funcs

    def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs):
        engine_default = engine is None and engine_kwargs is None
        # if func is None, will switch to user-provided "named aggregation" kwargs
        if func_is_none := func is None:
            columns, func = self._validate_func_kwargs(kwargs)
            kwargs = {}
        if isinstance(func, dict) and engine_default:
            raise SpecificationError("nested renamer is not supported")
        elif is_list_like(func) and engine_default:
            from .dataframe import DataFrame

            result = DataFrame(
                query_compiler=self._query_compiler.groupby_agg(
                    by=self._by,
                    agg_func=func,
                    axis=self._axis,
                    groupby_kwargs=self._kwargs,
                    agg_args=args,
                    agg_kwargs=kwargs,
                )
            )
            # query compiler always gives result a multiindex on the axis with the
            # function names, but series always gets a regular index on the columns
            # because there is no need to identify which original column's aggregation
            # the new column represents. alternatively we could give the query compiler
            # a hint that it's for a series, not a dataframe.
            if func_is_none:
                return result.set_axis(labels=columns, axis=1, copy=False)
            return result.set_axis(
                labels=self._try_get_str_func(func), axis=1, copy=False
            )
        else:
            return super().aggregate(
                func, *args, engine=engine, engine_kwargs=engine_kwargs, **kwargs
            )

    agg = aggregate
