from typing import Optional, Union

__all__ = [
    "Multiset",
]

import warnings

import numpy as np

from .rt_dataset import Dataset
from .rt_display import DisplayDetect, DisplayString, DisplayTable
from .rt_enum import DS_DISPLAY_TYPES, ColHeader, DisplayDetectModes, TypeRegister
from .rt_misc import build_header_tuples
from .rt_sort_cache import SortCache
from .rt_struct import Struct


# =====================================================================================
class Multiset(Struct):
    """
    Multisets contain datasets and/or multisets where all contained dataset have the
    same number of rows. Multisets can provide a convenient namespace for closely
    related datasets, such as those loaded from a single HDF5 file or generated by
    an aggregation applied to a GroupBy object.

    The columns within contained datasets may be displayed in an interleaved way.
    Example: Assume Jan and Feb are two datasets with 3 columns each:

    * Jan: Run1, Run2, Run3
    * Feb: Run1, Run2, Run3

    A Multiset containing these datasets would display with a multi-line header:

    Run1        Run2        Run3

    Jan   Feb    Jan  Feb    Jan  Feb

    One can access the Run1 column in the Jan dataset with the syntax:
    ms.Jan.Run1

    Examples
    --------
    >>> ds=rt.Dataset({'somenans': [0., 1., 2., nan, 4., 5.], 'morestuff': ['A','B','C','D','E','F']})
    >>> ds2=rt.Dataset({'somenans': [0., 1., nan, 3., 4., 5.], 'morestuff':['H','I','J','K','L','M']})
    >>> ms=rt.Multiset({'test':ds, 'test2':ds2})
    >>> ms
          somenans      morestuff
    #   test   test2   test   test2
    -   ----   -----   ----   -----
    0   0.00    0.00   A      H
    1   1.00    1.00   B      I
    2   2.00     nan   C      J
    3    nan    3.00   D      K
    4   4.00    4.00   E      L
    5   5.00    5.00   F      M

    >>> ms['morestuff']
         morestuff
    #   test   test2
    -   ----   -----
    0   A      H
    1   B      I
    2   C      J
    3   D      K
    4   E      L
    5   F      M

    >>> ms['test']
    #   somenans   morestuff
    -   --------   ---------
    0       0.00   A
    1       1.00   B
    2       2.00   C
    3        nan   D
    4       4.00   E
    5       5.00   F

    >>> ms[[2,3],'somenans']
          somenans
    #   test   test2
    -   ----   -----
    0   2.00     nan
    1    nan    3.00

    >>> ms[[2,3],'morestuff']
         morestuff
    #   test   test2
    -   ----   -----
    0   C      J
    1   D      K

    >>> ms[[2,3],['morestuff','somenans']]
         morestuff       somenans
    #   test   test2   test   test2
    -   ----   -----   ----   -----
    0   C      J       2.00     nan
    1   D      K        nan    3.00
    """

    # ------------------------------------------------------------
    def __init__(self, input_value: Optional[Union[dict, "Multiset"]] = None):
        if input_value is None:
            input_value = dict()
        self._pre_init()
        if isinstance(input_value, dict):
            self._init_from_dict(input_value)
        elif isinstance(input_value, Multiset):
            raise TypeError(f"Use {self.__class__.__name__}.copy(deep=True/False) as a copy constructor.")
        else:
            raise TypeError(f"{self.__class__.__name__} must be initialized with a dictionary.")
        self._gbkeys = None
        self._post_init()
        self._sorted_row_idx = None

    # ------------------------------------------------------------
    def _init_from_dict(self, dictionary):
        # all __init__ paths funnel into this
        req_nrows = 0  # number of required rows for each contained dataset and multiset
        for k, v in dictionary.items():
            if k[0] != "_":
                # only allow Datasets and Multisets, if their number of rows are equal
                if isinstance(v, (TypeRegister.Dataset, TypeRegister.Multiset)):
                    if not req_nrows:
                        req_nrows = v._nrows
                    elif req_nrows != v._nrows:
                        raise ValueError(f"Number of rows in datasets do not match")
                    # add the dataset to this class
                    self.col_set_value(k, v)
                else:
                    raise TypeError(f"Cannot add {type(v).__name__} to a multiset")
        # fixup _nrows
        self._nrows = None if self.col_get_len() == 0 else req_nrows

    # ------------------------------------------------------------
    def _check_addtype(self, name, value):
        """called from subclassed Struct when a new item is added"""
        # currently only allow Datasets and Multisets
        if not isinstance(value, (TypeRegister.Dataset, TypeRegister.Multiset)):
            raise TypeError(f"Multiset only holds Datasets and Multisets not {type(value).__name__}.")
        # number of rows must match
        if self._nrows and self._nrows != value._nrows:
            raise ValueError(f"Number of rows do not match")
        # passed all the checks
        return value

    # ------------------------------------------------------------
    def multiget(self, index, deep=False):
        """
        Returns a new Multiset representing a one-level sub-sampling of the original.

        Parameters
        ----------
        index: An index specification.
        deep: bool, False
           If set to True will make deep copies

        Returns
        -------
        A new Multiset.
        """
        dd = {}
        if isinstance(index, slice):
            for _k, _v in self.items():
                temp = _v[index]
                if temp.get_ncols() > 0:
                    dd[_k] = temp.copy(deep=True) if deep else temp
        else:
            for _k, _v in self.items():
                cols = _v._mask_get_item(index, by_col_arg=True)
                lcols = [_c for _c in cols if _c in _v]
                if lcols:
                    dd[_k] = _v[lcols].copy(deep=True) if deep else _v[lcols]
        ms = Multiset(dd)
        return ms

    # ------------------------------------------------------------
    def __len__(self):
        # For Multiset we will return the number of rows for length
        return self._nrows

    def __setitem__(self, index, value):
        """
        Parameters
        ----------
        index: colspec
        value: A Dataset or Multiset

        Returns
        -------
        None

        Raises
        ------
        IndexError, TypeError, ValueError
        """
        if isinstance(index, (tuple, type(None))):
            raise IndexError(
                f"Can only index {self.__class__.__name__} as st[c], where c is colname, list of colnames or boolean mask."
            )

        # return: col_idx, row_idx, ncols, nrows, row_arg
        col_idx, _, ncols, _, _ = self._extract_indexing(index)
        if col_idx is None:
            col_idx = list(self.keys())
        if ncols == 0:
            return
        if ncols == 1:
            if not isinstance(value, (TypeRegister.Dataset, Multiset)):
                raise TypeError(f"Multiset can only hold Datasets or Multisets not {type(value)!r}.")
            if self._nrows and self._nrows != value._nrows:
                raise ValueError(f"Number of rows do not match")
            if not isinstance(col_idx, str):
                col_idx = col_idx[0]
            if self.col_exists(col_idx):
                self.__setattr__(col_idx, value)
            elif self.is_valid_colname(col_idx):
                self.__setattr__(col_idx, value)
            else:
                raise IndexError(f"Invalid column name: {col_idx!r}")
            self._nrows = value._nrows
        else:
            if not all([self.col_exists(_k) for _k in col_idx]):
                raise IndexError("If creating a new column can only do one at a time.")
            for _k1, _v2 in zip(col_idx, value):
                if not isinstance(_v2, (TypeRegister.Dataset, Multiset)):
                    raise TypeError(f"Multiset can only hold Datasets or Multisets.")
                if self._nrows and self._nrows != _v2._nrows:
                    raise ValueError(f"Number of rows do not match")
                setattr(self, _k1, _v2)
                self._nrows = _v2._nrows
        return

    # --------------------------------------------------------
    def _autocomplete(self) -> str:
        return f"Multiset{self.shape}"

    # --------------------------------------------------------
    def _copy(self, deep=False, rows=None, cols=None, base_index=0, cls=None):
        """
        Bracket indexing that returns a multiset will funnel into this routine.

        Parameters
        ----------
        deep : if True, perform a deep copy on column array
        rows : row mask
        cols : column mask
        base_index : used for head/tail slicing
        cls : class of return type, for subclass super() calls

        First argument must be deep.  Deep cannnot be set to None.  It must be True or False.
        """
        if cls is None:
            cls = type(self)

        ms = Multiset({})
        ms_locked = self.is_locked()
        if ms_locked:
            ms._unlock()

        if rows is None and cols is None:
            for dsetname, dset in self.items():
                if deep:
                    ms[dsetname] = dset.copy(deep=deep)
                else:
                    ms[dsetname] = dset
        else:
            for dsetname, dset in self.items():
                ms[dsetname] = dset._copy(deep=deep, rows=rows, cols=cols, base_index=base_index)
        if ms_locked:
            ms._lock()
        return ms

    def copy(self, deep=True):
        """
        Returns a shallow or deep copy of the multiset
        Defaults to a deepy copy.

        Parameters
        ----------
        deep: bool, default True
            Set to False for a shallow copy.
        """
        return self._copy(deep)

    @staticmethod
    def _depth_first(curobject, curdict, level, returnlist):
        """returns the max depth, list of dictionaries"""
        maxlevel = level

        if isinstance(curobject, TypeRegister.Multiset):
            # drill deeper
            for values in curdict.values():
                level = Multiset._depth_first(values, values.asdict(), level + 1, returnlist)
                if level > maxlevel:
                    maxlevel = level
        elif isinstance(curobject, TypeRegister.Dataset):
            # collect dictionary for dataset
            returnlist.append(curdict)
            # newlist=[(k,v) for k,v in curdict.items()]
            # print("appending", newlist)
            # returnlist.append(newlist)
        else:
            raise TypeError("_depth_first: error dont understand type", type)

        return maxlevel

    @staticmethod
    def _build_col_headers(rootobject, rootdict):
        """return a list of lists of ColHeaders"""
        tier1_list = list(rootdict.keys())
        numTier1 = len(tier1_list)
        if numTier1 == 0:
            print("Aborting - No datasets")
            return None
        if numTier1 <= 1:
            operation = tier1_list[0]
            ds = rootdict[operation]

            if rootobject._gbkeys is None:
                cols = list(ds.keys())
            else:
                # single column also needs to remove potential gbkeys from result
                cols = [k for k in ds.keys() if k not in rootobject._gbkeys]
            num_cols = len(cols)
            op_list = [operation] * num_cols

            # first line
            multiline_col = [build_header_tuples(cols, 1, 0), build_header_tuples(op_list, 1, 0)]
            # second line (same operation repeated)

            # allArrays = [ds.__getattribute__(c) for c in cols]
            allArrays = [ds.__getattr__(c) for c in cols]

            # return [build_header_tuples]
            return multiline_col, allArrays

        # depth first to count datasets and depth
        returnlist = []
        maxlevel = Multiset._depth_first(rootobject, rootdict, 0, returnlist)

        # count up total number of numpy arrays
        count = 0
        for i, dicts in enumerate(returnlist):
            count += len(dicts)
            # remove the groupby columns from display (will still be held in dataset)
            if rootobject._gbkeys is not None:
                for k in rootobject._gbkeys:
                    del returnlist[i][k]

        # print("level ", maxlevel, "count ", count)

        if count <= 0:
            print("Dataset count is zero or larger than level", count, maxlevel)
            return None

        # print("returndict", returnlist)

        # setup the list of ColHeaders, assume two lines
        multiline_col = [[], []]

        ##########################################
        #  Multiset/
        #       Jan
        #            Run1,Run2,Run3
        #       Feb
        #            Run1,Run2,Run3
        # ----------------------------------------------
        #
        #     Run1        Run2        Run3
        #  Jan   Feb    Jan  Feb    Jan  Feb
        # ----------------------------------------------
        #
        # ("Run1", 2, 0)             ("Run2", 2, 1)             ("Run3", 2,2)
        # ("Jan", 1, 0)("Feb",1,0) ("Jan", 1, 1)("Feb",1,1) ("Jan", 1, 2)("Feb",1,2)
        # numpy arrays: Jan.Run1/Feb.Run1/Jan.Run2/Feb.Run2/Jan.Run3/Feb.Run3
        #

        # allArrays will contain the numpy arrays to display
        allArrays = []

        colorgroup = 0
        # pass over all datasets until all arrays have been paired up
        for outerindex in range(numTier1):

            # search the next dataset for ANY array
            # then try to match the next datasets with the same key
            for k, v in returnlist[outerindex].items():

                # always add the first item
                allArrays.append(v)
                cell_span = 1
                newcol = ColHeader(tier1_list[outerindex], 1, colorgroup)
                multiline_col[1].append(newcol)

                # now scan other datasets for the same key -- pair up with sister items
                for innerindex in range(outerindex + 1, numTier1):
                    if k in returnlist[innerindex]:
                        # add this sister item
                        allArrays.append(returnlist[innerindex][k])
                        cell_span += 1
                        newcol = ColHeader(tier1_list[innerindex], 1, colorgroup)
                        multiline_col[1].append(newcol)

                        # remove this item so we do not include it in next outerindex pass
                        del returnlist[innerindex][k]

                # the first line is added after the second line is calculated
                newcol = ColHeader(k, cell_span, colorgroup)
                multiline_col[0].append(newcol)
                colorgroup += 1

        # for index in range(itemsPer):
        #    # loop over all tier1 objects
        #    tindex=0
        #    newcol = ColHeader(returnlist[tindex][index][0], len(tier1_list), index)
        #    multiline_col[0].append(newcol)
        #    #print("multiline: ", tindex, newcol)
        #    for name in tier1_list:
        #        #print("**", tindex, index, " --> ", tier1_list[tindex], returnlist[tindex][index])
        #        newcol=ColHeader(tier1_list[tindex], 1, index)
        #        multiline_col[1].append(newcol)
        #        #print("second multiline: ", index, newcol)
        #        allArrays.append(returnlist[tindex][index][1])
        #        tindex += 1

        #        #if (numDatasets ==2):
        #        ## figure out which columns are missing in each

        #        #missing1 = {key: None for key in ds_list[1]._all_items if key not in ds_list[0]._all_items}
        #        #missing2 = {key: None for key in ds_list[0]._all_items if key not in ds_list[1]._all_items}
        # print("First line", multiline_col[0])
        # print("Second line", multiline_col[1])
        # print("Arrays", allArrays)

        return multiline_col, allArrays

    # -------------------------------------------------------
    def _build_footers(self):
        """
        Still testing.
        TODO: speed up this python loop
        """
        footers = None
        num_ds = len(self)
        if num_ds > 0:
            try:
                first_footers = list(getattr(self[0], "_footers").values())[0]
                footers = []
                for f_idx, footer in enumerate(first_footers):
                    footers.append(footer)  # insert the first
                    for i in range(1, num_ds):  # insert each for the other datasets at the same index
                        current_footers = list(self[i]._footers.values())[0]
                        footers.append(current_footers[f_idx])
                footers = {"Total": TypeRegister.FastArray(footers)}

            except:
                pass
            # for i in num_ds
        return footers

    # -------------------------------------------------------
    def _last_row_stats(self):
        return f"[{self._nrows} rows x {self._ncols} columns]"

    def make_table(self, display_type):
        """
        Pretty-print code used by infrastructure.

        :param display_type: See rt.rt_enum.DS_DISPLAY_TYPES.
        :return: Display object or string.
        """
        ## BUGGY?
        multiline_col, allArrays = self._build_col_headers(self, self.asdict())
        footers = self._build_footers()

        table = DisplayTable()

        # _repr_html_
        if display_type == DS_DISPLAY_TYPES.HTML:
            DisplayDetect.Mode = DisplayDetectModes.HTML
            table._display_mode = DisplayDetectModes.HTML
            from_str = False

        # __repr__
        elif display_type == DS_DISPLAY_TYPES.REPR:
            # this repr will be called before _repr_html_ in jupyter
            if DisplayDetect.Mode == DisplayDetectModes.HTML:
                return ""
            from_str = False

        # __str__
        else:
            from_str = True

        if self._sort_display and self._sorted_row_idx is not None:
            sorted_row_idx = self._sorted_row_idx
        else:
            # remove all row sorts
            sorted_row_idx = None

        if self._gbkeys is None:
            keytups = [ColHeader("#", 1, 0)]
        else:
            keytups = [ColHeader(k, 1, 0) for k in self._gbkeys]
            # pad the other rows
            # TODO: take the multiline padding routine out of display to stop this from happening twice
            # padtup = [ColHeader('',len(keytups),0)]
            # for i, header_row in enumerate(multiline_col[:-1]):
            #    multiline_col[i] = padtup + header_row

            # add groupby columns to main data
            keycols = [*self._gbkeys.values()]
            allArrays = keycols + allArrays
        # fix bottom row here
        multiline_col[-1] = keytups + multiline_col[-1]

        result = table.build_result_table(
            multiline_col,
            allArrays,
            self._nrows,
            keys=self._gbkeys,
            sortkeys=self._col_sortlist,
            from_str=from_str,
            sorted_row_idx=sorted_row_idx,
            transpose_on=self._transpose_on,
        )

        return result + "\n\n" + self._last_row_stats()

    # -------------------------------------------------------
    def __str__(self):
        return self.make_table(DS_DISPLAY_TYPES.STR)

    # -------------------------------------------------------
    def __repr__(self):
        # this will be called before _repr_html_ in jupyter
        return self.make_table(DS_DISPLAY_TYPES.REPR)

    # -------------------------------------------------------
    def _repr_html_(self):
        return self.make_table(DS_DISPLAY_TYPES.HTML)

    # -------------------------------------------------------
    @property
    def dtypes(self):
        """
        Returns dictionary of dtype for each column.

        Returns
        -------
        dict
            Dictionary containing the dtype for each column in the Multiset.
        """
        return {colname: getattr(self, colname).dtype for colname in self.keys()}

    # -------------------------------------------------------
    def __getitem__(self, index):
        """
        Parameters
        ----------
        index : (rowspec, colspec) or colspec

        Returns
        -------
        the indexed row(s), cols(s), sub-dataset or single value

        Examples
        --------
        >>> ds=rt.Dataset({'somenans': [0., 1., 2., nan, 4., 5.]})
        >>> ds2=rt.Dataset({'somenans': [0., 1., nan, 3., 4., 5.]})
        >>> ms=rt.Multiset({'test':ds, 'test2':ds2})
        >>> ms[2,:]
              somenans
        #   test   test2
        -   ----   -----
        0   2.00     nan

        Raises
        ------
        IndexError
            When an invalid column name is supplied.
        TypeError
        """

        def single_array(col_idx, row_idx):
            # will either return dataset or return an error
            try:
                ds = self.col_get_value(col_idx)
            except Exception:
                # search and collect all sub columns
                ms = {}
                for dsetname, ds in self.items():
                    try:
                        # have the sub column collect the information
                        newds = ds[row_idx, col_idx] if row_idx is not None else ds[col_idx]
                        if isinstance(newds, np.ndarray):
                            # convert to a dataset
                            ms[dsetname] = Dataset({col_idx: newds})
                        else:
                            ms[dsetname] = newds
                    except Exception:
                        pass
                if len(ms) == 0:
                    raise IndexError(f"Could not find column or sub column named: {col_idx}")
                else:
                    return Multiset(ms)

            if row_idx is not None:
                # array indexing takes place early here
                return ds[row_idx, :]
            else:
                return ds

        if isinstance(index, (int, np.integer, slice)):
            # call into Struct to get col number
            return super().__getitem__(index)

        col_idx, row_idx, ncols, nrows, row_arg = self._extract_indexing(index)

        # check for a single string which selects a single column
        if isinstance(col_idx, str):
            return single_array(col_idx, row_idx)

        # if a single integer specified, make a list of one number for fancy column indexing
        if isinstance(row_arg, (int, np.integer)):
            row_idx = [row_arg]

        return self._copy(deep=False, rows=row_idx, cols=col_idx)

    # -------------------------------------------------------
    def flatten(self, horizontal=True, delimiter="_", dset_col_name="Column"):
        """
        Return a single dataset constructed by concatenating all of the datasets and flattened
        multisets contained within the multiset. Horizontal flattening will concatenate the datasets
        horizontally, prepending the dataset name to each dataset's column names.
        Vertical flattening requires the names and order of columns in each dataset to be
        identical, adding a single column to the returned dataset containing the name
        of the dataset from which each row derives.

        Parameters
        ----------
        horizontal: bool
            If True, concatenate the Datasets horizontally, otherwise vertically.
        delimiter: char
            The character used when joining dataset and column names to create unique names.
        dset_col_name: string
            For vertical flattening, the name for the column containing dataset names.

        Returns
        -------
        Dataset

        Raises
        ------
        ValueError
        """
        initial = True
        for name in self:
            elem = self[name]
            from_multiset = False
            # Recursively flatten any contained Multisets
            if isinstance(elem, Multiset):
                elem = elem.flatten(horizontal, delimiter, dset_col_name)
                from_multiset = True
            if initial:
                # Requirements are based on the first dataset encountered
                label_col_names = elem.label_get_names()  # Label column names, if any
                col_names = [n for n in elem.keys() if n not in ([dset_col_name] + label_col_names)]
            else:
                if label_col_names != elem.label_get_names():
                    raise ValueError(f"Label columns in {name!r} do not match")
            val_col_names = [n for n in elem.keys() if n not in label_col_names]
            if horizontal:
                if initial:
                    dsr = elem[label_col_names] if label_col_names else TypeRegister.Dataset()
                dsr = TypeRegister.Dataset.concat_columns([dsr, elem[val_col_names]], do_copy=False)
                for col_name in val_col_names:
                    dsr.col_rename(col_name, delimiter.join([name, col_name]))
            else:
                in_col_names = [n for n in elem.keys() if n not in ([dset_col_name] + label_col_names)]
                if col_names != in_col_names:
                    raise ValueError(f"Columns in {name!r} do not match")
                inds = elem[label_col_names] if label_col_names else TypeRegister.Dataset()
                if not from_multiset:
                    if dset_col_name in elem.keys():
                        raise ValueError(f"Column name for datasets {dset_col_name!r} already exists in {name!r}")
                    inds[dset_col_name] = [name] * elem.shape[0]
                else:
                    # Append multiset name to dataset name column
                    elem[dset_col_name] = [delimiter.join([name, cn.decode()]) for cn in elem[dset_col_name]]
                inds = TypeRegister.Dataset.concat_columns([inds, elem[val_col_names]], do_copy=False)
                dsr = inds if initial else TypeRegister.Dataset.concat_rows([dsr, inds])
                # Move the label column(s) to front, so they are there even with label removed
                dsr.col_move_to_front(label_col_names)
            initial = False
        dsr.label_set_names(label_col_names)
        return dsr

    ## -------------------------------------------------------
    # def sort_view(self, refdataset, by, ascending=True, kind='mergesort', na_position='last'):
    #    '''
    #    Sorts all columns in all datasets only when displayed.
    #    This routine is fast and does not change data underneath.

    #    See also: sort_inplace and sort_copy
    #    '''
    #    refds = self[refdataset]
    #    self._sorted_row_idx = refds._sort_lexsort(by, ascending)
    #    self._sort_display = True
    #    return self

    # -------------------------------------------------------
    def label_set_names(self, listnames):
        """Set which column names can be used as labels in display"""
        gbkeys = {}
        for ds in self.values():
            for colname in listnames:
                gbkeys[colname] = ds[colname]
            # check if we found all the names
            if len(gbkeys) == len(listnames):
                break
        if len(gbkeys) == 0:
            raise ValueError(f"Could not find any columns named {listnames}")
        # tell display which columns to move to left
        self._gbkeys = gbkeys

    # -------------------------------------------------------
    def label_fixup(self):
        """Auto scan for which column names can be used as labels in display"""
        gbkeys = {}
        for ds in self.values():
            listnames = ds.label_get_names()
            if len(listnames) > 0:
                for colname in listnames:
                    gbkeys[colname] = ds[colname]
                break
        if len(gbkeys) == 0:
            self._gbkeys = None
        else:
            self._gbkeys = gbkeys
        # allow chaining from this method
        return self

    # -------------------------------------------------------
    def cascade(self, funcname, *args, **kwargs):
        """
        Depth first calling of functions, often into a Dataset.
        For each Dataset in the Multiset, the function will be called with the `args` and `kwargs`.
        The return result is expected to be a Dataset which will then be added back into a new Multiset
        and returned to the called.

        Parameters
        ----------
        funcname: string or callable function

        Returns
        -------
        Multiset
        """
        if callable(funcname):
            func = funcname
        else:
            func = MultisetFunc.get(funcname, None)

        if func is not None:
            ms = Multiset({})
            for dsetname, dset in self.items():
                ms[dsetname] = func(dset, *args, **kwargs)
            return ms.label_fixup()
        raise ValueError(f"The function name passed {funcname} could not be found or is not callable")

    # -------------------------------------------------------
    # Copied over from Dataset and then cascaded down
    def apply(self, *args, **kwargs):
        return self.cascade("apply", *args, **kwargs)

    def apply_cols(self, *args, **kwargs):
        return self.cascade("apply_cols", *args, **kwargs)

    def apply_rows(self, *args, **kwargs):
        return self.cascade("apply_rows", *args, **kwargs)

    def sort_inplace(self, *args, **kwargs):
        return self.cascade("sort_inplace", *args, **kwargs)

    def sort_copy(self, *args, **kwargs):
        return self.cascade("sort_copy", *args, **kwargs)

    def fillna(self, *args, **kwargs):
        return self.cascade("fillna", *args, **kwargs)

    def all(self, *args, **kwargs):
        return self.cascade("all", *args, **kwargs)

    def any(self, *args, **kwargs):
        return self.cascade("any", *args, **kwargs)

    def keep(self, *args, **kwargs):
        return self.cascade("keep", *args, **kwargs)

    def trim(self, *args, **kwargs):
        return self.cascade("trim", *args, **kwargs)

    def pivot(self, *args, **kwargs):
        return self.cascade("pivot", *args, **kwargs)

    def quantile(self, *args, **kwargs):
        return self.cascade("quantile", *args, **kwargs)

    def describe(self, *args, **kwargs):
        return self.cascade("describe", *args, **kwargs)

    def sum(self, *args, **kwargs):
        return self.cascade("sum", *args, **kwargs)

    def mean(self, *args, **kwargs):
        return self.cascade("mean", *args, **kwargs)

    def std(self, *args, **kwargs):
        return self.cascade("std", *args, **kwargs)

    def var(self, *args, **kwargs):
        return self.cascade("var", *args, **kwargs)

    def min(self, *args, **kwargs):
        return self.cascade("min", *args, **kwargs)

    def max(self, *args, **kwargs):
        return self.cascade("max", *args, **kwargs)

    def nansum(self, *args, **kwargs):
        return self.cascade("nansum", *args, **kwargs)

    def nanmean(self, *args, **kwargs):
        return self.cascade("nanmean", *args, **kwargs)

    def nanstd(self, *args, **kwargs):
        return self.cascade("nanstd", *args, **kwargs)

    def nanvar(self, *args, **kwargs):
        return self.cascade("nanvar", *args, **kwargs)

    def nanmin(self, *args, **kwargs):
        return self.cascade("nanmin", *args, **kwargs)

    def nanmax(self, *args, **kwargs):
        return self.cascade("nanmax", *args, **kwargs)

    def abs(self, *args, **kwargs):
        return self.cascade("abs", *args, **kwargs)

    def astype(self, *args, **kwargs):
        return self.cascade("astype", *args, **kwargs)


MultisetFunc = {}
# -----------------------------------------------------------
def _FixupDocStrings():
    """
    Load all the member function of Dataset module
    Look up functions we want to copy, copy over docstring from Dataset to Multiset
    """
    import inspect
    import sys

    mymodule = sys.modules[__name__]
    functions_to_copy = [
        "apply",
        "apply_cols",
        "apply_rows",
        "sort_inplace",
        "sort_copy",
        "fillna",
        "all",
        "any",
        "keep",
        "trim",
        "pivot",
        "quantile",
        "describe",
        "sum",
        "mean",
        "std",
        "var",
        "min",
        "max",
        "nansum",
        "nanmean",
        "nanstd",
        "nanvar",
        "nanmin",
        "nanmax",
        "abs",
        "astype",
    ]

    all_dataset_functions = inspect.getmembers(Dataset, inspect.isfunction)
    # print("**all", all_dataset_functions)
    # now for each function that has an np flavor, copy over the doc strings
    for funcs in all_dataset_functions:
        funcname = funcs[0]
        if funcname in functions_to_copy:
            dsfunc = getattr(Dataset, funcname)
            msfunc = getattr(Multiset, funcname)

            # copy doc string
            msfunc.__doc__ = dsfunc.__doc__

            # add to lookup table
            MultisetFunc[funcname] = dsfunc


# ----------------------------------------------------------
# this is called when the module is loaded
_FixupDocStrings()

# keep as last line
TypeRegister.Multiset = Multiset
