"""
Core definitions
"""
import warnings
import logging
from collections import deque
import numpy as np
import pandas as pd
from copy import deepcopy
from ..utils.utils import set_data_columns, spec_updater

log = logging.getLogger(name=__name__)


class Epithelium:
    """Base class defining a connective tissue in 2D or 3D.
    """

    def __init__(self, identifier, datasets, specs=None, coords=None, maxbackup=5):
        """Creates an epithelium

        Parameters
        ----------
        identifier : string
        datasets : dictionary of dataframes
            The keys correspond to the different geometrical elements
            constituting the epithelium:

            * `vert` contains a dataframe of vertices,
            * `edge` contains a dataframe of *oriented* half-edges between vertices,
            * `face` contains a dataframe of polygonal faces enclosed by half-edges,
            * `cell` contains a dataframe of polyhedral cells delimited by faces,

        specs : nested dictionnary of specifications
            The first key designs the element name: (`vert`, `edge`, `face`, `cell`),
            corresponding to the respective dataframes attribute in the dataset.
            The second level keys design column names of the above dataframes.
            For exemple:
            .. code::
                specs = {
                    "face": {
                        ## Face Geometry
                        "perimeter": 0.,
                        "area": 0.,
                        ## Coordinates
                        "x": 0.,
                        "y": 0.,
                        "z": 0.,
                        ## Topology
                        "num_sides": 6,
                        ## Masks
                        "is_alive": True},
                    "vert": {
                        ## Coordinates
                        "x": 0.,
                        "y": 0.,
                        "z": 0.,
                        ## Masks
                        "is_active": True},
                    "edge": {
                        ## Connections
                        "srce": 0,
                        "trgt": 1,
                        "face": 0,
                        "cell": 0,
                        ## Coordinates
                        "dx": 0.,
                        "dy": 0.,
                        "dz": 0.,
                        "length": 0.,
                        ## Normals
                        "nx": 0.,
                        "ny": 0.,
                        "nz": 1.}
                    "settings":
                        ## Custom values
                        "geometry": "flat"
                    }

        Note
        ----
        For efficiency reasons, we have to maintain a monotonous RangeIndex
        for each dataset. Thus, **the index of an element can change**,
        and should not be used as an identifier.

        """
        # backup container
        # TODO: pass the max backup number as a config argument
        self._backups = deque(maxlen=maxbackup)

        # each of those has a separate dataframe, as well as entries in
        # the specification files
        _frame_types = {"edge", "vert", "face", "cell"}
        self.identifier = identifier
        if not set(datasets).issubset(_frame_types):
            raise ValueError(
                "The `datasets` dictionnary should"
                " contain keys in {}".format(_frame_types)
            )
        self.datasets = datasets
        self.data_names = list(datasets.keys())
        self.element_names = ["srce", "trgt", "face", "cell"][: len(self.data_names)]
        # Infer specs from the first rows of the datasets
        if specs is None:
            specs = {elem: df.iloc[0].to_dict() for elem, df in datasets.items()}
        if "settings" not in specs:
            specs["settings"] = {}

        self.specs = specs
        self.update_specs(specs, reset=False)

        if coords is None:
            coords = [c for c in "xyz" if c in datasets["vert"].columns]

        self.coords = coords
        # edge's dx, dy, dz
        self.dcoords = ["d" + c for c in self.coords]
        # edge's unit length vector
        self.ucoords = ["u" + c for c in self.coords]

        self.dim = len(self.coords)
        # edge's normals
        if self.dim == 3:
            self.ncoords = ["n" + c for c in self.coords]

        self._bad = None
        self.bbox = None
        if "is_active" in self.vert_df.columns:
            self.active_verts = self.vert_df[self.vert_df.is_active == 1].index
        else:
            self.active_verts = self.vert_df.index
        self.set_bbox()

        self.position_buffer = None
        self.topo_changed = False

    @property
    def vert_df(self):
        """The face :class:`pd.DataFrame` containing vertex associated
        data e.g. the position of each vertex.
        """
        return self.datasets["vert"]

    @vert_df.setter
    def vert_df(self, value):
        self.datasets["vert"] = value

    @property
    def face_df(self):
        """The face :class:`pd.DataFrame` containing face associated
        data e.g. the position of their center or their area
        """
        return self.datasets["face"]

    @face_df.setter
    def face_df(self, value):
        self.datasets["face"] = value

    @property
    def edge_df(self):
        """The edge :class:`pd.DataFrame` containing edge associated
        data e.g. their length.This dataframe also contains the whole
        connexion of the epithelium through the `"srce", "trgt", "face", "cell"`
        indices. In 2D, a half-edge is associated with a single (face, srce, trgt)
        positively oriented triangle. In 3D, the (cell, face, srce, trgt)
        positively oriented terahedron is also unique.
        """
        return self.datasets["edge"]

    @edge_df.setter
    def edge_df(self, value):
        self.datasets["edge"] = value

    @property
    def cell_df(self):
        """The cell :class:`pd.DataFrame` containing cell associated
        data e.g. the position of their center or their volume
        """
        return self.datasets.get("cell", None)

    @cell_df.setter
    def cell_df(self, value):
        self.datasets["cell"] = value

    def copy(self, deep_copy=True):
        """
        Returns a copy of the epithelium

        Parameters
        ----------
        deep_copy : bool, default True
            if True, use a copy of the original object's datasets
            to create the new object. If False, datasets are not copied
        """
        if deep_copy:
            datasets = {element: df.copy() for element, df in self.datasets.items()}
            specs = deepcopy(self.specs)

        else:  # pragma: no cover
            log.info("New epithelium object from %s without deep copy", self.identifier)
            datasets = self.datasets
            specs = self.specs

        identifier = self.identifier + "_copy"

        new = type(self)(identifier, datasets, specs=specs, coords=self.coords)

        return new

    def backup(self):
        """Creates a copy of self and keeps a reference to it
        in the self._backups deque.

        """
        log.info("Backing up")
        self._backups.append(self.copy(deep_copy=True))

    def restore(self):
        """Resets the eptithelium data to its last backed up state

        A copy of the current state prior to restoring is kept in the
        `_bad` attribute for inspection.
        Calling this method multiple times (without calling backup) will
        go back in the epithelium backups.
        """

        log.info("Restoring")
        log.info(
            "a copy of the unrestored epithelium" " is stored in the `_bad` attribute"
        )
        bck = self._backups.pop()
        self._bad = self.copy(deep_copy=True)
        self.datasets = bck.datasets
        self.specs = bck.specs

    @property
    def settings(self):
        """ Accesses the `specs['settings']` dictionnary
        """
        return self.specs["settings"]

    def update_specs(self, new, reset=False):
        """Recursively updates the `self.specs` nested dictionnary,
        and set the new values to the corresponding columns
        in the datasets. If reset is `True`, existing values
        will be overwritten.
        """
        spec_updater(self.specs, new)
        set_data_columns(self.datasets, new, reset)

    def update_num_sides(self):
        """Updates the number of half-edges around the faces.
        The data is registered in the `"num_sides"` column of
        `self.face_df`.
        """
        self.face_df["num_sides"] = self.edge_df.face.value_counts()

    def update_num_faces(self):
        """Updates the number of faces around the cells.
        The data is registered in the `"num_faces"` column of
        `self.cell_df`.
        """
        self.cell_df["num_faces"] = self.edge_df.groupby("cell").apply(
            lambda df: df["face"].unique().size
        )
        self.cell_df["num_ridges"] = self.edge_df.cell.value_counts()

    def reset_topo(self):
        """Recomputes the number of sides for the faces and the
        number of faces for the cells.
        """
        log.debug("Resetting topology")
        self.update_num_sides()
        if "is_active" in self.vert_df.columns:
            self.active_verts = self.vert_df[self.vert_df.is_active == 1].index
        if "cell" in self.data_names:
            self.update_num_faces()

    @property
    def Nv(self):
        """The number of vertices in the epithelium
        """
        return self.vert_df.shape[0]

    @property
    def Ne(self):
        """The number of edges in the epithelium
        """
        return self.edge_df.shape[0]

    @property
    def Nf(self):
        """The number of faces in the epithelium
        """
        return self.face_df.shape[0]

    @property
    def Nc(self):
        """The number of cells in the epithelium
        """
        if "cell" in self.data_names:
            return self.cell_df.shape[0]
        elif "face" in self.data_names:
            return self.face_df.shape[0]
        return None

    def _upcast(self, idx, df):

        ## Assumes a flat index
        upcast = df.take(idx)
        try:
            upcast.index = self.edge_df.index
        except AttributeError:
            if len(upcast.shape) == 1:
                upcast = pd.Series(upcast, index=self.edge_df.index)
            else:
                upcast = pd.DataFrame(upcast, index=self.edge_df.index)
        return upcast

    def upcast_cols(self, element, columns):
        """Syntactic sugar to upcast from the epithelium datasets.

        Parameters
        ----------
        element: {'srce'|'trgt'|'face'|'cell'}
           corresponding self.edge_df column over which to index
           if element is 'srce' or 'trgt', the upcast data will be
           taken form self.vert_df
        columns: index
           the column(s) to be taken from the input dataset.

        """
        if element in ["srce", "trgt"]:
            dataset = "vert"
        else:
            dataset = element
        return self._upcast(self.edge_df[element], self.datasets[dataset][columns])

    def upcast_srce(self, df):
        """ Reindexes input data to self.edge_df.index
        by repeating the values for each source entry.

        Parameters
        ----------
        df : :class:`pd.DataFrame`, :class:`pd.Series` :class:`np.ndarray` or string
          The data to be upcasted. If array like, should have `self.Nv` elements.
          If a string is passed it should be a column of `self.vert_df`

        Returns
        -------
        upcast_df : :class:`pd.DataFrame` or :class:`pd.Series`
          The value repeated like the values of `self.edge_df["srce"]`
        """
        if isinstance(df, str):
            df = self.vert_df[df]
        return self._upcast(self.edge_df["srce"], df)

    def upcast_trgt(self, df):
        """ Reindexes input data to self.edge_df.index
        by repeating the values for each target entry

        Parameters
        ----------
        df : :class:`pd.DataFrame`, :class:`pd.Series` :class:`np.ndarray` or string
          The data to be upcasted. If array like, should have `self.Nv` elements.
          If a string is passed it should be a column of `self.vert_df`

        Returns
        -------
        upcast_df : :class:`pd.DataFrame` or :class:`pd.Series`
          The value repeated like the values of `self.edge_df["trgt"]`
        """
        if isinstance(df, str):
            df = self.vert_df[df]
        return self._upcast(self.edge_df["trgt"], df)

    def upcast_face(self, df):
        """ Reindexes input data to self.edge_df.index
        by repeating the values for each face entry

        Parameters
        ----------
        df : :class:`pd.DataFrame`, :class:`pd.Series` :class:`np.ndarray` or string
          The data to be upcasted. If array like, should have `self.Nf` elements.
          If a string is passed it should be a column of `self.face_df`

        Returns
        -------
        upcast_df : :class:`pd.DataFrame` or :class:`pd.Series`
          The value repeated like the values of `self.edge_df["face"]`

        """
        if isinstance(df, str):
            df = self.face_df[df]
        return self._upcast(self.edge_df["face"], df)

    def upcast_cell(self, df):
        """ Reindexes input data to self.edge_df.index
        by repeating the values for each cell entry

        Parameters
        ----------
        df : :class:`pd.DataFrame`, :class:`pd.Series` :class:`np.ndarray` or string
          The data to be upcasted. If array like, should have `self.Nc` elements.
          If a string is passed it should be a column of `self.cell_df`

        Returns
        -------
        upcast_df : :class:`pd.DataFrame` or :class:`pd.Series`
          The value repeated like the values of `self.edge_df["cell"]`
        """
        if isinstance(df, str):
            df = self.cell_df[df]
        return self._upcast(self.edge_df["cell"], df)

    def _lvl_sum(self, df, lvl):
        df_ = df
        if isinstance(df, np.ndarray):
            df_ = pd.DataFrame(df, index=self.edge_df.index)
        elif isinstance(df, pd.Series):
            df_ = df.to_frame()
        elif lvl not in df.columns:
            df_ = df.copy()
        df_[lvl] = self.edge_df[lvl]
        return df_.groupby(lvl).sum()

    def sum_srce(self, df):
        """Sums the values of the edge-indexed dataframe `df` grouped by
        the values of `self.edge_df["srce"]`

        Returns
        -------
        summed : :class:`pd.DataFrame` the summed data, indexed by the source vertices.
        """
        return self._lvl_sum(df, "srce")

    def sum_trgt(self, df):
        """Sums the values of the edge-indexed dataframe `df` grouped by
        the values of `self.edge_df["trgt"]`

        Returns
        -------
        summed : :class:`pd.DataFrame` the summed data, indexed by the source vertices.
        """
        return self._lvl_sum(df, "trgt")

    def sum_face(self, df):
        """Sums the values of the edge-indexed dataframe `df` grouped by
        the values of `self.edge_df["face"]`

        Returns
        -------
        summed : :class:`pd.DataFrame` the summed data, indexed by the source vertices.
        """
        return self._lvl_sum(df, "face")

    def sum_cell(self, df):
        """Sums the values of the edge-indexed dataframe `df` grouped by
        the values of `self.edge_df["cell"]`

        Returns
        -------
        summed : :class:`pd.DataFrame` the summed data, indexed by the source vertices.
        """
        return self._lvl_sum(df, "cell")

    def get_orbits(self, center, periph):
        """Returns a dataframe with a `(center, edge)` MultiIndex with `periph`
        elements.

        Parameters
        ----------
        center : str,
            the name of the center element for example 'face', 'srce'
        periph : str,
            the name of the periphery elements, for example 'trgt', 'cell'

        Example
        -------
        >>> cell_verts = sheet.get_orbits('face', 'srce')
        >>> cell_verts.loc[45]
        edge
        218    75
        219    78
        220    76
        221    81
        222    90
        223    87
        Name: srce, dtype: int64

        """
        orbits = self.edge_df.groupby(center).apply(lambda df: df[periph])
        return orbits

    def idx_lookup(self, elem_id, element):
        """returns the current index of the element with the `"id"` column equal to `elem_id`

        Parameters
        ----------
        elem_id : int
          id of the element to retrieve
        element : {"vert"|"edge"|"face"|"cell"}
          the corresponding dataset.
        """
        df = self.datasets[element]["id"]
        idx = df[df == elem_id].index
        if len(idx):
            return idx[0]
        else:
            return None

    def get_neighbors(self, elem_id, elem="cell"):
        """Returns the indexes of the adjacent elements (cells or faces) of
        the element of index `elem_id`.

        Parameters
        ----------
        elem_id : int
            the index of the central element (a face or a cell)
        element : {'cell' | 'face'}, default 'cell'

        Returns
        -------
        neghbors : set
            the cells (or faces) sharing an edge with the central cell (face)
        """

        topo = self.edge_df[["srce", "trgt", elem]]
        edges = self.edge_df[self.edge_df[elem] == elem_id][["srce", "trgt"]]

        neighbors = set(
            topo[
                (topo["srce"].isin(edges["srce"]) & topo["trgt"].isin(edges["trgt"]))
                | (topo["srce"].isin(edges["trgt"]) & topo["trgt"].isin(edges["srce"]))
            ][elem]
        )
        return neighbors - {elem_id}

    def get_neighborhood(self, elem_id, order, elem="cell"):
        """Returns `elem_id` neighborhood up to a degree of `order`

        For example, if `order` is 2, it wil return the adjacent cells (or faces)
        and theses cells neighbors.

        Returns
        -------
        neighbors : pd.DataFrame with two colums, the index
            of the neighboring cell (face), and it's neighboring order

        """

        neighbors = pd.DataFrame.from_dict({elem: [elem_id], "order": [0]})

        for k in range(order + 1):
            for neigh in neighbors[neighbors["order"] == k - 1][elem]:
                new_neighs = self.get_neighbors(neigh)
                new_neighs = set(new_neighs).difference(neighbors[elem])
                orders = np.ones(len(new_neighs), dtype=np.int) * (k)
                new_neighs = pd.DataFrame.from_dict(
                    {elem: list(new_neighs), "order": orders}, dtype=np.int
                )
                neighbors = pd.concat([neighbors, new_neighs])
        return neighbors.reset_index(drop=True).loc[1:]

    def face_polygons(self, coords=None):
        """ Returns a pd.Series of arrays with the coordinates the face polygons

        Each element of the Series is a (num_sides, num_dims) array of points
        ordered counterclockwise.
        """
        if coords is None:
            coords = self.coords

        def _get_verts_pos(face):
            edges = _ordered_edges(face)
            return np.array([self.vert_df.loc[idx[0], coords] for idx in edges])

        polys = self.edge_df.groupby("face").apply(_get_verts_pos).dropna()
        return polys

    def validate(self):
        """returns True if the mesh is validated

        e.g. has only closed polygons and polyhedra
        """
        return np.alltrue(self.get_valid())

    def get_valid(self):
        """Set the 'is_valid' column to true if the faces are all closed polygons,
        and the cells closed polyhedra.
        """
        is_valid_face = self.edge_df.groupby("face").apply(_test_valid)
        is_valid = self.upcast_face(is_valid_face)
        if "cell" in self.data_names:
            is_valid_cell = self.edge_df.groupby("cell").apply(_is_closed_cell)
            is_valid = np.logical_and(is_valid, self.upcast_cell(is_valid_cell))
        self.edge_df["is_valid"] = is_valid
        return is_valid

    def get_invalid(self):
        """Returns a mask over self.edge_df for invalid faces
        """
        is_valid = self.get_valid()
        return ~is_valid

    def sanitize(self, trim_borders=False):
        """Removes invalid faces and associated vertices

        If trim_borders is True (defaults to False), there will be a single
        border edge per border face.
        """
        invalid_edges = self.get_invalid()
        if not len(invalid_edges) and trim_borders:
            from ..topology.base_topology import merge_border_edges

            merge_border_edges(self)
        self.remove(invalid_edges, trim_borders)

    def remove(self, edge_out, trim_borders=False):
        """Remove the edges indexed by `edge_out` associated with all
        the cells and faces containing those edges

        If trim_borders is True (defaults to False), there will be a single
        border edge per border face.
        """
        top_level = self.element_names[-1]
        log.info("Removing cells at the %s level", top_level)
        fto_rm = self.edge_df.loc[edge_out, top_level].unique()
        if not fto_rm.shape[0]:
            log.info("Nothing to remove")
            return
        if fto_rm.shape[0] == self.datasets[top_level].shape[0]:
            raise ValueError("sanitize would delete the whole epithlium")

        fto_rm.sort()
        log.info("%d %s level elements will be removed", len(fto_rm), top_level)

        edge_df_ = (
            self.edge_df.set_index(top_level, append=True).swaplevel(0, 1).sort_index()
        )
        to_rm = np.concatenate([edge_df_.loc[c].index.values for c in fto_rm])
        to_rm.sort()
        self.edge_df = self.edge_df.drop(to_rm)

        remaining_verts = np.unique(self.edge_df[["srce", "trgt"]])
        self.vert_df = self.vert_df.loc[remaining_verts]
        if top_level == "face":
            self.face_df = self.face_df.drop(fto_rm)
        elif top_level == "cell":
            remaining_faces = np.unique(self.edge_df["face"])
            self.face_df = self.face_df.loc[remaining_faces]
            self.cell_df = self.cell_df.drop(fto_rm)
        self.reset_index()
        self.reset_topo()
        if trim_borders:
            from ..topology.base_topology import merge_border_edges

            merge_border_edges(self)

    def cut_out(self, bbox, coords=None):
        """Returns the index of edges with at least one vertex outside of the bounding box

        Parameters
        ----------
        bbox : sequence of shape (dim, 2)
             the bounding box as (min, max) pairs for each coordinates.
        coords : list of str of len dim, default None
             the coords corresponding to the bbox.
        """
        if coords is None:
            coords = self.coords
        outs = pd.DataFrame(index=self.edge_df.index, columns=coords)
        for c, bounds in zip(coords, bbox):
            out_vert_ = (self.vert_df[c] < bounds[0]) | (self.vert_df[c] > bounds[1])
            outs[c] = self.upcast_srce(out_vert_) | self.upcast_trgt(out_vert_)

        edge_out = outs.sum(axis=1).astype(np.bool)
        return self.edge_df[edge_out].index

    def set_bbox(self, margin=0.0):
        """Sets the attribute `bbox` with pairs of values bellow
        and above the min and max of the vert coords, with a margin.
        """
        self.bbox = np.array(
            [
                [self.vert_df[c].min() - margin, self.vert_df[c].max() + margin]
                for c in self.coords
            ]
        )

    def reset_index(self):
        """Resets the datasets  to have continuous indices
        """
        log.debug("reseting index for %s", self.identifier)
        self.topo_changed = True

        # remove disconnected vertices and faces
        self.vert_df = self.vert_df.reindex(
            set(self.edge_df.srce).union(self.edge_df.trgt)
        )
        self.face_df = self.face_df.reindex(set(self.edge_df.face))

        new_vertidx = pd.Series(
            np.arange(self.vert_df.shape[0]), index=self.vert_df.index
        )
        # Here we use loc and not the take from upcast

        self.edge_df["srce"] = new_vertidx.reindex(self.edge_df["srce"]).values
        self.edge_df["trgt"] = new_vertidx.reindex(self.edge_df["trgt"]).values

        new_fidx = pd.Series(np.arange(self.face_df.shape[0]), index=self.face_df.index)

        self.edge_df["face"] = new_fidx.loc[self.edge_df["face"]].values

        self.vert_df.reset_index(drop=True, inplace=True)
        self.vert_df.index.name = "vert"

        self.face_df.reset_index(drop=True, inplace=True)
        self.face_df.index.name = "face"

        if "cell" in self.data_names:
            new_cidx = pd.Series(
                np.arange(self.cell_df.shape[0]), index=self.cell_df.index
            )
            self.edge_df["cell"] = new_cidx.loc[self.edge_df["cell"]].values
            self.cell_df.reset_index(drop=True, inplace=True)
            self.cell_df.index.name = "cell"

        self.edge_df.reset_index(drop=True, inplace=True)
        self.edge_df.index.name = "edge"

    def triangular_mesh(self, coords=None, return_mask=True):
        """
        Return a triangulation of an epithelial sheet (2D in a 3D space),
        with added edges between face barycenters and junction vertices.

        Parameters
        ----------
        coords : list of str:
          pair of coordinates corresponding to column names
          for self.face_df and self.vert_df

        return_mask : bool, optional, default True
          if True, returns `face_mask`

        Returns
        -------
        vertices : (self.Nf+self.Nv, 3) ndarray
           all the vertices' coordinates
        triangles : (self.Ne, 3) ndarray of ints
           triple of the vertices' indexes forming
           the triangular elements. For each junction edge, this is simply
           the index (srce, trgt, face). This is correctly oriented.
        face_mask: (self.Nf + self.Nv,) mask with 1 iff the vertex corresponds
           to a face center
        """
        if coords is None:
            coords = self.coords

        vertices = np.concatenate((self.face_df[coords], self.vert_df[coords]), axis=0)

        # edge indices as (Nf + Nv) * 3 array
        triangles = self.edge_df[["srce", "trgt", "face"]].values
        # The src, trgt, face triangle is correctly oriented
        # both vert_idx cols are shifted by Nf
        triangles[:, :2] += self.Nf

        # Ensure returned arrays are C-contiguous
        if not vertices.data.c_contiguous:
            vertices = np.array(vertices, order="C")

        if not triangles.data.c_contiguous:
            triangles = np.array(triangles, order="C")

        if not return_mask:
            return vertices, triangles

        warnings.warn(
            "In version 0.3, the `return_face_mask argument` will be set to False by default"
        )
        face_mask = np.arange(self.Nf + self.Nv) < self.Nf
        return vertices, triangles, face_mask

    def vertex_mesh(self, coords, vertex_normals=True):
        """ Returns the vertex coordinates and a list of vertex indices
        for each face of the tissue.
        If `vertex_normals` is True, also returns the normals of each vertex
        (set as the average of the vertex' edges), suitable for .OBJ export
        """
        vertices = self.vert_df[coords]
        faces = self.edge_df.groupby("face").apply(_ordered_vert_idxs)
        faces = faces.dropna()

        if vertex_normals:
            normals = (
                self.edge_df.groupby("srce")[self.ncoords].mean()
                + self.edge_df.groupby("trgt")[self.ncoords].mean()
            ) / 2.0
            return vertices.values, faces.values, normals.values
        return vertices.values, faces.values

    def validate_closed_cells(self):
        is_closed = self.edge_df.groupby("cell").apply(_is_closed_cell)
        return is_closed

    def get_opposite_faces(self):
        """Populates the 'opposite' column of self.face_df with the index of
        the opposite face or -1 if the face has no opposite.

        """
        face_v = self.edge_df.groupby("face").apply(lambda df: frozenset(df["srce"]))
        face_v2 = pd.Series(data=face_v.index, index=face_v.values)
        grouped = face_v2.groupby(level=0)
        cardinal = grouped.apply(len)
        if cardinal.max() > 2:
            raise ValueError(
                "Invalid topology," " incorrect faces: {}".format(cardinal.argmax())
            )
        self.face_df["opposite"] = -1

        face_pairs = []
        grouped.apply(
            lambda s: face_pairs.append(list(s.values)) if len(s) == 2 else np.nan
        ).dropna()
        face_pairs = np.array(face_pairs)
        if not face_pairs.shape[0]:
            return
        self.face_df.loc[face_pairs[:, 0], "opposite"] = face_pairs[:, 1]
        self.face_df.loc[face_pairs[:, 1], "opposite"] = face_pairs[:, 0]


def get_opposite_faces(eptm):
    warnings.warn("Deprecated, use `eptm.get_opposite_faces()` instead")
    eptm.get_opposite_faces()


def _ordered_edges(face_edges):
    """Returns "srce", "trgt" and "face" indices
    organized clockwise for each face.

    Parameters
    ----------
    face_edges: `pd.DataFrame`
        exerpt of an edge_df for a single face

    Returns
    -------
    edges: list of 3 ints
        srce, trgt, face indices, ordered
    """

    srces, trgts, faces = (face_edges[col].values for col in ["srce", "trgt", "face"])
    srce, trgt, face_ = srces[0], trgts[0], faces[0]
    edges = [[srce, trgt, face_]]
    for face_ in faces[1:]:
        srce, trgt = trgt, trgts[srces == trgt][0]
        edges.append([srce, trgt, face_])
    return edges


def _ordered_vert_idxs(face):
    try:
        return [idxs[0] for idxs in _ordered_edges(face)]
    except IndexError:
        return np.nan


def get_next_edges(sheet):
    """
    returns a pd.Series with the index of the next
    edge for each edge
    """
    next_e = sheet.edge_df.groupby("face").apply(_next_edge)
    next_e.index = next_e.index.droplevel("face")
    return next_e.sort_index()


def get_prev_edges(sheet):
    """
    returns a pd.Series with the index of the next
    edge for each edge
    """
    prev_e = sheet.edge_df.groupby("face").apply(_prev_edge)
    prev_e.index = prev_e.index.droplevel("face")
    return prev_e.sort_index()


def _next_edge(edf):

    edf["edge"] = edf.index
    next_edge = edf.set_index("srce", append=False).loc[edf["trgt"], "edge"].values
    return pd.Series(index=edf.index, data=next_edge)


def _prev_edge(edf):

    edf["edge"] = edf.index
    next_edge = edf.set_index("trgt", append=False).loc[edf["srce"], "edge"].values
    return pd.Series(index=edf.index, data=next_edge)


def _is_closed_cell(e_df):
    edges = e_df[["srce", "trgt"]]
    for _, (srce, trgt) in edges.iterrows():
        if edges[(edges["srce"] == trgt) & (edges["trgt"] == srce)].index.size != 1:
            return False
    return True


def _test_invalid(face):
    """ Returns True if the source and target sets of the faces polygon
    are different or if the face polygon is not closed
    """

    s1 = set(face["srce"])
    s2 = set(face["trgt"])
    if s1 != s2:
        return True
    ordered = np.array(_ordered_edges(face))
    if not np.all(ordered[:, 0] == np.roll(ordered[:, 1], 1)):
        return True
    return False


def _test_valid(face):
    """ Returns true iff all sources are also targets for the faces polygon
    """
    return np.logical_not(_test_invalid(face))
