from __future__ import annotations

import pickle
import warnings
from functools import partial
from operator import add

import pytest

np = pytest.importorskip("numpy")

import dask.array as da
from dask.array.numpy_compat import NUMPY_GE_240
from dask.array.ufunc import da_frompyfunc
from dask.array.utils import assert_eq
from dask.base import tokenize

DISCLAIMER = """
This docstring was copied from numpy.{name}.

Some inconsistencies with the Dask version may exist.
"""


@pytest.mark.parametrize(
    "name",
    [
        "log",
        "modf",
        "frexp",
    ],
)
def test_ufunc_meta(name):

    disclaimer = DISCLAIMER.format(name=name)
    skip_test = "  # doctest: +SKIP"
    ufunc = getattr(da, name)
    assert ufunc.__name__ == name
    assert disclaimer in ufunc.__doc__

    if not NUMPY_GE_240:
        # https://github.com/numpy/numpy/issues/30095
        # ufunc inspection doesn't provide argument names, which
        # breaks our docstring rewriting
        assert (
            ufunc.__doc__.replace(disclaimer, "").replace(skip_test, "")
            == getattr(np, name).__doc__
        )


def test_ufunc():
    for attr in ["nin", "nargs", "nout", "ntypes", "identity", "signature", "types"]:
        assert getattr(da.log, attr) == getattr(np.log, attr)

    with pytest.raises(AttributeError):
        da.log.not_an_attribute

    assert repr(da.log) == repr(np.log)
    assert "nin" in dir(da.log)
    assert "outer" in dir(da.log)


binary_ufuncs = [
    "add",
    "arctan2",
    "copysign",
    "divide",
    "equal",
    "bitwise_and",
    "bitwise_or",
    "bitwise_xor",
    "floor_divide",
    "fmax",
    "fmin",
    "fmod",
    "greater",
    "greater_equal",
    "hypot",
    "ldexp",
    "left_shift",
    "less",
    "less_equal",
    "logaddexp",
    "logaddexp2",
    "logical_and",
    "logical_or",
    "logical_xor",
    "maximum",
    "minimum",
    "mod",
    "multiply",
    "nextafter",
    "not_equal",
    "power",
    "remainder",
    "right_shift",
    "subtract",
    "true_divide",
    "float_power",
]

unary_ufuncs = [
    "abs",
    "absolute",
    "arccos",
    "arccosh",
    "arcsin",
    "arcsinh",
    "arctan",
    "arctanh",
    "bitwise_not",
    "cbrt",
    "ceil",
    "conj",
    "cos",
    "cosh",
    "deg2rad",
    "degrees",
    "exp",
    "exp2",
    "expm1",
    "fabs",
    "fix",
    "floor",
    "invert",
    "isfinite",
    "isinf",
    "isnan",
    "log",
    "log10",
    "log1p",
    "log2",
    "logical_not",
    "negative",
    "positive",
    "rad2deg",
    "radians",
    "reciprocal",
    "rint",
    "sign",
    "signbit",
    "sin",
    "sinh",
    "spacing",
    "sqrt",
    "square",
    "tan",
    "tanh",
    "trunc",
]


@pytest.mark.parametrize("ufunc", unary_ufuncs)
def test_unary_ufunc(xfail, ufunc):
    if ufunc == "fix":
        xfail("fix calls floor in a way that we do not yet support")

    dafunc = getattr(da, ufunc)
    npfunc = getattr(np, ufunc)

    arr = np.random.randint(1, 100, size=(20, 20))
    darr = da.from_array(arr, 3)

    with warnings.catch_warnings():  # some invalid values (arccos, arcsin, etc.)
        warnings.filterwarnings("ignore", category=RuntimeWarning)
        # applying Dask ufunc doesn't trigger computation
        assert isinstance(dafunc(darr), da.Array)
        assert_eq(dafunc(darr), npfunc(arr), equal_nan=True)

    with warnings.catch_warnings():  # some invalid values (arccos, arcsin, etc.)
        warnings.filterwarnings("ignore", category=RuntimeWarning)
        # applying NumPy ufunc is lazy
        if isinstance(npfunc, np.ufunc):
            assert isinstance(npfunc(darr), da.Array)
        else:
            assert isinstance(npfunc(darr), np.ndarray)
        assert_eq(npfunc(darr), npfunc(arr), equal_nan=True)

    with warnings.catch_warnings():  # some invalid values (arccos, arcsin, etc.)
        warnings.filterwarnings("ignore", category=RuntimeWarning)
        # applying Dask ufunc to normal ndarray triggers computation
        assert isinstance(dafunc(arr), np.ndarray)
        assert_eq(dafunc(arr), npfunc(arr), equal_nan=True)


@pytest.mark.parametrize("ufunc", binary_ufuncs)
def test_binary_ufunc(ufunc):
    dafunc = getattr(da, ufunc)
    npfunc = getattr(np, ufunc)

    arr1 = np.random.randint(1, 100, size=(20, 20))
    darr1 = da.from_array(arr1, 3)

    arr2 = np.random.randint(1, 100, size=(20, 20))
    darr2 = da.from_array(arr2, 3)

    # applying Dask ufunc doesn't trigger computation
    assert isinstance(dafunc(darr1, darr2), da.Array)
    assert_eq(dafunc(darr1, darr2), npfunc(arr1, arr2))

    # applying NumPy ufunc triggers computation or is lazy
    assert isinstance(npfunc(darr1, darr2), da.Array)
    assert_eq(npfunc(darr1, darr2), npfunc(arr1, arr2))

    # applying Dask ufunc to normal ndarray triggers computation
    assert isinstance(dafunc(arr1, arr2), np.ndarray)
    assert_eq(dafunc(arr1, arr2), npfunc(arr1, arr2))

    # with scalar
    assert isinstance(dafunc(darr1, 10), da.Array)
    assert_eq(dafunc(darr1, 10), npfunc(arr1, 10))

    with warnings.catch_warnings():  # overflow in ldexp
        warnings.filterwarnings("ignore", category=RuntimeWarning)
        assert isinstance(dafunc(10, darr1), da.Array)
        assert_eq(dafunc(10, darr1), npfunc(10, arr1))

    assert isinstance(dafunc(arr1, 10), np.ndarray)
    assert_eq(dafunc(arr1, 10), npfunc(arr1, 10))

    with warnings.catch_warnings():  # overflow in ldexp
        warnings.filterwarnings("ignore", category=RuntimeWarning)
        assert isinstance(dafunc(10, arr1), np.ndarray)
        assert_eq(dafunc(10, arr1), npfunc(10, arr1))


def test_ufunc_outer():
    arr1 = np.random.randint(1, 100, size=20)
    darr1 = da.from_array(arr1, 3)

    arr2 = np.random.randint(1, 100, size=(10, 3))
    darr2 = da.from_array(arr2, 3)

    # Check output types
    assert isinstance(da.add.outer(darr1, darr2), da.Array)
    assert isinstance(da.add.outer(arr1, darr2), da.Array)
    assert isinstance(da.add.outer(darr1, arr2), da.Array)
    assert isinstance(da.add.outer(arr1, arr2), np.ndarray)

    # Check mix of dimensions, dtypes, and numpy/dask/object
    cases = [
        ((darr1, darr2), (arr1, arr2)),
        ((darr2, darr1), (arr2, arr1)),
        ((darr2, darr1.astype("f8")), (arr2, arr1.astype("f8"))),
        ((darr1, arr2), (arr1, arr2)),
        ((darr1, 1), (arr1, 1)),
        ((1, darr2), (1, arr2)),
        ((1.5, darr2), (1.5, arr2)),
        (([1, 2, 3], darr2), ([1, 2, 3], arr2)),
        ((darr1.sum(), darr2), (arr1.sum(), arr2)),
        ((np.array(1), darr2), (np.array(1), arr2)),
    ]

    for (dA, dB), (A, B) in cases:
        assert_eq(da.add.outer(dA, dB), np.add.outer(A, B))

    # Check dtype kwarg works
    assert_eq(
        da.add.outer(darr1, darr2, dtype="f8"), np.add.outer(arr1, arr2, dtype="f8")
    )

    with pytest.raises(ValueError):
        da.add.outer(darr1, darr2, out=arr1)

    with pytest.raises(ValueError):
        da.sin.outer(darr1, darr2)


@pytest.mark.parametrize("ufunc", ["isreal", "iscomplex", "real", "imag"])
def test_complex(ufunc):
    dafunc = getattr(da, ufunc)
    # Note that these functions are not NumPy ufuncs
    npfunc = getattr(np, ufunc)

    real = np.random.randint(1, 100, size=(20, 20))
    imag = np.random.randint(1, 100, size=(20, 20)) * 1j
    comp = real + imag

    dareal = da.from_array(real, 3)
    daimag = da.from_array(imag, 3)
    dacomp = da.from_array(comp, 3)

    assert_eq(dacomp.real, comp.real)
    assert_eq(dacomp.imag, comp.imag)
    assert_eq(dacomp.conj(), comp.conj())

    for darr, arr in [(dacomp, comp), (dareal, real), (daimag, imag)]:
        # applying Dask ufunc doesn't trigger computation
        assert isinstance(dafunc(darr), da.Array)
        assert_eq(dafunc(darr), npfunc(arr))
        assert_eq(npfunc(darr), npfunc(arr))

        # applying Dask ufunc to normal ndarray triggers computation
        assert isinstance(dafunc(arr), np.ndarray)
        assert_eq(dafunc(arr), npfunc(arr))


@pytest.mark.parametrize("ufunc", ["frexp", "modf"])
def test_ufunc_2results(ufunc):
    dafunc = getattr(da, ufunc)
    npfunc = getattr(np, ufunc)

    arr = np.random.randint(1, 100, size=(20, 20))
    darr = da.from_array(arr, 3)

    # applying Dask ufunc doesn't trigger computation
    res1, res2 = dafunc(darr)
    assert isinstance(res1, da.Array)
    assert isinstance(res2, da.Array)
    exp1, exp2 = npfunc(arr)
    assert_eq(res1, exp1)
    assert_eq(res2, exp2)

    # applying NumPy ufunc is now lazy
    res1, res2 = npfunc(darr)
    assert isinstance(res1, da.Array)
    assert isinstance(res2, da.Array)
    exp1, exp2 = npfunc(arr)
    assert_eq(res1, exp1)
    assert_eq(res2, exp2)

    # applying Dask ufunc to normal ndarray triggers computation
    res1, res2 = dafunc(arr)
    assert isinstance(res1, da.Array)
    assert isinstance(res2, da.Array)
    exp1, exp2 = npfunc(arr)
    assert_eq(res1, exp1)
    assert_eq(res2, exp2)


def test_clip():
    x = np.random.normal(0, 10, size=(10, 10))
    d = da.from_array(x, chunks=(3, 4))

    assert_eq(x.clip(5), d.clip(5))
    assert_eq(x.clip(1, 5), d.clip(1, 5))
    assert_eq(x.clip(min=5), d.clip(min=5))
    assert_eq(x.clip(max=5), d.clip(max=5))
    assert_eq(x.clip(max=1, min=5), d.clip(max=1, min=5))
    assert_eq(x.clip(min=1, max=5), d.clip(min=1, max=5))


def test_angle():
    real = np.random.randint(1, 100, size=(20, 20))
    imag = np.random.randint(1, 100, size=(20, 20)) * 1j
    comp = real + imag
    dacomp = da.from_array(comp, 3)

    assert_eq(da.angle(dacomp), np.angle(comp))
    assert_eq(da.angle(dacomp, deg=True), np.angle(comp, deg=True))
    assert isinstance(da.angle(comp), np.ndarray)
    assert_eq(da.angle(comp), np.angle(comp))


def test_issignedinf():
    with np.errstate(invalid="ignore", divide="ignore"):
        arr = np.random.randint(-1, 2, size=(20, 20)).astype(float) / 0
    darr = da.from_array(arr, 3)

    assert_eq(np.isneginf(arr), da.isneginf(darr))
    assert_eq(np.isposinf(arr), da.isposinf(darr))


@pytest.mark.parametrize("func", ["i0", "sinc", "nan_to_num"])
def test_non_ufunc_others(func):
    arr = np.random.randint(1, 100, size=(20, 20))
    darr = da.from_array(arr, 3)

    dafunc = getattr(da, func)
    npfunc = getattr(np, func)

    assert_eq(dafunc(darr), npfunc(arr), equal_nan=True)


def test_frompyfunc():
    myadd = da.frompyfunc(add, 2, 1)
    np_myadd = np.frompyfunc(add, 2, 1)

    x = np.random.normal(0, 10, size=(10, 10))
    dx = da.from_array(x, chunks=(3, 4))
    y = np.random.normal(0, 10, size=10)
    dy = da.from_array(y, chunks=2)

    assert_eq(myadd(dx, dy), np_myadd(x, y))
    assert_eq(myadd.outer(dx, dy), np_myadd.outer(x, y))

    with pytest.raises(NotImplementedError):
        da.frompyfunc(lambda x, y: (x + y, x - y), 2, 2)


def test_frompyfunc_wrapper():
    f = da_frompyfunc(add, 2, 1)
    np_f = np.frompyfunc(add, 2, 1)
    x = np.array([1, 2, 3])

    # Callable
    np.testing.assert_equal(f(x, 1), np_f(x, 1))

    # picklable
    f2 = pickle.loads(pickle.dumps(f))
    np.testing.assert_equal(f2(x, 1), np_f(x, 1))

    # Attributes
    assert f.ntypes == np_f.ntypes
    with pytest.raises(AttributeError):
        f.not_an_attribute

    # Tab completion
    assert "ntypes" in dir(f)

    # Methods
    np.testing.assert_equal(f.outer(x, x), np_f.outer(x, x))

    # funcname
    assert f.__name__ == "frompyfunc-add"

    # repr
    assert repr(f) == "da.frompyfunc<add, 2, 1>"

    # tokenize
    assert tokenize(da_frompyfunc(add, 2, 1)) == tokenize(da_frompyfunc(add, 2, 1))


def test_array_ufunc():
    x = np.arange(24).reshape((4, 6))
    d = da.from_array(x, chunks=(2, 3))

    for func in [np.sin, np.sum, np.negative, partial(np.prod, axis=0)]:
        assert isinstance(func(d), da.Array)
        assert_eq(func(d), func(x))


def test_array_ufunc_binop():
    x = np.arange(25).reshape((5, 5))
    d = da.from_array(x, chunks=(2, 2))

    for func in [np.add, np.multiply]:
        assert isinstance(func(d, d), da.Array)
        assert_eq(func(d, d), func(x, x))

        assert isinstance(func.outer(d, d), da.Array)
        assert_eq(func.outer(d, d), func.outer(x, x))


def test_array_ufunc_out():
    x = da.arange(10, chunks=(5,))
    np.sin(x, out=x)
    np.add(x, 10, out=x)
    assert_eq(x, np.sin(np.arange(10)) + 10)


def test_unsupported_ufunc_methods():
    x = da.arange(10, chunks=(5,))
    with pytest.raises(TypeError):
        assert np.add.reduce(x)


def test_out_numpy():
    x = da.arange(10, chunks=(5,))
    empty = np.empty(10, dtype=x.dtype)
    with pytest.raises((TypeError, NotImplementedError)) as info:
        np.add(x, 1, out=empty)

    assert "ndarray" in str(info.value)
    assert "Array" in str(info.value)


def test_out_shape_mismatch():
    x = da.arange(10, chunks=(5,))
    y = da.arange(15, chunks=(5,))
    with pytest.raises(ValueError):
        assert np.log(x, out=y)


def test_divmod():
    arr1 = np.random.randint(1, 100, size=(20, 20))
    arr2 = np.random.randint(1, 100, size=(20, 20))

    darr1 = da.from_array(arr1, 3)
    darr2 = da.from_array(arr2, 3)

    result = np.divmod(darr1, 2.0)
    expected = np.divmod(arr1, 2.0)
    assert_eq(result[0], expected[0])
    assert_eq(result[1], expected[1])

    result = np.divmod(darr1, darr2)
    expected = np.divmod(arr1, arr2)
    assert_eq(result[0], expected[0])
    assert_eq(result[1], expected[1])

    result = divmod(darr1, 2.0)
    expected = divmod(arr1, 2.0)
    assert_eq(result[0], expected[0])
    assert_eq(result[1], expected[1])

    result = divmod(darr1, darr2)
    expected = divmod(arr1, arr2)
    assert_eq(result[0], expected[0])
    assert_eq(result[1], expected[1])


@pytest.mark.parametrize("dt", ["float64", "float32", "int32", "int64"])
def test_dtype_kwarg(dt):
    arr1 = np.array([1, 2, 3])
    arr2 = np.array([4, 5, 6])

    darr1 = da.from_array(arr1)
    darr2 = da.from_array(arr2)

    expected = np.add(arr1, arr2, dtype=dt)
    result = np.add(darr1, darr2, dtype=dt)
    assert_eq(expected, result)

    result = da.add(darr1, darr2, dtype=dt)
    assert_eq(expected, result)


@pytest.mark.parametrize("dtype", [None, "f8"])
@pytest.mark.parametrize("left_is_da", [False, True])
@pytest.mark.parametrize("right_is_da", [False, True])
@pytest.mark.parametrize("where_kind", [True, False, "numpy", "dask"])
def test_ufunc_where(dtype, left_is_da, right_is_da, where_kind):
    left = np.arange(12).reshape((3, 4))
    right = np.arange(4)
    out = np.zeros_like(left, dtype=dtype)
    d_out = da.zeros_like(left, dtype=dtype)

    if where_kind in (True, False):
        d_where = where = where_kind
    else:
        d_where = where = np.array([False, True, True, False])
        if where_kind == "dask":
            d_where = da.from_array(where, chunks=2)

    d_left = da.from_array(left, chunks=2) if left_is_da else left
    d_right = da.from_array(right, chunks=2) if right_is_da else right

    expected = np.add(left, right, where=where, out=out, dtype=dtype)
    result = da.add(d_left, d_right, where=d_where, out=d_out, dtype=dtype)
    assert result is d_out
    assert_eq(expected, result)


@pytest.mark.parametrize("left_is_da", [False, True])
@pytest.mark.parametrize("right_is_da", [False, True])
@pytest.mark.parametrize("where_is_da", [False, True])
def test_ufunc_where_broadcasts(left_is_da, right_is_da, where_is_da):
    left = np.arange(4)
    right = np.arange(4, 8)
    where = np.array([[0, 1, 1, 0], [1, 0, 0, 1], [0, 1, 0, 1]]).astype("bool")
    out = np.zeros(where.shape, dtype=left.dtype)

    d_out = da.zeros(where.shape, dtype=left.dtype)
    d_where = da.from_array(where, chunks=2) if where_is_da else where
    d_left = da.from_array(left, chunks=2) if left_is_da else left
    d_right = da.from_array(right, chunks=2) if right_is_da else right

    expected = np.add(left, right, where=where, out=out)
    result = da.add(d_left, d_right, where=d_where, out=d_out)
    assert result is d_out
    assert_eq(expected, result)


def test_ufunc_where_no_out():
    left = np.arange(4)
    right = np.arange(4, 8)
    where = np.array([[0, 1, 1, 0], [1, 0, 0, 1], [0, 1, 0, 1]]).astype("bool")

    d_where = da.from_array(where, chunks=2)
    d_left = da.from_array(left, chunks=2)
    d_right = da.from_array(right, chunks=2)

    expected = np.add(left, right, where=where, out=None)
    result = da.add(d_left, d_right, where=d_where)

    # If no `out` is provided, numpy leaves elements that don't match `where`
    # uninitialized, so they effectively may be any random value.  We test that
    # the set values match, and that the unset values aren't equal to if
    # `where` wasn't provided (to test that `where` was actually passed).

    expected_masked = np.where(where, expected, 0)
    result_masked = np.where(where, expected, 0)
    assert_eq(expected_masked, result_masked)

    expected_no_where = np.add(left, right)
    assert not np.equal(result.compute(), expected_no_where).all()


def test_ufunc_where_doesnt_mutate_out():
    """Dask array's are immutable, ensure that the backing numpy array for
    `out` isn't actually mutated"""
    left = da.from_array(np.arange(4, dtype="i8"), chunks=2)
    right = da.from_array(np.arange(4, 8, dtype="i8"), chunks=2)
    where = da.from_array(np.array([1, 0, 0, 1], dtype="bool"), chunks=2)
    out_np = np.zeros(4, dtype="i8")
    out = da.from_array(out_np, chunks=2)
    result = da.add(left, right, where=where, out=out)
    assert out is result
    assert_eq(out, np.array([4, 0, 0, 10], dtype="i8"))

    # Check that original `out` array isn't mutated
    assert np.equal(out_np, 0).all()
