import os
import re
import tempfile
import uuid
from collections.abc import Generator, Iterable
from contextlib import contextmanager
from typing import Any, Literal, cast

import pytest
from langchain_core.embeddings import Embeddings
from langgraph.store.base import (
    GetOp,
    Item,
    ListNamespacesOp,
    MatchCondition,
    PutOp,
    SearchOp,
)

from langgraph.store.sqlite import SqliteStore
from langgraph.store.sqlite.base import SqliteIndexConfig


# Local embeddings implementation for testing vector search
class CharacterEmbeddings(Embeddings):
    """Simple character-frequency based embeddings using random projections."""

    def __init__(self, dims: int = 50, seed: int = 42):
        """Initialize with embedding dimensions and random seed."""
        import math
        import random
        from collections import defaultdict

        self._rng = random.Random(seed)
        self.dims = dims
        # Create projection vector for each character lazily
        self._char_projections: dict[str, list[float]] = defaultdict(
            lambda: [
                self._rng.gauss(0, 1 / math.sqrt(self.dims)) for _ in range(self.dims)
            ]
        )

    def _embed_one(self, text: str) -> list[float]:
        """Embed a single text."""
        import math
        from collections import Counter

        counts = Counter(text)
        total = sum(counts.values())

        if total == 0:
            return [0.0] * self.dims

        embedding = [0.0] * self.dims
        for char, count in counts.items():
            weight = count / total
            char_proj = self._char_projections[char]
            for i, proj in enumerate(char_proj):
                embedding[i] += weight * proj

        norm = math.sqrt(sum(x * x for x in embedding))
        if norm > 0:
            embedding = [x / norm for x in embedding]

        return embedding

    def embed_documents(self, texts: list[str]) -> list[list[float]]:
        """Embed a list of documents."""
        return [self._embed_one(text) for text in texts]

    def embed_query(self, text: str) -> list[float]:
        """Embed a query string."""
        return self._embed_one(text)

    def __eq__(self, other: Any) -> bool:
        return isinstance(other, CharacterEmbeddings) and self.dims == other.dims


@pytest.fixture(scope="function", params=["memory", "file"])
def store(request: Any) -> Generator[SqliteStore, None, None]:
    """Create a SqliteStore for testing."""
    if request.param == "memory":
        # In-memory store
        with SqliteStore.from_conn_string(":memory:") as store:
            store.setup()
            yield store
    else:
        # Temporary file store
        temp_file = tempfile.NamedTemporaryFile(delete=False)
        temp_file.close()
        try:
            with SqliteStore.from_conn_string(temp_file.name) as store:
                store.setup()
                yield store
        finally:
            os.unlink(temp_file.name)


@pytest.fixture(scope="function")
def fake_embeddings() -> CharacterEmbeddings:
    """Create fake embeddings for testing."""
    return CharacterEmbeddings(dims=500)


# Define vector types and distance types for parametrized tests
VECTOR_TYPES = ["cosine"]  # SQLite only supports cosine similarity


@contextmanager
def create_vector_store(
    fake_embeddings: CharacterEmbeddings,
    text_fields: list[str] | None = None,
    distance_type: str = "cosine",
    conn_type: Literal["memory", "file"] = "memory",
) -> Generator[SqliteStore, None, None]:
    """Create a SqliteStore with vector search enabled."""
    index_config: SqliteIndexConfig = {
        "dims": fake_embeddings.dims,
        "embed": fake_embeddings,
        "text_fields": text_fields,
        "distance_type": distance_type,  # This is for API consistency but SQLite only supports cosine
    }
    if conn_type == "memory":
        conn_str = ":memory:"
    else:
        temp_file = tempfile.NamedTemporaryFile(delete=False)
        temp_file.close()
        conn_str = temp_file.name

    try:
        with SqliteStore.from_conn_string(conn_str, index=index_config) as store:
            store.setup()
            yield store
    finally:
        if conn_type == "file":
            os.unlink(conn_str)


def test_batch_order(store: SqliteStore) -> None:
    # Setup test data
    store.put(("test", "foo"), "key1", {"data": "value1"})
    store.put(("test", "bar"), "key2", {"data": "value2"})

    ops = [
        GetOp(namespace=("test", "foo"), key="key1"),
        PutOp(namespace=("test", "bar"), key="key2", value={"data": "value2"}),
        SearchOp(
            namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
        ),
        ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0),
        GetOp(namespace=("test",), key="key3"),
    ]

    results = store.batch(
        cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops)
    )
    assert len(results) == 5
    assert isinstance(results[0], Item)
    assert isinstance(results[0].value, dict)
    assert results[0].value == {"data": "value1"}
    assert results[0].key == "key1"
    assert results[0].namespace == ("test", "foo")
    assert results[1] is None  # Put operation returns None
    assert isinstance(results[2], list)
    assert len(results[2]) == 1
    assert results[2][0].key == "key1"
    assert results[2][0].value == {"data": "value1"}
    assert isinstance(results[3], list)
    assert len(results[3]) > 0  # Should contain at least our test namespaces
    assert ("test", "foo") in results[3]
    assert ("test", "bar") in results[3]
    assert results[4] is None  # Non-existent key returns None

    # Test reordered operations
    ops_reordered = [
        SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
        GetOp(namespace=("test", "bar"), key="key2"),
        ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0),
        PutOp(namespace=("test",), key="key3", value={"data": "value3"}),
        GetOp(namespace=("test", "foo"), key="key1"),
    ]

    results_reordered = store.batch(
        cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops_reordered)
    )
    assert len(results_reordered) == 5
    assert isinstance(results_reordered[0], list)
    assert len(results_reordered[0]) >= 2  # Should find at least our two test items
    assert isinstance(results_reordered[1], Item)
    assert results_reordered[1].value == {"data": "value2"}
    assert results_reordered[1].key == "key2"
    assert results_reordered[1].namespace == ("test", "bar")
    assert isinstance(results_reordered[2], list)
    assert len(results_reordered[2]) > 0
    assert results_reordered[3] is None  # Put operation returns None
    assert isinstance(results_reordered[4], Item)
    assert results_reordered[4].value == {"data": "value1"}
    assert results_reordered[4].key == "key1"
    assert results_reordered[4].namespace == ("test", "foo")

    # Verify the put worked
    item3 = store.get(("test",), "key3")
    assert item3 is not None
    assert item3.value == {"data": "value3"}


def test_batch_get_ops(store: SqliteStore) -> None:
    # Setup test data
    store.put(("test",), "key1", {"data": "value1"})
    store.put(("test",), "key2", {"data": "value2"})

    ops = [
        GetOp(namespace=("test",), key="key1"),
        GetOp(namespace=("test",), key="key2"),
        GetOp(namespace=("test",), key="key3"),  # Non-existent key
    ]

    results = store.batch(ops)

    assert len(results) == 3
    assert results[0] is not None
    assert results[1] is not None
    assert results[2] is None
    assert results[0].key == "key1"
    assert results[1].key == "key2"


def test_batch_put_ops(store: SqliteStore) -> None:
    ops = [
        PutOp(namespace=("test",), key="key1", value={"data": "value1"}),
        PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
        PutOp(namespace=("test",), key="key3", value=None),  # Delete operation
    ]

    results = store.batch(ops)
    assert len(results) == 3
    assert all(result is None for result in results)

    # Verify the puts worked
    item1 = store.get(("test",), "key1")
    item2 = store.get(("test",), "key2")
    item3 = store.get(("test",), "key3")

    assert item1 and item1.value == {"data": "value1"}
    assert item2 and item2.value == {"data": "value2"}
    assert item3 is None


def test_batch_search_ops(store: SqliteStore) -> None:
    # Setup test data
    test_data = [
        (("test", "foo"), "key1", {"data": "value1", "tag": "a"}),
        (("test", "bar"), "key2", {"data": "value2", "tag": "a"}),
        (("test", "baz"), "key3", {"data": "value3", "tag": "b"}),
    ]
    for namespace, key, value in test_data:
        store.put(namespace, key, value)

    ops = [
        SearchOp(namespace_prefix=("test",), filter={"tag": "a"}, limit=10, offset=0),
        SearchOp(namespace_prefix=("test",), filter=None, limit=2, offset=0),
        SearchOp(namespace_prefix=("test", "foo"), filter=None, limit=10, offset=0),
    ]

    results = store.batch(ops)
    assert len(results) == 3

    # First search should find items with tag "a"
    assert len(results[0]) == 2
    assert all(item.value["tag"] == "a" for item in results[0])

    # Second search should return first 2 items
    assert len(results[1]) == 2

    # Third search should only find items in test/foo namespace
    assert len(results[2]) == 1
    assert results[2][0].namespace == ("test", "foo")


def test_batch_list_namespaces_ops(store: SqliteStore) -> None:
    # Setup test data with various namespaces
    test_data = [
        (("test", "documents", "public"), "doc1", {"content": "public doc"}),
        (("test", "documents", "private"), "doc2", {"content": "private doc"}),
        (("test", "images", "public"), "img1", {"content": "public image"}),
        (("prod", "documents", "public"), "doc3", {"content": "prod doc"}),
    ]
    for namespace, key, value in test_data:
        store.put(namespace, key, value)

    ops = [
        ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0),
        ListNamespacesOp(match_conditions=None, max_depth=2, limit=10, offset=0),
        ListNamespacesOp(
            match_conditions=tuple([MatchCondition("suffix", ("public",))]),
            max_depth=None,
            limit=10,
            offset=0,
        ),
    ]

    results = store.batch(
        cast(Iterable[GetOp | PutOp | SearchOp | ListNamespacesOp], ops)
    )
    assert len(results) == 3

    # First operation should list all namespaces
    assert len(results[0]) == len(test_data)

    # Second operation should only return namespaces up to depth 2
    assert all(len(ns) <= 2 for ns in results[1])

    # Third operation should only return namespaces ending with "public"
    assert all(ns[-1] == "public" for ns in results[2])


class TestSqliteStore:
    def test_basic_store_ops(self) -> None:
        with SqliteStore.from_conn_string(":memory:") as store:
            store.setup()
            namespace = ("test", "documents")
            item_id = "doc1"
            item_value = {"title": "Test Document", "content": "Hello, World!"}

            store.put(namespace, item_id, item_value)
            item = store.get(namespace, item_id)

            assert item
            assert item.namespace == namespace
            assert item.key == item_id
            assert item.value == item_value

            # Test update
            # Small delay to ensure the updated timestamp is different
            import time

            time.sleep(0.01)

            updated_value = {"title": "Updated Document", "content": "Hello, Updated!"}
            store.put(namespace, item_id, updated_value)
            updated_item = store.get(namespace, item_id)

            assert updated_item.value == updated_value
            # Don't check timestamps because SQLite execution might be too fast
            # assert updated_item.updated_at > item.updated_at

            # Test get from non-existent namespace
            different_namespace = ("test", "other_documents")
            item_in_different_namespace = store.get(different_namespace, item_id)
            assert item_in_different_namespace is None

            # Test delete
            store.delete(namespace, item_id)
            deleted_item = store.get(namespace, item_id)
            assert deleted_item is None

    def test_list_namespaces(self) -> None:
        with SqliteStore.from_conn_string(":memory:") as store:
            store.setup()
            # Create test data with various namespaces
            test_namespaces = [
                ("test", "documents", "public"),
                ("test", "documents", "private"),
                ("test", "images", "public"),
                ("test", "images", "private"),
                ("prod", "documents", "public"),
                ("prod", "documents", "private"),
            ]

            # Insert test data
            for namespace in test_namespaces:
                store.put(namespace, "dummy", {"content": "dummy"})

            # Test listing with various filters
            all_namespaces = store.list_namespaces()
            assert len(all_namespaces) == len(test_namespaces)

            # Test prefix filtering
            test_prefix_namespaces = store.list_namespaces(prefix=["test"])
            assert len(test_prefix_namespaces) == 4
            assert all(ns[0] == "test" for ns in test_prefix_namespaces)

            # Test suffix filtering
            public_namespaces = store.list_namespaces(suffix=["public"])
            assert len(public_namespaces) == 3
            assert all(ns[-1] == "public" for ns in public_namespaces)

            # Test max depth
            depth_2_namespaces = store.list_namespaces(max_depth=2)
            assert all(len(ns) <= 2 for ns in depth_2_namespaces)

            # Test pagination
            paginated_namespaces = store.list_namespaces(limit=3)
            assert len(paginated_namespaces) == 3

            # Cleanup
            for namespace in test_namespaces:
                store.delete(namespace, "dummy")

    def test_search(self) -> None:
        with SqliteStore.from_conn_string(":memory:") as store:
            store.setup()
            # Create test data
            test_data = [
                (
                    ("test", "docs"),
                    "doc1",
                    {"title": "First Doc", "author": "Alice", "tags": ["important"]},
                ),
                (
                    ("test", "docs"),
                    "doc2",
                    {"title": "Second Doc", "author": "Bob", "tags": ["draft"]},
                ),
                (
                    ("test", "images"),
                    "img1",
                    {"title": "Image 1", "author": "Alice", "tags": ["final"]},
                ),
            ]

            for namespace, key, value in test_data:
                store.put(namespace, key, value)

            # Test basic search
            all_items = store.search(["test"])
            assert len(all_items) == 3

            # Test namespace filtering
            docs_items = store.search(["test", "docs"])
            assert len(docs_items) == 2
            assert all(item.namespace == ("test", "docs") for item in docs_items)

            # Test value filtering
            alice_items = store.search(["test"], filter={"author": "Alice"})
            assert len(alice_items) == 2
            assert all(item.value["author"] == "Alice" for item in alice_items)

            # Test pagination
            paginated_items = store.search(["test"], limit=2)
            assert len(paginated_items) == 2

            offset_items = store.search(["test"], offset=2)
            assert len(offset_items) == 1

            # Cleanup
            for namespace, key, _ in test_data:
                store.delete(namespace, key)


def test_vector_store_initialization(fake_embeddings: CharacterEmbeddings) -> None:
    """Test store initialization with embedding config."""
    # Basic initialization
    with create_vector_store(fake_embeddings) as store:
        assert store.index_config is not None
        assert store.embeddings == fake_embeddings
        assert store.index_config["dims"] == fake_embeddings.dims
        assert store.index_config.get("text_fields") is None

    # With text fields specified
    text_fields = ["content", "title"]
    with create_vector_store(fake_embeddings, text_fields=text_fields) as store:
        assert store.index_config is not None
        assert store.embeddings == fake_embeddings
        assert store.index_config["dims"] == fake_embeddings.dims
        assert store.index_config["text_fields"] == text_fields

    # Ensure store setup properly creates the vector tables
    with create_vector_store(fake_embeddings) as store:
        # Check if vector tables exist
        cursor = store.conn.cursor()
        cursor.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%vector%'"
        )
        tables = cursor.fetchall()
        assert len(tables) >= 1, "Vector tables were not created"


@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
@pytest.mark.parametrize("conn_type", ["memory", "file"])
def test_vector_insert_with_auto_embedding(
    fake_embeddings: CharacterEmbeddings,
    distance_type: str,
    conn_type: Literal["memory", "file"],
) -> None:
    """Test inserting items that get auto-embedded."""
    with create_vector_store(
        fake_embeddings, distance_type=distance_type, conn_type=conn_type
    ) as store:
        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


@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
@pytest.mark.parametrize("conn_type", ["memory", "file"])
def test_vector_update_with_embedding(
    fake_embeddings: CharacterEmbeddings,
    distance_type: str,
    conn_type: Literal["memory", "file"],
) -> None:
    """Test that updating items properly updates their embeddings."""
    with create_vector_store(
        fake_embeddings, distance_type=distance_type, conn_type=conn_type
    ) as store:
        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

        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 < 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)


@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
def test_vector_search_with_filters(
    fake_embeddings: CharacterEmbeddings,
    distance_type: str,
) -> None:
    """Test combining vector search with filters."""
    with create_vector_store(fake_embeddings, distance_type=distance_type) as store:
        # 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:
            store.put(("test",), key, value)

        results = store.search(("test",), query="apple", filter={"color": "red"})

        # Check ordering and score - verify "doc1" is first result
        assert len(results) == 2
        assert results[0].key == "doc1"

        results = store.search(("test",), query="car", filter={"color": "red"})
        # Check ordering - verify "doc2" is first result
        assert len(results) > 0
        assert results[0].key == "doc2"

        results = store.search(
            ("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}}
        )
        # There should be 3 documents with score > 3.2
        assert len(results) == 3
        # Check that the blue car is the most similar to "bbbbluuu" query
        assert results[0].key == "doc4"  # The blue car should be the most relevant
        # Verify remaining docs are ordered by appropriate similarity
        high_score_keys = [r.key for r in results]
        assert "doc1" in high_score_keys  # score 4.5
        assert "doc3" in high_score_keys  # score 4.0

        # Multiple filters
        results = store.search(
            ("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"}
        )
        # Check that doc3 is the top result
        assert len(results) > 0
        assert results[0].key == "doc3"


@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
def test_vector_search_pagination(
    fake_embeddings: CharacterEmbeddings,
    distance_type: str,
) -> None:
    """Test pagination with vector search."""
    with create_vector_store(fake_embeddings, distance_type=distance_type) as store:
        # Insert multiple similar documents
        for i in range(5):
            store.put(("test",), f"doc{i}", {"text": f"test document number {i}"})

        # Test with different page sizes
        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
        # Make sure different pages have different results
        assert results_page1[0].key != results_page2[0].key
        assert results_page1[1].key != results_page2[0].key
        assert results_page1[0].key != results_page2[1].key
        assert results_page1[1].key != results_page2[1].key

        # Check scores are in descending order within each page
        assert results_page1[0].score >= results_page1[1].score
        assert results_page2[0].score >= results_page2[1].score

        # First page results should have higher scores than second page
        all_results = store.search(("test",), query="test", limit=10)
        assert len(all_results) == 5
        assert (
            all_results[0].score >= all_results[2].score
        )  # First page vs second page start


@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
def test_vector_search_edge_cases(
    fake_embeddings: CharacterEmbeddings,
    distance_type: str,
) -> None:
    """Test edge cases in vector search."""
    with create_vector_store(fake_embeddings, distance_type=distance_type) as store:
        store.put(("test",), "doc1", {"text": "test document"})

        results = store.search(("test",), query="")
        assert len(results) == 1

        results = store.search(("test",), query=None)
        assert len(results) == 1

        long_query = "test " * 100
        results = store.search(("test",), query=long_query)
        assert len(results) == 1

        special_query = "test!@#$%^&*()"
        results = store.search(("test",), query=special_query)
        assert len(results) == 1


@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
def test_embed_with_path(
    fake_embeddings: CharacterEmbeddings,
    distance_type: str,
) -> None:
    """Test vector search with specific text fields in SQLite store."""
    with create_vector_store(
        fake_embeddings,
        text_fields=["key0", "key1", "key3"],
        distance_type=distance_type,
    ) as store:
        # 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",
        }
        store.put(("test",), "doc1", doc1)
        store.put(("test",), "doc2", doc2)

        # doc2.key3 and doc1.key1 both would have the highest score
        results = store.search(("test",), query="xxx")
        assert len(results) == 2
        assert results[0].key != results[1].key
        assert results[0].score > 0.9
        assert results[1].score > 0.9

        # ~Only match doc2
        results = store.search(("test",), query="uuu")
        assert len(results) == 2
        assert results[0].key != results[1].key
        assert results[0].key == "doc2"
        assert results[0].score > results[1].score

        # ~Only match doc1
        results = store.search(("test",), query="zzz")
        assert len(results) == 2
        assert results[0].key != results[1].key
        assert results[0].key == "doc1"
        assert results[0].score > results[1].score

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


@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
def test_embed_with_path_operation_config(
    fake_embeddings: CharacterEmbeddings,
    distance_type: str,
) -> None:
    """Test operation-level field configuration for vector search."""
    with create_vector_store(
        fake_embeddings, text_fields=["key17"], distance_type=distance_type
    ) as store:
        doc3 = {
            "key0": "aaa",
            "key1": "bbb",
            "key2": "ccc",
            "key3": "ddd",
        }
        doc4 = {
            "key0": "eee",
            "key1": "bbb",  # Same as doc3.key1
            "key2": "fff",
            "key3": "ggg",
        }

        store.put(("test",), "doc3", doc3, index=["key0", "key1"])
        store.put(("test",), "doc4", doc4, index=["key1", "key3"])

        results = store.search(("test",), query="aaa")
        assert len(results) == 2
        assert results[0].key == "doc3"
        assert len(set(r.key for r in results)) == 2
        assert results[0].score > results[1].score

        results = store.search(("test",), query="ggg")
        assert len(results) == 2
        assert results[0].key == "doc4"
        assert results[0].score > results[1].score

        results = store.search(("test",), query="bbb")
        assert len(results) == 2
        assert results[0].key != results[1].key
        assert abs(results[0].score - results[1].score) < 0.1  # Similar scores

        results = store.search(("test",), query="ccc")
        assert len(results) == 2
        assert all(
            r.score < 0.9 for r in results
        )  # Unindexed field should have low scores

        # Test index=False behavior
        doc5 = {
            "key0": "hhh",
            "key1": "iii",
        }
        store.put(("test",), "doc5", doc5, index=False)
        results = store.search(("test",))
        assert len(results) == 3
        assert any(r.key == "doc5" for r in results)


# Helper functions for vector similarity calculations
def _cosine_similarity(X: list[float], Y: list[list[float]]) -> list[float]:
    """
    Compute cosine similarity between a vector X and a matrix Y.
    Lazy import numpy for efficiency.
    """

    similarities = []
    for y in Y:
        dot_product = sum(a * b for a, b in zip(X, y, strict=False))
        norm1 = sum(a * a for a in X) ** 0.5
        norm2 = sum(a * a for a in y) ** 0.5
        similarity = dot_product / (norm1 * norm2) if norm1 > 0 and norm2 > 0 else 0.0
        similarities.append(similarity)

    return similarities


@pytest.mark.parametrize("query", ["aaa", "bbb", "ccc", "abcd", "poisson"])
@pytest.mark.parametrize("conn_type", ["memory", "file"])
def test_scores(
    fake_embeddings: CharacterEmbeddings,
    query: str,
    conn_type: Literal["memory", "file"],
) -> None:
    """Test operation-level field configuration for vector search."""
    with create_vector_store(
        fake_embeddings,
        text_fields=["key0"],
        distance_type="cosine",
        conn_type=conn_type,
    ) as store:
        doc = {
            "key0": "aaa",
        }
        store.put(("test",), "doc", doc, index=["key0", "key1"])

        results = store.search((), query=query)
        vec0 = fake_embeddings.embed_query(doc["key0"])
        vec1 = fake_embeddings.embed_query(query)

        # SQLite uses cosine similarity by default
        similarities = _cosine_similarity(vec1, [vec0])

        assert len(results) == 1
        assert results[0].score == pytest.approx(similarities[0], abs=1e-3)


def test_nonnull_migrations() -> None:
    """Test that all migration statements are non-null."""
    _leading_comment_remover = re.compile(r"^/\*.*?\*/")
    for migration in SqliteStore.MIGRATIONS:
        statement = _leading_comment_remover.sub("", migration).split()[0]
        assert statement.strip(), f"Empty migration statement found: {migration}"


def test_basic_store_operations(
    fake_embeddings: CharacterEmbeddings,
) -> None:
    """Test basic store operations with SQLite store."""
    with create_vector_store(
        fake_embeddings, text_fields=["key0", "key1", "key3"]
    ) as store:
        uid = uuid.uuid4().hex
        namespace = (uid, "test", "documents")
        item_id = "doc1"
        item_value = {"title": "Test Document", "content": "Hello, World!"}
        results = store.search((uid,))
        assert len(results) == 0

        store.put(namespace, item_id, item_value)
        item = store.get(namespace, item_id)

        assert item is not None
        assert item.namespace == namespace
        assert item.key == item_id
        assert item.value == item_value
        assert item.created_at is not None
        assert item.updated_at is not None

        updated_value = {
            "title": "Updated Test Document",
            "content": "Hello, LangGraph!",
        }
        store.put(namespace, item_id, updated_value)
        updated_item = store.get(namespace, item_id)
        assert updated_item is not None

        assert updated_item.value == updated_value
        assert updated_item.updated_at >= item.updated_at

        different_namespace = (uid, "test", "other_documents")
        item_in_different_namespace = store.get(different_namespace, item_id)
        assert item_in_different_namespace is None

        new_item_id = "doc2"
        new_item_value = {"title": "Another Document", "content": "Greetings!"}
        store.put(namespace, new_item_id, new_item_value)

        items = store.search((uid, "test"), limit=10)
        assert len(items) == 2
        assert any(item.key == item_id for item in items)
        assert any(item.key == new_item_id for item in items)

        namespaces = store.list_namespaces(prefix=(uid, "test"))
        assert (uid, "test", "documents") in namespaces

        store.delete(namespace, item_id)
        store.delete(namespace, new_item_id)
        deleted_item = store.get(namespace, item_id)
        assert deleted_item is None

        deleted_item = store.get(namespace, new_item_id)
        assert deleted_item is None

        empty_search_results = store.search((uid, "test"), limit=10)
        assert len(empty_search_results) == 0


def test_list_namespaces_operations(
    fake_embeddings: CharacterEmbeddings,
) -> None:
    """Test list namespaces functionality with various filters."""
    with create_vector_store(
        fake_embeddings, text_fields=["key0", "key1", "key3"]
    ) as store:
        test_pref = str(uuid.uuid4())
        test_namespaces = [
            (test_pref, "test", "documents", "public", test_pref),
            (test_pref, "test", "documents", "private", test_pref),
            (test_pref, "test", "images", "public", test_pref),
            (test_pref, "test", "images", "private", test_pref),
            (test_pref, "prod", "documents", "public", test_pref),
            (test_pref, "prod", "documents", "some", "nesting", "public", test_pref),
            (test_pref, "prod", "documents", "private", test_pref),
        ]

        # Add test data
        for namespace in test_namespaces:
            store.put(namespace, "dummy", {"content": "dummy"})

        # Test prefix filtering
        prefix_result = store.list_namespaces(prefix=(test_pref, "test"))
        assert len(prefix_result) == 4
        assert all(ns[1] == "test" for ns in prefix_result)

        # Test specific prefix
        specific_prefix_result = store.list_namespaces(
            prefix=(test_pref, "test", "documents")
        )
        assert len(specific_prefix_result) == 2
        assert all(ns[1:3] == ("test", "documents") for ns in specific_prefix_result)

        # Test suffix filtering
        suffix_result = store.list_namespaces(suffix=("public", test_pref))
        assert len(suffix_result) == 4
        assert all(ns[-2] == "public" for ns in suffix_result)

        # Test combined prefix and suffix
        prefix_suffix_result = store.list_namespaces(
            prefix=(test_pref, "test"), suffix=("public", test_pref)
        )
        assert len(prefix_suffix_result) == 2
        assert all(
            ns[1] == "test" and ns[-2] == "public" for ns in prefix_suffix_result
        )

        # Test wildcard in prefix
        wildcard_prefix_result = store.list_namespaces(
            prefix=(test_pref, "*", "documents")
        )
        assert len(wildcard_prefix_result) == 5
        assert all(ns[2] == "documents" for ns in wildcard_prefix_result)

        # Test wildcard in suffix
        wildcard_suffix_result = store.list_namespaces(
            suffix=("*", "public", test_pref)
        )
        assert len(wildcard_suffix_result) == 4
        assert all(ns[-2] == "public" for ns in wildcard_suffix_result)

        wildcard_single = store.list_namespaces(
            suffix=("some", "*", "public", test_pref)
        )
        assert len(wildcard_single) == 1
        assert wildcard_single[0] == (
            test_pref,
            "prod",
            "documents",
            "some",
            "nesting",
            "public",
            test_pref,
        )

        # Test max depth
        max_depth_result = store.list_namespaces(max_depth=3)
        assert all(len(ns) <= 3 for ns in max_depth_result)

        max_depth_result = store.list_namespaces(
            max_depth=4, prefix=(test_pref, "*", "documents")
        )
        assert len(set(res for res in max_depth_result)) == len(max_depth_result) == 5

        # Test pagination
        limit_result = store.list_namespaces(prefix=(test_pref,), limit=3)
        assert len(limit_result) == 3

        offset_result = store.list_namespaces(prefix=(test_pref,), offset=3)
        assert len(offset_result) == len(test_namespaces) - 3

        empty_prefix_result = store.list_namespaces(prefix=(test_pref,))
        assert len(empty_prefix_result) == len(test_namespaces)
        assert set(empty_prefix_result) == set(test_namespaces)

        # Clean up
        for namespace in test_namespaces:
            store.delete(namespace, "dummy")


def test_search_items(
    fake_embeddings: CharacterEmbeddings,
) -> None:
    """Test search_items functionality by calling store methods directly."""
    base = "test_search_items"
    test_namespaces = [
        (base, "documents", "user1"),
        (base, "documents", "user2"),
        (base, "reports", "department1"),
        (base, "reports", "department2"),
    ]
    test_items = [
        {"title": "Doc 1", "author": "John Doe", "tags": ["important"]},
        {"title": "Doc 2", "author": "Jane Smith", "tags": ["draft"]},
        {"title": "Report A", "author": "John Doe", "tags": ["final"]},
        {"title": "Report B", "author": "Alice Johnson", "tags": ["draft"]},
    ]

    with create_vector_store(
        fake_embeddings, text_fields=["key0", "key1", "key3"]
    ) as store:
        # Insert test data
        for ns, item in zip(test_namespaces, test_items, strict=False):
            key = f"item_{ns[-1]}"
            store.put(ns, key, item)

        # 1. Search documents
        docs = store.search((base, "documents"))
        assert len(docs) == 2
        assert all(item.namespace[1] == "documents" for item in docs)

        # 2. Search reports
        reports = store.search((base, "reports"))
        assert len(reports) == 2
        assert all(item.namespace[1] == "reports" for item in reports)

        # 3. Pagination
        first_page = store.search((base,), limit=2, offset=0)
        second_page = store.search((base,), limit=2, offset=2)
        assert len(first_page) == 2
        assert len(second_page) == 2
        keys_page1 = {item.key for item in first_page}
        keys_page2 = {item.key for item in second_page}
        assert keys_page1.isdisjoint(keys_page2)
        all_items = store.search((base,))
        assert len(all_items) == 4

        john_items = store.search((base,), filter={"author": "John Doe"})
        assert len(john_items) == 2
        assert all(item.value["author"] == "John Doe" for item in john_items)

        draft_items = store.search((base,), filter={"tags": ["draft"]})
        assert len(draft_items) == 2
        assert all("draft" in item.value["tags"] for item in draft_items)

        for ns in test_namespaces:
            key = f"item_{ns[-1]}"
            store.delete(ns, key)


def test_sql_injection_vulnerability(store: SqliteStore) -> None:
    """Test that SQL injection via malicious filter keys is prevented."""
    # Add public and private documents
    store.put(("docs",), "public", {"access": "public", "data": "public info"})
    store.put(
        ("docs",), "private", {"access": "private", "data": "secret", "password": "123"}
    )

    # Normal query - returns 1 public document
    normal = store.search(("docs",), filter={"access": "public"})
    assert len(normal) == 1
    assert normal[0].value["access"] == "public"

    # SQL injection attempt via malicious key should raise ValueError
    malicious_key = "access') = 'public' OR '1'='1' OR json_extract(value, '$."

    with pytest.raises(ValueError, match="Invalid filter key"):
        store.search(("docs",), filter={malicious_key: "dummy"})


def test_sql_injection_filter_values(store: SqliteStore) -> None:
    """Test that SQL injection via malicious filter values is properly escaped."""
    # Setup: Create documents with different access levels
    store.put(("docs",), "doc1", {"access": "public", "title": "Public Document"})
    store.put(("docs",), "doc2", {"access": "private", "title": "Private Document"})
    store.put(("docs",), "doc3", {"access": "secret", "title": "Secret Document"})

    # Test 1: Basic SQL injection attempt with single quote
    malicious_value = "public' OR '1'='1"
    results = store.search(("docs",), filter={"access": malicious_value})
    # Should return 0 results because the malicious value is escaped and won't match anything
    assert len(results) == 0, "SQL injection via string value should be blocked"

    # Test 2: SQL injection with comment
    malicious_value = "public'; --"
    results = store.search(("docs",), filter={"access": malicious_value})
    assert len(results) == 0, "SQL comment injection should be blocked"

    # Test 3: UNION injection attempt
    malicious_value = "public' UNION SELECT * FROM store --"
    results = store.search(("docs",), filter={"access": malicious_value})
    assert len(results) == 0, "UNION injection should be blocked"

    # Test 4: Parameterized queries handle strings with null bytes and SQL injection attempts safely
    malicious_value = "public\x00' OR '1'='1"
    results = store.search(("docs",), filter={"access": malicious_value})
    assert len(results) == 0, (
        "Parameterized queries treat injection attempts as literal strings"
    )

    # Test 5: Multiple single quotes
    malicious_value = "''''"
    results = store.search(("docs",), filter={"access": malicious_value})
    assert len(results) == 0, "Multiple quotes should be handled safely"

    # Test 6: Legitimate value with single quote should work
    store.put(("docs",), "doc4", {"title": "O'Brien's Document", "access": "public"})
    results = store.search(("docs",), filter={"title": "O'Brien's Document"})
    assert len(results) == 1, "Legitimate single quotes should work"
    assert results[0].value["title"] == "O'Brien's Document"

    # Test 7: Unicode characters with injection attempt
    malicious_value = "public' OR 'א'='א"
    results = store.search(("docs",), filter={"access": malicious_value})
    assert len(results) == 0, "Unicode-based injection should be blocked"


def test_numeric_filter_safety(store: SqliteStore) -> None:
    """Test that numeric filter values are handled safely."""
    # Setup: Create documents with numeric fields
    store.put(("items",), "item1", {"price": 10, "quantity": 5})
    store.put(("items",), "item2", {"price": 20, "quantity": 3})
    store.put(("items",), "item3", {"price": 30, "quantity": 1})

    # Test 1: Normal numeric comparison
    results = store.search(("items",), filter={"price": {"$gt": 15}})
    assert len(results) == 2
    assert all(r.value["price"] > 15 for r in results)

    # Test 2: Special float values (infinity)
    results = store.search(("items",), filter={"price": {"$lt": float("inf")}})
    assert len(results) == 3, "All finite values should be less than infinity"

    # Test 3: Special float values (negative infinity)
    results = store.search(("items",), filter={"price": {"$gt": float("-inf")}})
    assert len(results) == 3, (
        "All finite values should be greater than negative infinity"
    )

    # Test 4: NaN handling - NaN comparisons should not cause errors
    try:
        results = store.search(("items",), filter={"price": {"$eq": float("nan")}})
        # NaN never equals anything, including itself, so should return 0 results
        assert len(results) == 0
    except Exception as e:
        pytest.fail(f"NaN handling should not raise exception: {e}")

    # Test 5: Very large numbers
    results = store.search(("items",), filter={"price": {"$lt": 10**100}})
    assert len(results) == 3, "Very large numbers should be handled safely"

    # Test 6: Negative numbers
    store.put(("items",), "item4", {"price": -10, "quantity": 0})
    results = store.search(("items",), filter={"price": {"$lt": 0}})
    assert len(results) == 1
    assert results[0].key == "item4"


def test_boolean_filter_safety(store: SqliteStore) -> None:
    """Test that boolean filter values are handled safely."""
    store.put(("flags",), "flag1", {"active": True, "name": "Feature A"})
    store.put(("flags",), "flag2", {"active": False, "name": "Feature B"})
    store.put(("flags",), "flag3", {"active": True, "name": "Feature C"})

    # Test boolean filters
    results = store.search(("flags",), filter={"active": True})
    assert len(results) == 2
    assert all(r.value["active"] is True for r in results)

    results = store.search(("flags",), filter={"active": False})
    assert len(results) == 1
    assert results[0].value["active"] is False


def test_filter_keys_with_hyphens_and_digits(store: SqliteStore) -> None:
    """Keys with hyphens or leading digits should be queryable via filters.

    Current unquoted JSON path construction (e.g., '$.access-level' or '$.123abc')
    is not valid JSON1 syntax, so this test will catch regressions in path handling.
    """
    # Documents with top-level and nested keys requiring bracket-quoted JSON paths
    store.put(
        ("docs",),
        "hyphen",
        {"access-level": "public", "user": {"access-level": "nested"}},
    )
    store.put(("docs",), "digit", {"123abc": "ok", "user": {"123abc": "ok2"}})

    # Top-level hyphenated key
    results = store.search(("docs",), filter={"access-level": "public"})
    assert [r.key for r in results] == ["hyphen"]

    # Nested hyphenated key via dotted path
    results = store.search(("docs",), filter={"user.access-level": "nested"})
    assert [r.key for r in results] == ["hyphen"]

    # Top-level digit-starting key
    results = store.search(("docs",), filter={"123abc": "ok"})
    assert [r.key for r in results] == ["digit"]

    # Nested digit-starting key via dotted path
    results = store.search(("docs",), filter={"user.123abc": "ok2"})
    assert [r.key for r in results] == ["digit"]


@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
def test_non_ascii(
    fake_embeddings: CharacterEmbeddings,
    distance_type: str,
) -> None:
    """Test support for non-ascii characters"""
    with create_vector_store(fake_embeddings, distance_type=distance_type) as store:
        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"
