import math

from toolz import memoize
import numpy as np

from datashader.glyphs.glyph import Glyph
from datashader.resampling import infer_interval_breaks
from datashader.utils import isreal, ngjit, ngjit_parallel
import numba
from numba import cuda, prange

try:
    import cupy
    from datashader.transfer_functions._cuda_utils import cuda_args
except Exception:
    cupy = None
    cuda_args = None


def _cuda_mapper(mapper):
    @cuda.jit
    def kernel(in_array, out_array):
        i, j = cuda.grid(2)
        if i < out_array.shape[0] and j < out_array.shape[1]:
            out_array[i, j] = mapper(in_array[i, j])

    def cuda_map(in_array):
        out_array = cupy.zeros(in_array.shape, dtype='float64')
        in_array = cuda.to_device(in_array)
        kernel[cuda_args(in_array.shape)](in_array, out_array)
        return out_array

    return cuda_map


class _QuadMeshLike(Glyph):
    def __init__(self, x, y, name):
        self.x = x
        self.y = y
        self.name = name

    @property
    def ndims(self):
        return 2

    @property
    def inputs(self):
        return (self.x, self.y, self.name)

    def validate(self, in_dshape):
        if not isreal(in_dshape.measure[str(self.x)]):
            raise ValueError('x must be real')
        elif not isreal(in_dshape.measure[str(self.y)]):
            raise ValueError('y must be real')
        elif not isreal(in_dshape.measure[str(self.name)]):
            raise ValueError('aggregate value must be real')

    @property
    def x_label(self):
        return self.x

    @property
    def y_label(self):
        return self.y


class QuadMeshRectilinear(_QuadMeshLike):
    def _compute_bounds_from_1d_centers(
            self, xr_ds, dim, maybe_expand=False, orient=True
    ):
        vals = xr_ds[dim].values

        # Assume dimension is sorted in ascending or descending order
        v0, v1, v_nm1, v_n = [
            vals[i] for i in [0, 1, -2, -1]
        ]

        # Check if we should swap order
        if v_n < v0:
            descending = True
            v0, v1, v_nm1, v_n = v_n, v_nm1, v1, v0
        else:
            descending = False

        bounds = (v0 - 0.5 * (v1 - v0), v_n + 0.5 * (v_n - v_nm1))
        if not orient and descending:
            # swap back to descending order
            bounds = bounds[1], bounds[0]

        if maybe_expand:
            bounds = self.maybe_expand_bounds(bounds)
        return bounds

    def compute_x_bounds(self, xr_ds):
        return self._compute_bounds_from_1d_centers(xr_ds, self.x, maybe_expand=True)

    def compute_y_bounds(self, xr_ds):
        return self._compute_bounds_from_1d_centers(xr_ds, self.y, maybe_expand=True)

    def compute_bounds_dask(self, xr_ds):
        return self.compute_x_bounds(xr_ds), self.compute_y_bounds(xr_ds)

    def infer_interval_breaks(self, centers):
        # Infer breaks for 1D array of centers
        return infer_interval_breaks(centers)

    @memoize
    def _build_extend(self, x_mapper, y_mapper, info, append, antialias_stage_2):
        x_name = self.x
        y_name = self.y
        name = self.name

        @ngjit
        @self.expand_aggs_and_cols(append)
        def perform_extend(i, j, xs, ys, *aggs_and_cols):
            x0i, x1i = xs[i], xs[i + 1]
            # Make sure x0 <= x1
            if x0i > x1i:
                x0i, x1i = x1i, x0i
            # Make sure single pixel quads are represented
            if x0i == x1i:
                x1i += 1
            y0i, y1i = ys[j], ys[j + 1]
            # Make sure  y0 <= y1
            if y0i > y1i:
                y0i, y1i = y1i, y0i
            # Make sure single pixel quads are represented
            if y0i == y1i:
                y1i += 1
            # x1i and y1i are not included in the iteration. this
            # serves to avoid overlapping quads and it avoids the need
            # for special handling of quads that end on exactly on the
            # upper bound.
            for xi in range(x0i, x1i):
                for yi in range(y0i, y1i):
                    append(j, i, xi, yi, *aggs_and_cols)

        @cuda.jit
        @self.expand_aggs_and_cols(append)
        def extend_cuda(xs, ys, *aggs_and_cols):
            i, j = cuda.grid(2)
            if i < (xs.shape[0] - 1) and j < (ys.shape[0] - 1):
                perform_extend(i, j, xs, ys, *aggs_and_cols)

        @ngjit
        @self.expand_aggs_and_cols(append)
        def extend_cpu(xs, ys, *aggs_and_cols):
            for i in range(len(xs) - 1):
                for j in range(len(ys) - 1):
                    perform_extend(i, j, xs, ys, *aggs_and_cols)

        def extend(aggs, xr_ds, vt, bounds, x_breaks=None, y_breaks=None):
            from datashader.core import LinearAxis
            use_cuda = cupy and isinstance(xr_ds[name].data, cupy.ndarray)

            # Build axis transform (mapper) functions
            if use_cuda:
                x_mapper2 = _cuda_mapper(x_mapper)
                y_mapper2 = _cuda_mapper(y_mapper)
            else:
                x_mapper2 = x_mapper
                y_mapper2 = y_mapper

            # Convert from bin centers to interval edges
            if x_breaks is None:
                x_centers = xr_ds[x_name].values
                if use_cuda:
                    x_centers = cupy.array(x_centers)
                x_breaks = self.infer_interval_breaks(x_centers)

            if y_breaks is None:
                y_centers = xr_ds[y_name].values
                if use_cuda:
                    y_centers = cupy.array(y_centers)
                y_breaks = self.infer_interval_breaks(y_centers)

            x0, x1, y0, y1 = bounds
            xspan = x1 - x0
            yspan = y1 - y0

            if x_mapper is LinearAxis.mapper:
                xscaled = (x_breaks - x0) / xspan
            else:
                xscaled = (x_mapper2(x_breaks) - x0) / xspan

            if y_mapper is LinearAxis.mapper:
                yscaled = (y_breaks - y0) / yspan
            else:
                yscaled = (y_mapper2(y_breaks) - y0) / yspan

            xinds = np.where((xscaled >= 0) & (xscaled <= 1))[0]
            yinds = np.where((yscaled >= 0) & (yscaled <= 1))[0]
            if len(xinds) == 0 or len(yinds) == 0:
                # Nothing to do
                return

            xm0, xm1 = max(xinds.min() - 1, 0), xinds.max() + 1
            ym0, ym1 = max(yinds.min() - 1, 0), yinds.max() + 1

            plot_height, plot_width = aggs[0].shape[:2]

            # Downselect xs and ys and convert to int
            xs = (xscaled[xm0:xm1 + 1] * plot_width).astype(int).clip(0, plot_width)
            ys = (yscaled[ym0:ym1 + 1] * plot_height).astype(int).clip(0, plot_height)

            # For input "column", down select to valid range
            cols_full = info(xr_ds.transpose(y_name, x_name))
            cols = tuple([c[ym0:ym1, xm0:xm1] for c in cols_full])

            aggs_and_cols = aggs + cols

            if use_cuda:
                do_extend = extend_cuda[cuda_args(xr_ds[name].shape)]
            else:
                do_extend = extend_cpu

            do_extend(xs, ys, *aggs_and_cols)

        return extend


@ngjit
def build_scale_translate(out_size, out0, out1, src_size, src0, src1):
    translate_y = src_size * (out0 - src0) / (src1 - src0)
    scale_y = (src_size * (out1 - out0)) / (out_size * (src1 - src0))
    return scale_y, translate_y


class QuadMeshRaster(QuadMeshRectilinear):
    def is_upsample(self, source, x, y, name, x_range, y_range, out_w, out_h):
        # Check upsampling in x
        src_w = len(source[x])
        if x_range is None:
            upsample_width = out_w >= src_w
        else:
            out_x0, out_x1 = x_range
            src_x0, src_x1 = self._compute_bounds_from_1d_centers(
                source, x, maybe_expand=False, orient=False
            )
            src_xbinsize = math.fabs((src_x1 - src_x0) / src_w)
            out_xbinsize = math.fabs((out_x1 - out_x0) / out_w)
            upsample_width = src_xbinsize >= out_xbinsize

        # Check upsampling in y
        src_h = len(source[y])
        if y_range is None:
            upsample_height = out_h >= src_h
        else:
            out_y0, out_y1 = y_range
            src_y0, src_y1 = self._compute_bounds_from_1d_centers(
                source, y, maybe_expand=False, orient=False
            )
            src_ybinsize = math.fabs((src_y1 - src_y0) / src_h)
            out_ybinsize = math.fabs((out_y1 - out_y0) / out_h)
            upsample_height = src_ybinsize >= out_ybinsize

        return upsample_width, upsample_height

    @memoize
    def _build_extend(self, x_mapper, y_mapper, info, append, antialias_stage_2):
        x_name = self.x
        y_name = self.y
        name = self.name

        @ngjit_parallel
        def upsample_cpu(
                src_w, src_h, translate_x, translate_y, scale_x, scale_y,
                offset_x, offset_y, out_w, out_h, agg, col
        ):
            for out_j in prange(out_h):
                src_j = int(math.floor(scale_y * (out_j + 0.5) + translate_y - offset_y))
                for out_i in range(out_w):
                    src_i = int(math.floor(scale_x * (out_i + 0.5) + translate_x - offset_x))
                    if src_j < 0 or src_j >= src_h or src_i < 0 or src_i >= src_w:
                        agg[out_j, out_i] = np.nan
                    else:
                        agg[out_j, out_i] = col[src_j, src_i]

        @cuda.jit
        def upsample_cuda(
                src_w, src_h, translate_x, translate_y, scale_x, scale_y,
                offset_x, offset_y, out_w, out_h, agg, col
        ):
            out_i, out_j = cuda.grid(2)
            if out_i < out_w and out_j < out_h:
                src_j = int(math.floor(scale_y * (out_j + 0.5) + translate_y - offset_y))
                src_i = int(math.floor(scale_x * (out_i + 0.5) + translate_x - offset_x))
                if src_j < 0 or src_j >= src_h or src_i < 0 or src_i >= src_w:
                    agg[out_j, out_i] = np.nan
                else:
                    agg[out_j, out_i] = col[src_j, src_i]

        @ngjit_parallel
        @self.expand_aggs_and_cols(append)
        def downsample_cpu(
                src_w, src_h, translate_x, translate_y, scale_x, scale_y,
                offset_x, offset_y, out_w, out_h, *aggs_and_cols
        ):
            for out_j in prange(out_h):
                src_j0 = int(max(
                    math.floor(scale_y * (out_j + 0.0) + translate_y - offset_y), 0
                ))
                src_j1 = int(min(
                    math.floor(scale_y * (out_j + 1.0) + translate_y - offset_y), src_h
                ))
                for out_i in range(out_w):
                    src_i0 = int(max(
                        math.floor(scale_x * (out_i + 0.0) + translate_x - offset_x), 0
                    ))
                    src_i1 = int(min(
                        math.floor(scale_x * (out_i + 1.0) + translate_x - offset_x), src_w
                    ))
                    for src_j in range(src_j0, src_j1):
                        for src_i in range(src_i0, src_i1):
                            append(src_j, src_i, out_i, out_j, *aggs_and_cols)

        @cuda.jit
        @self.expand_aggs_and_cols(append)
        def downsample_cuda(
                src_w, src_h, translate_x, translate_y, scale_x, scale_y,
                offset_x, offset_y, out_w, out_h, *aggs_and_cols
        ):
            out_i, out_j = cuda.grid(2)
            if out_i < out_w and out_j < out_h:
                src_j0 = max(
                    math.floor(scale_y * (out_j + 0.0) + translate_y - offset_y), 0
                )
                src_j1 = min(
                    math.floor(scale_y * (out_j + 1.0) + translate_y - offset_y), src_h
                )
                src_i0 = max(
                    math.floor(scale_x * (out_i + 0.0) + translate_x - offset_x), 0
                )
                src_i1 = min(
                    math.floor(scale_x * (out_i + 1.0) + translate_x - offset_x), src_w
                )
                for src_j in range(src_j0, src_j1):
                    for src_i in range(src_i0, src_i1):
                        append(src_j, src_i, out_i, out_j, *aggs_and_cols)

        def extend(aggs, xr_ds, vt, bounds,
                   scale_x=None, scale_y=None, translate_x=None, translate_y=None,
                   offset_x=None, offset_y=None, src_xbinsize=None, src_ybinsize=None):
            use_cuda = cupy and isinstance(xr_ds[name].data, cupy.ndarray)

            # Compute output constants
            out_h, out_w = aggs[0].shape
            out_x0, out_x1, out_y0, out_y1 = bounds
            out_xbinsize = math.fabs((out_x1 - out_x0) / out_w)
            out_ybinsize = math.fabs((out_y1 - out_y0) / out_h)

            # Compute source constants
            xr_ds = xr_ds.transpose(y_name, x_name)
            src_h, src_w = xr_ds[name].shape
            if (scale_x is None or scale_y is None or
                    translate_x is None or translate_y is None or
                    offset_x is None or offset_y is None or
                    src_xbinsize is None or src_ybinsize is None ):
                # Compute bin sizes from bounds
                src_x0, src_x1 = self._compute_bounds_from_1d_centers(
                    xr_ds, x_name, maybe_expand=False, orient=False
                )
                src_y0, src_y1 = self._compute_bounds_from_1d_centers(
                    xr_ds, y_name, maybe_expand=False, orient=False
                )
                src_xbinsize = math.fabs((src_x1 - src_x0) / src_w)
                src_ybinsize = math.fabs((src_y1 - src_y0) / src_h)

                # Compute scale/translate
                scale_y, translate_y = build_scale_translate(
                    out_h, out_y0, out_y1, src_h, src_y0, src_y1
                )

                scale_x, translate_x = build_scale_translate(
                    out_w, out_x0, out_x1, src_w, src_x0, src_x1
                )

                offset_x = offset_y = 0

            # Build aggs_and_cols tuple
            cols = info(xr_ds)
            aggs_and_cols = tuple(aggs) + tuple(cols)

            if src_h == 0 or src_w == 0 or out_h == 0 or out_w == 0:
                # Nothing to do
                return
            elif src_xbinsize >= out_xbinsize and src_ybinsize >= out_ybinsize:
                # Upsample
                if use_cuda:
                    do_sampling = upsample_cuda[cuda_args((out_w, out_h))]
                else:
                    do_sampling = upsample_cpu
                return do_sampling(
                    src_w, src_h, translate_x, translate_y, scale_x, scale_y,
                    offset_x, offset_y, out_w, out_h, aggs[0], cols[0]
                )
            else:
                # Downsample. Note that caller is responsible for making sure to not
                # mix upsampling and downsampling.
                if use_cuda:
                    do_sampling = downsample_cuda[cuda_args((out_w, out_h))]
                else:
                    do_sampling = downsample_cpu

                return do_sampling(
                    src_w, src_h, translate_x, translate_y, scale_x, scale_y,
                    offset_x, offset_y, out_w, out_h, *aggs_and_cols
                )

        return extend


class QuadMeshCurvilinear(_QuadMeshLike):
    def compute_x_bounds(self, xr_ds):
        x_breaks = self.infer_interval_breaks(xr_ds[self.x].values)
        bounds = Glyph._compute_bounds_2d(x_breaks)
        return self.maybe_expand_bounds(bounds)

    def compute_y_bounds(self, xr_ds):
        y_breaks = self.infer_interval_breaks(xr_ds[self.y].values)
        bounds = Glyph._compute_bounds_2d(y_breaks)
        return self.maybe_expand_bounds(bounds)

    def compute_bounds_dask(self, xr_ds):
        return self.compute_x_bounds(xr_ds), self.compute_y_bounds(xr_ds)

    def infer_interval_breaks(self, centers):
        # Infer breaks for 1D array of centers
        breaks = infer_interval_breaks(centers, axis=1)
        breaks = infer_interval_breaks(breaks, axis=0)
        return breaks

    @memoize
    def _build_extend(self, x_mapper, y_mapper, info, append, antialias_stage_2):
        x_name = self.x
        y_name = self.y
        name = self.name

        @ngjit
        @self.expand_aggs_and_cols(append)
        def perform_extend(
                i, j,
                plot_height, plot_width, xs, ys, xverts, yverts,
                yincreasing, eligible, intersect, *aggs_and_cols
        ):

            # make array of quad x any vertices
            xverts[0] = xs[j, i]
            xverts[1] = xs[j, i + 1]
            xverts[2] = xs[j + 1, i + 1]
            xverts[3] = xs[j + 1, i]
            xverts[4] = xverts[0]

            yverts[0] = ys[j, i]
            yverts[1] = ys[j, i + 1]
            yverts[2] = ys[j + 1, i + 1]
            yverts[3] = ys[j + 1, i]
            yverts[4] = yverts[0]

            # Compute the rectilinear bounding box around the quad and
            # skip quad if there is no chance for it to intersect
            # viewport
            xmin = min(min(xverts[0], xverts[1]), min(xverts[2], xverts[3]))
            if xmin >= plot_width:
                return
            xmin = max(xmin, 0)

            xmax = max(max(xverts[0], xverts[1]), max(xverts[2], xverts[3]))
            if xmax < 0:
                return
            xmax = min(xmax, plot_width)

            ymin = min(min(yverts[0], yverts[1]), min(yverts[2], yverts[3]))
            if ymin >= plot_height:
                return
            ymin = max(ymin, 0)

            ymax = max(max(yverts[0], yverts[1]), max(yverts[2], yverts[3]))
            if ymax < 0:
                return
            ymax = min(ymax, plot_height)

            # Handle subpixel quads
            if xmin == xmax or ymin == ymax:
                # If either dimension is a single pixel, then render it
                if xmin == xmax and xmax < plot_width:
                    xmax += 1
                if ymin == ymax and ymax < plot_height:
                    ymax += 1

                for yi in range(ymin, ymax):
                    for xi in range(xmin, xmax):
                        append(j, i, xi, yi, *aggs_and_cols)

                return

            # make yincreasing an array holding whether each edge is
            # increasing vertically (+1), decreasing vertically (-1),
            # or horizontal (0).
            yincreasing[:] = 0
            for k in range(4):
                if yverts[k + 1] > yverts[k]:
                    yincreasing[k] = 1
                elif yverts[k + 1] < yverts[k]:
                    yincreasing[k] = -1

            for yi in range(ymin, ymax):
                eligible[:] = 1
                for xi in range(xmin, xmax):
                    intersect[:] = 0
                    # Test edges
                    for edge_i in range(4):
                        # Skip if we already know edge is ineligible
                        if not eligible[edge_i]:
                            continue

                        # Check if edge is fully to left of point. If
                        # so, we don't need to consider it again for
                        # this row.
                        if ((xverts[edge_i] < xi) and
                                (xverts[edge_i + 1] < xi)):
                            eligible[edge_i] = 0
                            continue

                        # Check if edge is fully above or below point.
                        # If so, we don't need to consider it again
                        # for this  row.
                        if ((yverts[edge_i] > yi) ==
                                (yverts[edge_i + 1] > yi)):
                            eligible[edge_i] = 0
                            continue

                        # Now check if edge is to the right of point.
                        # A is vector from point to first vertex
                        ax = xverts[edge_i] - xi
                        ay = yverts[edge_i] - yi

                        # B is vector from point to second vertex
                        bx = xverts[edge_i + 1] - xi
                        by = yverts[edge_i + 1] - yi

                        # Compute cross product of B and A
                        bxa = (bx * ay - by * ax)

                        # If cross product has same sign as yincreasing
                        # then edge intersects to the right
                        intersect[edge_i] = (
                                bxa * yincreasing[edge_i] < 0
                        )
                    intersections = (
                            intersect[0] + intersect[1] + intersect[2] + intersect[3]
                    )
                    if intersections % 2 == 1:
                        # If odd number of intersections, point
                        # is inside quad
                        append(j, i, xi, yi, *aggs_and_cols)

        @cuda.jit
        @self.expand_aggs_and_cols(append)
        def extend_cuda(plot_height, plot_width, xs, ys, *aggs_and_cols):
            # # For consistency with CPU path, we initialize all arrays here
            # # xverts/yverts arrays
            xverts = cuda.local.array(5, dtype=numba.types.int32)
            yverts = cuda.local.array(5, dtype=numba.types.int32)
            #
            # # Array holding whether each edge is increasing
            # # vertically (+1), decreasing vertically (-1),
            # # or horizontal (0).
            yincreasing = cuda.local.array(4, dtype=numba.types.int8)

            # # Array that will hold mask of whether edges are
            # # eligible for intersection tests
            eligible = cuda.local.array(4, dtype=numba.types.int8)

            # # Array that will hold a mask of whether edges
            # # intersect the ray to the right of test point
            intersect = cuda.local.array(4, dtype=numba.types.int8)

            i, j = cuda.grid(2)
            if i < (xs.shape[0] - 1) and j < (ys.shape[0] - 1):
                perform_extend(
                    i, j, plot_height, plot_width, xs, ys,
                    xverts, yverts, yincreasing, eligible, intersect,
                    *aggs_and_cols
                )

        @ngjit
        @self.expand_aggs_and_cols(append)
        def extend_cpu(plot_height, plot_width, xs, ys, *aggs_and_cols):
            # For performance, we initialize all arrays once before the loop

            # xverts/yverts arrays
            xverts = np.zeros(5, dtype=np.int32)
            yverts = np.zeros(5, dtype=np.int32)

            # Array holding whether each edge is increasing
            # vertically (+1), decreasing vertically (-1),
            # or horizontal (0).
            yincreasing = np.zeros(4, dtype=np.int8)

            # Array that will hold mask of whether edges are
            # eligible for intersection tests
            eligible = np.ones(4, dtype=np.int8)

            # Array that will hold a mask of whether edges
            # intersect the ray to the right of test point
            intersect = np.zeros(4, dtype=np.int8)

            y_len, x_len, = xs.shape
            for i in range(x_len - 1):
                for j in range(y_len - 1):
                    perform_extend(
                        i, j, plot_height, plot_width, xs, ys,
                        xverts, yverts, yincreasing, eligible, intersect, *aggs_and_cols
                    )

        def extend(aggs, xr_ds, vt, bounds, x_breaks=None, y_breaks=None):
            from datashader.core import LinearAxis
            use_cuda = cupy and isinstance(xr_ds[name].data, cupy.ndarray)

            # Build axis transform (mapper) functions
            if use_cuda:
                x_mapper2 = _cuda_mapper(x_mapper)
                y_mapper2 = _cuda_mapper(y_mapper)
            else:
                x_mapper2 = x_mapper
                y_mapper2 = y_mapper

            # Convert from bin centers to interval edges
            if x_breaks is None:
                x_centers = xr_ds[x_name].values
                if use_cuda:
                    x_centers = cupy.array(x_centers)
                x_breaks = self.infer_interval_breaks(x_centers)

            if y_breaks is None:
                y_centers = xr_ds[y_name].values
                if use_cuda:
                    y_centers = cupy.array(y_centers)
                y_breaks = self.infer_interval_breaks(y_centers)

            # Scale x and y vertices into integer canvas coordinates
            x0, x1, y0, y1 = bounds
            xspan = x1 - x0
            yspan = y1 - y0

            if x_mapper is LinearAxis.mapper:
                xscaled = (x_breaks - x0) / xspan
            else:
                xscaled = (x_mapper2(x_breaks) - x0) / xspan

            if y_mapper is LinearAxis.mapper:
                yscaled = (y_breaks - y0) / yspan
            else:
                yscaled = (y_mapper2(y_breaks) - y0) / yspan

            plot_height, plot_width = aggs[0].shape[:2]

            xs = (xscaled * plot_width).astype(int)
            ys = (yscaled * plot_height).astype(int)

            coord_dims = xr_ds.coords[x_name].dims
            aggs_and_cols = aggs + info(xr_ds.transpose(*coord_dims))
            if use_cuda:
                do_extend = extend_cuda[cuda_args(xr_ds[name].shape)]
            else:
                do_extend = extend_cpu

            do_extend(
                plot_height, plot_width, xs, ys, *aggs_and_cols
            )

        return extend
