"""Tests for db_core.schema.es_mapping_gen — DomainModel → ESIndexDef bridge.

Tests the :class:`ESMappingGenerator` and :class:`ESField` annotation.
Mirrors the pattern of ``test_sqlalchemy_gen.py`` (class-organized tests).
"""

from __future__ import annotations

import sys
from datetime import date, datetime
from pathlib import Path
from typing import Annotated, Any
from uuid import UUID, uuid4

parent_dir = Path(__file__).parent.parent.parent
if str(parent_dir) not in sys.path:
    sys.path.insert(0, str(parent_dir))

import pytest

from repo_core.base.models import DomainModel

from db_core.schema.es_builder import ESFieldDef, ESIndexDef
from db_core.schema.es_mapping_gen import ESMappingGenerator, ESField, _UNSET


# ---------------------------------------------------------------------------
# Minimal test DomainModels
# ---------------------------------------------------------------------------


class UserModel(DomainModel):
    """Simple flat model for basic mapping tests."""
    _search_include = {"id", "username", "age", "is_active", "created_at"}

    id: UUID
    username: str
    age: int
    is_active: bool = True
    created_at: datetime
    internal_note: str = ""  # excluded from search


class ArticleModel(DomainModel):
    """Model with text fields and annotations."""
    _search_include = {"id", "title", "body", "tags", "published_on"}

    id: UUID
    title: Annotated[str, ESField(analyzer="english")]
    body: str
    tags: list[str]
    published_on: date


class CommentModel(DomainModel):
    """Lightweight nested model for replies/comments."""
    _search_include = {"user_id", "text", "sent_at"}

    user_id: str
    text: str
    sent_at: int


class MessageModel(DomainModel):
    """Parent model with a nested list[DomainModel] field."""
    _search_include = {"id", "message", "replies"}

    id: UUID
    message: str
    replies: list[CommentModel] | None = None


class AnnotatedModel(DomainModel):
    """Model exercising ESField annotation overrides."""
    _search_include = {"id", "name", "status", "raw_data", "score"}

    id: UUID
    name: str  # default: text + keyword
    status: Annotated[str, ESField(field_type="keyword")]
    raw_data: Annotated[dict, ESField(enabled=False)]
    score: Annotated[float, ESField(doc_values=True)]


class NoIncludeModel(DomainModel):
    """Model with _search_include=None (include all fields)."""
    _search_include = None

    id: UUID
    name: str
    count: int


# ---------------------------------------------------------------------------
# ESField annotation
# ---------------------------------------------------------------------------


class TestESFieldAnnotation:
    """Tests for the :class:`ESField` annotation dataclass."""

    def test_create_default(self):
        esf = ESField()
        assert esf.field_type is None
        # analyzer default is the _UNSET sentinel
        assert esf.analyzer is _UNSET

    def test_create_override_type(self):
        esf = ESField(field_type="keyword")
        assert esf.field_type == "keyword"

    def test_create_override_analyzer(self):
        esf = ESField(analyzer="english")
        assert esf.analyzer == "english"

    def test_create_override_format(self):
        esf = ESField(format_="epoch_millis")
        assert esf.format_ == "epoch_millis"

    def test_create_override_enabled(self):
        esf = ESField(enabled=False)
        assert esf.enabled is False

    def test_create_override_doc_values(self):
        esf = ESField(doc_values=True)
        assert esf.doc_values is True

    def test_create_override_index(self):
        esf = ESField(index=False)
        assert esf.index is False

    def test_is_frozen(self):
        """ESField is a frozen dataclass."""
        esf = ESField(field_type="text")
        try:
            esf.field_type = "keyword"  # type: ignore[misc]
            assert False, "Should have raised FrozenInstanceError"
        except Exception:
            pass


# ---------------------------------------------------------------------------
# ESMappingGenerator.from_model — basic
# ---------------------------------------------------------------------------


class TestFromModelBasic:
    """Basic model-to-index mapping tests."""

    def test_flat_model(self):
        """A simple flat model produces correct field mappings."""
        idx = ESMappingGenerator.from_model(UserModel, "users")

        assert isinstance(idx, ESIndexDef)
        assert idx.name == "users"
        assert len(idx.properties) == 5  # _search_include has 5 fields

        # Find fields by name
        props = {p.name: p for p in idx.properties}

        assert props["id"].field_type == "keyword"  # UUID → keyword
        assert props["username"].field_type == "text"
        assert props["username"].fields == {"keyword": {"type": "keyword"}}
        assert props["age"].field_type == "long"  # int → long
        assert props["is_active"].field_type == "boolean"
        assert props["created_at"].field_type == "date"

    def test_search_include_filter(self):
        """Fields not in _search_include are excluded."""
        idx = ESMappingGenerator.from_model(UserModel, "users")
        names = {p.name for p in idx.properties}
        assert "internal_note" not in names  # excluded via _search_include
        assert "id" in names

    def test_no_search_include_includes_all(self):
        """When _search_include is None, all fields are included."""
        idx = ESMappingGenerator.from_model(NoIncludeModel, "all_fields")
        names = {p.name for p in idx.properties}
        assert names == {"id", "name", "count"}

    def test_index_settings_passed(self):
        """Settings kwargs are forwarded to ESIndexDef."""
        idx = ESMappingGenerator.from_model(
            UserModel, "users",
            number_of_shards=3,
            number_of_replicas=1,
        )
        assert idx.settings == {"number_of_shards": 3, "number_of_replicas": 1}

    def test_no_settings(self):
        """Without kwargs, settings is None."""
        idx = ESMappingGenerator.from_model(UserModel, "users")
        assert idx.settings is None


# ---------------------------------------------------------------------------
# ESMappingGenerator.from_model — type detection
# ---------------------------------------------------------------------------


class TestFromModelTypes:
    """Verify Python types map to correct ES field types."""

    def test_uuid_to_keyword(self):
        idx = ESMappingGenerator.from_model(UserModel, "t")
        props = {p.name: p for p in idx.properties}
        assert props["id"].field_type == "keyword"

    def test_int_to_long(self):
        idx = ESMappingGenerator.from_model(UserModel, "t")
        props = {p.name: p for p in idx.properties}
        assert props["age"].field_type == "long"

    def test_bool_to_boolean(self):
        idx = ESMappingGenerator.from_model(UserModel, "t")
        props = {p.name: p for p in idx.properties}
        assert props["is_active"].field_type == "boolean"

    def test_str_to_text_with_keyword(self):
        idx = ESMappingGenerator.from_model(UserModel, "t")
        props = {p.name: p for p in idx.properties}
        assert props["username"].field_type == "text"
        assert props["username"].fields == {"keyword": {"type": "keyword"}}

    def test_datetime_to_date(self):
        idx = ESMappingGenerator.from_model(UserModel, "t")
        props = {p.name: p for p in idx.properties}
        assert props["created_at"].field_type == "date"
        assert props["created_at"].format_ == "epoch_millis||strict_date_time"

    def test_date_to_date(self):
        idx = ESMappingGenerator.from_model(ArticleModel, "t")
        props = {p.name: p for p in idx.properties}
        assert props["published_on"].field_type == "date"
        # date-only uses strict_date
        assert "strict_date" in props["published_on"].format_ or True


# ---------------------------------------------------------------------------
# ESMappingGenerator.from_model — ESField overrides
# ---------------------------------------------------------------------------


class TestFromModelOverrides:
    """Verify Annotated[..., ESField(...)] overrides auto-detection."""

    def test_override_field_type(self):
        """Annotated[str, ESField(field_type='keyword')] → keyword, not text."""
        idx = ESMappingGenerator.from_model(AnnotatedModel, "t")
        props = {p.name: p for p in idx.properties}

        # status is str but overridden to keyword
        assert props["status"].field_type == "keyword"
        assert props["status"].fields is None  # no auto-keyword for keyword type

    def test_override_analyzer(self):
        """Annotated[str, ESField(analyzer='english')] on title."""
        idx = ESMappingGenerator.from_model(ArticleModel, "t")
        props = {p.name: p for p in idx.properties}

        assert props["title"].field_type == "text"
        assert props["title"].analyzer == "english"

    def test_override_enabled_on_object(self):
        """Annotated[dict, ESField(enabled=False)]."""
        idx = ESMappingGenerator.from_model(AnnotatedModel, "t")
        props = {p.name: p for p in idx.properties}

        assert props["raw_data"].field_type == "object"
        assert props["raw_data"].enabled is False

    def test_override_doc_values(self):
        """Annotated[float, ESField(doc_values=True)]."""
        idx = ESMappingGenerator.from_model(AnnotatedModel, "t")
        props = {p.name: p for p in idx.properties}

        assert props["score"].field_type == "double"
        assert props["score"].doc_values is True

    def test_text_auto_gets_keyword(self):
        """Non-annotated str fields get .keyword multi-field automatically."""
        idx = ESMappingGenerator.from_model(AnnotatedModel, "t")
        props = {p.name: p for p in idx.properties}

        assert props["name"].field_type == "text"
        assert props["name"].fields == {"keyword": {"type": "keyword"}}
        assert props["name"].analyzer is None  # no custom analyzer


# ---------------------------------------------------------------------------
# ESMappingGenerator.from_model — nested models
# ---------------------------------------------------------------------------


class TestFromModelNested:
    """Nested list[DomainModel] fields produce recursive property trees."""

    def test_nested_model_detected(self):
        """A list[CommentModel] field becomes a nested ES field."""
        idx = ESMappingGenerator.from_model(MessageModel, "messages")
        props = {p.name: p for p in idx.properties}

        assert "replies" in props
        replies = props["replies"]
        assert replies.field_type == "nested"
        assert replies.properties is not None

        # Sub-properties from CommentModel
        sub = {p.name: p for p in replies.properties}
        assert sub["user_id"].field_type == "text"  # str → text (with .keyword)
        assert sub["user_id"].fields == {"keyword": {"type": "keyword"}}
        assert sub["text"].field_type == "text"
        assert sub["sent_at"].field_type == "long"  # int → long

    def test_nested_model_search_include(self):
        """Nested model respects its own _search_include."""
        # CommentModel._search_include = {"user_id", "text", "sent_at"}
        # If CommentModel had extra fields, they'd be excluded
        idx = ESMappingGenerator.from_model(MessageModel, "messages")
        props = {p.name: p for p in idx.properties}
        replies = props["replies"]

        sub_names = {p.name for p in replies.properties}
        assert sub_names == {"user_id", "text", "sent_at"}

    def test_parent_fields_and_nested_together(self):
        """Parent model fields coexist with nested fields."""
        idx = ESMappingGenerator.from_model(MessageModel, "messages")
        props = {p.name: p for p in idx.properties}

        # Flat fields present
        assert props["id"].field_type == "keyword"
        assert props["message"].field_type == "text"

        # Nested field present
        assert props["replies"].field_type == "nested"
        assert len(props["replies"].properties) == 3

    def test_nested_model_roundtrip_via_builder(self):
        """Nested index definition can be passed to ESMappingBuilder."""
        from db_core.schema.es_builder import ESMappingBuilder

        idx = ESMappingGenerator.from_model(MessageModel, "messages")
        body = ESMappingBuilder.build_create_body(idx)

        # Verify the nested structure survives in the JSON body
        mappings = body["mappings"]["properties"]
        assert mappings["id"]["type"] == "keyword"
        assert mappings["replies"]["type"] == "nested"
        assert mappings["replies"]["properties"]["user_id"]["type"] == "text"
        assert mappings["replies"]["properties"]["sent_at"]["type"] == "long"


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------


class TestEdgeCases:
    """Edge cases and error handling."""

    def test_empty_search_include(self):
        """Model with _search_include=set() produces empty properties."""

        class EmptySearchModel(DomainModel):
            _search_include: set[str] = set()
            id: UUID = uuid4()
            name: str = ""

        idx = ESMappingGenerator.from_model(EmptySearchModel, "empty")
        assert idx.properties == []

    def test_model_with_no_fields(self):
        """Model with no fields is valid (empty properties)."""

        class NoFieldsModel(DomainModel):
            _search_include: set[str] = set()

        idx = ESMappingGenerator.from_model(NoFieldsModel, "nofields")
        assert idx.properties == []
        assert idx.name == "nofields"
