import asyncio
import json
from collections.abc import Iterable
from datetime import datetime
from typing import Any

import pytest
from pytest_mock import MockerFixture

from langgraph.store.base import (
    GetOp,
    InvalidNamespaceError,
    Item,
    Op,
    PutOp,
    Result,
    get_text_at_path,
)
from langgraph.store.base.batch import AsyncBatchedBaseStore
from langgraph.store.memory import InMemoryStore
from tests.embed_test_utils import CharacterEmbeddings


class MockAsyncBatchedStore(AsyncBatchedBaseStore):
    def __init__(self, **kwargs: Any) -> None:
        super().__init__()
        self._store = InMemoryStore(**kwargs)

    def batch(self, ops: Iterable[Op]) -> list[Result]:
        return self._store.batch(ops)

    async def abatch(self, ops: Iterable[Op]) -> list[Result]:
        return self._store.batch(ops)


async def test_async_batch_store_resilience() -> None:
    """Test that AsyncBatchedBaseStore recovers gracefully from task cancellation."""
    doc = {"foo": "bar"}
    async_store = MockAsyncBatchedStore()

    await async_store.aput(("foo", "langgraph", "foo"), "bar", doc)

    # Store the original task reference
    original_task = async_store._task
    assert original_task is not None
    assert not original_task.done()

    # Cancel the background task
    original_task.cancel()
    await asyncio.sleep(0.01)
    assert original_task.cancelled()

    # Perform a new operation - this should trigger _ensure_task() to create a new task
    result = await async_store.asearch(("foo", "langgraph", "foo"))
    assert len(result) > 0
    assert result[0].value == doc

    # Verify a new task was created
    new_task = async_store._task
    assert new_task is not None
    assert new_task is not original_task
    assert not new_task.done()

    # Test that operations continue to work with the new task
    doc2 = {"baz": "qux"}
    await async_store.aput(("test", "namespace"), "key", doc2)
    result2 = await async_store.aget(("test", "namespace"), "key")
    assert result2 is not None
    assert result2.value == doc2


def test_get_text_at_path() -> None:
    nested_data = {
        "name": "test",
        "info": {
            "age": 25,
            "tags": ["a", "b", "c"],
            "metadata": {"created": "2024-01-01", "updated": "2024-01-02"},
        },
        "items": [
            {"id": 1, "value": "first", "tags": ["x", "y"]},
            {"id": 2, "value": "second", "tags": ["y", "z"]},
            {"id": 3, "value": "third", "tags": ["z", "w"]},
        ],
        "empty": None,
        "zeros": [0, 0.0, "0"],
        "empty_list": [],
        "empty_dict": {},
    }

    assert get_text_at_path(nested_data, "$") == [
        json.dumps(nested_data, sort_keys=True)
    ]

    assert get_text_at_path(nested_data, "name") == ["test"]
    assert get_text_at_path(nested_data, "info.age") == ["25"]

    assert get_text_at_path(nested_data, "info.metadata.created") == ["2024-01-01"]

    assert get_text_at_path(nested_data, "items[0].value") == ["first"]
    assert get_text_at_path(nested_data, "items[-1].value") == ["third"]
    assert get_text_at_path(nested_data, "items[1].tags[0]") == ["y"]

    values = get_text_at_path(nested_data, "items[*].value")
    assert set(values) == {"first", "second", "third"}

    metadata_dates = get_text_at_path(nested_data, "info.metadata.*")
    assert set(metadata_dates) == {"2024-01-01", "2024-01-02"}
    name_and_age = get_text_at_path(nested_data, "{name,info.age}")
    assert set(name_and_age) == {"test", "25"}

    item_fields = get_text_at_path(nested_data, "items[*].{id,value}")
    assert set(item_fields) == {"1", "2", "3", "first", "second", "third"}

    all_tags = get_text_at_path(nested_data, "items[*].tags[*]")
    assert set(all_tags) == {"x", "y", "z", "w"}

    assert get_text_at_path(None, "any.path") == []
    assert get_text_at_path({}, "any.path") == []
    assert get_text_at_path(nested_data, "") == [
        json.dumps(nested_data, sort_keys=True)
    ]
    assert get_text_at_path(nested_data, "nonexistent") == []
    assert get_text_at_path(nested_data, "items[99].value") == []
    assert get_text_at_path(nested_data, "items[*].nonexistent") == []

    assert get_text_at_path(nested_data, "empty") == []
    assert get_text_at_path(nested_data, "empty_list") == ["[]"]
    assert get_text_at_path(nested_data, "empty_dict") == ["{}"]

    zeros = get_text_at_path(nested_data, "zeros[*]")
    assert set(zeros) == {"0", "0.0"}

    assert get_text_at_path(nested_data, "items[].value") == []
    assert get_text_at_path(nested_data, "items[abc].value") == []
    assert get_text_at_path(nested_data, "{unclosed") == []
    assert get_text_at_path(nested_data, "nested[{invalid}]") == []


async def test_async_batch_store(mocker: MockerFixture) -> None:
    abatch = mocker.stub()

    class MockStore(AsyncBatchedBaseStore):
        def batch(self, ops: Iterable[Op]) -> list[Result]:
            raise NotImplementedError

        async def abatch(self, ops: Iterable[Op]) -> list[Result]:
            assert all(isinstance(op, GetOp) for op in ops)
            abatch(ops)
            return [
                Item(
                    value={},
                    key=getattr(op, "key", ""),
                    namespace=getattr(op, "namespace", ()),
                    created_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
                    updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
                )
                for op in ops
            ]

    store = MockStore()

    # concurrent calls are batched
    results = await asyncio.gather(
        store.aget(namespace=("a",), key="b"),
        store.aget(namespace=("c",), key="d"),
    )
    assert results == [
        Item(
            value={},
            key="b",
            namespace=("a",),
            created_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
            updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
        ),
        Item(
            value={},
            key="d",
            namespace=("c",),
            created_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
            updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
        ),
    ]
    assert abatch.call_count == 1
    assert [tuple(c.args[0]) for c in abatch.call_args_list] == [
        (
            GetOp(("a",), "b", refresh_ttl=True),
            GetOp(("c",), "d", refresh_ttl=True),
        ),
    ]


async def test_async_batch_store_handles_cancellation() -> None:
    class MockStore(AsyncBatchedBaseStore):
        def batch(self, ops: Iterable[Op]) -> list[Result]:
            raise NotImplementedError

        async def abatch(self, ops: Iterable[Op]) -> list[Result]:
            assert all(isinstance(op, GetOp) for op in ops)
            return [
                Item(
                    value={},
                    key=getattr(op, "key", ""),
                    namespace=getattr(op, "namespace", ()),
                    created_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
                    updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
                )
                for op in ops
            ]

    store = MockStore()

    # Simulate cancellation
    task = asyncio.create_task(store.aget(namespace=("a",), key="b"))
    await asyncio.sleep(0)
    task.cancel()
    await asyncio.sleep(0)

    # Cancelling individual queries against the store should not break the store
    result = await store.aget(namespace=("c",), key="d")
    assert result == Item(
        value={},
        key="d",
        namespace=("c",),
        created_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
        updated_at=datetime(2024, 9, 24, 17, 29, 10, 128397),
    )


def test_list_namespaces_basic() -> None:
    store = InMemoryStore()

    namespaces = [
        ("a", "b", "c"),
        ("a", "b", "d", "e"),
        ("a", "b", "d", "i"),
        ("a", "b", "f"),
        ("a", "c", "f"),
        ("b", "a", "f"),
        ("users", "123"),
        ("users", "456", "settings"),
        ("admin", "users", "789"),
    ]

    for i, ns in enumerate(namespaces):
        store.put(namespace=ns, key=f"id_{i}", value={"data": f"value_{i:02d}"})

    result = store.list_namespaces(prefix=("a", "b"))
    expected = [
        ("a", "b", "c"),
        ("a", "b", "d", "e"),
        ("a", "b", "d", "i"),
        ("a", "b", "f"),
    ]
    assert sorted(result) == sorted(expected)

    result = store.list_namespaces(suffix=("f",))
    expected = [
        ("a", "b", "f"),
        ("a", "c", "f"),
        ("b", "a", "f"),
    ]
    assert sorted(result) == sorted(expected)

    result = store.list_namespaces(prefix=("a",), suffix=("f",))
    expected = [
        ("a", "b", "f"),
        ("a", "c", "f"),
    ]
    assert sorted(result) == sorted(expected)

    # Test max_depth
    result = store.list_namespaces(prefix=("a", "b"), max_depth=3)
    expected = [
        ("a", "b", "c"),
        ("a", "b", "d"),
        ("a", "b", "f"),
    ]
    assert sorted(result) == sorted(expected)

    # Test limit and offset
    result = store.list_namespaces(prefix=("a", "b"), limit=2)
    expected = [
        ("a", "b", "c"),
        ("a", "b", "d", "e"),
    ]
    assert result == expected

    result = store.list_namespaces(prefix=("a", "b"), offset=2)
    expected = [
        ("a", "b", "d", "i"),
        ("a", "b", "f"),
    ]
    assert result == expected

    result = store.list_namespaces(prefix=("a", "*", "f"))
    expected = [
        ("a", "b", "f"),
        ("a", "c", "f"),
    ]
    assert sorted(result) == sorted(expected)

    result = store.list_namespaces(suffix=("*", "f"))
    expected = [
        ("a", "b", "f"),
        ("a", "c", "f"),
        ("b", "a", "f"),
    ]
    assert sorted(result) == sorted(expected)

    result = store.list_namespaces(prefix=("nonexistent",))
    assert result == []

    result = store.list_namespaces(prefix=("users", "123"))
    expected = [("users", "123")]
    assert result == expected


def test_list_namespaces_with_wildcards() -> None:
    store = InMemoryStore()

    namespaces = [
        ("users", "123"),
        ("users", "456"),
        ("users", "789", "settings"),
        ("admin", "users", "789"),
        ("guests", "123"),
        ("guests", "456", "preferences"),
    ]

    for i, ns in enumerate(namespaces):
        store.put(namespace=ns, key=f"id_{i}", value={"data": f"value_{i:02d}"})

    result = store.list_namespaces(prefix=("users", "*"))
    expected = [
        ("users", "123"),
        ("users", "456"),
        ("users", "789", "settings"),
    ]
    assert sorted(result) == sorted(expected)

    result = store.list_namespaces(suffix=("*", "preferences"))
    expected = [
        ("guests", "456", "preferences"),
    ]
    assert result == expected

    result = store.list_namespaces(prefix=("*", "users"), suffix=("*", "settings"))

    assert result == []

    store.put(
        namespace=("admin", "users", "settings", "789"),
        key="foo",
        value={"data": "some_val"},
    )
    expected = [
        ("admin", "users", "settings", "789"),
    ]


def test_list_namespaces_pagination() -> None:
    store = InMemoryStore()

    for i in range(20):
        ns = ("namespace", f"sub_{i:02d}")
        store.put(namespace=ns, key=f"id_{i:02d}", value={"data": f"value_{i:02d}"})

    result = store.list_namespaces(prefix=("namespace",), limit=5, offset=0)
    expected = [("namespace", f"sub_{i:02d}") for i in range(5)]
    assert result == expected

    result = store.list_namespaces(prefix=("namespace",), limit=5, offset=5)
    expected = [("namespace", f"sub_{i:02d}") for i in range(5, 10)]
    assert result == expected

    result = store.list_namespaces(prefix=("namespace",), limit=5, offset=15)
    expected = [("namespace", f"sub_{i:02d}") for i in range(15, 20)]
    assert result == expected


def test_list_namespaces_max_depth() -> None:
    store = InMemoryStore()

    namespaces = [
        ("a", "b", "c", "d"),
        ("a", "b", "c", "e"),
        ("a", "b", "f"),
        ("a", "g"),
        ("h", "i", "j", "k"),
    ]

    for i, ns in enumerate(namespaces):
        store.put(namespace=ns, key=f"id_{i}", value={"data": f"value_{i:02d}"})

    result = store.list_namespaces(max_depth=2)
    expected = [
        ("a", "b"),
        ("a", "g"),
        ("h", "i"),
    ]
    assert sorted(result) == sorted(expected)


def test_list_namespaces_no_conditions() -> None:
    store = InMemoryStore()

    namespaces = [
        ("a", "b"),
        ("c", "d"),
        ("e", "f", "g"),
    ]

    for i, ns in enumerate(namespaces):
        store.put(namespace=ns, key=f"id_{i}", value={"data": f"value_{i:02d}"})

    result = store.list_namespaces()
    expected = namespaces
    assert sorted(result) == sorted(expected)


def test_list_namespaces_empty_store() -> None:
    store = InMemoryStore()

    result = store.list_namespaces()
    assert result == []


async def test_cannot_put_empty_namespace() -> None:
    store = InMemoryStore()
    doc = {"foo": "bar"}

    with pytest.raises(InvalidNamespaceError):
        store.put((), "foo", doc)

    with pytest.raises(InvalidNamespaceError):
        await store.aput((), "foo", doc)

    with pytest.raises(InvalidNamespaceError):
        store.put(("the", "thing.about"), "foo", doc)

    with pytest.raises(InvalidNamespaceError):
        await store.aput(("the", "thing.about"), "foo", doc)

    with pytest.raises(InvalidNamespaceError):
        store.put(("some", "fun", ""), "foo", doc)

    with pytest.raises(InvalidNamespaceError):
        await store.aput(("some", "fun", ""), "foo", doc)

    with pytest.raises(InvalidNamespaceError):
        await store.aput(("langgraph", "foo"), "bar", doc)

    with pytest.raises(InvalidNamespaceError):
        store.put(("langgraph", "foo"), "bar", doc)

    await store.aput(("foo", "langgraph", "foo"), "bar", doc)
    assert (await store.aget(("foo", "langgraph", "foo"), "bar")).value == doc  # type: ignore[union-attr]
    assert (await store.asearch(("foo", "langgraph", "foo"), query="bar"))[
        0
    ].value == doc
    await store.adelete(("foo", "langgraph", "foo"), "bar")
    assert (await store.aget(("foo", "langgraph", "foo"), "bar")) is None
    store.put(("foo", "langgraph", "foo"), "bar", doc)
    assert store.get(("foo", "langgraph", "foo"), "bar").value == doc  # type: ignore[union-attr]
    assert store.search(("foo", "langgraph", "foo"), query="bar")[0].value == doc
    store.delete(("foo", "langgraph", "foo"), "bar")
    assert store.get(("foo", "langgraph", "foo"), "bar") is None

    # Do the same but go past the public put api
    await store.abatch([PutOp(("langgraph", "foo"), "bar", doc)])
    assert (await store.aget(("langgraph", "foo"), "bar")).value == doc  # type: ignore[union-attr]
    assert (await store.asearch(("langgraph", "foo")))[0].value == doc
    await store.adelete(("langgraph", "foo"), "bar")
    assert (await store.aget(("langgraph", "foo"), "bar")) is None
    store.batch([PutOp(("langgraph", "foo"), "bar", doc)])
    assert store.get(("langgraph", "foo"), "bar").value == doc  # type: ignore[union-attr]
    assert store.search(("langgraph", "foo"))[0].value == doc
    store.delete(("langgraph", "foo"), "bar")
    assert store.get(("langgraph", "foo"), "bar") is None

    async_store = MockAsyncBatchedStore()
    doc = {"foo": "bar"}

    with pytest.raises(InvalidNamespaceError):
        await async_store.aput((), "foo", doc)

    with pytest.raises(InvalidNamespaceError):
        await async_store.aput(("the", "thing.about"), "foo", doc)

    with pytest.raises(InvalidNamespaceError):
        await async_store.aput(("some", "fun", ""), "foo", doc)

    with pytest.raises(InvalidNamespaceError):
        await async_store.aput(("langgraph", "foo"), "bar", doc)

    await async_store.aput(("foo", "langgraph", "foo"), "bar", doc)
    val = await async_store.aget(("foo", "langgraph", "foo"), "bar")
    assert val is not None
    assert val.value == doc
    assert (await async_store.asearch(("foo", "langgraph", "foo")))[0].value == doc
    assert (await async_store.asearch(("foo", "langgraph", "foo"), query="bar"))[
        0
    ].value == doc
    await async_store.adelete(("foo", "langgraph", "foo"), "bar")
    assert (await async_store.aget(("foo", "langgraph", "foo"), "bar")) is None

    await async_store.abatch([PutOp(("valid", "namespace"), "key", doc)])
    val = await async_store.aget(("valid", "namespace"), "key")
    assert val is not None
    assert val.value == doc
    assert (await async_store.asearch(("valid", "namespace")))[0].value == doc
    await async_store.adelete(("valid", "namespace"), "key")
    assert (await async_store.aget(("valid", "namespace"), "key")) is None


async def test_async_batch_store_deduplication(mocker: MockerFixture) -> None:
    abatch = mocker.spy(InMemoryStore, "batch")
    store = MockAsyncBatchedStore()

    same_doc = {"value": "same"}
    diff_doc = {"value": "different"}
    await asyncio.gather(
        store.aput(namespace=("test",), key="same", value=same_doc),
        store.aput(namespace=("test",), key="different", value=diff_doc),
    )
    abatch.reset_mock()

    results = await asyncio.gather(
        store.aget(namespace=("test",), key="same"),
        store.aget(namespace=("test",), key="same"),
        store.aget(namespace=("test",), key="different"),
    )

    assert len(results) == 3
    assert results[0] == results[1]
    assert results[0] != results[2]
    assert results[0].value == same_doc  # type: ignore
    assert results[2].value == diff_doc  # type: ignore
    assert len(abatch.call_args_list) == 1
    ops = list(abatch.call_args_list[0].args[1])
    assert len(ops) == 2
    assert GetOp(("test",), "same", refresh_ttl=True) in ops
    assert GetOp(("test",), "different", refresh_ttl=True) in ops

    abatch.reset_mock()

    doc1 = {"value": 1}
    doc2 = {"value": 2}
    results = await asyncio.gather(
        store.aput(namespace=("test",), key="key", value=doc1),
        store.aput(namespace=("test",), key="key", value=doc2),
    )
    assert len(abatch.call_args_list) == 1
    ops = list(abatch.call_args_list[0].args[1])
    assert len(ops) == 1
    assert ops[0] == PutOp(("test",), "key", doc2)
    assert len(results) == 2
    assert all(result is None for result in results)

    result = await store.aget(namespace=("test",), key="key")
    assert result is not None
    assert result.value == doc2

    abatch.reset_mock()

    results = await asyncio.gather(
        store.asearch(("test",), filter={"value": 2}),
        store.asearch(("test",), filter={"value": 2}),
    )
    assert len(abatch.call_args_list) == 1
    ops = list(abatch.call_args_list[0].args[1])
    assert len(ops) == 1
    assert len(results) == 2
    assert results[0] == results[1]
    assert len(results[0]) == 1
    assert results[0][0].value == doc2

    abatch.reset_mock()


@pytest.fixture
def fake_embeddings() -> CharacterEmbeddings:
    return CharacterEmbeddings(dims=500)


def test_vector_store_initialization(fake_embeddings: CharacterEmbeddings) -> None:
    """Test store initialization with embedding config."""
    store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    assert store.index_config is not None
    assert store.index_config["dims"] == fake_embeddings.dims
    assert store.index_config["embed"] == fake_embeddings


def test_vector_insert_with_auto_embedding(
    fake_embeddings: CharacterEmbeddings,
) -> None:
    """Test inserting items that get auto-embedded."""
    store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    docs = [
        ("doc1", {"text": "short text"}),
        ("doc2", {"text": "longer text document"}),
        ("doc3", {"text": "longest text document here"}),
        ("doc4", {"description": "text in description field"}),
        ("doc5", {"content": "text in content field"}),
        ("doc6", {"body": "text in body field"}),
    ]

    for key, value in docs:
        store.put(("test",), key, value)

    results = store.search(("test",), query="long text")
    assert len(results) > 0

    doc_order = [r.key for r in results]
    assert "doc2" in doc_order
    assert "doc3" in doc_order


async def test_async_vector_insert_with_auto_embedding(
    fake_embeddings: CharacterEmbeddings,
) -> None:
    """Test inserting items that get auto-embedded using async methods."""
    store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    docs = [
        ("doc1", {"text": "short text"}),
        ("doc2", {"text": "longer text document"}),
        ("doc3", {"text": "longest text document here"}),
        ("doc4", {"description": "text in description field"}),
        ("doc5", {"content": "text in content field"}),
        ("doc6", {"body": "text in body field"}),
    ]

    for key, value in docs:
        await store.aput(("test",), key, value)

    results = await store.asearch(("test",), query="long text")
    assert len(results) > 0

    doc_order = [r.key for r in results]
    assert "doc2" in doc_order
    assert "doc3" in doc_order


def test_vector_update_with_embedding(fake_embeddings: CharacterEmbeddings) -> None:
    """Test that updating items properly updates their embeddings."""
    store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    store.put(("test",), "doc1", {"text": "zany zebra Xerxes"})
    store.put(("test",), "doc2", {"text": "something about dogs"})
    store.put(("test",), "doc3", {"text": "text about birds"})

    results_initial = store.search(("test",), query="Zany Xerxes")
    assert len(results_initial) > 0
    assert results_initial[0].key == "doc1"
    initial_score = results_initial[0].score
    assert initial_score is not None

    store.put(("test",), "doc1", {"text": "new text about dogs"})

    results_after = store.search(("test",), query="Zany Xerxes")
    after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0)
    assert after_score is not None
    assert after_score < initial_score

    results_new = store.search(("test",), query="new text about dogs")
    for r in results_new:
        if r.key == "doc1":
            assert r.score > after_score

    # Don't index this one
    store.put(("test",), "doc4", {"text": "new text about dogs"}, index=False)
    results_new = store.search(("test",), query="new text about dogs", limit=3)
    assert not any(r.key == "doc4" for r in results_new)


async def test_async_vector_update_with_embedding(
    fake_embeddings: CharacterEmbeddings,
) -> None:
    """Test that updating items properly updates their embeddings using async methods."""
    store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    await store.aput(("test",), "doc1", {"text": "zany zebra Xerxes"})
    await store.aput(("test",), "doc2", {"text": "something about dogs"})
    await store.aput(("test",), "doc3", {"text": "text about birds"})

    results_initial = await store.asearch(("test",), query="Zany Xerxes")
    assert len(results_initial) > 0
    assert results_initial[0].key == "doc1"
    initial_score = results_initial[0].score

    await store.aput(("test",), "doc1", {"text": "new text about dogs"})

    results_after = await store.asearch(("test",), query="Zany Xerxes")
    after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0)
    assert after_score is not None
    assert after_score < initial_score

    results_new = await store.asearch(("test",), query="new text about dogs")
    for r in results_new:
        if r.key == "doc1":
            assert r.score is not None
            assert r.score > after_score

    # Don't index this one
    await store.aput(("test",), "doc4", {"text": "new text about dogs"}, index=False)
    results_new = await store.asearch(("test",), query="new text about dogs", limit=3)
    assert not any(r.key == "doc4" for r in results_new)


def test_vector_search_with_filters(fake_embeddings: CharacterEmbeddings) -> None:
    """Test combining vector search with filters."""
    inmem_store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    # Insert test documents
    docs = [
        ("doc1", {"text": "red apple", "color": "red", "score": 4.5}),
        ("doc2", {"text": "red car", "color": "red", "score": 3.0}),
        ("doc3", {"text": "green apple", "color": "green", "score": 4.0}),
        ("doc4", {"text": "blue car", "color": "blue", "score": 3.5}),
    ]

    for key, value in docs:
        inmem_store.put(("test",), key, value)

    results = inmem_store.search(("test",), query="apple", filter={"color": "red"})
    assert len(results) == 2
    assert results[0].key == "doc1"

    results = inmem_store.search(("test",), query="car", filter={"color": "red"})
    assert len(results) == 2
    assert results[0].key == "doc2"

    results = inmem_store.search(
        ("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}}
    )
    assert len(results) == 3
    assert results[0].key == "doc4"

    # Multiple filters
    results = inmem_store.search(
        ("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"}
    )
    assert len(results) == 1
    assert results[0].key == "doc3"


async def test_async_vector_search_with_filters(
    fake_embeddings: CharacterEmbeddings,
) -> None:
    """Test combining vector search with filters using async methods."""
    store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    # Insert test documents
    docs = [
        ("doc1", {"text": "red apple", "color": "red", "score": 4.5}),
        ("doc2", {"text": "red car", "color": "red", "score": 3.0}),
        ("doc3", {"text": "green apple", "color": "green", "score": 4.0}),
        ("doc4", {"text": "blue car", "color": "blue", "score": 3.5}),
    ]

    for key, value in docs:
        await store.aput(("test",), key, value)

    results = await store.asearch(("test",), query="apple", filter={"color": "red"})
    assert len(results) == 2
    assert results[0].key == "doc1"

    results = await store.asearch(("test",), query="car", filter={"color": "red"})
    assert len(results) == 2
    assert results[0].key == "doc2"

    results = await store.asearch(
        ("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}}
    )
    assert len(results) == 3
    assert results[0].key == "doc4"

    # Multiple filters
    results = await store.asearch(
        ("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"}
    )
    assert len(results) == 1
    assert results[0].key == "doc3"


async def test_async_batched_vector_search_concurrent(
    fake_embeddings: CharacterEmbeddings,
) -> None:
    """Test concurrent vector search operations using async batched store."""
    store = MockAsyncBatchedStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )

    colors = ["red", "blue", "green", "yellow", "purple"]
    items = ["apple", "car", "house", "book", "phone"]
    scores = [3.0, 3.5, 4.0, 4.5, 5.0]

    docs = []
    for i in range(50):
        color = colors[i % len(colors)]
        item = items[i % len(items)]
        score = scores[i % len(scores)]
        docs.append(
            (
                f"doc{i}",
                {"text": f"{color} {item}", "color": color, "score": score, "index": i},
            )
        )
    coros = [
        *[store.aput(("test",), key, value) for key, value in docs],
        *[store.adelete(("test",), key) for key, value in docs],
        *[store.aput(("test",), key, value) for key, value in docs],
    ]
    await asyncio.gather(*coros)

    # Prepare multiple search queries with different filters
    search_queries: list[tuple[str, dict[str, Any]]] = [
        ("apple", {"color": "red"}),
        ("car", {"color": "blue"}),
        ("house", {"color": "green"}),
        ("phone", {"score": {"$gt": 4.99}}),
        ("book", {"score": {"$lte": 3.5}}),
        ("apple", {"score": {"$gte": 3.0}, "color": "red"}),
        ("car", {"score": {"$lt": 5.1}, "color": "blue"}),
        ("house", {"index": {"$gt": 25}}),
        ("phone", {"index": {"$lte": 10}}),
    ]

    all_results = await asyncio.gather(
        *[
            store.asearch(("test",), query=query, filter=filter_)
            for query, filter_ in search_queries
        ]
    )

    for results, (query, filter_) in zip(all_results, search_queries, strict=False):
        assert len(results) > 0, f"No results for query '{query}' with filter {filter_}"

        for result in results:
            if "color" in filter_:
                assert result.value["color"] == filter_["color"]

            if "score" in filter_:
                score = result.value["score"]
                for op, value in filter_["score"].items():
                    if op == "$gt":
                        assert score > value
                    elif op == "$gte":
                        assert score >= value
                    elif op == "$lt":
                        assert score < value
                    elif op == "$lte":
                        assert score <= value

            if "index" in filter_:
                index = result.value["index"]
                for op, value in filter_["index"].items():
                    if op == "$gt":
                        assert index > value
                    elif op == "$gte":
                        assert index >= value
                    elif op == "$lt":
                        assert index < value
                    elif op == "$lte":
                        assert index <= value


def test_vector_search_pagination(fake_embeddings: CharacterEmbeddings) -> None:
    """Test pagination with vector search."""
    store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    for i in range(5):
        store.put(("test",), f"doc{i}", {"text": f"test document number {i}"})

    results_page1 = store.search(("test",), query="test", limit=2)
    results_page2 = store.search(("test",), query="test", limit=2, offset=2)

    assert len(results_page1) == 2
    assert len(results_page2) == 2
    assert results_page1[0].key != results_page2[0].key

    all_results = store.search(("test",), query="test", limit=10)
    assert len(all_results) == 5


async def test_async_vector_search_pagination(
    fake_embeddings: CharacterEmbeddings,
) -> None:
    """Test pagination with vector search using async methods."""
    store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    for i in range(5):
        await store.aput(("test",), f"doc{i}", {"text": f"test document number {i}"})

    results_page1 = await store.asearch(("test",), query="test", limit=2)
    results_page2 = await store.asearch(("test",), query="test", limit=2, offset=2)

    assert len(results_page1) == 2
    assert len(results_page2) == 2
    assert results_page1[0].key != results_page2[0].key

    all_results = await store.asearch(("test",), query="test", limit=10)
    assert len(all_results) == 5


async def test_embed_with_path(fake_embeddings: CharacterEmbeddings) -> None:
    # Test store-level field configuration
    store = InMemoryStore(
        index={
            "dims": fake_embeddings.dims,
            "embed": fake_embeddings,
            # Key 2 isn't included. Don't index it.
            "fields": ["key0", "key1", "key3"],
        }
    )
    # This will have 2 vectors representing it
    doc1 = {
        # Omit key0 - check it doesn't raise an error
        "key1": "xxx",
        "key2": "yyy",
        "key3": "zzz",
    }
    # This will have 3 vectors representing it
    doc2 = {
        "key0": "uuu",
        "key1": "vvv",
        "key2": "www",
        "key3": "xxx",
    }
    await store.aput(("test",), "doc1", doc1)
    await store.aput(("test",), "doc2", doc2)

    # doc2.key3 and doc1.key1 both would have the highest score
    results = await store.asearch(("test",), query="xxx")
    assert len(results) == 2
    assert results[0].key != results[1].key
    ascore = results[0].score
    bscore = results[1].score
    assert ascore is not None and bscore is not None
    assert ascore == pytest.approx(bscore, abs=1e-5)

    results = await store.asearch(("test",), query="uuu")
    assert len(results) == 2
    assert results[0].key != results[1].key
    assert results[0].key == "doc2"
    assert results[0].score is not None and results[0].score > results[1].score
    assert ascore == pytest.approx(results[0].score, abs=1e-5)

    # Un-indexed - will have low results for both. Not zero (because we're projecting)
    # but less than the above.
    results = await store.asearch(("test",), query="www")
    assert len(results) == 2
    assert results[0].score < ascore
    assert results[1].score < ascore

    # Test operation-level field configuration
    store_no_defaults = InMemoryStore(
        index={
            "dims": fake_embeddings.dims,
            "embed": fake_embeddings,
            "fields": ["key17"],
        }
    )

    doc3 = {
        "key0": "aaa",
        "key1": "bbb",
        "key2": "ccc",
        "key3": "ddd",
    }
    doc4 = {
        "key0": "eee",
        "key1": "bbb",  # Same as doc3.key1
        "key2": "fff",
        "key3": "ggg",
    }

    await store_no_defaults.aput(("test",), "doc3", doc3, index=["key0", "key1"])
    await store_no_defaults.aput(("test",), "doc4", doc4, index=["key1", "key3"])

    results = await store_no_defaults.asearch(("test",), query="aaa")
    assert len(results) == 2
    assert results[0].key == "doc3"
    assert results[0].score is not None and results[0].score > results[1].score

    results = await store_no_defaults.asearch(("test",), query="ggg")
    assert len(results) == 2
    assert results[0].key == "doc4"
    assert results[0].score is not None and results[0].score > results[1].score

    results = await store_no_defaults.asearch(("test",), query="bbb")
    assert len(results) == 2
    assert results[0].key != results[1].key
    assert results[0].score == results[1].score

    results = await store_no_defaults.asearch(("test",), query="ccc")
    assert len(results) == 2
    assert all(r.score < ascore for r in results)

    doc5 = {
        "key0": "hhh",
        "key1": "iii",
    }
    await store_no_defaults.aput(("test",), "doc5", doc5, index=False)

    results = await store_no_defaults.asearch(("test",), query="hhh")
    assert len(results) == 3
    doc5_result = next(r for r in results if r.key == "doc5")
    assert doc5_result.score is None


def test_non_ascii(fake_embeddings: CharacterEmbeddings) -> None:
    """Test support for non-ascii characters"""
    store = InMemoryStore(
        index={"dims": fake_embeddings.dims, "embed": fake_embeddings}
    )
    store.put(("user_123", "memories"), "1", {"text": "这是中文"})  # Chinese
    store.put(("user_123", "memories"), "2", {"text": "これは日本語です"})  # Japanese
    store.put(("user_123", "memories"), "3", {"text": "이건 한국어야"})  # Korean
    store.put(("user_123", "memories"), "4", {"text": "Это русский"})  # Russian
    store.put(("user_123", "memories"), "5", {"text": "यह रूसी है"})  # Hindi

    result1 = store.search(("user_123", "memories"), query="这是中文")
    result2 = store.search(("user_123", "memories"), query="これは日本語です")
    result3 = store.search(("user_123", "memories"), query="이건 한국어야")
    result4 = store.search(("user_123", "memories"), query="Это русский")
    result5 = store.search(("user_123", "memories"), query="यह रूसी है")

    assert result1[0].key == "1"
    assert result2[0].key == "2"
    assert result3[0].key == "3"
    assert result4[0].key == "4"
    assert result5[0].key == "5"
