# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE

from __future__ import absolute_import

import ctypes

import numpy

import awkward as ak


def of(*arrays):
    libs = set()
    for array in arrays:
        nplike = getattr(array, "nplike", None)
        if isinstance(nplike, NumpyLike):
            libs.add(nplike)
        elif isinstance(array, numpy.ndarray):
            ptr_lib = "cpu"
        elif (
            type(array).__module__.startswith("cupy.")
            and type(array).__name__ == "ndarray"
        ):
            ptr_lib = "cuda"
        else:
            ptr_lib = ak.operations.convert.kernels(array)
            if ptr_lib is None:
                pass
            elif ptr_lib == "cpu":
                libs.add("cpu")
            elif ptr_lib == "cuda":
                libs.add("cuda")
            else:
                raise ValueError(
                    """structure mixes 'cpu' and 'cuda' buffers; use one of

    ak.to_kernels(array, 'cpu')
    ak.to_kernels(array, 'cuda')

to obtain an unmixed array in main memory or the GPU(s)."""
                    + ak._util.exception_suffix(__file__)
                )

    if libs == set() or libs == set(["cpu"]):
        return Numpy.instance()
    elif libs == set(["cuda"]):
        return Cupy.instance()
    elif len(libs) == 1:
        return next(iter(libs))
    else:
        raise ValueError(
            """attempting to use both a 'cpu' array and a 'cuda' array in the """
            """same operation; use one of

    ak.to_kernels(array, 'cpu')
    ak.to_kernels(array, 'cuda')

to move one or the other to main memory or the GPU(s)."""
            + ak._util.exception_suffix(__file__)
        )


class Singleton(object):
    _instance = None

    @classmethod
    def instance(cls):
        if cls._instance is None:
            cls._instance = cls()
        return cls._instance


class NumpyMetadata(Singleton):
    bool_ = numpy.bool_
    int8 = numpy.int8
    int16 = numpy.int16
    int32 = numpy.int32
    int64 = numpy.int64
    uint8 = numpy.uint8
    uint16 = numpy.uint16
    uint32 = numpy.uint32
    uint64 = numpy.uint64
    float32 = numpy.float32
    float64 = numpy.float64
    complex64 = numpy.complex64
    complex128 = numpy.complex128
    str_ = numpy.str_
    bytes_ = numpy.bytes_

    intp = numpy.intp
    integer = numpy.integer
    signedinteger = numpy.signedinteger
    unsignedinteger = numpy.unsignedinteger
    floating = numpy.floating
    number = numpy.number
    object_ = numpy.object_
    generic = numpy.generic

    dtype = numpy.dtype
    ufunc = numpy.ufunc
    iinfo = numpy.iinfo
    errstate = numpy.errstate
    newaxis = numpy.newaxis

    ndarray = numpy.ndarray

    nan = numpy.nan
    inf = numpy.inf

    nat = numpy.datetime64("NaT")
    datetime_data = numpy.datetime_data
    issubdtype = numpy.issubdtype

    AxisError = numpy.AxisError


if hasattr(numpy, "float16"):
    NumpyMetadata.float16 = numpy.float16

if hasattr(numpy, "float128"):
    NumpyMetadata.float128 = numpy.float128

if hasattr(numpy, "complex256"):
    NumpyMetadata.complex256 = numpy.complex256

if hasattr(numpy, "datetime64"):
    NumpyMetadata.datetime64 = numpy.datetime64

if hasattr(numpy, "timedelta64"):
    NumpyMetadata.timedelta64 = numpy.timedelta64

NumpyMetadata.all_complex = tuple(
    x for x in dir(NumpyMetadata) if x.startswith("complex")
)


class NumpyLike(Singleton):
    known_shape = True
    known_dtype = True

    ############################ array creation

    def array(self, *args, **kwargs):
        # data[, dtype=[, copy=]]
        return self._module.array(*args, **kwargs)

    def asarray(self, *args, **kwargs):
        # array[, dtype=][, order=]
        return self._module.asarray(*args, **kwargs)

    def ascontiguousarray(self, *args, **kwargs):
        # array[, dtype=]
        return self._module.ascontiguousarray(*args, **kwargs)

    def isscalar(self, *args, **kwargs):
        return self._module.isscalar(*args, **kwargs)

    def frombuffer(self, *args, **kwargs):
        # array[, dtype=]
        return self._module.frombuffer(*args, **kwargs)

    def zeros(self, *args, **kwargs):
        # shape/len[, dtype=]
        return self._module.zeros(*args, **kwargs)

    def ones(self, *args, **kwargs):
        # shape/len[, dtype=]
        return self._module.ones(*args, **kwargs)

    def empty(self, *args, **kwargs):
        # shape/len[, dtype=]
        return self._module.empty(*args, **kwargs)

    def full(self, *args, **kwargs):
        # shape/len, value[, dtype=]
        return self._module.full(*args, **kwargs)

    def zeros_like(self, *args, **kwargs):
        # array
        return self._module.zeros_like(*args, **kwargs)

    def ones_like(self, *args, **kwargs):
        # array
        return self._module.ones_like(*args, **kwargs)

    def full_like(self, *args, **kwargs):
        # array, fill_value
        return self._module.full_like(*args, **kwargs)

    def arange(self, *args, **kwargs):
        # stop[, dtype=]
        # start, stop[, dtype=]
        # start, stop, step[, dtype=]
        return self._module.arange(*args, **kwargs)

    def meshgrid(self, *args, **kwargs):
        # *arrays, indexing="ij"
        return self._module.meshgrid(*args, **kwargs)

    ############################ testing

    def shape(self, *args, **kwargs):
        # array
        return self._module.shape(*args, **kwargs)

    def array_equal(self, *args, **kwargs):
        # array1, array2
        return self._module.array_equal(*args, **kwargs)

    def size(self, *args, **kwargs):
        # array
        return self._module.size(*args, **kwargs)

    def searchsorted(self, *args, **kwargs):
        # haystack, needle, side="right"
        return self._module.searchsorted(*args, **kwargs)

    def argsort(self, *args, **kwargs):
        # array
        return self._module.argsort(*args, **kwargs)

    ############################ manipulation

    def broadcast_arrays(self, *args, **kwargs):
        # array1[, array2[, ...]]
        return self._module.broadcast_arrays(*args, **kwargs)

    def add(self, *args, **kwargs):
        # array1, array2[, out=]
        return self._module.add(*args, **kwargs)

    def cumsum(self, *args, **kwargs):
        # arrays[, out=]
        return self._module.cumsum(*args, **kwargs)

    def cumprod(self, *args, **kwargs):
        # arrays[, out=]
        return self._module.cumprod(*args, **kwargs)

    def nonzero(self, *args, **kwargs):
        # array
        return self._module.nonzero(*args, **kwargs)

    def unique(self, *args, **kwargs):
        # array
        return self._module.unique(*args, **kwargs)

    def concatenate(self, *args, **kwargs):
        # arrays
        return self._module.concatenate(*args, **kwargs)

    def repeat(self, *args, **kwargs):
        # array, int
        # array1, array2
        return self._module.repeat(*args, **kwargs)

    def stack(self, *args, **kwargs):
        # arrays
        return self._module.stack(*args, **kwargs)

    def vstack(self, *args, **kwargs):
        # arrays
        return self._module.vstack(*args, **kwargs)

    def packbits(self, *args, **kwargs):
        # array
        return self._module.packbits(*args, **kwargs)

    def unpackbits(self, *args, **kwargs):
        # array
        return self._module.unpackbits(*args, **kwargs)

    def atleast_1d(self, *args, **kwargs):
        # *arrays
        return self._module.atleast_1d(*args, **kwargs)

    def broadcast_to(self, *args, **kwargs):
        # array, shape
        return self._module.broadcast_to(*args, **kwargs)

    def append(self, *args, **kwargs):
        # array, element
        return self._module.append(*args, **kwargs)

    def where(self, *args, **kwargs):
        # array, element
        return self._module.where(*args, **kwargs)

    ############################ ufuncs

    def sqrt(self, *args, **kwargs):
        # array
        return self._module.sqrt(*args, **kwargs)

    def exp(self, *args, **kwargs):
        # array
        return self._module.exp(*args, **kwargs)

    def true_divide(self, *args, **kwargs):
        # array1, array2
        return self._module.true_divide(*args, **kwargs)

    def bitwise_or(self, *args, **kwargs):
        # array1, array2[, out=output]
        return self._module.bitwise_or(*args, **kwargs)

    def logical_and(self, *args, **kwargs):
        # array1, array2
        return self._module.logical_and(*args, **kwargs)

    def equal(self, *args, **kwargs):
        # array1, array2
        return self._module.equal(*args, **kwargs)

    def ceil(self, *args, **kwargs):
        # array
        return self._module.ceil(*args, **kwargs)

    ############################ almost-ufuncs

    def nan_to_num(self, *args, **kwargs):
        # array, copy=True, nan=0.0, posinf=None, neginf=None
        return self._module.nan_to_num(*args, **kwargs)

    def isclose(self, *args, **kwargs):
        # a, b, rtol=1e-05, atol=1e-08, equal_nan=False
        return self._module.isclose(*args, **kwargs)

    ############################ reducers

    def all(self, *args, **kwargs):
        # array
        return self._module.all(*args, **kwargs)

    def any(self, *args, **kwargs):
        # array
        kwargs.pop("prefer", None)
        return self._module.any(*args, **kwargs)

    def count_nonzero(self, *args, **kwargs):
        # array
        return self._module.count_nonzero(*args, **kwargs)

    def sum(self, *args, **kwargs):
        # array
        return self._module.sum(*args, **kwargs)

    def prod(self, *args, **kwargs):
        # array
        return self._module.prod(*args, **kwargs)

    def min(self, *args, **kwargs):
        # array
        return self._module.min(*args, **kwargs)

    def max(self, *args, **kwargs):
        # array
        return self._module.max(*args, **kwargs)

    def argmin(self, *args, **kwargs):
        # array[, axis=]
        return self._module.argmin(*args, **kwargs)

    def argmax(self, *args, **kwargs):
        # array[, axis=]
        return self._module.argmax(*args, **kwargs)

    def array_str(self, *args, **kwargs):
        # array, max_line_width, precision=None, suppress_small=None
        return self._module.array_str(*args, **kwargs)

    def datetime_as_string(self, *args, **kwargs):
        return self._module.datetime_as_string(*args, **kwargs)


class NumpyKernel(object):
    def __init__(self, kernel, name_and_types):
        self._kernel = kernel
        self._name_and_types = name_and_types

    def __repr__(self):
        return "<{0} {1}{2}>".format(
            type(self).__name__,
            self._name_and_types[0],
            "".join(", " + str(numpy.dtype(x)) for x in self._name_and_types[1:]),
        )

    @staticmethod
    def _cast(x, t):
        if issubclass(t, ctypes._Pointer):
            if isinstance(x, numpy.ndarray):
                return ctypes.cast(x.ctypes.data, t)
            else:
                return ctypes.cast(x, t)
        else:
            return x

    def __call__(self, *args):
        assert len(args) == len(self._kernel.argtypes)
        return self._kernel(
            *(self._cast(x, t) for x, t in zip(args, self._kernel.argtypes))
        )


class Numpy(NumpyLike):
    def to_rectilinear(self, array, *args, **kwargs):
        return ak.operations.convert.to_numpy(array, *args, **kwargs)

    def __getitem__(self, name_and_types):
        return NumpyKernel(ak._cpu_kernels.kernel[name_and_types], name_and_types)

    def __init__(self):
        self._module = numpy

    @property
    def ma(self):
        return self._module.ma

    @property
    def char(self):
        return self._module.char

    @property
    def ndarray(self):
        return self._module.ndarray


class Cupy(NumpyLike):
    def to_rectilinear(self, array, *args, **kwargs):
        return ak.operations.convert.to_cupy(array, *args, **kwargs)

    def __getitem__(self, name_and_types):
        raise NotImplementedError("no CUDA in v2 yet")

    def __init__(self):
        try:
            import cupy
        except ImportError:
            raise ImportError(
                """to use CUDA arrays in Python, install the 'cupy' package with:

    pip install cupy --upgrade

or

    conda install cupy"""
            )
        self._module = cupy

    @property
    def ma(self):
        raise ValueError(
            "CUDA arrays cannot have missing values until CuPy implements "
            "numpy.ma.MaskedArray" + ak._util.exception_suffix(__file__)
        )

    @property
    def char(self):
        raise ValueError(
            "CUDA arrays cannot do string manipulations until CuPy implements "
            "numpy.char" + ak._util.exception_suffix(__file__)
        )

    @property
    def ndarray(self):
        return self._module.ndarray

    def asarray(self, array, dtype=None):
        if isinstance(
            array,
            (
                ak.highlevel.Array,
                ak.highlevel.Record,
                ak.layout.Content,
                ak.layout.Record,
            ),
        ):
            out = ak.operations.convert.to_cupy(array)
            if dtype is not None and out.dtype != dtype:
                return self._module.asarray(out, dtype=dtype)
            else:
                return out
        else:
            return self._module.asarray(array, dtype=dtype)

    def ascontiguousarray(self, array, dtype=None):
        if isinstance(
            array,
            (
                ak.highlevel.Array,
                ak.highlevel.Record,
                ak.layout.Content,
                ak.layout.Record,
            ),
        ):
            out = ak.operations.convert.to_cupy(array)
            if dtype is not None and out.dtype != dtype:
                return self._module.ascontiguousarray(out, dtype=dtype)
            else:
                return out
        else:
            return self._module.ascontiguousarray(array, dtype=dtype)

    def zeros(self, *args, **kwargs):
        return self._module.zeros(*args, **kwargs)

    def frombuffer(self, *args, **kwargs):
        np_array = numpy.frombuffer(*args, **kwargs)
        return self._module.array(np_array)

    def array_equal(self, array1, array2):
        # CuPy issue?
        if array1.shape != array2.shape:
            return False
        else:
            return self._module.all(array1 - array2 == 0)

    def repeat(self, array, repeats):
        # https://github.com/cupy/cupy/issues/3849
        if isinstance(repeats, self._module.ndarray):
            all_stops = self._module.cumsum(repeats)
            parents = self._module.zeros(all_stops[-1].item(), dtype=int)
            stops, stop_counts = self._module.unique(all_stops[:-1], return_counts=True)
            parents[stops] = stop_counts
            self._module.cumsum(parents, out=parents)
            return array[parents]
        else:
            return self._module.repeat(array, repeats)

    def nan_to_num(self, array, copy=True, nan=0.0, posinf=None, neginf=None):
        # https://github.com/cupy/cupy/issues/4867
        if copy:
            array = self._module.copy(array)
        if posinf is None:
            if array.dtype.kind == "f":
                posinf = numpy.finfo(array.dtype.type).max
            else:
                posinf = numpy.iinfo(array.dtype.type).max
        if neginf is None:
            if array.dtype.kind == "f":
                neginf = numpy.finfo(array.dtype.type).min
            else:
                neginf = numpy.iinfo(array.dtype.type).min

        array[self._module.isnan(array)] = nan
        array[self._module.isinf(array) & (array > 0)] = posinf
        array[self._module.isinf(array) & (array < 0)] = neginf
        return array

    # For all reducers: https://github.com/cupy/cupy/issues/3819

    def all(self, array, axis=None, **kwargs):
        kwargs.pop("prefer", None)
        out = self._module.all(array, axis=axis)
        if axis is None and isinstance(out, self._module.ndarray):
            return out.item()
        else:
            return out

    def any(self, array, axis=None, **kwargs):
        kwargs.pop("prefer", None)
        out = self._module.any(array, axis=axis)
        if axis is None and isinstance(out, self._module.ndarray):
            return out.item()
        else:
            return out

    def count_nonzero(self, array, axis=None):
        out = self._module.count_nonzero(array, axis=axis)
        if axis is None and isinstance(out, self._module.ndarray):
            return out.item()
        else:
            return out

    def sum(self, array, axis=None):
        out = self._module.sum(array, axis=axis)
        if axis is None and isinstance(out, self._module.ndarray):
            return out.item()
        else:
            return out

    def prod(self, array, axis=None):
        out = self._module.prod(array, axis=axis)
        if axis is None and isinstance(out, self._module.ndarray):
            return out.item()
        else:
            return out

    def min(self, array, axis=None):
        out = self._module.min(array, axis=axis)
        if axis is None and isinstance(out, self._module.ndarray):
            return out.item()
        else:
            return out

    def max(self, array, axis=None):
        out = self._module.max(array, axis=axis)
        if axis is None and isinstance(out, self._module.ndarray):
            return out.item()
        else:
            return out

    def argmin(self, array, axis=None):
        out = self._module.argmin(array, axis=axis)
        if axis is None and isinstance(out, self._module.ndarray):
            return out.item()
        else:
            return out

    def argmax(self, array, axis=None):
        out = self._module.argmax(array, axis=axis)
        if axis is None and isinstance(out, self._module.ndarray):
            return out.item()
        else:
            return out

    def array_str(
        self, array, max_line_width=None, precision=None, suppress_small=None
    ):
        # array, max_line_width, precision=None, suppress_small=None
        return self._module.array_str(array, max_line_width, precision, suppress_small)
