import numpy

from .Qt import QtGui
from . import functions
from .util.cupy_helper import getCupy
from .util.numba_helper import getNumbaFunctions


def _apply_lut_for_uint(xp, image, lut):
    # Note: compared to makeARGB(), we have already clipped the data to range

    # if lut is 1d, then lut[image] is fastest
    # if lut is 2d, then lut.take(image, axis=0) is faster than lut[image]
    lut = _convert_2dlut_to_1dlut(xp, lut)

    if xp == numpy and (fn_numba := getNumbaFunctions()) is not None:
        # numba "take" supports only the 1st 2 arguments of np.take,
        # therefore we have to convert the lut to 1d.
        # "take" will output a c contiguous array regardless of its input.
        image = fn_numba.numba_take(lut, image)
    else:
        # advanced indexing is memory order aware.
        # its output can be either C or F contiguous.
        image = lut[image]

    if image.dtype == xp.uint32:
        # "view" requires c contiguous for numpy < 1.23
        image = xp.ascontiguousarray(image)
        image = image[..., xp.newaxis].view(xp.uint8)

    return image


def _convert_lut_to_rgba(xp, lut):
    # converts:
    #   - None to (256, 4)
    #   - uint8 (N,) to uint8 (N, 4)
    #   - uint8 (N, 1) to uint8 (N, 4)
    #   - uint8 (N, 3) to uint8 (N, 4)

    if not (
        lut is None
        or lut.ndim == 1
        or (
            lut.ndim == 2
            and lut.shape[1] in (1, 3, 4)
        )
    ):
        raise ValueError("unsupported lut shape")

    N = lut.shape[0] if lut is not None else 256

    if lut is None:
        lut = xp.arange(N, dtype=xp.uint8)

    # convert (N,) to (N, 1)
    if lut.ndim == 1:
        lut = lut[:, xp.newaxis]

    if lut.shape[1] == 4:
        return lut

    out = xp.full((N, 4), 255, dtype=xp.uint8)
    out[:, 0:3] = lut
    return out


def _convert_2dlut_to_1dlut(xp, lut):
    # converts:
    #   - uint8 (N, 1) to uint8 (N,)
    #   - uint8 (N, 3) or (N, 4) to uint32 (N,)
    # this allows faster lookup as 1d lookup is faster

    if lut.ndim == 1:
        return lut

    if lut.shape[1] == 3:  # rgb
        # convert rgb lut to rgba so that it is 32-bits
        lut = xp.column_stack([lut, xp.full(lut.shape[0], 255, dtype=xp.uint8)])
    if lut.shape[1] == 4:  # rgba
        lut = lut.view(xp.uint32)
    lut = lut.ravel()

    return lut


def _rescale_and_lookup_float(xp, image, levels, lut, *, forceApplyLut):
    # It is usually more performant to _not_ apply the lut and
    # instead use it as an Indexed8 ColorTable. This is only
    # applicable if the lut has <= 256 entries.

    if forceApplyLut and lut is None:
        raise ValueError("forceApplyLut True but lut not provided")

    # Decide on maximum scaled value
    if lut is not None:
        num_colors = lut.shape[0]
        max_scale_value = num_colors
    else:
        num_colors = 256
        max_scale_value = 255.0
    dtype = xp.min_scalar_type(num_colors - 1)

    # note: "dtype == uint16" ==> lut provided ==> mono-channel image
    #       i.e. multi-channel image ==> lut is None ==> dtype == uint8
    #
    #       the library defaults to using 256-entry luts, so
    #       "dtype == uint8" is the common case

    apply_lut = forceApplyLut or dtype == xp.uint16

    minVal, maxVal = levels
    rng = maxVal - minVal
    rng = 1 if rng == 0 else rng
    offset = minVal
    scale = max_scale_value / rng

    if xp == numpy and (fn_numba := getNumbaFunctions()) is not None:
        if apply_lut:
            # this path does rescale and apply lut in one step
            lut = _convert_2dlut_to_1dlut(xp, lut)
            image = fn_numba.rescale_and_lookup(image, scale, offset, lut)
            lut = None
            if image.dtype == xp.uint32:
                # "view" requires c contiguous for numpy < 1.23
                image = xp.ascontiguousarray(image)
                image = image[..., xp.newaxis].view(xp.uint8)
        else:
            image = fn_numba.rescale_and_clip(image, scale, offset, 0, num_colors - 1)
    else:
        image = functions.rescaleData(
            image, scale, offset, dtype=dtype, clip=(0, num_colors - 1)
        )
        if apply_lut:
            image = _apply_lut_for_uint(xp, image, lut)
            lut = None

    # image is now of type uint8
    return image, lut


def _combine_levels_and_lut(xp, image, levels, lut):
    if (
        image.dtype == xp.uint16
        and levels is None
        and image.ndim == 3
        and image.shape[2] == 3
    ):
        # uint16 rgb can't be directly displayed, so make it
        # pass through effective lut processing
        levels = [0, 65535]

    if levels is None and lut is None:
        # nothing to combine
        return image, lut

    # distinguish between lut for levels and colors
    levels_lut = None
    colors_lut = lut

    eflsize = 2 ** (image.itemsize * 8)
    if levels is None:
        info = xp.iinfo(image.dtype)
        minlev, maxlev = info.min, info.max
    else:
        minlev, maxlev = levels
    levdiff = maxlev - minlev
    levdiff = 1 if levdiff == 0 else levdiff  # don't allow division by 0
    offset = minlev

    if colors_lut is None:
        scale = 255.0 / levdiff
        if image.dtype == xp.ubyte and image.ndim == 2:
            # uint8 mono image
            ind = xp.arange(eflsize)
            levels_lut = functions.rescaleData(ind, scale, offset, dtype=xp.ubyte)
            # image data is not scaled. instead, levels_lut is used
            # as (grayscale) Indexed8 ColorTable to get the same effect.
            # due to the small size of the input to rescaleData(), we
            # do not bother caching the result
            return image, levels_lut
        else:
            # uint16 mono, uint8 rgb, uint16 rgb
            # rescale image data by computation instead of by memory lookup
            if xp == numpy and (fn_numba := getNumbaFunctions()) is not None:
                image = fn_numba.rescale_and_clip(image, scale, offset, 0, 255)
            else:
                image = functions.rescaleData(image, scale, offset, dtype=xp.ubyte)
            return image, colors_lut
    else:
        num_colors = colors_lut.shape[0]
        scale = num_colors / levdiff
        lutdtype = xp.min_scalar_type(num_colors - 1)

        if image.dtype == xp.ubyte or lutdtype != xp.ubyte:
            # combine if either:
            #   1) uint8 mono image
            #   2) colors_lut has more entries than will fit within 8-bits
            ind = xp.arange(eflsize)
            levels_lut = functions.rescaleData(
                ind, scale, offset, dtype=lutdtype, clip=(0, num_colors - 1),
            )
            efflut = colors_lut[levels_lut]

            # apply the effective lut early for the following types:
            if image.dtype == xp.uint16 and image.ndim == 2:
                image = _apply_lut_for_uint(xp, image, efflut)
                efflut = None
            return image, efflut
        else:
            # uint16 image with colors_lut <= 256 entries
            # don't combine, we will use QImage ColorTable
            if xp == numpy and (fn_numba := getNumbaFunctions()) is not None:
                image = fn_numba.rescale_and_clip(image, scale, offset, 0, num_colors - 1)
            else:
                image = functions.rescaleData(
                    image, scale, offset, dtype=lutdtype, clip=(0, num_colors - 1),
                )
            return image, colors_lut


def try_make_qimage(image, *, levels, lut, transparentLocations=None):
    """
    Internal function to make an QImage from an ndarray without going
    through the full generality of makeARGB().
    Only certain combinations of input arguments are supported.
    """

    # this function assumes that image has no nans.
    # checking for nans is an expensive operation; it is expected that
    # the caller would want to cache the result rather than have this
    # function check for nans unconditionally.

    cp = getCupy()
    xp = cp.get_array_module(image) if cp else numpy

    # float images always need levels
    if image.dtype.kind == "f" and levels is None:
        return None

    if levels is not None:
        levels = xp.asarray(levels)

        # can't handle multi-channel levels
        if levels.ndim != 1:
            return None

        # if levels is provided, multi-channel images must be 3 channels only.
        # (because it doesn't make sense to scale a 4th alpha channel.)
        if image.ndim == 3 and image.shape[2] != 3:
            return None

    if lut is not None and lut.dtype != xp.uint8:
        raise ValueError("lut dtype must be uint8")

    alpha_channel_required = (
        (   # image itself has alpha channel
            image.ndim == 3
            and image.shape[2] == 4
        )
        or
        (    # lut has alpha channel
            lut is not None
            and lut.ndim == 2
            and lut.shape[1] == 4
        )
    )

    if image.dtype.kind == "f":
        if image.ndim == 2:
            # mono float images
            if transparentLocations is None:
                image, lut = _rescale_and_lookup_float(
                    xp, image, levels, lut, forceApplyLut=False
                )
                levels = None
                # on return, we will have an uint8 image.
                # lut if not None will have <= 256 entries
            else:
                # this path creates an alpha channel
                lut = _convert_lut_to_rgba(xp, lut)
                alpha_channel_required = True

                image, lut = _rescale_and_lookup_float(
                    xp, image, levels, lut, forceApplyLut=True
                )
                levels = None
                if lut is not None:
                    raise AssertionError("LUT should be consumed before alpha-channel transparency is applied")
                image[..., 3][transparentLocations] = 0
        else:
            # RGB float images
            # lut can only be None for RGB images
            image, lut = _rescale_and_lookup_float(
                xp, image, levels, lut, forceApplyLut=False
            )
            levels = None

            if transparentLocations is not None:
                alpha_channel_required = True
                mask = xp.full(image.shape[:2], 255, dtype=xp.uint8)
                mask[transparentLocations] = 0
                image = xp.dstack((image, mask))

    # if the image data is a small int, then we can combine levels + lut
    # into a single lut for better performance
    elif image.dtype in (xp.ubyte, xp.uint16):
        image, lut = _combine_levels_and_lut(xp, image, levels, lut)
        levels = None

    ubyte_nolvl = image.dtype == xp.ubyte and levels is None
    is_passthru8 = ubyte_nolvl and lut is None
    is_indexed8 = (
        ubyte_nolvl and image.ndim == 2 and lut is not None and lut.shape[0] <= 256
    )
    is_passthru16 = image.dtype == xp.uint16 and levels is None and lut is None
    can_grayscale16 = (
        is_passthru16
        and image.ndim == 2
        and hasattr(QtGui.QImage.Format, "Format_Grayscale16")
    )
    is_rgba64 = is_passthru16 and image.ndim == 3 and image.shape[2] == 4

    # bypass makeARGB for supported combinations
    supported = is_passthru8 or is_indexed8 or can_grayscale16 or is_rgba64
    if not supported:
        return None

    if xp == cp:
        image = image.get()

    # worthwhile supporting non-contiguous arrays
    image = numpy.ascontiguousarray(image)

    fmt = None
    ctbl = None
    if is_passthru8:
        # both levels and lut are None
        # these images are suitable for display directly
        if image.ndim == 2:
            fmt = QtGui.QImage.Format.Format_Grayscale8
        elif image.shape[2] == 3:
            fmt = QtGui.QImage.Format.Format_RGB888
        elif image.shape[2] == 4:
            if alpha_channel_required:
                fmt = QtGui.QImage.Format.Format_RGBA8888
            else:
                fmt = QtGui.QImage.Format.Format_RGBX8888
    elif is_indexed8:
        # levels and/or lut --> lut-only
        fmt = QtGui.QImage.Format.Format_Indexed8
        if lut.ndim == 1 or lut.shape[1] == 1:
            ctbl = [QtGui.qRgb(x, x, x) for x in lut.ravel().tolist()]
        elif lut.shape[1] == 3:
            ctbl = [QtGui.qRgb(*rgb) for rgb in lut.tolist()]
        elif lut.shape[1] == 4:
            ctbl = [QtGui.qRgba(*rgba) for rgba in lut.tolist()]
    elif can_grayscale16:
        # single channel uint16
        # both levels and lut are None
        fmt = QtGui.QImage.Format.Format_Grayscale16
    elif is_rgba64:
        # uint16 rgba
        # both levels and lut are None
        fmt = QtGui.QImage.Format.Format_RGBA64  # endian-independent
    if fmt is None:
        raise ValueError("unsupported image type")
    qimage = functions.ndarray_to_qimage(image, fmt)
    if ctbl is not None:
        qimage.setColorTable(ctbl)
    return qimage
