"""
pint.compat
~~~~~~~~~~~

Compatibility layer.

:copyright: 2013 by Pint Authors, see AUTHORS for more details.
:license: BSD, see LICENSE for more details.
"""

from __future__ import annotations

import math
import sys
from collections.abc import Callable, Iterable, Mapping
from decimal import Decimal
from importlib import import_module
from importlib.util import find_spec
from numbers import Number
from typing import (
    Any,
    # Remove once all dependent packages change their imports.
    Never,  # noqa
    NoReturn,
    Self,  # noqa
    TypeAlias,  # noqa
    Unpack,  # noqa
)

if sys.version_info >= (3, 13):
    from warnings import deprecated  # noqa
else:
    from typing_extensions import deprecated  # noqa


def missing_dependency(
    package: str, display_name: str | None = None
) -> Callable[..., NoReturn]:
    """Return a helper function that raises an exception when used.

    It provides a way delay a missing dependency exception until it is used.
    """
    display_name = display_name or package

    def _inner(*args: Any, **kwargs: Any) -> NoReturn:
        raise Exception(
            "This feature requires %s. Please install it by running:\n"
            "pip install %s" % (display_name, package)
        )

    return _inner


def fully_qualified_name(t: type) -> str:
    """Return the fully qualified name of a type."""
    module = t.__module__
    name = t.__qualname__

    if module is None or module == "builtins":
        return name

    return f"{module}.{name}"


def check_upcast_type(obj: type) -> bool:
    """Check if the type object is an upcast type."""

    # TODO: merge or unify name with is_upcast_type

    fqn = fully_qualified_name(obj)
    if fqn not in upcast_type_map:
        return False
    else:
        module_name, class_name = fqn.rsplit(".", 1)
        cls = getattr(import_module(module_name), class_name)

    upcast_type_map[fqn] = cls
    # This is to check we are importing the same thing.
    # and avoid weird problems. Maybe instead of return
    # we should raise an error if false.
    return obj in upcast_type_map.values()


def is_upcast_type(other: type) -> bool:
    """Check if the type object is an upcast type."""

    # TODO: merge or unify name with check_upcast_type

    if other in upcast_type_map.values():
        return True
    return check_upcast_type(other)


def is_duck_array_type(cls: type) -> bool:
    """Check if the type object represents a (non-Quantity) duck array type."""
    # TODO (NEP 30): replace duck array check with hasattr(other, "__duckarray__")
    return issubclass(cls, ndarray) or (
        not hasattr(cls, "_magnitude")
        and not hasattr(cls, "_units")
        and HAS_NUMPY_ARRAY_FUNCTION
        and hasattr(cls, "__array_function__")
        and hasattr(cls, "ndim")
        and hasattr(cls, "dtype")
    )


def is_duck_array(obj: type) -> bool:
    """Check if an object represents a (non-Quantity) duck array type."""
    return is_duck_array_type(type(obj))


def eq(lhs: Any, rhs: Any, check_all: bool) -> bool | Iterable[bool]:
    """Comparison of scalars and arrays.

    Parameters
    ----------
    lhs
        left-hand side
    rhs
        right-hand side
    check_all
        if True, reduce sequence to single bool;
        return True if all the elements are equal.

    Returns
    -------
    bool or array_like of bool
    """
    out = lhs == rhs
    if check_all and is_duck_array_type(type(out)):
        return out.all()
    return out


def isnan(obj: Any, check_all: bool) -> bool | Iterable[bool]:
    """Test for NaN or NaT.

    Parameters
    ----------
    obj
        scalar or vector
    check_all
        if True, reduce sequence to single bool;
        return True if any of the elements are NaN.

    Returns
    -------
    bool or array_like of bool.
        Always return False for non-numeric types.
    """
    if is_duck_array_type(type(obj)):
        if obj.dtype.kind in "ifc":
            out = np.isnan(obj)
        elif obj.dtype.kind in "Mm":
            out = np.isnat(obj)
        else:
            if HAS_UNCERTAINTIES:
                try:
                    out = unp.isnan(obj)
                except TypeError:
                    # Not a numeric or UFloat type
                    out = np.full(obj.shape, False)
            else:
                # Not a numeric or datetime type
                out = np.full(obj.shape, False)
        return out.any() if check_all else out
    if isinstance(obj, np_datetime64):
        return np.isnat(obj)
    elif HAS_UNCERTAINTIES and isinstance(obj, UFloat):
        return unp.isnan(obj)
    try:
        return math.isnan(obj)
    except TypeError:
        return False


def zero_or_nan(obj: Any, check_all: bool) -> bool | Iterable[bool]:
    """Test if obj is zero, NaN, or NaT.

    Parameters
    ----------
    obj
        scalar or vector
    check_all
        if True, reduce sequence to single bool;
        return True if all the elements are zero, NaN, or NaT.

    Returns
    -------
    bool or array_like of bool.
        Always return False for non-numeric types.
    """
    out = eq(obj, 0, False) + isnan(obj, False)
    if check_all and is_duck_array_type(type(out)):
        return out.all()
    return out


# TODO: remove this warning after v0.10
class BehaviorChangeWarning(UserWarning):
    pass


##############
# try imports
##############

try:
    import babel  # noqa: F401
    from babel import units as babel_units

    HAS_BABEL = hasattr(babel_units, "format_unit")
except ImportError:
    HAS_BABEL = False

try:
    import uncertainties  # noqa: F401

    HAS_UNCERTAINTIES = True
except ImportError:
    HAS_UNCERTAINTIES = False

try:
    import numpy  # noqa: F401

    HAS_NUMPY = True
except ImportError:
    HAS_NUMPY = False

try:
    import scipy  # noqa: F401

    HAS_SCIPY = True
except ImportError:
    HAS_SCIPY = False

HAS_DASK = find_spec("dask") is not None


##############################
# Imports are handled here
# in order to be able to have
# them as constants
# in mypy configuration.
##############################

if HAS_BABEL:
    from babel import Locale
    from babel import units as babel_units

    babel_parse = Locale.parse
else:
    babel_parse = missing_dependency("Babel")  # noqa: F811 # type:ignore
    babel_units = babel_parse
    Locale = missing_dependency

if HAS_UNCERTAINTIES:
    from uncertainties import UFloat, ufloat

    unp = None
else:
    UFloat = ufloat = unp = None


if HAS_NUMPY:
    import numpy as np
    from numpy import datetime64 as np_datetime64
    from numpy import (
        exp,  # noqa: F401
        log,  # noqa: F401
        ndarray,
    )

    NUMPY_VER = np.__version__
    if HAS_UNCERTAINTIES:
        from uncertainties import unumpy as unp

        NUMERIC_TYPES = (Number, Decimal, ndarray, np.number, UFloat)
    else:
        NUMERIC_TYPES = (Number, Decimal, ndarray, np.number)

    def _to_magnitude(value, force_ndarray=False, force_ndarray_like=False):
        if isinstance(value, (dict, bool)) or value is None:
            raise TypeError(f"Invalid magnitude for Quantity: {value!r}")
        elif isinstance(value, str) and value == "":
            raise ValueError("Quantity magnitude cannot be an empty string.")
        elif isinstance(value, (list, tuple)):
            return np.asarray(value)
        elif HAS_UNCERTAINTIES:
            from pint.facets.measurement.objects import Measurement

            if isinstance(value, Measurement):
                return ufloat(value.value, value.error)
        if force_ndarray or (
            force_ndarray_like and not is_duck_array_type(type(value))
        ):
            return np.asarray(value)
        return value

    def _test_array_function_protocol():
        # Test if the __array_function__ protocol is enabled
        try:

            class FakeArray:
                def __array_function__(self, *args, **kwargs):
                    return

            np.concatenate([FakeArray()])
            return True
        except ValueError:
            return False

    HAS_NUMPY_ARRAY_FUNCTION = _test_array_function_protocol()

    NP_NO_VALUE = np._NoValue

else:
    np = None

    class ndarray:
        pass

    class np_datetime64:
        pass

    from math import (
        exp,  # noqa: F401
        log,  # noqa: F401
    )

    NUMPY_VER = "0"
    NUMERIC_TYPES = (Number, Decimal)
    HAS_NUMPY_ARRAY_FUNCTION = False
    NP_NO_VALUE = None

    def _to_magnitude(value, force_ndarray=False, force_ndarray_like=False):
        if force_ndarray or force_ndarray_like:
            raise ValueError(
                "Cannot force to ndarray or ndarray-like when NumPy is not present."
            )
        elif isinstance(value, (dict, bool)) or value is None:
            raise TypeError(f"Invalid magnitude for Quantity: {value!r}")
        elif isinstance(value, str) and value == "":
            raise ValueError("Quantity magnitude cannot be an empty string.")
        elif isinstance(value, (list, tuple)):
            raise TypeError(
                "lists and tuples are valid magnitudes for "
                "Quantity only when NumPy is present."
            )
        elif HAS_UNCERTAINTIES:
            from pint.facets.measurement.objects import Measurement

            if isinstance(value, Measurement):
                return ufloat(value.value, value.error)
        return value


if HAS_SCIPY:
    import scipy
else:
    scipy = missing_dependency("scipy")


# Define location of pint.Quantity in NEP-13 type cast hierarchy by defining upcast
# types using guarded imports

if HAS_DASK:
    from dask.base import compute, persist, visualize

    class _LazyDaskArray:
        def __getattr__(self, attr):
            return getattr(import_module("dask.array"), attr)

    dask_array = _LazyDaskArray()
else:
    compute, persist, visualize = None, None, None
    dask_array = None


# TODO: merge with upcast_type_map

#: List upcast type names
upcast_type_names = (
    "pint_pandas.pint_array.PintArray",
    "xarray.core.dataarray.DataArray",
    "xarray.core.dataset.Dataset",
    "xarray.core.variable.Variable",
    "pandas.core.series.Series",
    "pandas.core.frame.DataFrame",
    "pandas.Series",
    "pandas.DataFrame",
    "xarray.core.dataarray.DataArray",
)

#: Map type name to the actual type (for upcast types).
upcast_type_map: Mapping[str, type | None] = {k: None for k in upcast_type_names}
