"""DomainModel → Elasticsearch mapping bridge.

Converts a :class:`DomainModel` subclass into an :class:`ESIndexDef` by
inspecting its Pydantic field types and :attr:`_search_include` whitelist.
Nested fields are handled recursively: a ``list[DomainModel]`` field
becomes an ES ``"nested"`` mapping with the child model's properties.

Type mapping
------------

=============== ============ ===========================================
Python type      ES type      Notes
=============== ============ ===========================================
``str``          ``text``     Includes ``.keyword`` multi-field
``int``          ``long``
``float``        ``double``
``bool``         ``boolean``
``datetime``     ``date``     ``format="epoch_millis||strict_date_time"``
``date``         ``date``     ``format="strict_date"`
``UUID``         ``keyword``
``dict``         ``object``   ``enabled=True``
``list[dict]``   ``nested``   No sub-properties (dynamic)
``list[Model]``  ``nested``   Recursive sub-properties from Model
=============== ============ ===========================================

Field annotations for overrides
-------------------------------

Use :class:`ESField` in a ``typing.Annotated`` wrapper to override the
auto-detected mapping::

    from typing import Annotated
    from db_core.schema.es_mapping_gen import ESMappingGenerator, ESField

    class Article(DomainModel):
        _search_include = {"title", "body", "tags"}

        title: Annotated[str, ESField(analyzer="english")]
        body: str
        tags: list[str]  # → keyword array
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any, get_args, get_origin

from db_core.schema.es_builder import ESFieldDef, ESIndexDef

# Sentinel for "no override" — distinct from None (which means "omit field")
_UNSET = object()


@dataclass(frozen=True)
class ESField:
    """Annotation metadata to override auto-detected ES field mapping.

    Place inside ``typing.Annotated`` on a :class:`DomainModel` field::

        class MyModel(DomainModel):
            title: Annotated[str, ESField(analyzer="english")]
            status: Annotated[str, ESField(field_type="keyword")]
            raw: Annotated[dict, ESField(enabled=False)]
    """

    field_type: str | None = None       # Override the ES type
    analyzer: str | None = _UNSET       # analyzer name (None = no analyzer)
    fields: dict | None = _UNSET        # Multi-fields
    format_: str | None = _UNSET        # Date format
    enabled: bool | None = _UNSET       # Object enabled flag
    copy_to: str | None = _UNSET        # Copy-to target
    doc_values: bool | None = _UNSET    # Doc values toggle
    index: bool | None = _UNSET         # Index toggle


# ---------------------------------------------------------------------------
# Python type → ES field type mapping
# ---------------------------------------------------------------------------


def _unwrap_optional(annotation: type) -> type:
    """If *annotation* is ``Optional[X]`` (``X | None``), return ``X``.

    Otherwise returns *annotation* unchanged.
    """
    origin = get_origin(annotation)
    # UnionType (X | Y in Python 3.10+) or typing.Union[X, Y]
    if origin is not None:
        # Check if it looks like a Union (has __args__)
        args = get_args(annotation)
        if args:
            non_none = [a for a in args if a is not type(None)]
            if len(non_none) == 1:
                return non_none[0]
    return annotation


def _get_es_field_type(py_type: type) -> str:
    """Resolve a Python type to its default ES field type string."""
    py_type = _unwrap_optional(py_type)
    origin = get_origin(py_type)
    if origin is not None:
        py_type = origin

    if py_type is int:
        return "long"
    if py_type is float:
        return "double"
    if py_type is bool:
        return "boolean"
    if py_type in (datetime, date):
        return "date"
    if py_type is uuid.UUID:
        return "keyword"
    if py_type is dict:
        return "object"
    if py_type is list:
        return "nested"
    # Catch-all: str, bytes, and anything untyped
    return "text"


def _is_domain_model(annotation: type) -> bool:
    """Return ``True`` if *annotation* is a :class:`DomainModel` subclass."""
    try:
        # Avoid circular import — DomainModel lives in repo_core
        from repo_core.base.models import DomainModel

        annotation = _unwrap_optional(annotation)
        origin = get_origin(annotation)
        if origin is list:
            args = get_args(annotation)
            if args:
                inner = args[0]
                return isinstance(inner, type) and issubclass(inner, DomainModel)
        if isinstance(annotation, type):
            return issubclass(annotation, DomainModel)
    except ImportError:
        pass
    return False


def _resolve_search_include(model_cls: type) -> set[str] | None:
    """Extract ``_search_include`` from a DomainModel class.

    Pydantic v2 stores underscore-prefixed typed class attributes in
    ``__private_attributes__`` instead of ``__dict__``.  We read the
    default value from there directly — this is the documented Pydantic
    mechanism for ``PrivateAttr``.
    """
    private_attrs: dict = getattr(model_cls, "__private_attributes__", {})
    pa = private_attrs.get("_search_include")
    if pa is not None:
        return pa.default

    # Fallback for plain (non-Pydantic) classes
    raw = getattr(model_cls, "_search_include", None)
    if isinstance(raw, set):
        return raw
    return None


def _resolve_date_format(py_type: type) -> str:
    """Return a reasonable ES date format string for the Python type."""
    if py_type is date:
        return "strict_date"
    # datetime → accept epoch_millis OR ISO string
    return "epoch_millis||strict_date_time"


# ---------------------------------------------------------------------------
# Annotation extraction
# ---------------------------------------------------------------------------


def _get_es_override(field_info) -> ESField | None:
    """Extract an :class:`ESField` annotation from a Pydantic field, if any."""
    for item in field_info.metadata:
        if isinstance(item, ESField):
            return item
    return None


# ---------------------------------------------------------------------------
# Generator
# ---------------------------------------------------------------------------


class ESMappingGenerator:
    """Generate :class:`ESIndexDef` from :class:`DomainModel` subclasses.

    All methods are ``@staticmethod`` and return value objects — they
    perform no I/O.

    Usage::

        from db_core.schema.es_mapping_gen import ESMappingGenerator
        from db_core.schema.es_builder import ESMappingBuilder

        index_def = ESMappingGenerator.from_model(
            TelegramMessage, "telegram_messages",
            settings={"number_of_shards": 1},
        )
        body = ESMappingBuilder.build_create_body(index_def)
        await es_client.create_index("telegram_messages", body)
    """

    @staticmethod
    def from_model(
        model_cls: type,
        index_name: str,
        **settings: Any,
    ) -> ESIndexDef:
        """Build an :class:`ESIndexDef` from a DomainModel class.

        Inspects ``model_cls._search_include`` to determine which fields
        to include, and ``model_cls.model_fields`` for type information.
        Nested ``list[DomainModel]`` fields are handled recursively.

        Args:
            model_cls: A :class:`DomainModel` subclass.
            index_name: ES index name (e.g. ``"telegram_messages"``).
            **settings: Kwargs passed as the index settings dict
                (e.g. ``number_of_shards=1``).

        Returns:
            A fully populated :class:`ESIndexDef`.
        """
        search_include = _resolve_search_include(model_cls)
        model_fields = model_cls.model_fields
        properties: list[ESFieldDef] = []

        for field_name, field_info in model_fields.items():
            # Respect _search_include whitelist
            if search_include is not None and field_name not in search_include:
                continue

            field_def = ESMappingGenerator._build_field_def(
                field_name, field_info
            )
            if field_def is not None:
                properties.append(field_def)

        index_settings = settings if settings else None
        return ESIndexDef(
            name=index_name,
            properties=properties,
            settings=index_settings,
        )

    @staticmethod
    def _build_field_def(
        field_name: str,
        field_info,
    ) -> ESFieldDef | None:
        """Build a single :class:`ESFieldDef` from a Pydantic field.

        Handles recursive nested models via :meth:`_build_nested_properties`.
        """
        annotation = field_info.annotation
        override = _get_es_override(field_info)

        # Determine base ES type
        if override is not None and override.field_type is not None:
            es_type = override.field_type
        else:
            es_type = _get_es_field_type(annotation)

        # --- Nested: list[DomainModel] or list[dict] ---
        is_nested_model = _is_domain_model(annotation)
        if is_nested_model:
            inner_annotation = _unwrap_optional(annotation)
            args = get_args(inner_annotation)
            if args:
                child_model = args[0]
                nested_props = ESMappingGenerator._build_nested_properties(child_model)
                return ESFieldDef(
                    name=field_name,
                    field_type="nested",
                    properties=nested_props,
                )
        elif es_type == "nested":
            # list[dict] or similar — dynamic nested, no model to introspect
            return ESFieldDef(name=field_name, field_type="nested")

        # --- Object: dict fields ---
        if es_type == "object":
            kwargs: dict = {}
            kwargs["enabled"] = (
                override.enabled if (override and override.enabled is not _UNSET)
                else True
            )
            return ESFieldDef(name=field_name, field_type="object", **kwargs)

        # --- Text: auto-add .keyword multi-field ---
        if es_type == "text":
            kwargs = ESMappingGenerator._apply_text_defaults(override)
            return ESFieldDef(name=field_name, field_type="text", **kwargs)

        # --- Date: auto-add format ---
        if es_type == "date":
            kwargs = ESMappingGenerator._apply_date_defaults(annotation, override)
            return ESFieldDef(name=field_name, field_type="date", **kwargs)

        # --- Scalar types (keyword, long, double, boolean, etc.) ---
        kwargs = ESMappingGenerator._apply_scalar_overrides(override)
        return ESFieldDef(name=field_name, field_type=es_type, **kwargs)

    @staticmethod
    def _build_nested_properties(model_cls: type) -> list[ESFieldDef]:
        """Recursively build property list from a nested DomainModel."""
        search_include = _resolve_search_include(model_cls)
        model_fields = model_cls.model_fields
        properties: list[ESFieldDef] = []

        for field_name, field_info in model_fields.items():
            if search_include is not None and field_name not in search_include:
                continue
            field_def = ESMappingGenerator._build_field_def(field_name, field_info)
            if field_def is not None:
                properties.append(field_def)

        return properties

    # ------------------------------------------------------------------
    # Default helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _apply_text_defaults(override: ESField | None) -> dict:
        """Build kwargs for a ``text`` field, applying any :class:`ESField` override."""
        kwargs: dict = {
            "fields": {"keyword": {"type": "keyword"}},
        }
        if override is not None:
            if override.fields is not _UNSET:
                kwargs["fields"] = override.fields
            if override.analyzer is not _UNSET:
                if override.analyzer is not None:
                    kwargs["analyzer"] = override.analyzer
                # analyzer=None → don't set analyzer (use ES default)
        return kwargs

    @staticmethod
    def _apply_date_defaults(annotation: type, override: ESField | None) -> dict:
        """Build kwargs for a ``date`` field."""
        kwargs: dict = {}
        fmt = _resolve_date_format(annotation)
        if override is not None and override.format_ is not _UNSET:
            fmt = override.format_  # type: ignore[assignment]
        if fmt is not None:
            kwargs["format_"] = fmt
        return kwargs

    @staticmethod
    def _apply_scalar_overrides(override: ESField | None) -> dict:
        """Apply optional ESField overrides (index, doc_values, copy_to) to kwargs."""
        kwargs: dict = {}
        if override is not None:
            if override.copy_to is not _UNSET:
                kwargs["copy_to"] = override.copy_to
            if override.doc_values is not _UNSET:
                kwargs["doc_values"] = override.doc_values
            if override.index is not _UNSET:
                kwargs["index"] = override.index
        return kwargs
