"""
Database interaction utilities for django-mantle.
"""

import logging
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from typing import Any, Callable, Generic, Literal, Type, TypeVar
from uuid import UUID

import attrs
import cattrs
from attrs import define, field
from django.db import connection, transaction
from django.core.exceptions import FieldDoesNotExist
from django.db.models import Model, QuerySet
from django_readers import specs

from .readers import to_spec

logger = logging.getLogger(__name__)

M = TypeVar("M", bound=Model)
S = TypeVar("S", bound=attrs.AttrsInstance)
WriteOperation = Literal["create", "update"]


def make_default_converter() -> cattrs.Converter:
    """
    Create a cattrs converter with sensible defaults for Django types.

    Handles:
    - date: Pass through date objects, parse ISO strings
    - datetime: Pass through datetime objects
    - time: Pass through time objects
    - timedelta: Pass through timedelta objects
    - UUID: Pass through UUID objects, parse strings
    - Decimal: Convert through string to preserve precision
    """
    converter = cattrs.Converter()

    def convert_date(value: Any, _) -> date | None:
        if value is None:
            return None
        if isinstance(value, date):
            return value
        return date.fromisoformat(value)

    def convert_datetime(value: Any, _) -> datetime | None:
        if value is None:
            return None
        if isinstance(value, datetime):
            return value
        return datetime.fromisoformat(value)

    def convert_time(value: Any, _) -> time | None:
        if value is None:
            return None
        if isinstance(value, time):
            return value
        return time.fromisoformat(value)

    def convert_timedelta(value: Any, _) -> timedelta | None:
        if value is None:
            return None
        if isinstance(value, timedelta):
            return value
        # Timedelta can't be parsed from ISO string in standard lib,
        # so we just pass through (Django already gives us timedelta objects)
        return value

    def convert_uuid(value: Any, _) -> UUID | None:
        if value is None:
            return None
        if isinstance(value, UUID):
            return value
        return UUID(value)

    def convert_decimal(value: Any, _) -> Decimal:
        if isinstance(value, Decimal):
            return value
        return Decimal(str(value))

    converter.register_structure_hook(date, convert_date)
    converter.register_structure_hook(datetime, convert_datetime)
    converter.register_structure_hook(time, convert_time)
    converter.register_structure_hook(timedelta, convert_timedelta)
    converter.register_structure_hook(UUID, convert_uuid)
    converter.register_structure_hook(Decimal, convert_decimal)

    return converter


# Default converter instance
default_converter = make_default_converter()


@define
class QueryLogConfig:
    """
    Logging configuration for Query operations.

    Attributes:
        level: Logging level (e.g., logging.DEBUG, logging.INFO)
        log_spec: Log the prepared spec (via django_readers_debug.debug_print)
        log_sql: Log the generated SQL query
        log_queries: Log the number and actual queries made
        log_dicts: Log the projected dictionaries from ORM
        log_shapes: Log the final structured attrs instances
    """

    level: int = logging.INFO
    log_spec: bool = False
    log_sql: bool = False
    log_queries: bool = False
    log_dicts: bool = False
    log_shapes: bool = False


class Query(Generic[S]):
    """
    Query class for retrieving Django ORM data as type-safe attrs instances.

    Encapsulates the full django-readers → cattrs flow:
    1. Generate spec from attrs class
    2. Process spec into (prepare, project) pair
    3. Prepare queryset with optimizations
    4. Execute query and project to dicts
    5. Structure dicts as attrs instances

    Usage:
        query = Query(Bookmark.objects.all(), BookmarkAttrs)
        bookmarks = query.all()  # Returns list[BookmarkAttrs]
        bookmark = query.get()   # Returns BookmarkAttrs
    """

    def __init__(
        self,
        queryset: QuerySet,
        shape_class: Type[S],
        converter: cattrs.Converter | None = None,
        log_config: QueryLogConfig | None = None,
    ):
        """
        Initialize a Query.

        Args:
            queryset: Django QuerySet to query
            shape_class: attrs class defining the shape of results
            converter: Custom cattrs converter (uses default if None)
            log_config: Logging configuration (no logging if None)
        """
        self.queryset = queryset
        self.shape_class = shape_class
        self.converter = converter or default_converter
        self.log_config = log_config or QueryLogConfig()

        # Generate spec and process it once during initialization
        self.spec = to_spec(shape_class)
        self.prepare, self.project = specs.process(self.spec)

        # Log spec if configured
        if self.log_config.log_spec:
            self._log_spec()

    def _log_spec(self):
        """Log the prepared spec using django_readers_debug."""
        try:
            from django_readers_debug import debug_print

            logger.log(self.log_config.level, "Generated spec and prepare function:")
            debug_print(self.prepare)
        except ImportError:
            logger.log(self.log_config.level, f"Generated spec: {self.spec}")

    def _log_sql(self, prepared_qs: QuerySet):
        """Log the SQL query that will be executed."""
        if self.log_config.log_sql:
            logger.log(self.log_config.level, f"SQL Query: {prepared_qs.query}")

    def _log_queries(self, initial_queries: int):
        """Log the queries that were executed."""
        if self.log_config.log_queries:
            final_queries = len(connection.queries)
            num_queries = final_queries - initial_queries
            logger.log(self.log_config.level, f"Executed {num_queries} queries")
            for query in connection.queries[initial_queries:]:
                logger.log(self.log_config.level, f"  {query['sql']}")

    def _log_dicts(self, dicts: list[dict] | dict):
        """Log the projected dictionaries."""
        if self.log_config.log_dicts:
            logger.log(self.log_config.level, f"Projected dicts: {dicts}")

    def _log_shapes(self, shapes: list[S] | S):
        """Log the final structured shapes."""
        if self.log_config.log_shapes:
            logger.log(self.log_config.level, f"Final shapes: {shapes}")

    def all(self) -> list[S]:
        """
        Execute query and return all results as attrs instances.

        Returns:
            List of attrs instances of shape_class type
        """
        # Prepare queryset
        prepared_qs = self.prepare(self.queryset)

        # Log SQL if configured
        self._log_sql(prepared_qs)

        # Track queries if logging
        initial_queries = len(connection.queries) if self.log_config.log_queries else 0

        # Execute query and project to dicts
        instances = list(prepared_qs)
        dicts = [self.project(instance) for instance in instances]

        # Log queries if configured
        if self.log_config.log_queries:
            self._log_queries(initial_queries)

        # Log dicts if configured
        self._log_dicts(dicts)

        # Structure as a list of attrs instances
        shapes = self.converter.structure(dicts, list[self.shape_class])  # type: ignore[name-defined]

        # Log shapes if configured
        self._log_shapes(shapes)

        return shapes

    def get(self) -> S:
        """
        Execute query and return a single result as attrs instance.

        Raises:
            model.DoesNotExist: If no results found
            model.MultipleObjectsReturned: If multiple results found

        Returns:
            Single attrs instance of shape_class type
        """
        # Prepare queryset
        prepared_qs = self.prepare(self.queryset)

        # Log SQL if configured
        self._log_sql(prepared_qs)

        # Track queries if logging
        initial_queries = len(connection.queries) if self.log_config.log_queries else 0

        # Execute query and project to dict (using .get())
        instance = prepared_qs.get()
        data = self.project(instance)

        # Log queries if configured
        if self.log_config.log_queries:
            self._log_queries(initial_queries)

        # Log dict if configured
        self._log_dicts(data)

        # Structure as attrs instance
        shape = self.converter.structure(data, self.shape_class)

        # Log shape if configured
        self._log_shapes(shape)

        return shape


@define(frozen=True)
class WriteContext(Generic[M, S]):
    """Validation context passed to create/update validator callables."""

    operation: WriteOperation
    model_class: Type[M]
    instance: M | None
    shape: S
    scalar_data: dict[str, Any]
    m2m_data: dict[str, Any]


WriteValidator = Callable[[WriteContext[M, S]], None]


def compose_validators(*validators: WriteValidator[M, S]) -> WriteValidator[M, S]:
    """Compose multiple validators into one fail-fast validator."""

    def _composed(context: WriteContext[M, S]) -> None:
        for validator in validators:
            validator(context)

    return _composed


def _exclude_instance(queryset, instance: Model | None):
    """Exclude the current instance from uniqueness checks on updates."""
    if instance is None:
        return queryset
    if instance.pk is None:
        return queryset
    return queryset.exclude(pk=instance.pk)


def unique_field(
    field_name: str, message: str | None = None
) -> WriteValidator[M, S]:
    """Validate that a scalar model field value is unique."""

    def _validator(context: WriteContext[M, S]) -> None:
        try:
            context.model_class._meta.get_field(field_name)
        except FieldDoesNotExist as exc:
            model_name = context.model_class.__name__
            raise ValueError(
                f"{model_name} has no field named '{field_name}'"
            ) from exc

        if field_name not in context.scalar_data:
            return

        value = context.scalar_data[field_name]
        queryset = context.model_class._default_manager.filter(**{field_name: value})
        queryset = _exclude_instance(queryset, context.instance)
        if queryset.exists():
            default_message = (
                f"{context.model_class.__name__} with {field_name}={value!r} already exists"
            )
            raise ValueError(message or default_message)

    return _validator


def unique_together(
    *field_names: str, message: str | None = None
) -> WriteValidator[M, S]:
    """Validate uniqueness across a set of scalar model fields."""
    if not field_names:
        raise ValueError("unique_together requires at least one field name")

    def _validator(context: WriteContext[M, S]) -> None:
        changed_any = any(field_name in context.scalar_data for field_name in field_names)
        if context.operation == "update" and not changed_any:
            return

        filter_data: dict[str, Any] = {}

        for field_name in field_names:
            try:
                context.model_class._meta.get_field(field_name)
            except FieldDoesNotExist as exc:
                model_name = context.model_class.__name__
                raise ValueError(
                    f"{model_name} has no field named '{field_name}'"
                ) from exc

            if field_name in context.scalar_data:
                filter_data[field_name] = context.scalar_data[field_name]
                continue

            if context.instance is not None:
                filter_data[field_name] = getattr(context.instance, field_name)
                continue

            # Missing create-time values are skipped; caller may rely on model defaults.
            return

        queryset = context.model_class._default_manager.filter(**filter_data)
        queryset = _exclude_instance(queryset, context.instance)
        if queryset.exists():
            values_str = ", ".join(
                f"{field_name}={filter_data[field_name]!r}" for field_name in field_names
            )
            default_message = (
                f"{context.model_class.__name__} with ({values_str}) already exists"
            )
            raise ValueError(message or default_message)

    return _validator


def _shape_to_dict_and_fields(shape: S) -> tuple[dict[str, Any], list[str]]:
    """Validate an attrs shape instance and return (data, field_names)."""
    shape_class = type(shape)
    if not attrs.has(shape_class):
        actual = type(shape).__name__
        raise TypeError(f"shape must be an attrs instance, got {actual}")

    field_names = [f.name for f in attrs.fields(shape_class)]
    data = attrs.asdict(shape, recurse=False)
    return data, field_names


def _partition_model_data(
    model_class: Type[Model], data: dict[str, Any], field_names: list[str]
) -> tuple[dict[str, Any], dict[str, Any], list[str]]:
    """
    Split shape data into scalar fields vs many-to-many fields.

    Also validates fields exist on model and rejects reverse/auto-created relations.
    """
    model_meta = model_class._meta
    invalid_fields: list[str] = []
    scalar_data: dict[str, Any] = {}
    m2m_data: dict[str, Any] = {}
    scalar_field_names: list[str] = []

    for field_name in field_names:
        try:
            model_field = model_meta.get_field(field_name)
        except FieldDoesNotExist:
            invalid_fields.append(field_name)
            continue

        is_auto_created_rel = bool(
            getattr(model_field, "auto_created", False)
            and not getattr(model_field, "concrete", False)
        )
        if is_auto_created_rel:
            invalid_fields.append(field_name)
            continue

        if getattr(model_field, "many_to_many", False):
            m2m_data[field_name] = data[field_name]
            continue

        scalar_data[field_name] = data[field_name]
        scalar_field_names.append(field_name)

    if invalid_fields:
        model_name = model_class.__name__
        fields_str = ", ".join(sorted(invalid_fields))
        raise ValueError(
            f"shape has unsupported fields for {model_name}: {fields_str}"
        )

    return scalar_data, m2m_data, scalar_field_names


def _set_many_to_many_fields(instance: Model, m2m_data: dict[str, Any]) -> None:
    """Apply many-to-many values using replacement semantics."""
    for field_name, value in m2m_data.items():
        getattr(instance, field_name).set(value)


def _run_validator(
    operation: WriteOperation,
    model_class: Type[M],
    instance: M | None,
    shape: S,
    scalar_data: dict[str, Any],
    m2m_data: dict[str, Any],
    validator: WriteValidator[M, S] | None,
) -> None:
    """Invoke the optional write validator with operation context."""
    if validator is None:
        return

    validator(
        WriteContext(
            operation=operation,
            model_class=model_class,
            instance=instance,
            shape=shape,
            scalar_data=scalar_data,
            m2m_data=m2m_data,
        )
    )


def create(
    model_class: Type[M],
    shape: S,
    validator: WriteValidator[M, S] | None = None,
) -> M:
    """Create and return a model instance from an attrs shape instance."""
    data, field_names = _shape_to_dict_and_fields(shape)
    scalar_data, m2m_data, _ = _partition_model_data(model_class, data, field_names)
    _run_validator(
        operation="create",
        model_class=model_class,
        instance=None,
        shape=shape,
        scalar_data=scalar_data,
        m2m_data=m2m_data,
        validator=validator,
    )

    with transaction.atomic():
        instance = model_class._default_manager.create(**scalar_data)
        _set_many_to_many_fields(instance, m2m_data)

    return instance


def update(
    instance: M,
    shape: S,
    validator: WriteValidator[M, S] | None = None,
) -> M:
    """
    Update and return an existing model instance from an attrs shape instance.

    Note: update uses the shape's declared fields as update_fields.
    """
    data, field_names = _shape_to_dict_and_fields(shape)
    scalar_data, m2m_data, scalar_field_names = _partition_model_data(
        type(instance), data, field_names
    )
    _run_validator(
        operation="update",
        model_class=type(instance),
        instance=instance,
        shape=shape,
        scalar_data=scalar_data,
        m2m_data=m2m_data,
        validator=validator,
    )

    with transaction.atomic():
        for key, value in scalar_data.items():
            setattr(instance, key, value)

        if scalar_field_names:
            instance.save(update_fields=scalar_field_names)

        _set_many_to_many_fields(instance, m2m_data)

    return instance
