"""
Tests for array coercion, mainly through testing `np.array` results directly.
Note that other such tests exist e.g. in `test_api.py` and many corner-cases
are tested (sometimes indirectly) elsewhere.
"""

import pytest
from pytest import param

from itertools import product

import numpy as np
from numpy.core._rational_tests import rational

from numpy.testing import (
    assert_array_equal, assert_warns, IS_PYPY)


def arraylikes():
    """
    Generator for functions converting an array into various array-likes.
    If full is True (default) includes array-likes not capable of handling
    all dtypes
    """
    # base array:
    def ndarray(a):
        return a

    yield param(ndarray, id="ndarray")

    # subclass:
    class MyArr(np.ndarray):
        pass

    def subclass(a):
        return a.view(MyArr)

    yield subclass

    # Array-interface
    class ArrayDunder:
        def __init__(self, a):
            self.a = a

        def __array__(self, dtype=None):
            return self.a

    yield param(ArrayDunder, id="__array__")

    # memory-view
    yield param(memoryview, id="memoryview")

    # Array-interface
    class ArrayInterface:
        def __init__(self, a):
            self.a = a  # need to hold on to keep interface valid
            self.__array_interface__ = a.__array_interface__

    yield param(ArrayInterface, id="__array_interface__")

    # Array-Struct
    class ArrayStruct:
        def __init__(self, a):
            self.a = a  # need to hold on to keep struct valid
            self.__array_struct__ = a.__array_struct__

    yield param(ArrayStruct, id="__array_struct__")


def scalar_instances(times=True, extended_precision=True, user_dtype=True):
    # Hard-coded list of scalar instances.
    # Floats:
    yield param(np.sqrt(np.float16(5)), id="float16")
    yield param(np.sqrt(np.float32(5)), id="float32")
    yield param(np.sqrt(np.float64(5)), id="float64")
    if extended_precision:
        yield param(np.sqrt(np.longdouble(5)), id="longdouble")

    # Complex:
    yield param(np.sqrt(np.complex64(2+3j)), id="complex64")
    yield param(np.sqrt(np.complex128(2+3j)), id="complex128")
    if extended_precision:
        yield param(np.sqrt(np.longcomplex(2+3j)), id="clongdouble")

    # Bool:
    # XFAIL: Bool should be added, but has some bad properties when it
    # comes to strings, see also gh-9875
    # yield param(np.bool_(0), id="bool")

    # Integers:
    yield param(np.int8(2), id="int8")
    yield param(np.int16(2), id="int16")
    yield param(np.int32(2), id="int32")
    yield param(np.int64(2), id="int64")

    yield param(np.uint8(2), id="uint8")
    yield param(np.uint16(2), id="uint16")
    yield param(np.uint32(2), id="uint32")
    yield param(np.uint64(2), id="uint64")

    # Rational:
    if user_dtype:
        yield param(rational(1, 2), id="rational")

    # Cannot create a structured void scalar directly:
    structured = np.array([(1, 3)], "i,i")[0]
    assert isinstance(structured, np.void)
    assert structured.dtype == np.dtype("i,i")
    yield param(structured, id="structured")

    if times:
        # Datetimes and timedelta
        yield param(np.timedelta64(2), id="timedelta64[generic]")
        yield param(np.timedelta64(23, "s"), id="timedelta64[s]")
        yield param(np.timedelta64("NaT", "s"), id="timedelta64[s](NaT)")

        yield param(np.datetime64("NaT"), id="datetime64[generic](NaT)")
        yield param(np.datetime64("2020-06-07 12:43", "ms"), id="datetime64[ms]")

    # Strings and unstructured void:
    yield param(np.bytes_(b"1234"), id="bytes")
    yield param(np.unicode_("2345"), id="unicode")
    yield param(np.void(b"4321"), id="unstructured_void")


def is_parametric_dtype(dtype):
    """Returns True if the the dtype is a parametric legacy dtype (itemsize
    is 0, or a datetime without units)
    """
    if dtype.itemsize == 0:
        return True
    if issubclass(dtype.type, (np.datetime64, np.timedelta64)):
        if dtype.name.endswith("64"):
            # Generic time units
            return True
    return False


class TestStringDiscovery:
    @pytest.mark.parametrize("obj",
            [object(), 1.2, 10**43, None, "string"],
            ids=["object", "1.2", "10**43", "None", "string"])
    def test_basic_stringlength(self, obj):
        if not isinstance(obj, (str, int)):
            pytest.xfail(
                "The Single object (first assert) uses a different branch "
                "and thus gives a different result (either wrong or longer"
                "string than normally discovered).")

        length = len(str(obj))
        expected = np.dtype(f"S{length}")

        assert np.array(obj, dtype="S").dtype == expected
        assert np.array([obj], dtype="S").dtype == expected

        # A nested array is also discovered correctly
        arr = np.array(obj, dtype="O")
        assert np.array(arr, dtype="S").dtype == expected

    @pytest.mark.xfail(reason="Only single array unpacking is supported")
    @pytest.mark.parametrize("obj",
            [object(), 1.2, 10**43, None, "string"],
            ids=["object", "1.2", "10**43", "None", "string"])
    def test_nested_arrays_stringlength(self, obj):
        length = len(str(obj))
        expected = np.dtype(f"S{length}")
        arr = np.array(obj, dtype="O")
        assert np.array([arr, arr], dtype="S").dtype == expected

    @pytest.mark.xfail(reason="Only single array unpacking is supported")
    @pytest.mark.parametrize("arraylike", arraylikes())
    def test_unpack_first_level(self, arraylike):
        # We unpack exactly one level of array likes
        obj = np.array([None])
        obj[0] = np.array(1.2)
        # the length of the included item, not of the float dtype
        length = len(str(obj[0]))
        expected = np.dtype(f"S{length}")

        obj = arraylike(obj)
        # casting to string usually calls str(obj)
        arr = np.array([obj], dtype="S")
        assert arr.shape == (1, 1)
        assert arr.dtype == expected


class TestScalarDiscovery:
    def test_void_special_case(self):
        # Void dtypes with structures discover tuples as elements
        arr = np.array((1, 2, 3), dtype="i,i,i")
        assert arr.shape == ()
        arr = np.array([(1, 2, 3)], dtype="i,i,i")
        assert arr.shape == (1,)

    def test_char_special_case(self):
        arr = np.array("string", dtype="c")
        assert arr.shape == (6,)
        assert arr.dtype.char == "c"
        arr = np.array(["string"], dtype="c")
        assert arr.shape == (1, 6)
        assert arr.dtype.char == "c"

    def test_char_special_case_deep(self):
        # Check that the character special case errors correctly if the
        # array is too deep:
        nested = ["string"]  # 2 dimensions (due to string being sequence)
        for i in range(np.MAXDIMS - 2):
            nested = [nested]

        arr = np.array(nested, dtype='c')
        assert arr.shape == (1,) * (np.MAXDIMS - 1) + (6,)
        with pytest.raises(ValueError):
            np.array([nested], dtype="c")

    def test_unknown_object(self):
        arr = np.array(object())
        assert arr.shape == ()
        assert arr.dtype == np.dtype("O")

    @pytest.mark.parametrize("scalar", scalar_instances())
    def test_scalar(self, scalar):
        arr = np.array(scalar)
        assert arr.shape == ()
        assert arr.dtype == scalar.dtype

        if type(scalar) is np.bytes_:
            pytest.xfail("Nested bytes use len(str(scalar)) currently.")

        arr = np.array([[scalar, scalar]])
        assert arr.shape == (1, 2)
        assert arr.dtype == scalar.dtype

    # Additionally to string this test also runs into a corner case
    # with datetime promotion (the difference is the promotion order).
    @pytest.mark.xfail(reason="Coercion to string is not symmetric")
    def test_scalar_promotion(self):
        for sc1, sc2 in product(scalar_instances(), scalar_instances()):
            sc1, sc2 = sc1.values[0], sc2.values[0]
            # test all combinations:
            arr = np.array([sc1, sc2])
            assert arr.shape == (2,)
            try:
                dt1, dt2 = sc1.dtype, sc2.dtype
                expected_dtype = np.promote_types(dt1, dt2)
                assert arr.dtype == expected_dtype
            except TypeError as e:
                # Will currently always go to object dtype
                assert arr.dtype == np.dtype("O")

    @pytest.mark.parametrize("scalar", scalar_instances())
    def test_scalar_coercion(self, scalar):
        # This tests various scalar coercion paths, mainly for the numerical
        # types.  It includes some paths not directly related to `np.array`
        if isinstance(scalar, np.inexact):
            # Ensure we have a full-precision number if available
            scalar = type(scalar)((scalar * 2)**0.5)

        if is_parametric_dtype(scalar.dtype) or type(scalar) is rational:
            # datetime with unit will be named "datetime64[unit]"
            # Rational generally fails due to a missing cast. In the future
            # object casts should automatically be defined based on `setitem`.
            pytest.xfail("0-D object array to a unit-less datetime cast fails")

        # Use casting from object:
        arr = np.array(scalar, dtype=object).astype(scalar.dtype)

        # Test various ways to create an array containing this scalar:
        arr1 = np.array(scalar).reshape(1)
        arr2 = np.array([scalar])
        arr3 = np.empty(1, dtype=scalar.dtype)
        arr3[0] = scalar
        arr4 = np.empty(1, dtype=scalar.dtype)
        arr4[:] = [scalar]
        # All of these methods should yield the same results
        assert_array_equal(arr, arr1)
        assert_array_equal(arr, arr2)
        assert_array_equal(arr, arr3)
        assert_array_equal(arr, arr4)

    @pytest.mark.xfail(IS_PYPY, reason="`int(np.complex128(3))` fails on PyPy")
    @pytest.mark.filterwarnings("ignore::numpy.ComplexWarning")
    # After change, can enable times here, and below and it will work,
    # Right now times are too complex, so map out some details below.
    @pytest.mark.parametrize("cast_to", scalar_instances(times=False))
    def test_scalar_coercion_same_as_cast_and_assignment(self, cast_to):
        """
        Test that in most cases:
           * `np.array(scalar, dtype=dtype)`
           * `np.empty((), dtype=dtype)[()] = scalar`
           * `np.array(scalar).astype(dtype)`
        should behave the same.  The only exceptions are paramteric dtypes
        (mainly datetime/timedelta without unit) and void without fields.
        """
        dtype = cast_to.dtype  # use to parametrize only the target dtype

        # XFAIL: Some extended precision tests fail, because assigning to
        #        complex256 will use float(float128). Rational fails currently.
        for scalar in scalar_instances(
                times=False, extended_precision=False, user_dtype=False):
            scalar = scalar.values[0]

            if dtype.type == np.void:
               if scalar.dtype.fields is not None and dtype.fields is None:
                    # Here, coercion to "V6" works, but the cast fails.
                    # Since the types are identical, SETITEM takes care of
                    # this, but has different rules than the cast.
                    with pytest.raises(TypeError):
                        np.array(scalar).astype(dtype)
                    # XFAIL: np.array(scalar, dtype=dtype)
                    np.array([scalar], dtype=dtype)
                    continue

            # The main test, we first try to use casting and if it succeeds
            # continue below testing that things are the same, otherwise
            # test that the alternative paths at least also fail.
            try:
                cast = np.array(scalar).astype(dtype)
            except (TypeError, ValueError, RuntimeError):
                # coercion should also raise (error type may change)
                with pytest.raises(Exception):
                    np.array(scalar, dtype=dtype)
                # assignment should also raise
                res = np.zeros((), dtype=dtype)
                with pytest.raises(Exception):
                    res[()] = scalar

                return

            # Non error path:
            arr = np.array(scalar, dtype=dtype)
            assert_array_equal(arr, cast)
            # assignment behaves the same
            ass = np.zeros((), dtype=dtype)
            ass[()] = scalar
            assert_array_equal(ass, cast)


class TestTimeScalars:
    @pytest.mark.parametrize("dtype", [np.int64, np.float32])
    @pytest.mark.parametrize("scalar",
            [param(np.timedelta64("NaT", "s"), id="timedelta64[s](NaT)"),
             param(np.timedelta64(123, "s"), id="timedelta64[s]"),
             param(np.datetime64("NaT", "generic"), id="datetime64[generic](NaT)"),
             param(np.datetime64(1, "D"), id="datetime64[D]")],)
    @pytest.mark.xfail(
            reason="This uses int(scalar) or float(scalar) to assign, which "
                   "fails.  However, casting currently does not fail.")
    def test_coercion_basic(self, dtype, scalar):
        arr = np.array(scalar, dtype=dtype)
        cast = np.array(scalar).astype(dtype)
        ass = np.ones((), dtype=dtype)
        ass[()] = scalar  # raises, as would np.array([scalar], dtype=dtype)

        assert_array_equal(arr, cast)
        assert_array_equal(cast, cast)

    @pytest.mark.parametrize("dtype", [np.int64, np.float32])
    @pytest.mark.parametrize("scalar",
            [param(np.timedelta64(123, "ns"), id="timedelta64[ns]"),
             param(np.timedelta64(12, "generic"), id="timedelta64[generic]")])
    def test_coercion_timedelta_convert_to_number(self, dtype, scalar):
        # Only "ns" and "generic" timedeltas can be converted to numbers
        # so these are slightly special.
        arr = np.array(scalar, dtype=dtype)
        cast = np.array(scalar).astype(dtype)
        ass = np.ones((), dtype=dtype)
        ass[()] = scalar  # raises, as would np.array([scalar], dtype=dtype)

        assert_array_equal(arr, cast)
        assert_array_equal(cast, cast)

    @pytest.mark.parametrize(["val", "unit"],
            [param(123, "s", id="[s]"), param(123, "D", id="[D]")])
    @pytest.mark.parametrize("scalar_type", [np.datetime64, np.timedelta64])
    @pytest.mark.xfail(reason="Error not raised for assignment")
    def test_coercion_assignment_times(self, scalar_type, val, unit):
        scalar = scalar_type(val, unit)

        # The error type is not ideal, fails because string is too short:
        with pytest.raises(RuntimeError):
            np.array(scalar, dtype="S6")
        with pytest.raises(RuntimeError):
            cast = np.array(scalar).astype("S6")
        ass = np.ones((), dtype="S6")
        with pytest.raises(RuntimeError):
            ass[()] = scalar


class TestNested:
    @pytest.mark.xfail(reason="No deprecation warning given.")
    def test_nested_simple(self):
        initial = [1.2]
        nested = initial
        for i in range(np.MAXDIMS - 1):
            nested = [nested]

        arr = np.array(nested, dtype="float64")
        assert arr.shape == (1,) * np.MAXDIMS
        with pytest.raises(ValueError):
            np.array([nested], dtype="float64")

        # We discover object automatically at this time:
        with assert_warns(np.VisibleDeprecationWarning):
            arr = np.array([nested])
        assert arr.dtype == np.dtype("O")
        assert arr.shape == (1,) * np.MAXDIMS
        assert arr.item() is initial

    def test_pathological_self_containing(self):
        # Test that this also works for two nested sequences
        l = []
        l.append(l)
        arr = np.array([l, l, l], dtype=object)
        assert arr.shape == (3,) + (1,) * (np.MAXDIMS - 1)

        # Also check a ragged case:
        arr = np.array([l, [None], l], dtype=object)
        assert arr.shape == (3, 1)

    @pytest.mark.xfail(
            reason="For arrays and memoryview, this used to not complain "
                   "and assign to a too small array instead. For other "
                   "array-likes the error is different because fewer (only "
                   "MAXDIM-1) dimensions are found, failing the last test.")
    @pytest.mark.parametrize("arraylike", arraylikes())
    def test_nested_arraylikes(self, arraylike):
        # We try storing an array like into an array, but the array-like
        # will have too many dimensions.  This means the shape discovery
        # decides that the array-like must be treated as an object (a special
        # case of ragged discovery).  The result will be an array with one
        # dimension less than the maximum dimensions, and the array being
        # assigned to it (which does work for object or if `float(arraylike)`
        # works).
        initial = arraylike(np.ones((1, 1)))
        #if not isinstance(initial, (np.ndarray, memoryview)):
        #    pytest.xfail(
        #        "When coercing to object, these cases currently discover "
        #        "fewer dimensions than ndarray failing the second part.")

        nested = initial
        for i in range(np.MAXDIMS - 1):
            nested = [nested]

        with pytest.raises(ValueError):
            # It will refuse to assign the array into
            np.array(nested, dtype="float64")

        # If this is object, we end up assigning a (1, 1) array into (1,)
        # (due to running out of dimensions), this is currently supported but
        # a special case which is not ideal.
        arr = np.array(nested, dtype=object)
        assert arr.shape == (1,) * np.MAXDIMS
        assert arr.item() == np.array(initial).item()

    @pytest.mark.parametrize("arraylike", arraylikes())
    def test_uneven_depth_ragged(self, arraylike):
        arr = np.arange(4).reshape((2, 2))
        arr = arraylike(arr)

        # Array is ragged in the second dimension already:
        out = np.array([arr, [arr]], dtype=object)
        assert out.shape == (2,)
        assert out[0] is arr
        assert type(out[1]) is list

        if not isinstance(arr, (np.ndarray, memoryview)):
            pytest.xfail(
                "does not raise ValueError below, because it discovers "
                "the dimension as (2,) and not (2, 2, 2)")

        # Array is ragged in the third dimension:
        with pytest.raises(ValueError):
            # This is a broadcast error during assignment, because
            # the array shape would be (2, 2, 2) but `arr[0, 0] = arr` fails.
            np.array([arr, [arr, arr]], dtype=object)

    def test_empty_sequence(self):
        arr = np.array([[], [1], [[1]]], dtype=object)
        assert arr.shape == (3,)

        # The empty sequence stops further dimension discovery, so the
        # result shape will be (0,) which leads to an error during:
        with pytest.raises(ValueError):
            np.array([[], np.empty((0, 1))], dtype=object)


class TestBadSequences:
    # These are tests for bad objects passed into `np.array`, in general
    # these have undefined behaviour.  In the old code they partially worked
    # when now they will fail.  We could (and maybe should) create a copy
    # of all sequences to be safe against bad-actors.

    def test_growing_list(self):
        # List to coerce, `mylist` will append to it during coercion
        obj = []
        class mylist(list):
            def __len__(self):
                obj.append([1, 2])
                return super().__len__()

        obj.append(mylist([1, 2]))

        with pytest.raises(ValueError):  # changes to RuntimeError
            np.array(obj)

    # Note: We do not test a shrinking list.  These do very evil things
    #       and the only way to fix them would be to copy all sequences.
    #       (which may be a real option in the future).

    def test_mutated_list(self):
        # List to coerce, `mylist` will mutate the first element
        obj = []
        class mylist(list):
            def __len__(self):
                obj[0] = [2, 3]  # replace with a different list.
                return super().__len__()

        obj.append([2, 3])
        obj.append(mylist([1, 2]))
        #with pytest.raises(RuntimeError):  # Will error in the future
        np.array(obj)

    def test_replace_0d_array(self):
        # List to coerce, `mylist` will mutate the first element
        obj = []
        class baditem:
            def __len__(self):
                obj[0][0] = 2  # replace with a different list.
                raise ValueError("not actually a sequence!")

            def __getitem__(self):
                pass

        # Runs into a corner case in the new code, the `array(2)` is cached
        # so replacing it invalidates the cache.
        obj.append([np.array(2), baditem()])
        # with pytest.raises(RuntimeError):  # Will error in the future
        np.array(obj)


class TestArrayLikes:
    @pytest.mark.parametrize("arraylike", arraylikes())
    def test_0d_object_special_case(self, arraylike):
        arr = np.array(0.)
        obj = arraylike(arr)
        # A single array-like is always converted:
        res = np.array(obj, dtype=object)
        assert_array_equal(arr, res)

        # But a single 0-D nested array-like never:
        res = np.array([obj], dtype=object)
        assert res[0] is obj

    def test_0d_generic_special_case(self):
        class ArraySubclass(np.ndarray):
            def __float__(self):
                raise TypeError("e.g. quantities raise on this")

        arr = np.array(0.)
        obj = arr.view(ArraySubclass)
        res = np.array(obj)
        # The subclass is simply cast:
        assert_array_equal(arr, res)

        # If the 0-D array-like is included, __float__ is currently
        # guaranteed to be used.  We may want to change that, quantities
        # and masked arrays half make use of this.
        with pytest.raises(TypeError):
            np.array([obj])

        # The same holds for memoryview:
        obj = memoryview(arr)
        res = np.array(obj)
        assert_array_equal(arr, res)
        with pytest.raises(ValueError):
            # The error type does not matter much here.
            np.array([obj])
