# type: ignore

import abc
import dataclasses
import datetime
import decimal
import uuid
from collections import namedtuple
from typing import Dict, List, Optional, Set, Union
from unittest import mock

import pytest
import pytest

from redis_om import (
    Coordinates,
    Field,
    GeoFilter,
    HashModel,
    Migrator,
    NotFoundError,
    QueryNotSupportedError,
    RedisModel,
    RedisModelError,
    VectorFieldOptions,
)
from redis_om.model.model import ExpressionProxy

# We need to run this check as sync code (during tests) even in async mode
# because we call it in the top-level module scope.
from redis_om import has_redisearch
from tests._compat import ValidationError

from .conftest import py_test_mark_sync


if not has_redisearch():
    pytestmark = pytest.mark.skip

today = datetime.date.today()


@pytest.fixture
def m(key_prefix, redis):
    class BaseHashModel(HashModel, abc.ABC):
        class Meta:
            global_key_prefix = key_prefix
            database = redis

    class Order(BaseHashModel, index=True):
        total: decimal.Decimal
        currency: str
        created_on: datetime.datetime

    class Member(BaseHashModel, index=True):
        id: int = Field(index=True, primary_key=True)
        first_name: str = Field(index=True, case_sensitive=True)
        last_name: str = Field(index=True)
        email: str = Field(index=True)
        join_date: datetime.date
        age: int = Field(sortable=True)
        bio: str = Field(full_text_search=True)

        class Meta:
            model_key_prefix = "member"
            primary_key_pattern = ""

    Migrator(conn=redis).run()

    return namedtuple("Models", ["BaseHashModel", "Order", "Member"])(
        BaseHashModel, Order, Member
    )


@pytest.fixture
def members(m):
    member1 = m.Member(
        id=0,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        age=38,
        join_date=today,
        bio="This is member 1 whose greatness makes him the life and soul of any party he goes to.",
    )

    member2 = m.Member(
        id=1,
        first_name="Kim",
        last_name="Brookins",
        email="k@example.com",
        age=34,
        join_date=today,
        bio="This is member 2 who can be quite anxious until you get to know them.",
    )

    member3 = m.Member(
        id=2,
        first_name="Andrew",
        last_name="Smith",
        email="as@example.com",
        age=100,
        join_date=today,
        bio="This is member 3 who is a funny and lively sort of person.",
    )
    member1.save()
    member2.save()
    member3.save()

    yield member1, member2, member3


@py_test_mark_sync
def test_exact_match_queries(members, m):
    member1, member2, member3 = members

    actual = m.Member.find(m.Member.last_name == "Brookins").sort_by("age").all()
    assert actual == [member2, member1]

    actual = m.Member.find(
        (m.Member.last_name == "Brookins") & ~(m.Member.first_name == "Andrew")
    ).all()
    assert actual == [member2]

    actual = m.Member.find(~(m.Member.last_name == "Brookins")).all()
    assert actual == [member3]

    actual = m.Member.find(m.Member.last_name != "Brookins").all()
    assert actual == [member3]

    actual = (
        m.Member.find(
            (m.Member.last_name == "Brookins") & (m.Member.first_name == "Andrew")
            | (m.Member.first_name == "Kim")
        )
        .sort_by("age")
        .all()
    )
    assert actual == [member2, member1]

    actual = m.Member.find(
        m.Member.first_name == "Kim", m.Member.last_name == "Brookins"
    ).all()
    assert actual == [member2]

    actual = m.Member.find(m.Member.id == 0).all()
    assert actual == [member1]


@py_test_mark_sync
def test_delete_non_exist(members, m):
    member1, member2, member3 = members
    actual = m.Member.find(
        (m.Member.last_name == "Brookins") & ~(m.Member.first_name == "Andrew")
    ).all()
    assert actual == [member2]
    assert (
        1
        == m.Member.find(
            (m.Member.last_name == "Brookins") & ~(m.Member.first_name == "Andrew")
        ).delete()
    )
    assert (
        0
        == m.Member.find(
            (m.Member.last_name == "Brookins") & ~(m.Member.first_name == "Andrew")
        ).delete()
    )


@py_test_mark_sync
def test_full_text_search_queries(members, m):
    member1, member2, member3 = members

    actual = m.Member.find(m.Member.bio % "great").all()

    assert actual == [member1]

    actual = m.Member.find(~(m.Member.bio % "anxious")).sort_by("age").all()

    assert actual == [member1, member3]


@py_test_mark_sync
def test_pagination_queries(members, m):
    member1, member2, member3 = members

    actual = m.Member.find(m.Member.last_name == "Brookins").sort_by("id").page()

    assert actual == [member1, member2]

    actual = m.Member.find().sort_by("id").page(1, 1)

    assert actual == [member2]

    actual = m.Member.find().sort_by("id").page(0, 1)

    assert actual == [member1]


@py_test_mark_sync
def test_recursive_query_resolution(members, m):
    member1, member2, member3 = members

    actual = (
        m.Member.find(
            (m.Member.last_name == "Brookins")
            | (m.Member.age == 100) & (m.Member.last_name == "Smith")
        )
        .sort_by("age")
        .all()
    )
    assert actual == [member2, member1, member3]


@py_test_mark_sync
def test_tag_queries_boolean_logic(members, m):
    member1, member2, member3 = members

    actual = (
        m.Member.find(
            (m.Member.first_name == "Andrew") & (m.Member.last_name == "Brookins")
            | (m.Member.last_name == "Smith")
        )
        .sort_by("age")
        .all()
    )
    assert actual == [member1, member3]


@py_test_mark_sync
def test_tag_queries_punctuation(m):
    member1 = m.Member(
        id=0,
        first_name="Andrew, the Michael",
        last_name="St. Brookins-on-Pier",
        email="a|b@example.com",  # NOTE: This string uses the TAG field separator.
        age=38,
        join_date=today,
        bio="This is a test user on our system.",
    )
    member1.save()

    member2 = m.Member(
        id=1,
        first_name="Bob",
        last_name="the Villain",
        email="a|villain@example.com",  # NOTE: This string uses the TAG field separator.
        age=38,
        join_date=today,
        bio="This is a villain, they are a really bad person!",
    )
    member2.save()

    result = m.Member.find(m.Member.first_name == "Andrew, the Michael").first()
    assert result == member1

    result = m.Member.find(m.Member.last_name == "St. Brookins-on-Pier").first()
    assert result == member1

    # Notice that when we index and query multiple values that use the internal
    # TAG separator for single-value exact-match fields, like an indexed string,
    # the queries will succeed. We apply a workaround that queries for the union
    # of the two values separated by the tag separator.
    results = m.Member.find(m.Member.email == "a|b@example.com").all()
    assert results == [member1]
    results = m.Member.find(m.Member.email == "a|villain@example.com").all()
    assert results == [member2]


@py_test_mark_sync
def test_tag_queries_negation(members, m):
    member1, member2, member3 = members

    """
           ┌first_name
     NOT EQ┤
           └Andrew

    """
    query = m.Member.find(~(m.Member.first_name == "Andrew"))
    assert query.all() == [member2]

    """
               ┌first_name
        ┌NOT EQ┤
        |      └Andrew
     AND┤
        |  ┌last_name
        └EQ┤
           └Brookins

    """
    query = m.Member.find(
        ~(m.Member.first_name == "Andrew") & (m.Member.last_name == "Brookins")
    )
    assert query.all() == [member2]

    """
               ┌first_name
        ┌NOT EQ┤
        |      └Andrew
     AND┤
        |     ┌last_name
        |  ┌EQ┤
        |  |  └Brookins
        └OR┤
           |  ┌last_name
           └EQ┤
              └Smith
    """
    query = m.Member.find(
        ~(m.Member.first_name == "Andrew")
        & ((m.Member.last_name == "Brookins") | (m.Member.last_name == "Smith"))
    )
    assert query.all() == [member2]

    """
                  ┌first_name
           ┌NOT EQ┤
           |      └Andrew
       ┌AND┤
       |   |  ┌last_name
       |   └EQ┤
       |      └Brookins
     OR┤
       |  ┌last_name
       └EQ┤
          └Smith
    """
    query = m.Member.find(
        ~(m.Member.first_name == "Andrew") & (m.Member.last_name == "Brookins")
        | (m.Member.last_name == "Smith")
    )
    assert query.sort_by("age").all() == [member2, member3]

    actual = m.Member.find(
        (m.Member.first_name == "Andrew") & ~(m.Member.last_name == "Brookins")
    ).all()
    assert actual == [member3]


@py_test_mark_sync
def test_numeric_queries(members, m):
    member1, member2, member3 = members

    actual = m.Member.find(m.Member.age == 34).all()
    assert actual == [member2]

    actual = m.Member.find(m.Member.age > 34).sort_by("age").all()
    assert actual == [member1, member3]

    actual = m.Member.find(m.Member.age < 35).all()
    assert actual == [member2]

    actual = m.Member.find(m.Member.age <= 34).all()
    assert actual == [member2]

    actual = m.Member.find(m.Member.age >= 100).all()
    assert actual == [member3]

    actual = m.Member.find(m.Member.age != 34).sort_by("age").all()
    assert actual == [member1, member3]

    actual = m.Member.find(~(m.Member.age == 100)).sort_by("age").all()
    assert actual == [member2, member1]

    actual = m.Member.find(m.Member.age > 30, m.Member.age < 40).sort_by("age").all()
    assert actual == [member2, member1]


@py_test_mark_sync
def test_sorting(members, m):
    member1, member2, member3 = members

    actual = m.Member.find(m.Member.age > 34).sort_by("age").all()
    assert actual == [member1, member3]

    actual = m.Member.find(m.Member.age > 34).sort_by("-age").all()
    assert actual == [member3, member1]

    with pytest.raises(QueryNotSupportedError):
        # This field does not exist.
        m.Member.find().sort_by("not-a-real-field").all()

    with pytest.raises(QueryNotSupportedError):
        # This field is not sortable.
        m.Member.find().sort_by("join_date").all()


@py_test_mark_sync
def test_case_sensitive(members, m):
    member1, member2, member3 = members

    actual = m.Member.find(
        m.Member.first_name == "Andrew" and m.Member.pk == member1.pk
    ).all()
    assert actual == [member1]

    actual = m.Member.find(m.Member.first_name == "andrew").all()
    assert actual == []


def test_validates_required_fields(m):
    # Raises ValidationError: last_name is required
    # TODO: Test the error value
    with pytest.raises(ValidationError):
        try:
            m.Member(id=0, first_name="Andrew", zipcode="97086", join_date=today)
        except Exception as e:
            raise e


def test_validates_field(m):
    # Raises ValidationError: join_date is not a date
    # TODO: Test the error value
    with pytest.raises(ValidationError):
        m.Member(id=0, first_name="Andrew", last_name="Brookins", join_date="yesterday")


def test_validation_passes(m):
    member = m.Member(
        id=0,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        join_date=today,
        age=38,
        bio="This is the bio field.",
    )
    assert member.first_name == "Andrew"


@py_test_mark_sync
def test_retrieve_first(m):
    member = m.Member(
        id=0,
        first_name="Simon",
        last_name="Prickett",
        email="s@example.com",
        join_date=today,
        age=99,
        bio="This is the bio field for this user.",
    )

    member.save()

    member2 = m.Member(
        id=1,
        first_name="Another",
        last_name="Member",
        email="m@example.com",
        join_date=today,
        age=98,
        bio="This is the bio field for this user.",
    )

    member2.save()

    member3 = m.Member(
        id=2,
        first_name="Third",
        last_name="Member",
        email="t@example.com",
        join_date=today,
        age=97,
        bio="This is the bio field for this user.",
    )

    member3.save()

    first_one = m.Member.find().sort_by("age").first()
    assert first_one == member3


@py_test_mark_sync
def test_saves_model_and_creates_pk(m):
    member = m.Member(
        id=0,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        join_date=today,
        age=38,
        bio="This is the bio field for this user.",
    )
    # Save a model instance to Redis
    member.save()

    member2 = m.Member.get(pk=member.id)
    assert member2 == member


@py_test_mark_sync
def test_all_pks(m):
    member = m.Member(
        id=0,
        first_name="Simon",
        last_name="Prickett",
        email="s@example.com",
        join_date=today,
        age=97,
        bio="This is a test user to be deleted.",
    )

    member.save()

    member1 = m.Member(
        id=1,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        join_date=today,
        age=38,
        bio="This is a test user to be deleted.",
    )

    member1.save()

    pk_list = []
    for pk in m.Member.all_pks():
        pk_list.append(pk)

    assert sorted(pk_list) == ["0", "1"]


@py_test_mark_sync
def test_all_pks_with_complex_pks(key_prefix):
    class City(HashModel, index=True):
        name: str

        class Meta:
            global_key_prefix = key_prefix
            model_key_prefix = "city"

    city1 = City(
        pk="ca:on:toronto",
        name="Toronto",
    )

    city1.save()

    city2 = City(
        pk="ca:qc:montreal",
        name="Montreal",
    )

    city2.save()

    pk_list = []
    for pk in City.all_pks():
        pk_list.append(pk)

    assert sorted(pk_list) == ["ca:on:toronto", "ca:qc:montreal"]


@py_test_mark_sync
def test_delete(m):
    member = m.Member(
        id=0,
        first_name="Simon",
        last_name="Prickett",
        email="s@example.com",
        join_date=today,
        age=97,
        bio="This is a test user to be deleted.",
    )

    member.save()
    response = m.Member.delete(pk=member.id)
    assert response == 1


@py_test_mark_sync
def test_expire(m):
    member = m.Member(
        id=0,
        first_name="Expire",
        last_name="Test",
        email="e@example.com",
        join_date=today,
        age=93,
        bio="This is a test user for expiry",
    )

    member.save()
    member.expire(60)

    ttl = m.Member.db().ttl(member.key())
    assert ttl > 0


def test_raises_error_with_embedded_models(m):
    class Address(m.BaseHashModel):
        address_line_1: str
        address_line_2: Optional[str]
        city: str
        country: str
        postal_code: str

    with pytest.raises(RedisModelError):

        class InvalidMember(m.BaseHashModel):
            name: str = Field(index=True)
            address: Address


def test_raises_error_with_dataclasses(m):
    @dataclasses.dataclass
    class Address:
        address_line_1: str

    with pytest.raises(RedisModelError):

        class InvalidMember(m.BaseHashModel):
            address: Address


def test_raises_error_with_dicts(m):
    with pytest.raises(RedisModelError):

        class InvalidMember(m.BaseHashModel):
            address: Dict[str, str]


def test_raises_error_with_sets(m):
    with pytest.raises(RedisModelError):

        class InvalidMember(m.BaseHashModel):
            friend_ids: Set[str]


def test_raises_error_with_lists(m):
    with pytest.raises(RedisModelError):

        class InvalidMember(m.BaseHashModel):
            friend_ids: List[str]


@py_test_mark_sync
def test_saves_many(m):
    member1 = m.Member(
        id=0,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        join_date=today,
        age=38,
        bio="This is the user bio.",
    )
    member2 = m.Member(
        id=1,
        first_name="Kim",
        last_name="Brookins",
        email="k@example.com",
        join_date=today,
        age=34,
        bio="This is the bio for Kim.",
    )
    members = [member1, member2]
    result = m.Member.add(members)
    assert result == [member1, member2]

    assert m.Member.get(pk=member1.id) == member1
    assert m.Member.get(pk=member2.id) == member2


@py_test_mark_sync
def test_delete_many(m):
    member1 = m.Member(
        id=0,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        join_date=today,
        age=38,
        bio="This is the user bio.",
    )
    member2 = m.Member(
        id=1,
        first_name="Kim",
        last_name="Brookins",
        email="k@example.com",
        join_date=today,
        age=34,
        bio="This is the bio for Kim.",
    )
    members = [member1, member2]
    result = m.Member.add(members)
    assert result == [member1, member2]
    result = m.Member.delete_many(members)
    assert result == 2
    with pytest.raises(NotFoundError):
        m.Member.get(pk=member1.key())


@py_test_mark_sync
def test_updates_a_model(members, m):
    member1, member2, member3 = members
    member1.update(last_name="Smith")
    member = m.Member.get(member1.id)
    assert member.last_name == "Smith"


@py_test_mark_sync
def test_paginate_query(members, m):
    member1, member2, member3 = members
    actual = m.Member.find().sort_by("age").all(batch_size=1)
    assert actual == [member2, member1, member3]


@py_test_mark_sync
def test_access_result_by_index_cached(members, m):
    member1, member2, member3 = members
    query = m.Member.find().sort_by("age")
    # Load the cache, throw away the result.
    assert query._model_cache == []
    query.execute()
    assert query._model_cache == [member2, member1, member3]

    # Access an item that should be in the cache.
    with mock.patch.object(query.model, "db") as mock_db:
        assert query.get_item(0) == member2
        assert not mock_db.called


@py_test_mark_sync
def test_access_result_by_index_not_cached(members, m):
    member1, member2, member3 = members
    query = m.Member.find().sort_by("age")

    # Assert that we don't have any models in the cache yet -- we
    # haven't made any requests of Redis.
    assert query._model_cache == []
    assert query.get_item(0) == member2
    assert query.get_item(1) == member1
    assert query.get_item(2) == member3


def test_schema(m):
    class Address(m.BaseHashModel):
        a_string: str = Field(index=True)
        a_full_text_string: str = Field(index=True, full_text_search=True)
        an_integer: int = Field(index=True, sortable=True)
        a_float: float = Field(index=True)
        another_integer: int
        another_float: float

    # We need to build the key prefix because it will differ based on whether
    # these tests were copied into the tests_sync folder and unasynce'd.
    key_prefix = Address.make_key(Address._meta.primary_key_pattern.format(pk=""))
    assert (
        Address.redisearch_schema()
        == f"ON HASH PREFIX 1 {key_prefix} SCHEMA pk TAG SEPARATOR | a_string TAG SEPARATOR | a_full_text_string TAG SEPARATOR | a_full_text_string AS a_full_text_string_fts TEXT an_integer NUMERIC SORTABLE a_float NUMERIC"
    )


@py_test_mark_sync
def test_primary_key_model_error(m):
    class Customer(m.BaseHashModel, index=True):
        id: int = Field(primary_key=True, index=True)
        first_name: str = Field(primary_key=True, index=True)
        last_name: str
        bio: Optional[str]

    Migrator().run()

    with pytest.raises(
        RedisModelError, match="You must define only one primary key for a model"
    ):
        _ = Customer(
            id=0,
            first_name="Mahmoud",
            last_name="Harmouch",
            bio="Python developer, wanna work at Redis, Inc.",
        )


@py_test_mark_sync
def test_primary_pk_exists(m):
    class Customer1(m.BaseHashModel, index=True):
        id: int
        first_name: str
        last_name: str
        bio: Optional[str]

    class Customer2(m.BaseHashModel, index=True):
        id: int = Field(primary_key=True, index=True)
        first_name: str
        last_name: str
        bio: Optional[str]

    Migrator().run()

    customer = Customer1(
        id=0,
        first_name="Mahmoud",
        last_name="Harmouch",
        bio="Python developer, wanna work at Redis, Inc.",
    )

    assert "pk" in customer.model_fields

    customer = Customer2(
        id=1,
        first_name="Kim",
        last_name="Brookins",
        bio="This is member 2 who can be quite anxious until you get to know them.",
    )

    assert "pk" not in customer.model_fields


@py_test_mark_sync
def test_count(members, m):
    # member1, member2, member3 = members
    actual_count = m.Member.find(
        (m.Member.first_name == "Andrew") & (m.Member.last_name == "Brookins")
        | (m.Member.last_name == "Smith")
    ).count()
    assert actual_count == 2

    actual_count = m.Member.find(
        m.Member.first_name == "Kim", m.Member.last_name == "Brookins"
    ).count()
    assert actual_count == 1


@py_test_mark_sync
def test_type_with_union(members, m):
    class TypeWithUnion(m.BaseHashModel, index=True):
        field: Union[str, int]

    twu_str = TypeWithUnion(field="hello world")
    res = twu_str.save()
    assert res.pk == twu_str.pk
    twu_str_rematerialized = TypeWithUnion.get(twu_str.pk)
    assert (
        isinstance(twu_str_rematerialized.field, str)
        and twu_str_rematerialized.pk == twu_str.pk
    )

    twu_int = TypeWithUnion(field=42)
    twu_int.save()
    twu_int_rematerialized = TypeWithUnion.get(twu_int.pk)

    # Note - we will not be able to automatically serialize an int back to this union type,
    # since as far as we know from Redis this item is a string
    assert twu_int_rematerialized.pk == twu_int.pk


@py_test_mark_sync
def test_type_with_uuid():
    class TypeWithUuid(HashModel, index=True):
        uuid: uuid.UUID

    item = TypeWithUuid(uuid=uuid.uuid4())

    item.save()


@py_test_mark_sync
def test_xfix_queries(members, m):
    member1, member2, member3 = members

    result = m.Member.find(
        m.Member.first_name.startswith("And") and m.Member.last_name == "Brookins"
    ).first()
    assert result.last_name == "Brookins"

    result = m.Member.find(
        m.Member.last_name.endswith("ins") and m.Member.last_name == "Brookins"
    ).first()
    assert result.last_name == "Brookins"

    result = m.Member.find(
        m.Member.last_name.contains("ook") and m.Member.last_name == "Brookins"
    ).first()
    assert result.last_name == "Brookins"

    result = m.Member.find(
        m.Member.bio % "great*" and m.Member.first_name == "Andrew"
    ).first()
    assert result.first_name == "Andrew"

    result = m.Member.find(
        m.Member.bio % "*rty" and m.Member.first_name == "Andrew"
    ).first()
    assert result.first_name == "Andrew"

    result = m.Member.find(
        m.Member.bio % "*eat*" and m.Member.first_name == "Andrew"
    ).first()
    assert result.first_name == "Andrew"


@py_test_mark_sync
def test_none():
    class ModelWithNoneDefault(HashModel, index=True):
        test: Optional[str] = Field(index=True, default=None)

    class ModelWithStringDefault(HashModel, index=True):
        test: Optional[str] = Field(index=True, default="None")

    Migrator().run()

    a = ModelWithNoneDefault()
    a.save()
    res = ModelWithNoneDefault.find(ModelWithNoneDefault.pk == a.pk).first()
    assert res.test is None

    b = ModelWithStringDefault()
    b.save()
    res = ModelWithStringDefault.find(ModelWithStringDefault.pk == b.pk).first()
    assert res.test == "None"


@py_test_mark_sync
def test_update_validation():
    class TestUpdate(HashModel, index=True):
        name: str
        age: int

    Migrator().run()
    t = TestUpdate(name="steve", age=34)
    t.save()
    update_dict = dict()
    update_dict["age"] = "cat"

    with pytest.raises(ValidationError):
        t.update(**update_dict)

    rematerialized = TestUpdate.find(TestUpdate.pk == t.pk).first()
    assert rematerialized.age == 34


@py_test_mark_sync
def test_literals(key_prefix, redis):
    from typing import Literal

    class TestLiterals(HashModel, index=True):
        flavor: Literal["apple", "pumpkin"] = Field(index=True, default="apple")

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    schema = TestLiterals.redisearch_schema()

    expected_key_prefix = TestLiterals.make_key(
        TestLiterals._meta.primary_key_pattern.format(pk="")
    )
    assert schema == (
        f"ON HASH PREFIX 1 {expected_key_prefix} SCHEMA pk TAG SEPARATOR | flavor TAG SEPARATOR |"
    )
    Migrator().run()
    item = TestLiterals(flavor="pumpkin")
    item.save()
    rematerialized = TestLiterals.find(TestLiterals.flavor == "pumpkin").first()
    assert rematerialized.pk == item.pk


@py_test_mark_sync
def test_child_class_expression_proxy():
    # https://github.com/redis/redis-om-python/issues/669 seeing weird issue with child classes initializing all their undefined members as ExpressionProxies
    class Model(HashModel):
        first_name: str
        last_name: str
        age: int = Field(default=18)
        bio: Optional[str] = Field(default=None)

    class Child(Model, index=True):
        other_name: str
        # is_new: bool = Field(default=True)

    Migrator().run()
    m = Child(first_name="Steve", last_name="Lorello", other_name="foo")
    m.save()
    print(m.age)
    assert m.age == 18

    rematerialized = Child.find(Child.pk == m.pk).first()

    assert rematerialized.age == 18
    assert rematerialized.bio is None


@py_test_mark_sync
def test_child_class_expression_proxy_with_mixin():
    # https://github.com/redis/redis-om-python/issues/669 seeing weird issue with child classes initializing all their undefined members as ExpressionProxies
    class Model(RedisModel, abc.ABC):
        first_name: str
        last_name: str
        age: int = Field(default=18)
        bio: Optional[str] = Field(default=None)

    class Child(Model, HashModel, index=True):
        other_name: str
        # is_new: bool = Field(default=True)

    Migrator().run()
    m = Child(first_name="Steve", last_name="Lorello", other_name="foo")
    m.save()
    print(m.age)
    assert m.age == 18

    rematerialized = Child.find(Child.pk == m.pk).first()

    assert rematerialized.age == 18
    assert rematerialized.bio is None


@py_test_mark_sync
def test_model_validate_uses_default_values():
    class ChildCls:
        def __init__(self, first_name: str, other_name: str):
            self.first_name = first_name
            self.other_name = other_name

    class Model(HashModel):
        first_name: str
        age: int = Field(default=18)
        bio: Optional[str] = Field(default=None)

    class Child(Model):
        other_name: str

    child_dict = {"first_name": "Anna", "other_name": "Maria"}
    child_cls = ChildCls(**child_dict)

    child_ctor = Child(**child_dict)

    assert child_ctor.first_name == "Anna"
    assert child_ctor.age == 18
    assert child_ctor.bio is None
    assert child_ctor.other_name == "Maria"

    child_validate = Child.model_validate(child_cls, from_attributes=True)

    assert child_validate.first_name == "Anna"
    assert child_validate.age == 18
    assert child_validate.bio is None
    assert child_validate.other_name == "Maria"


@py_test_mark_sync
def test_model_with_alias_can_be_searched(key_prefix, redis):
    class Model(HashModel, index=True):
        first_name: str = Field(alias="firstName", index=True)
        last_name: str = Field(alias="lastName")

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    model = Model(first_name="Steve", last_name="Lorello")
    model.save()

    rematerialized = Model.find(Model.first_name == "Steve").first()
    assert rematerialized.pk == model.pk


@py_test_mark_sync
def test_can_search_on_coordinates(key_prefix, redis):
    class Location(HashModel, index=True):
        coordinates: Coordinates = Field(index=True)

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    latitude = 45.5231
    longitude = -122.6765

    loc = Location(coordinates=(latitude, longitude))

    loc.save()

    rematerialized: Location = Location.find(
        Location.coordinates
        == GeoFilter(longitude=longitude, latitude=latitude, radius=10, unit="mi")
    ).first()

    assert rematerialized.pk == loc.pk
    assert rematerialized.coordinates.latitude == latitude
    assert rematerialized.coordinates.longitude == longitude


@py_test_mark_sync
def test_does_not_return_coordinates_if_outside_radius(key_prefix, redis):
    class Location(HashModel, index=True):
        coordinates: Coordinates = Field(index=True)

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    latitude = 45.5231
    longitude = -122.6765

    loc = Location(coordinates=(latitude, longitude))

    loc.save()

    with pytest.raises(NotFoundError):
        Location.find(
            Location.coordinates
            == GeoFilter(longitude=0, latitude=0, radius=0.1, unit="mi")
        ).first()


@py_test_mark_sync
def test_does_not_return_coordinates_if_location_is_none(key_prefix, redis):
    class Location(HashModel, index=True):
        coordinates: Optional[Coordinates] = Field(index=True)

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    loc = Location(coordinates=None)

    loc.save()

    with pytest.raises(NotFoundError):
        Location.find(
            Location.coordinates
            == GeoFilter(longitude=0, latitude=0, radius=0.1, unit="mi")
        ).first()


@py_test_mark_sync
def test_values_method_with_specific_fields(members, m):
    member1, member2, member3 = members
    actual = (
        m.Member.find(
            (m.Member.first_name == "Andrew") & (m.Member.last_name == "Brookins")
            | (m.Member.last_name == "Smith")
        )
        .sort_by("last_name")
        .values("first_name", "last_name")
        .all()
    )
    assert actual == [
        {"first_name": "Andrew", "last_name": "Brookins"},
        {"first_name": "Andrew", "last_name": "Smith"},
    ]


@py_test_mark_sync
def test_values_method_all_fields(members, m):
    member1, member2, member3 = members
    actual = m.Member.find(m.Member.first_name == "Andrew").values().all()

    # Check that it returns all fields as dicts
    assert len(actual) == 2  # Should find Andrew Brookins and Andrew Smith
    # Verify it contains all fields as dictionaries
    for result in actual:
        assert "first_name" in result
        assert "last_name" in result
        assert "email" in result
        assert "age" in result
        assert "pk" in result  # Should include primary key
        assert result["first_name"] == "Andrew"


@py_test_mark_sync
def test_only_method_basic(members, m):
    """Test basic .only() method functionality - partial model instances."""
    member1, member2, member3 = members

    # Test .only() with specific fields
    actual = (
        m.Member.find(m.Member.first_name == "Andrew")
        .sort_by("last_name")
        .only("first_name", "last_name")
        .all()
    )

    # Should return PartialModel instances, not dicts
    assert len(actual) == 2

    result = actual[0]  # Test with first result

    # Should be able to access loaded fields
    assert result.first_name == "Andrew"
    assert result.last_name in ["Brookins", "Smith"]

    # Should NOT be a dict
    assert not isinstance(result, dict)

    # Should be a PartialModel instance
    from redis_om.model.model import PartialModel

    assert isinstance(result, PartialModel)

    # Accessing unloaded fields should raise AttributeError
    with pytest.raises(AttributeError, match="Field 'email' was not loaded"):
        _ = result.email

    with pytest.raises(AttributeError, match="Field 'age' was not loaded"):
        _ = result.age


@py_test_mark_sync
def test_only_method_error_messages(members, m):
    """Test that .only() provides helpful error messages."""
    member1, member2, member3 = members

    # Test .only() with one field
    results = m.Member.find().only("first_name").all()
    result = results[0]

    # Accessing unloaded field should suggest the correct .only() usage
    with pytest.raises(AttributeError) as exc_info:
        _ = result.email

    error_message = str(exc_info.value)
    assert "Field 'email' was not loaded" in error_message
    assert ".only('email')" in error_message or ".only(" in error_message


@py_test_mark_sync
def test_values_type_conversion(members, m):
    """Test that .values() returns properly typed values, not just strings."""
    member1, member2, member3 = members

    # Test .values() with specific fields - should return proper types
    results = m.Member.find().values("first_name", "age").all()
    result = results[0]

    # Should be dictionary with proper types
    assert isinstance(result, dict)
    assert isinstance(result["first_name"], str)
    assert isinstance(result["age"], int), (
        f"Expected int, got {type(result['age'])} with value {result['age']}"
    )


@py_test_mark_sync
def test_only_type_conversion(members, m):
    """Test that .only() returns properly typed values, not just strings."""
    member1, member2, member3 = members

    # Test .only() with specific fields - should return proper types in PartialModel
    results = m.Member.find().only("first_name", "age").all()
    result = results[0]

    # Should be PartialModel with proper types
    from redis_om.model.model import PartialModel

    assert isinstance(result, PartialModel)
    assert isinstance(result.first_name, str)
    assert isinstance(result.age, int), (
        f"Expected int, got {type(result.age)} with value {result.age}"
    )


@py_test_mark_sync
def test_boolean_fields_work_with_hash_model(key_prefix, redis):
    """Test that boolean fields work correctly with HashModel."""

    class BoolTestModel(HashModel, index=True):
        name: str = Field(index=True)
        is_active: bool = Field(index=True)
        is_admin: bool = Field(index=True, default=False)

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    # Test saving and retrieving boolean fields
    model = BoolTestModel(name="test_user", is_active=True, is_admin=False)
    model.save()

    # Test get() method
    retrieved = BoolTestModel.get(model.pk)
    assert retrieved.name == "test_user"
    assert retrieved.is_active is True
    assert retrieved.is_admin is False
    assert isinstance(retrieved.is_active, bool)
    assert isinstance(retrieved.is_admin, bool)

    # Test querying by boolean fields
    active_users = BoolTestModel.find(BoolTestModel.is_active == True).all()
    assert len(active_users) == 1
    assert active_users[0].name == "test_user"
    assert active_users[0].is_active is True

    non_admin_users = BoolTestModel.find(BoolTestModel.is_admin == False).all()
    assert len(non_admin_users) == 1
    assert non_admin_users[0].name == "test_user"
    assert non_admin_users[0].is_admin is False

    # Test .values() with boolean fields
    values_result = BoolTestModel.find().values("name", "is_active", "is_admin").all()
    assert len(values_result) == 1
    result = values_result[0]
    assert result["name"] == "test_user"
    assert result["is_active"] is True
    assert result["is_admin"] is False
    assert isinstance(result["is_active"], bool)
    assert isinstance(result["is_admin"], bool)

    # Test .only() with boolean fields
    only_result = BoolTestModel.find().only("name", "is_active").all()
    assert len(only_result) == 1
    result = only_result[0]
    assert result.name == "test_user"
    assert result.is_active is True
    assert isinstance(result.is_active, bool)


@py_test_mark_sync
def test_can_search_on_multiple_fields_with_geo_filter(key_prefix, redis):
    class Location(HashModel, index=True):
        coordinates: Coordinates = Field(index=True)
        name: str = Field(index=True)

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    latitude = 45.5231
    longitude = -122.6765

    loc1 = Location(coordinates=(latitude, longitude), name="Portland")
    # Offset by 0.01 degrees (~1.1 km at this latitude) to create a nearby location
    # This ensures "Nearby" is within the 10 mile search radius but not at the exact same location
    loc2 = Location(coordinates=(latitude + 0.01, longitude + 0.01), name="Nearby")

    loc1.save()
    loc2.save()

    rematerialized: List[Location] = Location.find(
        (
            Location.coordinates
            == GeoFilter(longitude=longitude, latitude=latitude, radius=10, unit="mi")
        )
        & (Location.name == "Portland")
    ).all()

    assert len(rematerialized) == 1
    assert rematerialized[0].pk == loc1.pk


@py_test_mark_sync
def test_save_nx_only_saves_if_not_exists(m):
    """Test that save(nx=True) only saves if the key doesn't exist."""
    Migrator().run()

    member = m.Member(
        id=1000,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        join_date=today,
        age=38,
        bio="Original bio",
    )

    # First save should succeed with nx=True
    result = member.save(nx=True)
    assert result is not None
    assert result.pk == member.pk

    # Second save with same pk should return None (key exists)
    member2 = m.Member(
        id=1000,
        first_name="Different",
        last_name="Name",
        email="b@example.com",
        join_date=today,
        age=25,
        bio="Different bio",
    )
    result = member2.save(nx=True)
    assert result is None

    # Verify original data is unchanged
    fetched = m.Member.get(member.id)
    assert fetched.first_name == "Andrew"


@py_test_mark_sync
def test_save_xx_only_saves_if_exists(m):
    """Test that save(xx=True) only saves if the key already exists."""
    Migrator().run()

    member = m.Member(
        id=2000,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        join_date=today,
        age=38,
        bio="Original bio",
    )

    # First save with xx=True should return None (key doesn't exist)
    result = member.save(xx=True)
    assert result is None

    # Save without flags to create the key
    member.save()

    # Now update with xx=True should succeed
    member.first_name = "Updated"
    result = member.save(xx=True)
    assert result is not None

    # Verify data was updated
    fetched = m.Member.get(member.id)
    assert fetched.first_name == "Updated"


@py_test_mark_sync
def test_save_nx_xx_mutually_exclusive(m):
    """Test that save() raises ValueError if both nx and xx are True."""
    Migrator().run()

    member = m.Member(
        id=3000,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        join_date=today,
        age=38,
        bio="Some bio",
    )

    with pytest.raises(ValueError, match="Cannot specify both nx and xx"):
        member.save(nx=True, xx=True)


@py_test_mark_sync
def test_save_nx_with_pipeline_raises_error(m):
    """Test that save(nx=True) with pipeline raises an error for HashModel."""
    Migrator().run()

    member = m.Member(
        id=4000,
        first_name="Andrew",
        last_name="Brookins",
        email="a@example.com",
        join_date=today,
        age=38,
        bio="Bio 1",
    )

    # HashModel doesn't support nx/xx with pipeline (HSET doesn't support it natively)
    with m.Member.db().pipeline(transaction=True) as pipe:
        with pytest.raises(ValueError, match="Cannot use nx or xx with pipeline"):
            member.save(pipeline=pipe, nx=True)


@py_test_mark_sync
def test_bytes_field_with_binary_data(key_prefix, redis):
    """Test that bytes fields can store arbitrary binary data including non-UTF8 bytes.

    Regression test for GitHub issue #779: bytes fields failed with UnicodeDecodeError
    when storing actual binary data (non-UTF8 bytes).
    """

    class FileHash(HashModel, index=True):
        filename: str = Field(index=True)
        content: bytes

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    # Test with binary data that is NOT valid UTF-8 (PNG header)
    binary_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"

    f = FileHash(filename="image.png", content=binary_content)
    f.save()

    # Retrieve and verify
    retrieved = FileHash.get(f.pk)
    assert retrieved.content == binary_content
    assert retrieved.filename == "image.png"

    # Test with null bytes and other non-printable characters
    null_content = b"\x00\x01\x02\x03\xff\xfe\xfd"
    f2 = FileHash(filename="binary.bin", content=null_content)
    f2.save()

    retrieved2 = FileHash.get(f2.pk)
    assert retrieved2.content == null_content


@py_test_mark_sync
def test_optional_bytes_field(key_prefix, redis):
    """Test that Optional[bytes] fields work correctly."""
    from typing import Optional

    class Attachment(HashModel, index=True):
        name: str = Field(index=True)
        data: Optional[bytes] = None

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    # Without data
    a1 = Attachment(name="empty")
    a1.save()
    r1 = Attachment.get(a1.pk)
    assert r1.data is None

    # With binary data
    a2 = Attachment(name="with_data", data=b"\x89PNG\x00\xff")
    a2.save()
    r2 = Attachment.get(a2.pk)
    assert r2.data == b"\x89PNG\x00\xff"


@py_test_mark_sync
def test_custom_primary_key_pk_property(key_prefix, redis):
    """Test that .pk returns the actual value when using a custom primary key.

    Regression test for GitHub issue #570: accessing .pk on a model with
    custom primary_key=True returned an ExpressionProxy instead of the value.
    """

    class ModelWithCustomPK(HashModel, index=True):
        x: int = Field(primary_key=True)
        name: str

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    instance = ModelWithCustomPK(x=42, name="test")

    # pk should return the actual value, not an ExpressionProxy
    assert instance.pk == 42
    assert not isinstance(instance.pk, ExpressionProxy)

    # The custom field should also work for queries
    instance.save()
    retrieved = ModelWithCustomPK.get(42)
    assert retrieved.pk == 42
    assert retrieved.x == 42
    assert retrieved.name == "test"


@py_test_mark_sync
def test_hashmodel_vector_field_with_list(key_prefix, redis):
    """Test that HashModel allows list[float] fields when used with vector_options.

    Regression test for GitHub issue #544: HashModel rejected list fields
    even when they were vector fields that require list[float] type.
    """
    vector_options = VectorFieldOptions.flat(
        type=VectorFieldOptions.TYPE.FLOAT32,
        dimension=4,
        distance_metric=VectorFieldOptions.DISTANCE_METRIC.COSINE,
    )

    # This should NOT raise an error - vector fields are allowed to be lists
    class VectorDocument(HashModel, index=True):
        name: str
        embedding: list[float] = Field(default=[], vector_options=vector_options)

        class Meta:
            global_key_prefix = key_prefix
            database = redis

    Migrator().run()

    # Create and save a document with a vector
    doc = VectorDocument(name="test", embedding=[0.1, 0.2, 0.3, 0.4])
    doc.save()

    # Retrieve and verify
    retrieved = VectorDocument.get(doc.pk)
    assert retrieved.name == "test"
