"""CustomAttribute model and related managers for flexible key-value storage.

CustomAttribute stores values across typed columns (``text_value``, ``int_value``,
``bool_value``, etc.) so that each value type is stored natively in the database.
A virtual ``value`` property and type-aware QuerySets allow transparent read/write
access without knowing which column a value lives in.
"""

import datetime
import decimal
import json
import operator
from functools import reduce
from typing import Any

from django.db import models
from django.db.models import Prefetch, Q, UniqueConstraint

from .base import MAX_FIELD_SIZE, FieldValueTooLarge, Model

VALUE_FIELD_MAP: dict[type, str] = {
    bool: "bool_value",
    str: "text_value",
    int: "int_value",
    float: "decimal_value",
    decimal.Decimal: "decimal_value",
    datetime.datetime: "timestamp_value",
    datetime.date: "date_value",
    dict: "json_value",
    list: "json_value",
}

VALUE_FIELDS = tuple(dict.fromkeys(VALUE_FIELD_MAP.values()))


def _q_contains_value_lookup(q: Q, match_key: str) -> bool:
    """Check if a Q object tree contains any lookup matching match_key."""
    for child in q.children:
        if isinstance(child, Q):
            if _q_contains_value_lookup(child, match_key):
                return True
        elif isinstance(child, tuple) and len(child) == 2:
            key = child[0]
            if key == match_key or key.startswith(f"{match_key}__"):
                return True
    return False


def _rewrite_value_lookups(
    args: tuple[Any, ...],
    kwargs: dict[str, Any],
    match_key: str = "value",
) -> tuple[tuple[Any, ...], dict[str, Any]]:
    """Rewrite ``value`` kwargs to the correct typed column.

    Args:
        args: Positional Q-object arguments from filter()/exclude().
        kwargs: Keyword arguments from filter()/exclude().
        match_key: The kwarg key (or prefix before ``__``) to intercept.
            Defaults to ``"value"`` for direct CustomAttribute queries.
            Use ``"custom_attributes__value"`` for cross-relation queries.

    Returns:
        A (args, kwargs) tuple with value lookups rewritten.

    Raises:
        TypeError: If a Q object in ``args`` contains a ``value`` lookup,
            since those cannot be automatically rewritten.
    """
    for arg in args:
        if isinstance(arg, Q) and _q_contains_value_lookup(arg, match_key):
            raise TypeError(
                f"Q() objects containing '{match_key}' lookups are not supported "
                f"because they cannot be automatically rewritten to typed columns. "
                f"Use keyword arguments instead: .filter({match_key}=...) "
                f"rather than .filter(Q({match_key}=...))."
            )

    new_kwargs: dict[str, Any] = {}
    extra_q: list[Q] = []
    prefix_len = len(match_key)

    for key, val in kwargs.items():
        if key == match_key or key.startswith(f"{match_key}__"):
            suffix = key[prefix_len:]  # "" or "__gte" etc.
            target_prefix = match_key.rsplit("value", 1)[0]  # "" or "custom_attributes__"
            _rewrite_one(suffix, val, target_prefix, new_kwargs, extra_q)
        else:
            new_kwargs[key] = val

    return (*args, *extra_q), new_kwargs


def _rewrite_one(
    suffix: str,
    val: Any,
    target_prefix: str,
    new_kwargs: dict[str, Any],
    extra_q: list[Q],
) -> None:
    """Rewrite a single value lookup into typed column lookup(s)."""
    is_cross_relation = bool(target_prefix)

    # value=None → all columns must be NULL
    if not suffix and val is None:
        if is_cross_relation:
            raise TypeError(
                "Filtering by value=None across a relation is not supported. "
                "Use the specific column name (e.g., custom_attributes__text_value__isnull=True) directly."
            )
        for field in VALUE_FIELDS:
            new_kwargs[f"{field}__isnull"] = True
        return

    # value__isnull
    if suffix == "__isnull":
        if is_cross_relation:
            raise TypeError(
                "Filtering by value__isnull across a relation is not supported. "
                "Use the specific column name (e.g., custom_attributes__text_value__isnull=True) directly."
            )
        if val:
            for field in VALUE_FIELDS:
                new_kwargs[f"{field}__isnull"] = True
        else:
            extra_q.append(
                reduce(
                    operator.or_,
                    (Q(**{f"{f}__isnull": False}) for f in VALUE_FIELDS),
                )
            )
        return

    # value__in → all items must share a single type
    if suffix == "__in":
        types = {type(v) for v in val}
        if len(types) != 1:
            raise TypeError(
                "All values in a 'value__in' lookup must be the same type, got: "
                + ", ".join(t.__name__ for t in types)
            )
        val_type = types.pop()
    else:
        val_type = type(val)

    typed_field = VALUE_FIELD_MAP.get(val_type)
    if typed_field is None:
        raise TypeError(
            f"Cannot filter CustomAttribute.value by type '{val_type.__name__}'. "
            f"Use the specific column name (e.g., json_value) directly."
        )
    new_kwargs[f"{target_prefix}{typed_field}{suffix}"] = val


class CustomAttributeQuerySet(models.QuerySet):
    """QuerySet that rewrites ``value`` lookups to the correct typed column.

    Allows filtering by the virtual ``value`` property using the Python type of
    the right-hand side to select the database column::

        CustomAttribute.objects.filter(value="blue")       # → text_value="blue"
        CustomAttribute.objects.filter(value__gte=5)       # → int_value__gte=5
        CustomAttribute.objects.filter(value=None)         # → all columns NULL
        CustomAttribute.objects.filter(value__isnull=True) # → all columns NULL
    """

    def filter(self, *args: Any, **kwargs: Any) -> "CustomAttributeQuerySet":
        """Filter with automatic ``value`` → typed-column rewriting."""
        args, kwargs = _rewrite_value_lookups(args, kwargs)
        return super().filter(*args, **kwargs)

    def exclude(self, *args: Any, **kwargs: Any) -> "CustomAttributeQuerySet":
        """Exclude with automatic ``value`` → typed-column rewriting."""
        args, kwargs = _rewrite_value_lookups(args, kwargs)
        return super().exclude(*args, **kwargs)


CustomAttributeManager = models.Manager.from_queryset(CustomAttributeQuerySet)


class CustomAttribute(Model):
    """A flexible attribute storage model attached to AttributeHub via FK.

    Supports multiple value types stored in separate columns.
    """

    class Meta:
        db_table = "custom_attribute"

        constraints = [
            models.UniqueConstraint(fields=["hub", "name"], name="unique_custom_attribute")
        ]

    objects = CustomAttributeManager()

    hub = models.ForeignKey(
        "AttributeHub", on_delete=models.CASCADE, related_name="custom_attributes"
    )

    name = models.TextField()

    # Value storage fields - only one should be populated per attribute
    text_value = models.TextField(null=True, blank=True)
    date_value = models.DateField(null=True, blank=True)
    timestamp_value = models.DateTimeField(null=True, blank=True)
    int_value = models.IntegerField(null=True, blank=True)
    decimal_value = models.DecimalField(max_digits=20, decimal_places=10, null=True, blank=True)
    bool_value = models.BooleanField(null=True, blank=True)
    json_value = models.JSONField(null=True, blank=True)

    def __str__(self) -> str:
        """Return a human-readable representation of the attribute."""
        return f"{self.hub} - {self.name}: {self.value}"

    @property
    def value(self) -> Any | None:
        """Return the actual value regardless of which field it's stored in."""
        if self.text_value is not None:
            return self.text_value
        elif self.date_value is not None:
            return self.date_value
        elif self.timestamp_value is not None:
            return self.timestamp_value
        elif self.int_value is not None:
            return self.int_value
        elif self.decimal_value is not None:
            return self.decimal_value
        elif self.bool_value is not None:
            return self.bool_value
        elif self.json_value is not None:
            return self.json_value
        return None

    @value.setter
    def value(self, value: Any) -> None:
        """Set the value in the appropriate field based on type."""
        for field in VALUE_FIELDS:
            setattr(self, field, None)

        if value is None:
            return

        typed_field = VALUE_FIELD_MAP.get(type(value))
        if typed_field is None:
            self.json_value = value
        else:
            setattr(self, typed_field, value)

        if self.text_value is not None and len(self.text_value) > MAX_FIELD_SIZE:
            raise FieldValueTooLarge(
                f"Attribute text value has size {len(self.text_value):,} characters, "
                f"exceeding the {MAX_FIELD_SIZE:,} character limit."
            )
        if self.json_value is not None and len(json.dumps(self.json_value)) > MAX_FIELD_SIZE:
            raise FieldValueTooLarge(
                f"Attribute JSON value has size {len(json.dumps(self.json_value)):,} characters, "
                f"exceeding the {MAX_FIELD_SIZE:,} character limit."
            )


class CustomAttributeAwareQuerySet(models.QuerySet):
    """QuerySet for models with custom attributes.

    Rewrites ``custom_attributes__value`` lookups to the correct typed column
    so that parent models can filter by attribute value across the join::

        AttributeHub.objects.filter(custom_attributes__value="blue")
        StaffProxy.objects.filter(custom_attributes__value__gte=5)
    """

    def filter(self, *args: Any, **kwargs: Any) -> "CustomAttributeAwareQuerySet":
        """Filter with automatic ``custom_attributes__value`` → typed-column rewriting."""
        args, kwargs = _rewrite_value_lookups(args, kwargs, match_key="custom_attributes__value")
        return super().filter(*args, **kwargs)

    def exclude(self, *args: Any, **kwargs: Any) -> "CustomAttributeAwareQuerySet":
        """Exclude with automatic ``custom_attributes__value`` → typed-column rewriting."""
        args, kwargs = _rewrite_value_lookups(args, kwargs, match_key="custom_attributes__value")
        return super().exclude(*args, **kwargs)


class CustomAttributeAwareManager(models.Manager):
    """Manager that automatically prefetches custom attributes and supports
    ``custom_attributes__value`` lookups across the join.
    """

    def get_queryset(self) -> CustomAttributeAwareQuerySet:
        """Prefetch all custom attributes for the queryset."""
        return CustomAttributeAwareQuerySet(self.model, using=self._db).prefetch_related(
            "custom_attributes"
        )

    def with_only(
        self, attribute_names: str | list[str] | None = None
    ) -> CustomAttributeAwareQuerySet:
        """Prefetch only specific custom attributes by name.

        attribute_names may be a single string or list of strings.
        If None, prefetches all attributes.
        """
        if attribute_names is None:
            return self.get_queryset()

        if isinstance(attribute_names, str):
            attribute_names = [attribute_names]

        # Remove only the default "custom_attributes" prefetch, preserving any
        # others the programmer may have added, then add the filtered one.
        qs = self.get_queryset()
        qs._prefetch_related_lookups = tuple(  # type: ignore[attr-defined]
            lookup
            for lookup in qs._prefetch_related_lookups  # type: ignore[attr-defined]
            if (lookup if isinstance(lookup, str) else lookup.prefetch_to) != "custom_attributes"
        )
        return qs.prefetch_related(
            Prefetch(
                "custom_attributes",
                queryset=CustomAttribute.objects.filter(name__in=attribute_names),
            )
        )


class AttributeHub(Model):
    """A standalone hub for custom attributes (key-value storage).

    Plugins use this to store arbitrary key-value data. The reverse relation
    ``custom_attributes`` comes from the FK on CustomAttribute.
    """

    class Meta:
        db_table = "attribute_hub"
        constraints = [UniqueConstraint(fields=["type", "id"], name="attribute_hub_type_id_key")]

    objects = CustomAttributeAwareManager()

    # Only id and type fields - all other data stored as custom attributes
    type = models.CharField(max_length=100)
    id = models.CharField(max_length=100)

    def __str__(self) -> str:
        """Return a human-readable representation of the hub."""
        return f"AttributeHub({self.type}): {self.id}"

    def get_attribute(self, name: str) -> Any:
        """Get a custom attribute value by name."""
        if (
            hasattr(self, "_prefetched_objects_cache")
            and "custom_attributes" in self._prefetched_objects_cache
        ):
            # Use prefetched data first
            for attr in self.custom_attributes.all():
                if attr.name == name:
                    return attr.value
            # Attribute not in prefetch cache — fall back to DB so that
            # with_only() doesn't silently return None for attributes that
            # exist but weren't prefetched.

        # Fall back to database query
        try:
            attr = CustomAttribute.objects.get(hub=self, name=name)
            return attr.value
        except CustomAttribute.DoesNotExist:
            return None

    def set_attribute(self, name: str, value: Any) -> "CustomAttribute":
        """Set a custom attribute value.

        Uses INSERT ... ON CONFLICT DO UPDATE (upsert) for a single SQL
        round-trip instead of get_or_create + save.
        """
        self._check_write_permission()
        attr = CustomAttribute(hub=self, name=name)
        attr.value = value
        return CustomAttribute.objects.bulk_create(
            [attr],
            update_conflicts=True,
            unique_fields=["hub", "name"],
            update_fields=list(VALUE_FIELDS),
        )[0]

    def set_attributes(self, attributes: dict[str, Any]) -> list["CustomAttribute"]:
        """Set multiple custom attributes using bulk operations.

        Uses INSERT ... ON CONFLICT DO UPDATE (upsert) to atomically create or
        update attributes, avoiding race conditions under concurrent writes.
        """
        self._check_write_permission()
        if not attributes:
            return []

        attrs = []
        for attr_name, attr_value in attributes.items():
            attr = CustomAttribute(hub=self, name=attr_name)
            attr.value = attr_value
            attrs.append(attr)

        return CustomAttribute.objects.bulk_create(
            attrs,
            update_conflicts=True,
            unique_fields=["hub", "name"],
            update_fields=list(VALUE_FIELDS),
        )

    def delete_attribute(self, name: str) -> bool:
        """Delete a custom attribute by name. Returns True if deleted, False if not found."""
        self._check_write_permission()
        try:
            CustomAttribute.objects.get(hub=self, name=name).delete()
            return True
        except CustomAttribute.DoesNotExist:
            return False


__exports__ = (
    "CustomAttribute",
    "CustomAttributeQuerySet",
    "CustomAttributeAwareQuerySet",
    "CustomAttributeAwareManager",
    "AttributeHub",
    "VALUE_FIELD_MAP",
    "VALUE_FIELDS",
)
