"""
Unit tests for entity labels models and helpers.

Tests label parsing, enum building, prompt generation, lookup building,
entity post-processing, and embedding augmentation.

Also includes LLM integration tests (require DB + LLM) that call retain
and verify label entities are extracted and stored correctly.
"""

import uuid
from unittest.mock import MagicMock

import pytest

from hindsight_api.engine.retain.entity_labels import (
    EntityLabelsConfig,
    LabelGroup,
    LabelValue,
    build_labels_lookup,
    parse_entity_labels,
)

# ─── parse_entity_labels ───────────────────────────────────────────────────────


def test_parse_entity_labels_none():
    result = parse_entity_labels(None)
    assert result is None


def test_parse_entity_labels_empty_list():
    result = parse_entity_labels([])
    assert result is None


def test_parse_entity_labels_list_format():
    """Legacy list format: just a list of attribute dicts (using new type field)."""
    raw = [
        {
            "key": "pedagogy",
            "description": "Teaching strategy",
            "type": "multi-values",
            "values": [
                {"value": "scaffolding", "description": "Break down tasks"},
                {"value": "active_engagement", "description": "Group work"},
            ],
        }
    ]
    result = parse_entity_labels(raw)
    assert result is not None
    assert isinstance(result, EntityLabelsConfig)
    assert len(result.attributes) == 1
    attr = result.attributes[0]
    assert attr.key == "pedagogy"
    assert attr.type == "multi-values"
    assert len(attr.values) == 2
    assert attr.values[0].value == "scaffolding"


def test_parse_entity_labels_dict_format():
    """New dict format (free_form_entities is now a separate config field, not in EntityLabelsConfig)."""
    raw = {
        "attributes": [
            {
                "key": "interest",
                "description": "User interest area",
                "values": [{"value": "active", "description": "Active hobbies"}],
            }
        ],
    }
    result = parse_entity_labels(raw)
    assert result is not None
    assert len(result.attributes) == 1
    assert result.attributes[0].key == "interest"


def test_parse_entity_labels_dict_format_defaults():
    """Dict format parses attributes correctly."""
    raw = {
        "attributes": [
            {"key": "topic", "values": [{"value": "math", "description": "Mathematics"}]}
        ]
    }
    result = parse_entity_labels(raw)
    assert result is not None
    assert len(result.attributes) == 1


# ─── build_labels_model ────────────────────────────────────────────────────────

# free_values schema variants


def test_build_labels_model_single_value():
    """Single-value group → Literal | None field (anyOf), defaults to None."""
    from hindsight_api.engine.retain.entity_labels import build_labels_model

    labels_cfg = EntityLabelsConfig(
        attributes=[
            LabelGroup(
                key="engagement",
                values=[LabelValue(value="active"), LabelValue(value="passive")],
            )
        ]
    )
    Model = build_labels_model(labels_cfg)
    assert Model is not None

    schema = Model.model_json_schema()
    props = schema["properties"]
    assert "engagement" in props
    # Single-value: Pydantic emits anyOf[{enum: [...]}, {type: null}]
    any_of = props["engagement"]["anyOf"]
    enum_values = next(branch["enum"] for branch in any_of if "enum" in branch)
    assert set(enum_values) == {"active", "passive"}

    # Defaults to None when omitted
    instance = Model()
    assert instance.engagement is None  # type: ignore[attr-defined]


def test_build_labels_model_multi_value():
    """Multi-value group → list[Literal] field, defaults to empty list."""
    from hindsight_api.engine.retain.entity_labels import build_labels_model

    labels_cfg = EntityLabelsConfig(
        attributes=[
            LabelGroup(
                key="pedagogy",
                type="multi-values",
                values=[LabelValue(value="scaffolding"), LabelValue(value="active_engagement")],
            )
        ]
    )
    Model = build_labels_model(labels_cfg)
    assert Model is not None

    schema = Model.model_json_schema()
    props = schema["properties"]
    assert "pedagogy" in props
    assert props["pedagogy"]["type"] == "array"
    assert set(props["pedagogy"]["items"]["enum"]) == {"scaffolding", "active_engagement"}

    instance = Model()
    assert instance.pedagogy == []  # type: ignore[attr-defined]


def test_build_labels_model_mixed():
    """Mixed single + multi-value groups both present."""
    from hindsight_api.engine.retain.entity_labels import build_labels_model

    labels_cfg = EntityLabelsConfig(
        attributes=[
            LabelGroup(key="engagement", values=[LabelValue(value="active")]),
            LabelGroup(key="pedagogy", type="multi-values", values=[LabelValue(value="scaffolding")]),
        ]
    )
    Model = build_labels_model(labels_cfg)
    assert Model is not None
    schema = Model.model_json_schema()
    assert "engagement" in schema["properties"]
    assert "pedagogy" in schema["properties"]


def test_build_labels_model_none_when_no_values():
    """Returns None when no groups have values."""
    from hindsight_api.engine.retain.entity_labels import build_labels_model

    labels_cfg = EntityLabelsConfig(attributes=[LabelGroup(key="empty", values=[])])
    assert build_labels_model(labels_cfg) is None


def test_build_labels_model_free_values_optional():
    """type='text', optional=True → str | None field."""
    from hindsight_api.engine.retain.entity_labels import build_labels_model

    labels_cfg = EntityLabelsConfig(
        attributes=[LabelGroup(key="topic", type="text", optional=True, values=[])]
    )
    Model = build_labels_model(labels_cfg)
    assert Model is not None
    schema = Model.model_json_schema()
    topic = schema["properties"]["topic"]
    any_of_types = {branch.get("type") for branch in topic["anyOf"]}
    assert "string" in any_of_types and "null" in any_of_types
    assert Model().topic is None  # type: ignore[attr-defined]


def test_build_labels_model_free_values_always_optional():
    """type='text' with optional=False is still treated as str | None — always optional."""
    from hindsight_api.engine.retain.entity_labels import build_labels_model

    labels_cfg = EntityLabelsConfig(
        attributes=[LabelGroup(key="topic", type="text", optional=False, values=[])]
    )
    Model = build_labels_model(labels_cfg)
    assert Model is not None
    schema = Model.model_json_schema()
    # free_values groups are always optional (str | None), never in required
    assert "topic" not in schema.get("required", [])
    anyOf = schema["properties"]["topic"].get("anyOf", [])
    assert any(b.get("type") == "string" for b in anyOf)


def test_build_labels_model_free_values_multi_still_optional():
    """type='text' is always str | None — multi-values only applies to enum types."""
    from hindsight_api.engine.retain.entity_labels import build_labels_model

    labels_cfg = EntityLabelsConfig(
        attributes=[LabelGroup(key="tags", type="text", values=[])]
    )
    Model = build_labels_model(labels_cfg)
    assert Model is not None
    schema = Model.model_json_schema()
    # free_values groups are always str | None regardless of multi_value
    assert "tags" not in schema.get("required", [])
    anyOf = schema["properties"]["tags"].get("anyOf", [])
    assert any(b.get("type") == "string" for b in anyOf)


def test_build_labels_model_free_values_no_values_still_creates_field():
    """type='text' group with no values still creates a field (description holds examples)."""
    from hindsight_api.engine.retain.entity_labels import build_labels_model

    labels_cfg = EntityLabelsConfig(
        attributes=[LabelGroup(key="mood", type="text", values=[])]
    )
    Model = build_labels_model(labels_cfg)
    assert Model is not None
    assert "mood" in Model.model_json_schema()["properties"]


# ─── is_label_entity ──────────────────────────────────────────────────────────


def test_is_label_entity_enum_match():
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, is_label_entity, parse_entity_labels

    cfg = parse_entity_labels([{"key": "engagement", "values": [{"value": "active"}]}])
    lookup = build_labels_lookup(cfg)
    assert is_label_entity("engagement:active", cfg, lookup) is True


def test_is_label_entity_enum_no_match():
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, is_label_entity, parse_entity_labels

    cfg = parse_entity_labels([{"key": "engagement", "values": [{"value": "active"}]}])
    lookup = build_labels_lookup(cfg)
    assert is_label_entity("engagement:unknown", cfg, lookup) is False
    assert is_label_entity("Alice", cfg, lookup) is False


def test_is_label_entity_free_values_prefix_match():
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, is_label_entity, parse_entity_labels

    cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": []}])
    lookup = build_labels_lookup(cfg)
    assert is_label_entity("topic:algebra", cfg, lookup) is True
    assert is_label_entity("topic:anything at all", cfg, lookup) is True


def test_is_label_entity_free_values_no_match_other_key():
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, is_label_entity, parse_entity_labels

    cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": []}])
    lookup = build_labels_lookup(cfg)
    assert is_label_entity("Alice", cfg, lookup) is False
    assert is_label_entity("engagement:active", cfg, lookup) is False


# ─── build_labels_lookup ───────────────────────────────────────────────────────


def test_build_labels_lookup():
    labels_cfg = EntityLabelsConfig(
        attributes=[
            LabelGroup(
                key="Pedagogy",
                values=[
                    LabelValue(value="Scaffolding"),
                    LabelValue(value="Active_Engagement"),
                ],
            )
        ]
    )
    lookup = build_labels_lookup(labels_cfg)
    assert "pedagogy:scaffolding" in lookup
    assert "pedagogy:active_engagement" in lookup
    # Should be lowercase
    assert all(v == v.lower() for v in lookup)


def test_build_labels_lookup_raw_list():
    """build_labels_lookup accepts raw list format for backwards compatibility."""
    raw = [
        {
            "key": "interest",
            "values": [{"value": "active", "description": "Active interest"}],
        }
    ]
    lookup = build_labels_lookup(raw)
    assert "interest:active" in lookup


def test_build_labels_lookup_none():
    lookup = build_labels_lookup(None)
    assert lookup == set()


# ─── _build_labels_prompt_section ─────────────────────────────────────────────


def test_build_labels_prompt_section_none():
    from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section

    result = _build_labels_prompt_section(None)
    assert result == ""


def test_build_labels_prompt_section_empty_config():
    from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section

    result = _build_labels_prompt_section(EntityLabelsConfig(attributes=[]))
    assert result == ""


def test_build_labels_prompt_section_generates_key_values():
    from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section

    labels_cfg = EntityLabelsConfig(
        attributes=[
            LabelGroup(
                key="pedagogy",
                description="Teaching strategy",
                type="multi-values",
                values=[
                    LabelValue(value="scaffolding", description="Break down tasks"),
                    LabelValue(value="active_engagement", description="Group work"),
                ],
            )
        ]
    )
    result = _build_labels_prompt_section(labels_cfg)
    # Structured format: values listed as "value" bullets under the key name
    assert "scaffolding" in result
    assert "active_engagement" in result
    assert "pedagogy" in result
    assert "Teaching strategy" in result
    assert "multi" in result  # prompt mentions multi-value nature


def test_build_labels_prompt_section_free_form_true():
    from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section

    labels_cfg = EntityLabelsConfig(
        attributes=[
            LabelGroup(
                key="topic",
                values=[LabelValue(value="math")],
            )
        ],
    )
    result = _build_labels_prompt_section(labels_cfg, free_form_entities=True)
    # When free_form_entities=True: prompt says to also fill 'entities' field
    assert "labels" in result
    assert "entities" in result


def test_build_labels_prompt_section_free_form_false():
    from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section

    labels_cfg = EntityLabelsConfig(
        attributes=[
            LabelGroup(
                key="topic",
                values=[LabelValue(value="math")],
            )
        ],
    )
    result = _build_labels_prompt_section(labels_cfg, free_form_entities=False)
    assert "labels-only mode" in result


# ─── augment_texts_with_entities ──────────────────────────────────────────────


def test_augment_texts_with_entities():
    """Entity names appear in embedding input but fact_text is unchanged."""
    from datetime import UTC, datetime

    from hindsight_api.engine.retain.embedding_processing import augment_texts_with_dates
    from hindsight_api.engine.retain.types import ExtractedFact

    event_date = datetime(2024, 6, 1, tzinfo=UTC)
    fact = ExtractedFact(
        fact_text="User attended workshop",
        fact_type="world",
        entities=["pedagogy:scaffolding", "user"],
        mentioned_at=event_date,
    )

    def fmt_date(dt):
        return "June 2024"

    augmented = augment_texts_with_dates([fact], fmt_date)
    assert len(augmented) == 1
    # Entity names should appear in augmented text
    assert "pedagogy:scaffolding" in augmented[0]
    assert "user" in augmented[0]
    # Original fact text should be present
    assert "User attended workshop" in augmented[0]


# ─── _inject_label_tags ───────────────────────────────────────────────────────


def test_inject_label_tags_adds_tagged_entities():
    """tag=True group: extracted label entities are added to fact.tags."""
    from unittest.mock import MagicMock

    from hindsight_api.engine.retain.fact_extraction import _inject_label_tags
    from hindsight_api.engine.retain.types import ExtractedFact

    config = MagicMock()
    config.entity_labels = [
        {"key": "pedagogy", "type": "value", "tag": True, "values": [{"value": "scaffolding"}]},
        {"key": "engagement", "type": "value", "tag": False, "values": [{"value": "active"}]},
    ]

    fact = ExtractedFact(
        fact_text="Teacher used scaffolding",
        fact_type="world",
        entities=["pedagogy:scaffolding", "engagement:active"],
        tags=["session-1"],
    )
    _inject_label_tags([fact], config)

    # pedagogy group has tag=True → added to tags
    assert "pedagogy:scaffolding" in fact.tags
    # engagement group has tag=False → NOT added
    assert "engagement:active" not in fact.tags
    # original tag preserved
    assert "session-1" in fact.tags


def test_inject_label_tags_no_duplicate():
    """No duplicate if label entity already in tags."""
    from unittest.mock import MagicMock

    from hindsight_api.engine.retain.fact_extraction import _inject_label_tags
    from hindsight_api.engine.retain.types import ExtractedFact

    config = MagicMock()
    config.entity_labels = [
        {"key": "pedagogy", "type": "value", "tag": True, "values": [{"value": "scaffolding"}]},
    ]

    fact = ExtractedFact(
        fact_text="...",
        fact_type="world",
        entities=["pedagogy:scaffolding"],
        tags=["pedagogy:scaffolding"],
    )
    _inject_label_tags([fact], config)
    assert fact.tags.count("pedagogy:scaffolding") == 1


def test_inject_label_tags_no_tag_groups_is_noop():
    """When no groups have tag=True, tags are unchanged."""
    from unittest.mock import MagicMock

    from hindsight_api.engine.retain.fact_extraction import _inject_label_tags
    from hindsight_api.engine.retain.types import ExtractedFact

    config = MagicMock()
    config.entity_labels = [
        {"key": "pedagogy", "type": "value", "tag": False, "values": [{"value": "scaffolding"}]},
    ]

    fact = ExtractedFact(fact_text="...", fact_type="world", entities=["pedagogy:scaffolding"])
    _inject_label_tags([fact], config)
    assert fact.tags == []


def test_inject_label_tags_no_labels_config_is_noop():
    """When entity_labels is None, tags are unchanged."""
    from unittest.mock import MagicMock

    from hindsight_api.engine.retain.fact_extraction import _inject_label_tags
    from hindsight_api.engine.retain.types import ExtractedFact

    config = MagicMock()
    config.entity_labels = None

    fact = ExtractedFact(fact_text="...", fact_type="world", entities=["pedagogy:scaffolding"])
    _inject_label_tags([fact], config)
    assert fact.tags == []


# ─── entity label post-processing ─────────────────────────────────────────────


def test_label_entity_post_processing():
    """Structured labels dict is parsed into key:value entity strings; invalid values filtered."""
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
    from hindsight_api.engine.retain.fact_extraction import Entity

    labels_cfg = parse_entity_labels(
        [
            {
                "key": "pedagogy",
                "values": [
                    {"value": "scaffolding", "description": ""},
                    {"value": "active_engagement", "description": ""},
                ],
            }
        ]
    )
    assert labels_cfg is not None
    labels_lookup = build_labels_lookup(labels_cfg)

    # Simulated LLM response — structured dict, not a flat list
    labels_data = {"pedagogy": "scaffolding"}  # single-value field

    validated_entities: list[Entity] = []
    if isinstance(labels_data, dict) and labels_lookup:
        existing_texts_lower: set[str] = set()
        for group in labels_cfg.attributes:
            value = labels_data.get(group.key)
            if not value:
                continue
            values_list = value if isinstance(value, list) else [value]
            for v in values_list:
                label_str = f"{group.key}:{v}"
                if label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower:
                    validated_entities.append(Entity(text=label_str))
                    existing_texts_lower.add(label_str.lower())

    entity_texts = {e.text for e in validated_entities}
    assert "pedagogy:scaffolding" in entity_texts


def test_label_entity_post_processing_invalid_value_ignored():
    """Values not in the lookup are silently dropped."""
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
    from hindsight_api.engine.retain.fact_extraction import Entity

    labels_cfg = parse_entity_labels(
        [{"key": "pedagogy", "values": [{"value": "scaffolding", "description": ""}]}]
    )
    labels_lookup = build_labels_lookup(labels_cfg)

    labels_data = {"pedagogy": "unknown_value"}

    validated_entities: list[Entity] = []
    existing_texts_lower: set[str] = set()
    for group in labels_cfg.attributes:
        value = labels_data.get(group.key)
        if not value:
            continue
        values_list = value if isinstance(value, list) else [value]
        for v in values_list:
            label_str = f"{group.key}:{v}"
            if label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower:
                validated_entities.append(Entity(text=label_str))

    assert validated_entities == []


def test_label_entity_post_processing_multi_value():
    """Multi-value list field produces one entity per value."""
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
    from hindsight_api.engine.retain.fact_extraction import Entity

    labels_cfg = parse_entity_labels(
        [
            {
                "key": "pedagogy",
                "multi_value": True,
                "values": [
                    {"value": "scaffolding", "description": ""},
                    {"value": "active_engagement", "description": ""},
                ],
            }
        ]
    )
    labels_lookup = build_labels_lookup(labels_cfg)

    # Multi-value: LLM returns a list
    labels_data = {"pedagogy": ["scaffolding", "active_engagement"]}

    validated_entities: list[Entity] = []
    existing_texts_lower: set[str] = set()
    for group in labels_cfg.attributes:
        value = labels_data.get(group.key)
        if not value:
            continue
        values_list = value if isinstance(value, list) else [value]
        for v in values_list:
            label_str = f"{group.key}:{v}"
            if label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower:
                validated_entities.append(Entity(text=label_str))
                existing_texts_lower.add(label_str.lower())

    entity_texts = {e.text for e in validated_entities}
    assert "pedagogy:scaffolding" in entity_texts
    assert "pedagogy:active_engagement" in entity_texts


def _run_label_post_processing(labels_cfg, labels_data: dict) -> set[str]:
    """Helper: mirrors the production label post-processing logic, returns entity text set."""
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup
    from hindsight_api.engine.retain.fact_extraction import Entity

    labels_lookup = build_labels_lookup(labels_cfg)
    validated_entities: list[Entity] = []
    existing_texts_lower: set[str] = set()

    effective_data = labels_data or {}
    if isinstance(effective_data, dict):
        for group in labels_cfg.attributes:
            value = effective_data.get(group.key)
            if not value:
                continue
            values_list = value if isinstance(value, list) else [value]
            for v in values_list:
                if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
                    continue
                label_str = f"{group.key}:{v.strip()}"
                if group.type == "text":
                    if label_str.lower() not in existing_texts_lower:
                        validated_entities.append(Entity(text=label_str))
                        existing_texts_lower.add(label_str.lower())
                elif label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower:
                    validated_entities.append(Entity(text=label_str))
                    existing_texts_lower.add(label_str.lower())

    return {e.text for e in validated_entities}


def test_free_values_label_accepts_any_string():
    """type='text' group: any non-empty string produces a key:value entity."""
    from hindsight_api.engine.retain.entity_labels import parse_entity_labels

    labels_cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": []}])
    entity_texts = _run_label_post_processing(labels_cfg, {"topic": "quadratic equations"})
    assert "topic:quadratic equations" in entity_texts


def test_free_values_label_rejects_none_sentinel():
    """type='text' group: string 'None' / 'null' / 'n/a' are rejected."""
    from hindsight_api.engine.retain.entity_labels import parse_entity_labels

    labels_cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": []}])
    for sentinel in ("None", "null", "n/a", "NULL", "NONE"):
        result = _run_label_post_processing(labels_cfg, {"topic": sentinel})
        assert result == set(), f"Sentinel '{sentinel}' should not produce an entity, got: {result}"


def test_free_values_label_is_single_value():
    """type='text' groups are always single-value (str | None)."""
    from hindsight_api.engine.retain.entity_labels import build_labels_model, parse_entity_labels

    labels_cfg = parse_entity_labels(
        [{"key": "topic", "type": "text", "values": []}]
    )
    Model = build_labels_model(labels_cfg)
    assert Model is not None
    schema = Model.model_json_schema()
    # Must be str | None, not list
    assert schema["properties"]["topic"].get("type") != "array"
    anyOf = schema["properties"]["topic"].get("anyOf", [])
    assert any(b.get("type") == "string" for b in anyOf)


def test_free_values_label_not_in_lookup():
    """type='text' group values do NOT appear in the lookup set (no fixed vocabulary)."""
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels

    labels_cfg = parse_entity_labels(
        [{"key": "topic", "type": "text", "values": [{"value": "algebra"}]}]
    )
    lookup = build_labels_lookup(labels_cfg)
    assert "topic:algebra" not in lookup  # example hints not added to lookup
    assert len(lookup) == 0


def test_optional_label_null_produces_no_entity():
    """JSON null (Python None) for an optional label → no entity created."""
    from hindsight_api.engine.retain.entity_labels import parse_entity_labels

    labels_cfg = parse_entity_labels(
        [{"key": "engagement", "optional": True, "values": [{"value": "active"}, {"value": "passive"}]}]
    )

    # LLM returned null — content didn't match any value
    entity_texts = _run_label_post_processing(labels_cfg, {"engagement": None})
    assert entity_texts == set(), f"Expected no entities for null optional label, got: {entity_texts}"


def test_optional_label_absent_key_produces_no_entity():
    """Missing key in labels dict for an optional label → no entity created."""
    from hindsight_api.engine.retain.entity_labels import parse_entity_labels

    labels_cfg = parse_entity_labels(
        [{"key": "engagement", "optional": True, "values": [{"value": "active"}, {"value": "passive"}]}]
    )

    # LLM omitted the key entirely
    entity_texts = _run_label_post_processing(labels_cfg, {})
    assert entity_texts == set(), f"Expected no entities for absent optional label, got: {entity_texts}"


def test_optional_label_string_none_produces_no_entity():
    """String 'None' from LLM for an optional label → no entity created (not in lookup)."""
    from hindsight_api.engine.retain.entity_labels import parse_entity_labels

    labels_cfg = parse_entity_labels(
        [{"key": "engagement", "optional": True, "values": [{"value": "active"}, {"value": "passive"}]}]
    )

    # LLM returned the string "None" instead of JSON null — must not be stored
    entity_texts = _run_label_post_processing(labels_cfg, {"engagement": "None"})
    assert entity_texts == set(), (
        f"String 'None' must not produce engagement:None entity, got: {entity_texts}"
    )


def test_optional_label_null_does_not_affect_other_labels():
    """Null for one optional label doesn't suppress other valid labels on the same fact."""
    from hindsight_api.engine.retain.entity_labels import parse_entity_labels

    labels_cfg = parse_entity_labels(
        [
            {"key": "engagement", "optional": True, "values": [{"value": "active"}, {"value": "passive"}]},
            {"key": "topic", "optional": True, "values": [{"value": "math"}, {"value": "science"}]},
        ]
    )

    # engagement is null, but topic is set
    entity_texts = _run_label_post_processing(labels_cfg, {"engagement": None, "topic": "math"})
    assert "topic:math" in entity_texts, f"Expected topic:math entity, got: {entity_texts}"
    assert not any("engagement" in t for t in entity_texts), (
        f"engagement should not appear, got: {entity_texts}"
    )


def test_free_form_entities_false_clears_entities():
    """When retain_free_form_entities=False, non-label entities are removed."""
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
    from hindsight_api.engine.retain.fact_extraction import Entity

    labels_cfg = parse_entity_labels(
        {
            "attributes": [
                {
                    "key": "pedagogy",
                    "values": [{"value": "scaffolding", "description": ""}],
                }
            ],
        }
    )
    labels_lookup = build_labels_lookup(labels_cfg)
    free_form_entities = False  # standalone config field

    # Mix of label and free-form entities
    validated_entities = [
        Entity(text="pedagogy:scaffolding"),
        Entity(text="Alice"),
        Entity(text="Google"),
    ]

    # Apply free_form filtering
    if not free_form_entities and labels_lookup:
        validated_entities = [e for e in validated_entities if e.text.lower() in labels_lookup]

    entity_texts = {e.text for e in validated_entities}
    assert "pedagogy:scaffolding" in entity_texts
    assert "Alice" not in entity_texts
    assert "Google" not in entity_texts


def test_free_form_entities_true_keeps_all():
    """When retain_free_form_entities=True (default), all entities are kept."""
    from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels
    from hindsight_api.engine.retain.fact_extraction import Entity

    labels_cfg = parse_entity_labels(
        {
            "attributes": [
                {
                    "key": "pedagogy",
                    "values": [{"value": "scaffolding", "description": ""}],
                }
            ],
        }
    )
    labels_lookup = build_labels_lookup(labels_cfg)
    free_form_entities = True  # default value

    validated_entities = [
        Entity(text="pedagogy:scaffolding"),
        Entity(text="Alice"),
    ]

    # With free_form_entities=True, should NOT filter
    if not free_form_entities and labels_lookup:
        validated_entities = [e for e in validated_entities if e.text.lower() in labels_lookup]

    entity_texts = {e.text for e in validated_entities}
    assert "pedagogy:scaffolding" in entity_texts
    assert "Alice" in entity_texts


# ─── _build_extraction_prompt_and_schema with labels ──────────────────────────


def test_extraction_schema_includes_labels_model():
    """When entity_labels configured, response schema has a structured Labels field."""
    from unittest.mock import MagicMock

    from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema

    config = MagicMock()
    config.entity_labels = [
        {
            "key": "engagement",
            "values": [{"value": "active"}, {"value": "passive"}],
        },
        {
            "key": "pedagogy",
            "type": "multi-values",
            "values": [{"value": "scaffolding"}, {"value": "active_engagement"}],
        },
    ]
    config.entities_allow_free_form = True
    config.retain_extraction_mode = "concise"
    config.retain_extract_causal_links = False
    config.retain_mission = None
    config.retain_custom_instructions = None

    prompt, schema = _build_extraction_prompt_and_schema(config)

    # Schema should be a dynamic response model
    json_schema = schema.model_json_schema()
    assert "facts" in json_schema["properties"]

    # Drill into the fact item schema
    fact_schema = json_schema["$defs"]["LabelsFact"]
    assert "labels" in fact_schema["properties"]
    assert "labels" in fact_schema["required"]

    # Labels should be a nested object (not a flat array)
    labels_ref = fact_schema["properties"]["labels"]
    labels_def_key = labels_ref["$ref"].split("/")[-1]
    labels_def = json_schema["$defs"][labels_def_key]

    assert "engagement" in labels_def["properties"]
    assert "pedagogy" in labels_def["properties"]

    # engagement: single-value → anyOf[{enum: [...]}, {type: null}]
    any_of = labels_def["properties"]["engagement"]["anyOf"]
    engagement_enums = next(b["enum"] for b in any_of if "enum" in b)
    assert set(engagement_enums) == {"active", "passive"}

    # pedagogy: multi-value → array of enum
    assert labels_def["properties"]["pedagogy"]["type"] == "array"
    assert set(labels_def["properties"]["pedagogy"]["items"]["enum"]) == {"scaffolding", "active_engagement"}

    # Prompt should reference the labels object
    assert "labels" in prompt
    assert "engagement" in prompt
    assert "pedagogy" in prompt


def test_extraction_schema_labels_in_required():
    """labels field is in the required array so OpenAI structured outputs enforce it."""
    from unittest.mock import MagicMock

    from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema

    config = MagicMock()
    config.entity_labels = [{"key": "topic", "values": [{"value": "math"}]}]
    config.entities_allow_free_form = True
    config.retain_extraction_mode = "concise"
    config.retain_extract_causal_links = False
    config.retain_mission = None
    config.retain_custom_instructions = None

    _, schema = _build_extraction_prompt_and_schema(config)
    fact_schema = schema.model_json_schema()["$defs"]["LabelsFact"]
    assert "labels" in fact_schema["required"]


def test_extraction_schema_no_labels_when_unconfigured():
    """Without entity_labels, schema falls back to a base FactExtraction class (no dynamic model)."""
    from unittest.mock import MagicMock

    from hindsight_api.engine.retain.fact_extraction import (
        _build_extraction_prompt_and_schema,
    )

    config = MagicMock()
    config.entity_labels = None
    config.entities_allow_free_form = True
    config.retain_extraction_mode = "concise"
    config.retain_extract_causal_links = False
    config.retain_mission = None
    config.retain_custom_instructions = None

    _, schema = _build_extraction_prompt_and_schema(config)
    # No labels field in schema — it's a plain base response model
    json_schema = schema.model_json_schema()
    # Verify 'labels' is NOT a required or present field in any fact definition
    fact_defs = {k: v for k, v in json_schema.get("$defs", {}).items() if "facts" not in k.lower()}
    for name, defn in fact_defs.items():
        assert "labels" not in defn.get("properties", {}), f"Found 'labels' in {name}"


# ─── LLM integration tests (require DB + LLM) ─────────────────────────────────


@pytest.mark.asyncio
async def test_retain_extracts_single_value_label(memory, request_context):
    """
    End-to-end: retain content with entity_labels configured (single-value).
    Verify that the LLM assigns the label and it ends up as a key:value entity on the memory unit.
    """
    from hindsight_api.engine.memory_engine import fq_table

    bank_id = f"test-labels-single-{uuid.uuid4().hex[:8]}"
    try:
        await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)

        # Configure entity_labels on the bank
        await memory._config_resolver.update_bank_config(
            bank_id=bank_id,
            updates={
                "entity_labels": [
                    {
                        "key": "engagement",
                        "description": "Student engagement level during the session",
                        "values": [
                            {"value": "active", "description": "Student is actively participating"},
                            {"value": "passive", "description": "Student is listening but not participating"},
                        ],
                    }
                ],
                "entities_allow_free_form": False,  # labels-only mode
            },
            context=request_context,
        )

        unit_ids = await memory.retain_async(
            bank_id=bank_id,
            content=(
                "During today's tutoring session, Maria asked many questions, "
                "participated in every exercise, and solved the problems independently. "
                "She was very engaged throughout."
            ),
            request_context=request_context,
        )

        assert len(unit_ids) > 0, "Should have extracted at least one fact"

        # Query entity names for the retained units
        async with memory._pool.acquire() as conn:
            rows = await conn.fetch(
                f"""
                SELECT e.canonical_name
                FROM {fq_table("unit_entities")} ue
                JOIN {fq_table("entities")} e ON e.id = ue.entity_id
                WHERE ue.unit_id = ANY($1::uuid[])
                """,
                [u for u in unit_ids],
            )

        entity_names = {r["canonical_name"].lower() for r in rows}
        assert "engagement:active" in entity_names, (
            f"Expected 'engagement:active' label entity. Got: {entity_names}"
        )
        # In labels-only mode, free-form entities like 'Maria' should be absent
        assert not any("maria" in n for n in entity_names), (
            f"Free-form entity 'Maria' should not appear in labels-only mode. Got: {entity_names}"
        )
    finally:
        await memory.delete_bank(bank_id, request_context=request_context)


@pytest.mark.asyncio
async def test_retain_extracts_multi_value_label(memory, request_context):
    """
    End-to-end: retain content with a multi_value entity_labels group.
    Verify that multiple label values can be assigned to a single fact.
    """
    from hindsight_api.engine.memory_engine import fq_table

    bank_id = f"test-labels-multi-{uuid.uuid4().hex[:8]}"
    try:
        await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)

        await memory._config_resolver.update_bank_config(
            bank_id=bank_id,
            updates={
                "entity_labels": [
                    {
                        "key": "pedagogy",
                        "description": "Teaching strategies observed in the session",
                        "type": "multi-values",
                        "values": [
                            {"value": "scaffolding", "description": "Teacher breaks tasks into smaller steps"},
                            {"value": "direct_instruction", "description": "Teacher explains concepts directly"},
                            {"value": "socratic_questioning", "description": "Teacher guides via questions"},
                        ],
                    }
                ],
                "entities_allow_free_form": False,
            },
            context=request_context,
        )

        unit_ids = await memory.retain_async(
            bank_id=bank_id,
            content=(
                "The teacher broke the algebra problem into small steps and guided the student "
                "through each one with questions like 'What do you notice about this equation?' "
                "and 'What would happen if you moved this term to the other side?'. "
                "The lesson was clearly structured with scaffolding and socratic questioning."
            ),
            request_context=request_context,
        )

        assert len(unit_ids) > 0

        async with memory._pool.acquire() as conn:
            rows = await conn.fetch(
                f"""
                SELECT e.canonical_name
                FROM {fq_table("unit_entities")} ue
                JOIN {fq_table("entities")} e ON e.id = ue.entity_id
                WHERE ue.unit_id = ANY($1::uuid[])
                """,
                [u for u in unit_ids],
            )

        entity_names = {r["canonical_name"].lower() for r in rows}
        # At least one pedagogy label should be assigned
        pedagogy_labels = {n for n in entity_names if n.startswith("pedagogy:")}
        assert len(pedagogy_labels) > 0, (
            f"Expected at least one pedagogy:* label entity. Got: {entity_names}"
        )
    finally:
        await memory.delete_bank(bank_id, request_context=request_context)


@pytest.mark.asyncio
async def test_retain_extracts_free_values_label(memory, request_context):
    """
    End-to-end: retain content with a free_values entity_labels group.
    Verify that the LLM produces a key:value entity with an open-ended value
    (not constrained to a predefined enum list).
    """
    from hindsight_api.engine.memory_engine import fq_table

    bank_id = f"test-labels-free-{uuid.uuid4().hex[:8]}"
    try:
        await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)

        await memory._config_resolver.update_bank_config(
            bank_id=bank_id,
            updates={
                "entity_labels": [
                    {
                        "key": "topic",
                        "description": "The specific subject being discussed in this session. Examples: algebra, geometry, quadratic equations.",
                        "type": "text",
                        "optional": True,
                        "values": [],
                    }
                ],
                "entities_allow_free_form": False,
            },
            context=request_context,
        )

        unit_ids = await memory.retain_async(
            bank_id=bank_id,
            content=(
                "The student and tutor spent the session working through quadratic equations. "
                "They factored several expressions and practised the quadratic formula."
            ),
            request_context=request_context,
        )

        assert len(unit_ids) > 0

        async with memory._pool.acquire() as conn:
            rows = await conn.fetch(
                f"""
                SELECT e.canonical_name
                FROM {fq_table("unit_entities")} ue
                JOIN {fq_table("entities")} e ON e.id = ue.entity_id
                WHERE ue.unit_id = ANY($1::uuid[])
                """,
                [u for u in unit_ids],
            )

        entity_names = {r["canonical_name"].lower() for r in rows}
        # A topic:* entity must exist — value is free-form so we only check the prefix
        topic_entities = {n for n in entity_names if n.startswith("topic:")}
        assert len(topic_entities) > 0, (
            f"Expected at least one topic:* free-value entity. Got: {entity_names}"
        )
        # The value must not be the literal string "none" or "null"
        assert not any(n in ("topic:none", "topic:null", "topic:n/a") for n in topic_entities), (
            f"topic entity should not be a null sentinel. Got: {topic_entities}"
        )
    finally:
        await memory.delete_bank(bank_id, request_context=request_context)
