"""
DataType classes for schema-based value conversion.

This module provides a clean, object-oriented approach to schema definition
and value normalization. Each DataType implements an `apply()` method that
converts values according to the type's rules.
"""

from abc import ABC, abstractmethod
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, List, Optional, cast

import pyarrow as pa


class DataType(ABC):
    """Base class for all data types."""

    @abstractmethod
    def convert(self, value: Any) -> Any:
        """
        Apply type conversion to a value.

        Args:
            value: Value to convert

        Returns:
            Converted value
        """
        raise NotImplementedError("Subclasses must implement convert()")

    def nullable(self) -> "NullableDataType":
        """
        Wrap this type in a nullable type.

        Returns:
            NullableDataType wrapping this type
        """
        return NullableDataType(self)

    @abstractmethod
    def to_arrow(self) -> pa.DataType:
        """
        Convert this type to a PyArrow DataType.

        Returns:
            PyArrow DataType
        """
        raise NotImplementedError("Subclasses must implement to_arrow()")

    @abstractmethod
    def to_numpy(self) -> str:
        """
        Convert this type to a numpy/pandas dtype string.

        Returns:
            Numpy dtype string
        """
        raise NotImplementedError("Subclasses must implement to_numpy()")

    def to_numpy_legacy(self) -> str:
        """
        Convert this type to a numpy/pandas dtype string in a backward-compatible way.

        Returns:
            Numpy dtype string
        """
        return self.to_numpy()


class NullableDataType(DataType):
    """Wraps another DataType to make it nullable."""

    def __init__(self, inner: DataType):
        self.inner = inner

    def convert(self, value: Any) -> Optional[Any]:
        if value is None:
            return None
        return self.inner.convert(value)

    def nullable(self) -> "NullableDataType":
        return self

    def to_arrow(self) -> pa.DataType:
        # In PyArrow nullability is a property of field.
        return self.inner.to_arrow()

    def to_numpy(self) -> str:
        return self.inner.to_numpy()

    def __repr__(self) -> str:
        return f"{str(self.inner)}.nullable()"


class IntDataType(DataType):
    """Integer data type."""

    def convert(self, value: str) -> int:
        assert isinstance(value, str)
        return int(value)

    def to_arrow(self) -> pa.DataType:
        return pa.int64()

    def to_numpy(self) -> str:
        return "int64"

    def __repr__(self) -> str:
        return "INT"


class DecimalDataType(DataType):
    """Decimal data type."""

    PRECISION = 38
    SCALE = 18

    def convert(self, value: str) -> Decimal:
        assert isinstance(value, str)
        return Decimal(value).quantize(Decimal(10) ** -DecimalDataType.SCALE, rounding=ROUND_HALF_UP)

    def to_arrow(self) -> pa.DataType:
        return pa.decimal128(DecimalDataType.PRECISION, DecimalDataType.SCALE)

    def to_numpy(self) -> str:
        return "Float64"  # Pandas uses float64 for decimals

    def __repr__(self) -> str:
        return "DECIMAL"


class StringDataType(DataType):
    """String data type."""

    def convert(self, value: str) -> str:
        assert isinstance(value, str)
        return value

    def to_arrow(self) -> pa.DataType:
        return pa.string()

    def to_numpy(self) -> str:
        return "string"

    def __repr__(self) -> str:
        return "STRING"


class BooleanDataType(DataType):
    """Boolean data type."""

    def convert(self, value: bool) -> bool:
        assert isinstance(value, bool)
        return value

    def to_arrow(self) -> pa.DataType:
        return pa.bool_()

    def to_numpy(self) -> str:
        return "boolean"

    def __repr__(self) -> str:
        return "BOOLEAN"


class DateTimeDataType(DataType):
    """DateTime data type."""

    def convert(self, value: str) -> datetime:
        assert isinstance(value, str)
        return datetime.fromisoformat(value)

    def to_arrow(self) -> pa.DataType:
        return pa.timestamp("ns", tz="UTC")

    def to_numpy(self) -> str:
        return "datetime64[ns, UTC]"

    def to_numpy_legacy(self) -> str:
        return "string"

    def __repr__(self) -> str:
        return "DATE_TIME"


class ObjectDataType(DataType):
    """Object/struct data type with nested fields."""

    def __init__(self, items: Dict[str, DataType]):
        self.items = items

    def convert(self, value: Dict[str, Any]) -> Dict[str, Any]:
        """
        Apply type conversion to a record (dict). Ignores fields not present in the `self.schema`.

        Args:
            value: Value to convert (should be a dict)

        Returns:
            Converted dict (empty dict if input is invalid)
        """
        assert isinstance(value, dict)
        result: Dict[str, Any] = {}

        for field_name, field_type in self.items.items():
            field_value = value.get(field_name)
            result[field_name] = field_type.convert(field_value)

        return result

    def get_field_names(self) -> List[str]:
        """Get all field names from this object schema."""
        return list(self.items.keys())

    def to_arrow(self) -> pa.DataType:
        fields = [
            pa.field(field_name, field_type.to_arrow(), isinstance(field_type, NullableDataType))
            for field_name, field_type in self.items.items()
        ]
        return pa.struct(fields)

    def to_numpy(self) -> str:
        return "object"  # Pandas uses object for nested structures

    def __repr__(self) -> str:
        return f"ObjectDataType({self.items})"


class ListDataType(DataType):
    """List/array data type with element type."""

    def __init__(self, item: DataType):
        self.item = item

    def convert(self, value: List[Any]) -> Optional[List[Any]]:
        assert isinstance(value, list)
        return [self.item.convert(item) for item in value]

    def to_arrow(self) -> pa.DataType:
        item_type = self.item.to_arrow()
        return pa.list_(item_type)

    def to_numpy(self) -> str:
        return "object"  # Pandas uses object for lists

    def get_field_names(self) -> List[str]:
        """Get field names from the list's item type if it's an ObjectDataType."""
        if isinstance(self.item, ObjectDataType):
            return self.item.get_field_names()
        return []

    def __repr__(self) -> str:
        return f"ListDataType({self.item})"


class SchemaWrapper:
    """
    Wrapper around schema objects that adds helpful error messages when schema operations fail.
    This provides better user experience by directing users to upgrade the client when schema mismatches occur.
    """

    def __init__(self, schema: "DataType", endpoint: str):
        self._schema = schema
        self._endpoint = endpoint

    def _upgrade_message(self, operation: str, error: Exception) -> str:
        """Generate helpful upgrade message for schema errors."""
        return (
            f"Schema {operation} failed for endpoint '{self._endpoint}': {error}\n\n"
            "This may indicate a mismatch between your Python client version and the API schema. "
            "Please upgrade to the latest version of the Python client:\n"
            "  pip install --upgrade coinmetrics-api-client\n\n"
            f"Error details: {type(error).__name__}: {str(error)}"
        )

    def convert(self, value: Any) -> Any:
        """Apply type conversion with error handling."""
        try:
            return self._schema.convert(value)
        except (AssertionError, AttributeError, KeyError, TypeError, ValueError) as e:
            raise RuntimeError(self._upgrade_message("conversion", e)) from e

    def get_field_names(self) -> List[str]:
        """Get field names with error handling."""
        try:
            return self._schema.get_field_names()  # type: ignore
        except (AttributeError, TypeError, ValueError) as e:
            raise RuntimeError(self._upgrade_message("field name extraction", e)) from e

    def to_arrow(self) -> pa.DataType:
        """Convert to Arrow format with error handling."""
        try:
            return self._schema.to_arrow()
        except (AttributeError, TypeError, ValueError) as e:
            raise RuntimeError(self._upgrade_message("Arrow conversion", e)) from e

    def to_numpy(self) -> str:
        """Convert to NumPy format - delegates to underlying schema."""
        return self._schema.to_numpy()

    @property
    def item(self) -> "SchemaWrapper":
        """Access the item property for ListDataType schemas."""
        if hasattr(self._schema, 'item'):
            # Wrap the item schema as well
            return SchemaWrapper(self._schema.item, self._endpoint)
        raise AttributeError(f"Schema for {self._endpoint} does not have 'item' attribute")

    @property
    def items(self) -> Dict[str, "DataType"]:
        """Access the items property for ObjectDataType schemas."""
        if hasattr(self._schema, 'items'):
            return cast(Dict[str, "DataType"], self._schema.items)
        raise AttributeError(f"Schema for {self._endpoint} does not have 'items' attribute")

    def __repr__(self) -> str:
        return f"SchemaWrapper({self._schema}, endpoint='{self._endpoint}')"


BOOLEAN = BooleanDataType()
DATE_TIME = DateTimeDataType()
DECIMAL = DecimalDataType()
INT = IntDataType()
STRING = StringDataType()
