from typing import Iterable, Any, Tuple

import numpy as np
import scipy.spatial

try:
    from scipy.spatial import Delaunay as spDelaunay
except Exception:  # pragma: no cover
    from scipy.spatial.qhull import Delaunay as spDelaunay

from .utils.topology import unique_topo_data
from .utils.tri import edges_tri

from .config import __hasvtk__, __haspyvista__, __hasmatplotlib__

if __hasvtk__:
    from vtk import vtkIdList

if __haspyvista__:
    import pyvista as pv

if __hasmatplotlib__:
    import matplotlib.tri as tri


__all__ = ["triangulate"]


def triangulate(
    *args,
    points: np.ndarray = None,
    size: tuple = None,
    shape: tuple = None,
    origo: tuple = None,
    backend: str = "mpl",
    random: bool = False,
    triangles: Iterable = None,
    triobj=None,
    return_lines: bool = False,
    **kwargs,
):
    """
    Crates a triangulation using different backends.

    Parameters
    ----------
    points: numpy.ndarray, Optional
        A 2d array of coordinates of a flat surface. This or `shape`
        must be provided.
    size: tuple, Optional
        A 2-tuple, containg side lengths of a rectangular domain.
        Should be provided alongside `shape`. Must be provided if
        points is None.
    shape: tuple or int, Optional
        A 2-tuple, describing subdivisions along coordinate directions,
        or the number of base points used for triangulation.
        Should be provided alongside `size`.
    origo: numpy.ndarray, Optional
        1d float array, specifying the origo of the mesh.
    backend: str, Optional
        The backend to use. It can be 'mpl' (matplotlib), 'pv' (pyvista),
        or 'scipy'. Default is 'mpl'.
    random: bool, Optional
        If `True`, and points are provided by `shape`, it creates random
        points in the region defined by `size`. Default is False.
    triangles: numpy.ndarray
        2d integer array of vertex indices. If both `points` and
        `triangles` are specified, the only thing this function does is to
        create a triangulation object according to the argument `backend`.
    triobj: object
        An object representing a triangulation. It can be
        - a result of a call to matplotlib.tri.Triangulation
        - a Delaunay object from scipy
        - an instance of pyvista.PolyData
        In this case, the function can be used to transform between
        the supported backends.
    return_lines: bool, Optional
        If `True` the function return the unique edges of the
        triangulation and the indices of the edges for each triangle.

    Returns
    -------
    numpy.ndarray
        A 2d float array of coordinates of points.
    numpy.ndarray
        A 2d integer array representing the topology.
    MeshLike
        An object representing the triangulation, according to the specified backend.

    Examples
    --------
    Triangulate a rectangle of size 800x600 with a subdivision of 10x10

    >>> from sigmaepsilon.mesh import triangulate
    >>> coords, topo, triobj = triangulate(size=(800, 600), shape=(10, 10))
    ...

    Triangulate a rectangle of size 800x600 with a number of 100 randomly
    distributed points

    >>> coords, topo, triobj = triangulate(size=(800, 600), shape=100, random=True)
    ...
    """
    if len(args) > 0:
        if _is_triobj(args[0]):
            triobj = args[0]

    if triobj is not None:
        points, triangles = get_triobj_data(triobj, *args, **kwargs)
    else:
        # create points from input
        if points is None:
            assert size is not None, (
                "Either a collection of points, or the size of a "
                "rectangular domain must be provided!"
            )

            if origo is None:
                origo = (0, 0, 0)
            else:
                if len(origo) == 2:
                    origo = origo + (0,)

            if shape is None:
                shape = (3, 3)

            if isinstance(shape, int):
                if random:
                    x = np.hstack(
                        [np.array([0, 1, 1, 0], dtype=float), np.random.rand(shape)]
                    )
                    y = np.hstack(
                        [np.array([0, 0, 1, 1], dtype=float), np.random.rand(shape)]
                    )
                    z = np.zeros(len(x), dtype=float)
                    points = np.c_[
                        x * size[0] - origo[0], y * size[1] - origo[1], z - origo[2]
                    ]
                else:
                    shape = (shape, shape)

            if points is None and isinstance(size, tuple):
                x = np.linspace(-origo[0], size[0] - origo[0], num=shape[0])
                y = np.linspace(-origo[1], size[1] - origo[1], num=shape[1])
                z = np.zeros(len(x), dtype=float) - origo[2]
                xx, yy = np.meshgrid(x, y)
                zz = np.zeros(xx.shape, dtype=xx.dtype)
                # Get the points as a 2D NumPy array (N by 2)
                points = np.c_[xx.reshape(-1), yy.reshape(-1), zz.reshape(-1)]

        # generate triangles
        if triangles is None:
            if backend == "mpl":
                triobj = tri.Triangulation(points[:, 0], points[:, 1])
                triangles = triobj.triangles
            elif backend == "scipy":
                triobj = scipy.spatial.Delaunay(points[:, 0:2])
                triangles = triobj.simplices
            elif backend == "pv":
                if not __haspyvista__ or not __hasvtk__:
                    raise ImportError("PyVista must be installed for this.")
                cloud = pv.PolyData(points)
                triobj = cloud.delaunay_2d()
                nCell = triobj.n_cells
                triangles = np.zeros((nCell, 3), dtype=int)
                for cellID in range(nCell):
                    idlist = vtkIdList()
                    triobj.GetCellPoints(cellID, idlist)
                    n = idlist.GetNumberOfIds()
                    triangles[cellID] = [idlist.GetId(i) for i in range(n)]
        else:
            assert backend == "mpl", (
                "This feature is not yet supported by "
                "other backends, only matplotlib."
            )
            triobj = tri.Triangulation(points[:, 0], points[:, 1], triangles=triangles)

    if return_lines:
        edges, edgeIDs = unique_topo_data(edges_tri(triangles))
        return points, edges, triangles, edgeIDs, triobj

    return points, triangles, triobj


def triobj_to_mpl(triobj, *args, **kwargs) -> tri.Triangulation:
    """
    Transforms a triangulation into a matplotlib.tri.Triangulation
    object.
    """
    assert _is_triobj(triobj)
    if isinstance(triobj, tri.Triangulation):
        return triobj
    else:
        points, triangles = get_triobj_data(triobj, *args, **kwargs)
        kwargs["backend"] = "mpl"
        _, _, triang = triangulate(*args, points=points, triangles=triangles, **kwargs)
        return triang


def get_triobj_data(
    obj: Any = None, *_, trim2d: bool = True, **__
) -> Tuple[np.ndarray]:
    coords, topo = None, None

    if isinstance(obj, spDelaunay):
        coords = obj.points
        topo = obj.simplices
    elif isinstance(obj, tri.Triangulation):
        coords = np.vstack((obj.x, obj.y)).T
        topo = obj.triangles
    else:
        if __haspyvista__ and __hasvtk__:
            if isinstance(obj, pv.PolyData):
                if trim2d:
                    coords = obj.points[:, 0:2]
                else:
                    coords = obj.points
                triang = obj.delaunay_2d()
                nCell = triang.n_cells
                topo = np.zeros((nCell, 3), dtype=np.int32)
                for cellID in range(nCell):
                    idlist = vtkIdList()
                    triang.GetCellPoints(cellID, idlist)
                    n = idlist.GetNumberOfIds()
                    topo[cellID] = [idlist.GetId(i) for i in range(n)]

    if coords is None or topo is None:
        raise RuntimeError("Failed to recognize a valid input.")

    return coords, topo


def _is_triobj(triobj: Any) -> bool:
    try:
        if isinstance(triobj, spDelaunay) or isinstance(triobj, tri.Triangulation):
            return True
        else:
            if __haspyvista__:
                if isinstance(triobj, pv.PolyData):
                    if hasattr(triobj, "delaunay_2d"):
                        return True
        return False
    except Exception:  # pragma: no cover
        return False
