from __future__ import annotations

import enum
from collections import OrderedDict
from collections.abc import Iterable, Mapping
from datetime import timezone
from itertools import starmap
from typing import TYPE_CHECKING

from narwhals._utils import (
    _DeferredIterable,
    isinstance_or_issubclass,
    qualified_type_name,
)
from narwhals.exceptions import InvalidOperationError

if TYPE_CHECKING:
    from collections.abc import Iterator, Sequence
    from typing import Any

    import _typeshed
    from typing_extensions import Self, TypeIs

    from narwhals.typing import IntoDType, TimeUnit


def _validate_dtype(dtype: DType | type[DType]) -> None:
    if not isinstance_or_issubclass(dtype, DType):
        msg = (
            f"Expected Narwhals dtype, got: {type(dtype)}.\n\n"
            "Hint: if you were trying to cast to a type, use e.g. nw.Int64 instead of 'int64'."
        )
        raise TypeError(msg)


def _is_into_dtype(obj: Any) -> TypeIs[IntoDType]:
    return isinstance(obj, DType) or (
        isinstance(obj, DTypeClass) and not issubclass(obj, NestedType)
    )


def _is_nested_type(obj: Any) -> TypeIs[type[NestedType]]:
    return isinstance(obj, DTypeClass) and issubclass(obj, NestedType)


def _validate_into_dtype(dtype: Any) -> None:
    if not _is_into_dtype(dtype):
        if _is_nested_type(dtype):
            name = f"nw.{dtype.__name__}"
            msg = (
                f"{name!r} is not valid in this context.\n\n"
                f"Hint: instead of:\n\n"
                f"    {name}\n\n"
                "use:\n\n"
                f"    {name}(...)"
            )
        else:
            msg = f"Expected Narwhals dtype, got: {qualified_type_name(dtype)!r}."
        raise TypeError(msg)


class DTypeClass(type):
    """Metaclass for DType classes.

    - Nicely print classes.
    - Ensure [`__slots__`] are always defined to prevent `__dict__` creation (empty by default).

    [`__slots__`]: https://docs.python.org/3/reference/datamodel.html#object.__slots__
    """

    def __repr__(cls) -> str:
        return cls.__name__

    # https://github.com/python/typeshed/blob/776508741d76b58f9dcb2aaf42f7d4596a48d580/stdlib/abc.pyi#L13-L19
    # https://github.com/python/typeshed/blob/776508741d76b58f9dcb2aaf42f7d4596a48d580/stdlib/_typeshed/__init__.pyi#L36-L40
    # https://github.com/astral-sh/ruff/issues/8353#issuecomment-1786238311
    # https://docs.python.org/3/reference/datamodel.html#creating-the-class-object
    def __new__(
        metacls: type[_typeshed.Self],
        cls_name: str,
        bases: tuple[type, ...],
        namespace: dict[str, Any],
        /,
        **kwds: Any,
    ) -> _typeshed.Self:
        namespace.setdefault("__slots__", ())
        return super().__new__(metacls, cls_name, bases, namespace, **kwds)  # type: ignore[no-any-return, misc]


class DType(metaclass=DTypeClass):
    """Base class for all Narwhals data types."""

    __slots__ = ()  # NOTE: Keep this one defined manually for the type checker

    def __repr__(self) -> str:
        return self.__class__.__qualname__

    @classmethod
    def base_type(cls) -> type[Self]:
        """Return this DType's fundamental/root type class.

        Examples:
            >>> import narwhals as nw
            >>> nw.Datetime("us").base_type()
            Datetime
            >>> nw.String.base_type()
            String
            >>> nw.List(nw.Int64).base_type()
            List
        """
        return cls

    @classmethod
    def is_numeric(cls: type[Self]) -> bool:
        """Check whether the data type is a numeric type."""
        return issubclass(cls, NumericType)

    @classmethod
    def is_integer(cls: type[Self]) -> bool:
        """Check whether the data type is an integer type."""
        return issubclass(cls, IntegerType)

    @classmethod
    def is_signed_integer(cls: type[Self]) -> bool:
        """Check whether the data type is a signed integer type."""
        return issubclass(cls, SignedIntegerType)

    @classmethod
    def is_unsigned_integer(cls: type[Self]) -> bool:
        """Check whether the data type is an unsigned integer type."""
        return issubclass(cls, UnsignedIntegerType)

    @classmethod
    def is_float(cls: type[Self]) -> bool:
        """Check whether the data type is a floating point type."""
        return issubclass(cls, FloatType)

    @classmethod
    def is_decimal(cls: type[Self]) -> bool:
        """Check whether the data type is a decimal type."""
        return issubclass(cls, Decimal)

    @classmethod
    def is_temporal(cls: type[Self]) -> bool:
        """Check whether the data type is a temporal type."""
        return issubclass(cls, TemporalType)

    @classmethod
    def is_nested(cls: type[Self]) -> bool:
        """Check whether the data type is a nested type."""
        return issubclass(cls, NestedType)

    @classmethod
    def is_boolean(cls: type[Self]) -> bool:
        """Check whether the data type is a boolean type."""
        return issubclass(cls, Boolean)

    def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
        """Check if this DType is equivalent to another DType.

        Examples:
            >>> import narwhals as nw
            >>> nw.String() == nw.String()
            True
            >>> nw.String() == nw.String
            True
            >>> nw.Int16() == nw.Int32
            False
            >>> nw.Boolean() == nw.Int8
            False
            >>> nw.Date() == nw.Datetime
            False
        """
        return isinstance_or_issubclass(other, type(self))

    def __hash__(self) -> int:
        return hash(self.__class__)


class NumericType(DType):
    """Base class for numeric data types."""


class IntegerType(NumericType):
    """Base class for integer data types."""


class SignedIntegerType(IntegerType):
    """Base class for signed integer data types."""


class UnsignedIntegerType(IntegerType):
    """Base class for unsigned integer data types."""


class FloatType(NumericType):
    """Base class for float data types."""


class TemporalType(DType):
    """Base class for temporal data types."""


class NestedType(DType):
    """Base class for nested data types."""


class Decimal(NumericType):
    """Decimal 128-bit type with an optional precision and non-negative scale.

    Arguments:
        precision: Maximum number of digits in each number. If set to `None` (default),
            the precision is set to 38 (the maximum supported by Polars).
        scale: Number of digits to the right of the decimal point in each number.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s = pl.Series(["1.5"], dtype=pl.Decimal)
        >>> nw.from_native(s, series_only=True).dtype
        Decimal(precision=2, scale=1)
    """

    __slots__ = ("precision", "scale")

    precision: int
    scale: int

    # !NOTE: Reason for `precision: int | None = None` rather than `precision: int = 38`
    # is to mirror polars signature https://github.com/pola-rs/polars/blob/bb79993c3aa91d0db7d20be8f75c8075cad97067/py-polars/src/polars/datatypes/classes.py#L450-L454
    def __init__(self, precision: int | None = None, scale: int = 0) -> None:
        precision = 38 if precision is None else precision

        if not ((is_int := isinstance(precision, int)) and 0 <= precision <= 38):
            msg = f"precision must be a positive integer between 0 and 38, found {precision!r}"
            raise ValueError(msg) if is_int else TypeError(msg)

        if not ((is_int := isinstance(scale, int)) and scale >= 0):
            msg = f"scale must be a positive integer, found {scale!r}"
            raise ValueError(msg) if is_int else TypeError(msg)

        if scale > precision:
            msg = "scale must be less than or equal to precision"
            raise InvalidOperationError(msg)

        self.precision = precision
        self.scale = scale

    def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
        return (other is Decimal) or (
            isinstance(other, self.__class__)
            and self.precision == other.precision
            and self.scale == other.scale
        )

    def __hash__(self) -> int:  # pragma: no cover
        return hash((self.__class__, self.precision, self.scale))

    def __repr__(self) -> str:  # pragma: no cover
        class_name = self.__class__.__name__
        return f"{class_name}(precision={self.precision!r}, scale={self.scale!r})"


class Int128(SignedIntegerType):
    """128-bit signed integer type.

    Examples:
        >>> import polars as pl
        >>> import pyarrow as pa
        >>> import narwhals as nw
        >>> import duckdb
        >>> s_native = pl.Series([2, 1, 3, 7])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> df_native = pa.table({"a": [2, 1, 3, 7]})
        >>> rel = duckdb.sql(" SELECT CAST (a AS INT128) AS a FROM df_native ")

        >>> s.cast(nw.Int128).dtype
        Int128
        >>> nw.from_native(rel).collect_schema()["a"]
        Int128
    """


class Int64(SignedIntegerType):
    """64-bit signed integer type.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s_native = pl.Series([2, 1, 3, 7])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.Int64).dtype
        Int64
    """


class Int32(SignedIntegerType):
    """32-bit signed integer type.

    Examples:
        >>> import pyarrow as pa
        >>> import narwhals as nw
        >>> s_native = pa.chunked_array([[2, 1, 3, 7]])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.Int32).dtype
        Int32
    """


class Int16(SignedIntegerType):
    """16-bit signed integer type.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s_native = pl.Series([2, 1, 3, 7])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.Int16).dtype
        Int16
    """


class Int8(SignedIntegerType):
    """8-bit signed integer type.

    Examples:
        >>> import pandas as pd
        >>> import narwhals as nw
        >>> s_native = pd.Series([2, 1, 3, 7])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.Int8).dtype
        Int8
    """


class UInt128(UnsignedIntegerType):
    """128-bit unsigned integer type.

    Examples:
        >>> import pandas as pd
        >>> import narwhals as nw
        >>> import duckdb
        >>> df_native = pd.DataFrame({"a": [2, 1, 3, 7]})
        >>> rel = duckdb.sql(" SELECT CAST (a AS UINT128) AS a FROM df_native ")
        >>> nw.from_native(rel).collect_schema()["a"]
        UInt128
    """


class UInt64(UnsignedIntegerType):
    """64-bit unsigned integer type.

    Examples:
        >>> import pandas as pd
        >>> import narwhals as nw
        >>> s_native = pd.Series([2, 1, 3, 7])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.UInt64).dtype
        UInt64
    """


class UInt32(UnsignedIntegerType):
    """32-bit unsigned integer type.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s_native = pl.Series([2, 1, 3, 7])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.UInt32).dtype
        UInt32
    """


class UInt16(UnsignedIntegerType):
    """16-bit unsigned integer type.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s_native = pl.Series([2, 1, 3, 7])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.UInt16).dtype
        UInt16
    """


class UInt8(UnsignedIntegerType):
    """8-bit unsigned integer type.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s_native = pl.Series([2, 1, 3, 7])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.UInt8).dtype
        UInt8
    """


class Float64(FloatType):
    """64-bit floating point type.

    Examples:
        >>> import pyarrow as pa
        >>> import narwhals as nw
        >>> s_native = pa.chunked_array([[0.001, 0.1, 0.01, 0.1]])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.Float64).dtype
        Float64
    """


class Float32(FloatType):
    """32-bit floating point type.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s_native = pl.Series([0.001, 0.1, 0.01, 0.1])
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.cast(nw.Float32).dtype
        Float32
    """


class Float16(FloatType):
    """16-bit floating point type.

    Examples:
        >>> import pyarrow as pa
        >>> import narwhals as nw
        >>> s_native = pa.chunked_array([[0.001, 0.1, 0.01, 0.1]], type=pa.float16())
        >>> s = nw.from_native(s_native, series_only=True)
        >>> s.dtype
        Float16
    """


class String(DType):
    """UTF-8 encoded string type.

    Examples:
        >>> import pandas as pd
        >>> import narwhals as nw
        >>> s_native = pd.Series(["beluga", "narwhal", "orca", "vaquita"])
        >>> nw.from_native(s_native, series_only=True).dtype
        String
    """


class Boolean(DType):
    """Boolean type.

    Examples:
        >>> import pyarrow as pa
        >>> import narwhals as nw
        >>> s_native = pa.chunked_array([[True, False, False, True]])
        >>> nw.from_native(s_native, series_only=True).dtype
        Boolean
    """


class Object(DType):
    """Data type for wrapping arbitrary Python objects.

    Examples:
        >>> import pandas as pd
        >>> import narwhals as nw
        >>> class Foo: ...
        >>> s_native = pd.Series([Foo(), Foo()])
        >>> nw.from_native(s_native, series_only=True).dtype
        Object
    """


class Unknown(DType):
    """Type representing DataType values that could not be determined statically.

    Examples:
        >>> import pandas as pd
        >>> import narwhals as nw
        >>> s_native = pd.Series(pd.period_range("2000-01", periods=4, freq="M"))
        >>> nw.from_native(s_native, series_only=True).dtype
        Unknown
    """


class _DatetimeMeta(DTypeClass):
    @property
    def time_unit(cls) -> TimeUnit:
        """Unit of time. Defaults to `'us'` (microseconds)."""
        return "us"

    @property
    def time_zone(cls) -> str | None:
        """Time zone string. Defaults to `None`."""
        return None


class Datetime(TemporalType, metaclass=_DatetimeMeta):
    """Data type representing a calendar date and time of day.

    Arguments:
        time_unit: Unit of time. Defaults to `'us'` (microseconds).
        time_zone: Time zone string, as defined in zoneinfo (to see valid strings run
            `import zoneinfo; zoneinfo.available_timezones()` for a full list).

    Notes:
        Adapted from [Polars implementation](https://github.com/pola-rs/polars/blob/py-1.7.1/py-polars/polars/datatypes/classes.py#L398-L457)

    Examples:
        >>> from datetime import datetime, timedelta
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s_native = (
        ...     pl.Series([datetime(2024, 12, 9) + timedelta(days=n) for n in range(5)])
        ...     .cast(pl.Datetime("ms"))
        ...     .dt.replace_time_zone("Africa/Accra")
        ... )
        >>> nw.from_native(s_native, series_only=True).dtype
        Datetime(time_unit='ms', time_zone='Africa/Accra')
    """

    __slots__ = ("time_unit", "time_zone")

    def __init__(
        self, time_unit: TimeUnit = "us", time_zone: str | timezone | None = None
    ) -> None:
        if time_unit not in {"s", "ms", "us", "ns"}:
            msg = (
                "invalid `time_unit`"
                f"\n\nExpected one of {{'ns','us','ms', 's'}}, got {time_unit!r}."
            )
            raise ValueError(msg)

        if isinstance(time_zone, timezone):
            time_zone = str(time_zone)

        self.time_unit: TimeUnit = time_unit
        """Unit of time."""
        self.time_zone: str | None = time_zone
        """Time zone string, as defined in zoneinfo.

        Notes:
            To see valid strings run `import zoneinfo; zoneinfo.available_timezones()` for a full list.
        """

    def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
        """Check if this Datetime is equivalent to another DType.

        Examples:
            >>> import narwhals as nw
            >>> nw.Datetime("s") == nw.Datetime("s")
            True
            >>> nw.Datetime() == nw.Datetime("us")
            True
            >>> nw.Datetime("us") == nw.Datetime("ns")
            False
            >>> nw.Datetime("us", "UTC") == nw.Datetime(time_unit="us", time_zone="UTC")
            True
            >>> nw.Datetime(time_zone="UTC") == nw.Datetime(time_zone="EST")
            False
            >>> nw.Datetime() == nw.Duration()
            False
            >>> nw.Datetime("ms") == nw.Datetime
            True
        """
        if type(other) is _DatetimeMeta:
            return True
        if isinstance(other, self.__class__):
            return self.time_unit == other.time_unit and self.time_zone == other.time_zone
        return False  # pragma: no cover

    def __hash__(self) -> int:  # pragma: no cover
        return hash((self.__class__, self.time_unit, self.time_zone))

    def __repr__(self) -> str:  # pragma: no cover
        class_name = self.__class__.__name__
        return f"{class_name}(time_unit={self.time_unit!r}, time_zone={self.time_zone!r})"


class _DurationMeta(DTypeClass):
    @property
    def time_unit(cls) -> TimeUnit:
        """Unit of time. Defaults to `'us'` (microseconds)."""
        return "us"


class Duration(TemporalType, metaclass=_DurationMeta):
    """Data type representing a time duration.

    Arguments:
        time_unit: Unit of time. Defaults to `'us'` (microseconds).

    Notes:
        Adapted from [Polars implementation](https://github.com/pola-rs/polars/blob/py-1.7.1/py-polars/polars/datatypes/classes.py#L460-L502)

    Examples:
        >>> from datetime import timedelta
        >>> import pyarrow as pa
        >>> import narwhals as nw
        >>> s_native = pa.chunked_array(
        ...     [[timedelta(seconds=d) for d in range(1, 4)]], type=pa.duration("ms")
        ... )
        >>> nw.from_native(s_native, series_only=True).dtype
        Duration(time_unit='ms')
    """

    __slots__ = ("time_unit",)

    def __init__(self, time_unit: TimeUnit = "us") -> None:
        if time_unit not in {"s", "ms", "us", "ns"}:
            msg = (
                "invalid `time_unit`"
                f"\n\nExpected one of {{'ns','us','ms', 's'}}, got {time_unit!r}."
            )
            raise ValueError(msg)

        self.time_unit: TimeUnit = time_unit
        """Unit of time."""

    def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
        """Check if this Duration is equivalent to another DType.

        Examples:
            >>> import narwhals as nw
            >>> nw.Duration("us") == nw.Duration("us")
            True
            >>> nw.Duration() == nw.Duration("us")
            True
            >>> nw.Duration("us") == nw.Duration("ns")
            False
            >>> nw.Duration() == nw.Datetime()
            False
            >>> nw.Duration("ms") == nw.Duration
            True
        """
        if type(other) is _DurationMeta:
            return True
        if isinstance(other, self.__class__):
            return self.time_unit == other.time_unit
        return False  # pragma: no cover

    def __hash__(self) -> int:  # pragma: no cover
        return hash((self.__class__, self.time_unit))

    def __repr__(self) -> str:  # pragma: no cover
        class_name = self.__class__.__name__
        return f"{class_name}(time_unit={self.time_unit!r})"


class Categorical(DType):
    """A categorical encoding of a set of strings.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s_native = pl.Series(["beluga", "narwhal", "orca"])
        >>> nw.from_native(s_native, series_only=True).cast(nw.Categorical).dtype
        Categorical
    """


class Enum(DType):
    """A fixed categorical encoding of a unique set of strings.

    Polars has an Enum data type. In pandas, ordered categories get mapped
    to Enum. PyArrow has no Enum equivalent.

    Examples:
       >>> import narwhals as nw
       >>> nw.Enum(["beluga", "narwhal", "orca"])
       Enum(categories=['beluga', 'narwhal', 'orca'])
    """

    __slots__ = ("_cached_categories", "_delayed_categories")

    def __init__(self, categories: Iterable[str] | type[enum.Enum]) -> None:
        self._delayed_categories: _DeferredIterable[str] | None = None
        self._cached_categories: tuple[str, ...] | None = None

        if isinstance(categories, _DeferredIterable):
            self._delayed_categories = categories
        elif isinstance(categories, type) and issubclass(categories, enum.Enum):
            self._cached_categories = tuple(member.value for member in categories)
        else:
            self._cached_categories = tuple(categories)

    @property
    def categories(self) -> tuple[str, ...]:
        """The categories in the dataset."""
        if (cached := self._cached_categories) is not None:
            return cached
        if (delayed := self._delayed_categories) is not None:
            self._cached_categories = delayed.to_tuple()
            return self._cached_categories
        msg = f"Internal structure of {type(self).__name__!r} is invalid."  # pragma: no cover
        raise TypeError(msg)  # pragma: no cover

    def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
        """Check if this Enum is equivalent to another DType.

        Examples:
            >>> import narwhals as nw
            >>> nw.Enum(["a", "b", "c"]) == nw.Enum(["a", "b", "c"])
            True
            >>> import polars as pl
            >>> categories = pl.Series(["a", "b", "c"])
            >>> nw.Enum(["a", "b", "c"]) == nw.Enum(categories)
            True
            >>> nw.Enum(["a", "b", "c"]) == nw.Enum(["b", "a", "c"])
            False
            >>> nw.Enum(["a", "b", "c"]) == nw.Enum(["a"])
            False
            >>> nw.Enum(["a", "b", "c"]) == nw.Categorical
            False
            >>> nw.Enum(["a", "b", "c"]) == nw.Enum
            True
        """
        if type(other) is DTypeClass:
            return other is Enum
        return isinstance(other, type(self)) and self.categories == other.categories

    def __hash__(self) -> int:
        return hash((self.__class__, tuple(self.categories)))

    def __repr__(self) -> str:
        return f"{type(self).__name__}(categories={list(self.categories)!r})"


class Field:
    """Definition of a single field within a `Struct` DType.

    Arguments:
        name: The name of the field within its parent `Struct`.
        dtype: The `DType` of the field's values.

    Examples:
       >>> import pyarrow as pa
       >>> import narwhals as nw
       >>> data = [{"a": 1, "b": ["narwhal", "beluga"]}, {"a": 2, "b": ["orca"]}]
       >>> ser_pa = pa.chunked_array([data])
       >>> nw.from_native(ser_pa, series_only=True).dtype.fields
       [Field('a', Int64), Field('b', List(String))]
    """

    __slots__ = ("dtype", "name")
    name: str
    """The name of the field within its parent `Struct`."""
    dtype: IntoDType
    """The `DType` of the field's values."""

    def __init__(self, name: str, dtype: IntoDType) -> None:
        self.name = name
        self.dtype = dtype

    def __eq__(self, other: Field) -> bool:  # type: ignore[override]
        """Check if this Field is equivalent to another Field.

        Two fields are equivalent if they have the same name and the same dtype.

        Examples:
            >>> import narwhals as nw
            >>> nw.Field("a", nw.String) == nw.Field("a", nw.String())
            True
            >>> nw.Field("a", nw.String) == nw.Field("a", nw.String)
            True
            >>> nw.Field("a", nw.String) == nw.Field("a", nw.Datetime)
            False
            >>> nw.Field("a", nw.String) == nw.Field("b", nw.String)
            False
            >>> nw.Field("a", nw.String) == nw.String
            False
        """
        return (
            isinstance(other, Field)
            and (self.name == other.name)
            and (self.dtype == other.dtype)
        )

    def __hash__(self) -> int:
        return hash((self.name, self.dtype))

    def __repr__(self) -> str:
        class_name = self.__class__.__name__
        return f"{class_name}({self.name!r}, {self.dtype})"


class Struct(NestedType):
    """Struct composite type.

    Arguments:
        fields: The fields that make up the struct. Can be either a sequence of Field
            objects or a mapping of column names to data types.

    Examples:
       >>> import pyarrow as pa
       >>> import narwhals as nw
       >>> s_native = pa.chunked_array(
       ...     [[{"a": 1, "b": ["narwhal", "beluga"]}, {"a": 2, "b": ["orca"]}]]
       ... )
       >>> nw.from_native(s_native, series_only=True).dtype
       Struct({'a': Int64, 'b': List(String)})
    """

    __slots__ = ("fields",)
    fields: list[Field]
    """The fields that make up the struct."""

    def __init__(self, fields: Sequence[Field] | Mapping[str, IntoDType]) -> None:
        if isinstance(fields, Mapping):
            self.fields = list(starmap(Field, fields.items()))
        else:
            self.fields = list(fields)

    def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
        """Check if this Struct is equivalent to another DType.

        Examples:
            >>> import narwhals as nw
            >>> nw.Struct({"a": nw.Int64}) == nw.Struct({"a": nw.Int64})
            True
            >>> nw.Struct({"a": nw.Int64}) == nw.Struct({"a": nw.Boolean})
            False
            >>> nw.Struct({"a": nw.Int64}) == nw.Struct({"b": nw.Int64})
            False
            >>> nw.Struct({"a": nw.Int64}) == nw.Struct([nw.Field("a", nw.Int64)])
            True

            If a parent type is not specific about its inner type, we infer it as equal

            >>> nw.Struct({"a": nw.Int64}) == nw.Struct
            True
        """
        if type(other) is DTypeClass and issubclass(other, self.__class__):
            return True
        if isinstance(other, self.__class__):
            return self.fields == other.fields
        return False

    def __hash__(self) -> int:
        return hash((self.__class__, tuple(self.fields)))

    def __iter__(self) -> Iterator[tuple[str, IntoDType]]:  # pragma: no cover
        for fld in self.fields:
            yield fld.name, fld.dtype

    def __reversed__(self) -> Iterator[tuple[str, IntoDType]]:
        for fld in reversed(self.fields):
            yield fld.name, fld.dtype

    def __repr__(self) -> str:
        class_name = self.__class__.__name__
        return f"{class_name}({dict(self)})"

    def to_schema(self) -> OrderedDict[str, IntoDType]:
        """Return Struct dtype as a schema dict."""
        return OrderedDict(self)


class List(NestedType):
    """Variable length list type.

    Examples:
       >>> import pandas as pd
       >>> import pyarrow as pa
       >>> import narwhals as nw
       >>> s_native = pd.Series(
       ...     [["narwhal", "orca"], ["beluga", "vaquita"]],
       ...     dtype=pd.ArrowDtype(pa.large_list(pa.large_string())),
       ... )
       >>> nw.from_native(s_native, series_only=True).dtype
       List(String)
    """

    __slots__ = ("inner",)
    inner: IntoDType
    """The DType of the values within each list."""

    def __init__(self, inner: IntoDType) -> None:
        self.inner = inner

    def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
        """Check if this List is equivalent to another DType.

        Examples:
            >>> import narwhals as nw
            >>> nw.List(nw.Int64) == nw.List(nw.Int64)
            True
            >>> nw.List(nw.Int64) == nw.List(nw.Float32)
            False

            If a parent type is not specific about its inner type, we infer it as equal

            >>> nw.List(nw.Int64) == nw.List
            True
        """
        if type(other) is DTypeClass and issubclass(other, self.__class__):
            return True
        if isinstance(other, self.__class__):
            return self.inner == other.inner
        return False

    def __hash__(self) -> int:
        return hash((self.__class__, self.inner))

    def __repr__(self) -> str:
        class_name = self.__class__.__name__
        return f"{class_name}({self.inner!r})"


class Array(NestedType):
    """Fixed length list type.

    Arguments:
        inner: The datatype of the values within each array.
        shape: The shape of the arrays.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> s_native = pl.Series([[1, 2], [3, 4], [5, 6]], dtype=pl.Array(pl.Int32, 2))
        >>> nw.from_native(s_native, series_only=True).dtype
        Array(Int32, shape=(2,))
    """

    __slots__ = ("inner", "shape", "size")
    inner: IntoDType
    """The DType of the values within each array."""
    size: int
    """The size of the Array."""
    shape: tuple[int, ...]
    """The shape of the arrays."""

    def __init__(self, inner: IntoDType, shape: int | tuple[int, ...]) -> None:
        inner_shape: tuple[int, ...] = inner.shape if isinstance(inner, Array) else ()
        if isinstance(shape, int):
            self.inner = inner
            self.size = shape
            self.shape = (shape, *inner_shape)

        elif isinstance(shape, tuple) and len(shape) != 0 and isinstance(shape[0], int):
            if len(shape) > 1:
                inner = Array(inner, shape[1:])

            self.inner = inner
            self.size = shape[0]
            self.shape = shape + inner_shape

        else:
            msg = f"invalid input for shape: {shape!r}"
            raise TypeError(msg)

    def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
        """Check if this Array is equivalent to another DType.

        Examples:
            >>> import narwhals as nw
            >>> nw.Array(nw.Int64, 2) == nw.Array(nw.Int64, 2)
            True
            >>> nw.Array(nw.Int64, 2) == nw.Array(nw.String, 2)
            False
            >>> nw.Array(nw.Int64, 2) == nw.Array(nw.Int64, 4)
            False

            If a parent type is not specific about its inner type, we infer it as equal

            >>> nw.Array(nw.Int64, 2) == nw.Array
            True
        """
        if type(other) is DTypeClass and issubclass(other, self.__class__):
            return True
        if isinstance(other, self.__class__):
            if self.shape != other.shape:
                return False
            return self.inner == other.inner
        return False

    def __hash__(self) -> int:
        return hash((self.__class__, self.inner, self.shape))

    def __repr__(self) -> str:
        # Get leaf type
        dtype: Self | IntoDType = self
        tp_self = type(self)
        # NOTE: This might look odd, but if we used a `while` loop
        # it won't trigger a `RecursionError` like `for` does.
        # https://github.com/narwhals-dev/narwhals/pull/3651
        for _ in self.shape:
            if isinstance(dtype, tp_self):
                dtype = dtype.inner
            else:  # pragma: no cover
                msg = "Either something has gone horribly wrong in narwhals, or you've fiddled with the internals!"
                raise TypeError(msg)
        return f"{tp_self.__name__}({dtype!r}, shape={self.shape})"


class Date(TemporalType):
    """Data type representing a calendar date.

    Examples:
        >>> from datetime import date, timedelta
        >>> import pyarrow as pa
        >>> import narwhals as nw
        >>> s_native = pa.chunked_array(
        ...     [[date(2024, 12, 1) + timedelta(days=d) for d in range(4)]]
        ... )
        >>> nw.from_native(s_native, series_only=True).dtype
        Date
    """


class Time(TemporalType):
    """Data type representing the time of day.

    Examples:
       >>> import polars as pl
       >>> import pyarrow as pa
       >>> import narwhals as nw
       >>> import duckdb
       >>> from datetime import time
       >>> data = [time(9, 0), time(9, 1, 10), time(9, 2)]
       >>> ser_pl = pl.Series(data)
       >>> ser_pa = pa.chunked_array([pa.array(data, type=pa.time64("ns"))])
       >>> rel = duckdb.sql(
       ...     " SELECT * FROM (VALUES (TIME '12:00:00'), (TIME '14:30:15')) df(t)"
       ... )

       >>> nw.from_native(ser_pl, series_only=True).dtype
       Time
       >>> nw.from_native(ser_pa, series_only=True).dtype
       Time
       >>> nw.from_native(rel).collect_schema()["t"]
       Time
    """


class Binary(DType):
    """Binary type.

    Examples:
        >>> import polars as pl
        >>> import narwhals as nw
        >>> import pyarrow as pa
        >>> import duckdb
        >>> data = [b"test1", b"test2"]
        >>> ser_pl = pl.Series(data, dtype=pl.Binary)
        >>> ser_pa = pa.chunked_array([pa.array(data, type=pa.binary())])
        >>> rel = duckdb.sql(
        ...     "SELECT * FROM (VALUES (BLOB 'test1'), (BLOB 'test2')) AS df(t)"
        ... )

        >>> nw.from_native(ser_pl, series_only=True).dtype
        Binary
        >>> nw.from_native(ser_pa, series_only=True).dtype
        Binary
        >>> nw.from_native(rel).collect_schema()["t"]
        Binary
    """
