# type: ignore
from __future__ import annotations

import re
import time
from contextlib import contextmanager
from typing import Any
from uuid import uuid4

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

from langgraph.store.postgres import PostgresStore
from langgraph.store.postgres.base import (
    _escape_like_literal,
    _namespace_match_pattern,
)
from tests.conftest import (
    DEFAULT_URI,
    VECTOR_TYPES,
    CharacterEmbeddings,
)

TTL_SECONDS = 6
TTL_MINUTES = TTL_SECONDS / 60


@pytest.fixture(scope="function", params=["default", "pipe", "pool"])
def store(request) -> PostgresStore:
    database = f"test_{uuid4().hex[:16]}"
    uri_parts = DEFAULT_URI.split("/")
    uri_base = "/".join(uri_parts[:-1])
    query_params = ""
    if "?" in uri_parts[-1]:
        _, query_params = uri_parts[-1].split("?", 1)
        query_params = "?" + query_params

    conn_string = f"{uri_base}/{database}{query_params}"
    admin_conn_string = DEFAULT_URI
    ttl_config = {
        "default_ttl": TTL_MINUTES,
        "refresh_on_read": True,
        "sweep_interval_minutes": TTL_MINUTES / 2,
    }
    with Connection.connect(admin_conn_string, autocommit=True) as conn:
        conn.execute(f"CREATE DATABASE {database}")
    try:
        with PostgresStore.from_conn_string(conn_string, ttl=ttl_config) as store:
            store.MIGRATIONS = [
                (
                    mig.replace("ttl_minutes INT;", "ttl_minutes FLOAT;")
                    if isinstance(mig, str)
                    else mig
                )
                for mig in store.MIGRATIONS
            ]
            store.setup()

        if request.param == "pipe":
            with PostgresStore.from_conn_string(
                conn_string,
                pipeline=True,
                ttl=ttl_config,
            ) as store:
                store.start_ttl_sweeper()
                yield store

                store.stop_ttl_sweeper()
        elif request.param == "pool":
            with PostgresStore.from_conn_string(
                conn_string,
                pool_config={"min_size": 1, "max_size": 10},
                ttl=ttl_config,
            ) as store:
                store.start_ttl_sweeper()
                yield store

                store.stop_ttl_sweeper()
        else:  # default
            with PostgresStore.from_conn_string(conn_string, ttl=ttl_config) as store:
                store.start_ttl_sweeper()
                yield store

                store.stop_ttl_sweeper()
    finally:
        with Connection.connect(admin_conn_string, autocommit=True) as conn:
            conn.execute(f"DROP DATABASE {database}")


def test_batch_order(store: PostgresStore) -> 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(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[1] is None  # Put operation returns None
    assert isinstance(results[2], list)
    assert len(results[2]) == 1
    assert isinstance(results[3], list)
    assert len(results[3]) > 0  # Should contain at least our test namespaces
    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(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 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"


def test_batch_get_ops(store: PostgresStore) -> 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: PostgresStore) -> 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: PostgresStore) -> 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: PostgresStore) -> 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=[MatchCondition("suffix", "public")],
            max_depth=None,
            limit=10,
            offset=0,
        ),
    ]

    results = store.batch(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])


def test_basic_store_ops(store) -> None:
    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
    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
    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(store) -> None:
    # 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_escape_like_literal() -> None:
    assert _escape_like_literal("users.alice") == "users.alice"
    assert _escape_like_literal("user_1") == r"user\_1"
    assert _escape_like_literal("100%") == r"100\%"
    assert _escape_like_literal("a\\b") == "a\\\\b"
    assert _escape_like_literal("") == ""


def test_namespace_match_pattern() -> None:
    assert _namespace_match_pattern(("foo",), "prefix") == r"^foo(\.|\Z)"
    assert _namespace_match_pattern(("uid", "users"), "prefix") == r"^uid\.users(\.|\Z)"
    assert (
        _namespace_match_pattern(("uid", "*", "alice"), "prefix")
        == r"^uid\.[^.]+\.alice(\.|\Z)"
    )
    assert _namespace_match_pattern(("alice",), "suffix") == r"(^|\.)alice\Z"

    # Regex metacharacters in a label are quoted, not interpreted.
    pattern = _namespace_match_pattern(("a.b+c",), "prefix")
    assert re.match(pattern, "a.b+c.child")
    assert not re.match(pattern, "axbbbc")


def test_search_namespace_segment_boundary(store) -> None:
    """Prefix scoping must stop at namespace segment boundaries.

    Namespaces are stored dot-joined, so matching the raw text also returns
    siblings sharing leading characters. Callers isolate tenants by namespace,
    so prefix-shaped ids (1 vs 12) would cross-read.
    """
    for namespace in [
        ("foo",),
        ("foo", "child"),
        ("foo", "child", "deep"),
        ("foobar",),
        ("foobar", "baz"),
        ("foo2",),
    ]:
        store.put(namespace, "k", {"v": 1})

    def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
        return {item.namespace for item in store.search(prefix, limit=100)}

    assert _namespaces(("foo",)) == {
        ("foo",),
        ("foo", "child"),
        ("foo", "child", "deep"),
    }
    # The sibling scope is independent, not merely narrower.
    assert _namespaces(("foobar",)) == {("foobar",), ("foobar", "baz")}
    assert _namespaces(("foo", "child")) == {("foo", "child"), ("foo", "child", "deep")}
    assert _namespaces(("foo2",)) == {("foo2",)}
    assert _namespaces(("fo",)) == set()


def test_search_empty_prefix_is_unconstrained(store) -> None:
    """An empty prefix constrains nothing and must return every namespace."""
    for namespace in [("a",), ("b", "c"), ("d", "e", "f")]:
        store.put(namespace, "k", {"v": 1})

    assert {item.namespace for item in store.search((), limit=100)} == {
        ("a",),
        ("b", "c"),
        ("d", "e", "f"),
    }


def test_search_namespace_like_metacharacters(store) -> None:
    """`_` and `%` are legal namespace labels, not LIKE wildcards."""
    for namespace in [
        ("user_1",),
        ("user_1", "child"),
        ("userX1",),
        ("a%b",),
        ("axxb",),
    ]:
        store.put(namespace, "k", {"v": 1})

    def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
        return {item.namespace for item in store.search(prefix, limit=100)}

    # Also asserts the namespace still matches itself, which catches escaping
    # the equality arm by mistake.
    assert _namespaces(("user_1",)) == {("user_1",), ("user_1", "child")}
    assert _namespaces(("a%b",)) == {("a%b",)}


def test_list_namespaces_segment_boundary(store) -> None:
    for namespace in [
        ("foo",),
        ("foo", "child"),
        ("foobar",),
        ("foobar", "baz"),
        ("uid", "users", "alice"),
        ("uid", "users", "malice"),
        ("uid", "a", "b", "alice"),
    ]:
        store.put(namespace, "k", {"v": 1})

    assert set(store.list_namespaces(prefix=["foo"], limit=100)) == {
        ("foo",),
        ("foo", "child"),
    }
    # Suffix must align to a segment: "malice" does not end with the "alice"
    # segment.
    assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
        ("uid", "users", "alice"),
        ("uid", "a", "b", "alice"),
    }
    # "*" spans exactly one segment.
    assert set(store.list_namespaces(prefix=["uid", "*", "alice"], limit=100)) == {
        ("uid", "users", "alice"),
    }
    # Prefix matching stays open-ended across depth.
    assert set(store.list_namespaces(prefix=["uid"], limit=100)) == {
        ("uid", "users", "alice"),
        ("uid", "users", "malice"),
        ("uid", "a", "b", "alice"),
    }


def test_search(store) -> None:
    # 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)


@contextmanager
def _create_vector_store(
    vector_type: str,
    distance_type: str,
    fake_embeddings: Embeddings,
    text_fields: list[str] | None = None,
    enable_ttl: bool = True,
) -> PostgresStore:
    """Create a store with vector search enabled."""
    database = f"test_{uuid4().hex[:16]}"
    uri_parts = DEFAULT_URI.split("/")
    uri_base = "/".join(uri_parts[:-1])
    query_params = ""
    if "?" in uri_parts[-1]:
        db_name, query_params = uri_parts[-1].split("?", 1)
        query_params = "?" + query_params

    conn_string = f"{uri_base}/{database}{query_params}"
    admin_conn_string = DEFAULT_URI

    index_config = {
        "dims": fake_embeddings.dims,
        "embed": fake_embeddings,
        "ann_index_config": {
            "vector_type": vector_type,
        },
        "distance_type": distance_type,
        "fields": text_fields,
    }

    with Connection.connect(admin_conn_string, autocommit=True) as conn:
        conn.execute(f"CREATE DATABASE {database}")
    try:
        with PostgresStore.from_conn_string(
            conn_string,
            index=index_config,
            ttl={"default_ttl": 2, "refresh_on_read": True} if enable_ttl else None,
        ) as store:
            store.setup()
            with store._cursor() as cur:
                # drop the migration index
                cur.execute("DROP TABLE IF EXISTS store_migrations")
            store.setup()  # Will fail if migrations aren't idempotent
            yield store
    finally:
        with Connection.connect(admin_conn_string, autocommit=True) as conn:
            conn.execute(f"DROP DATABASE {database}")


_vector_params = [
    (vector_type, distance_type, True)
    for vector_type in VECTOR_TYPES
    for distance_type in (
        ["hamming"] if vector_type == "bit" else ["l2", "inner_product", "cosine"]
    )
]
_vector_params += [(*_vector_params[-1][:2], False)]


@pytest.fixture(
    scope="function",
    params=_vector_params,
    ids=lambda p: f"{p[0]}_{p[1]}",
)
def vector_store(
    request,
    fake_embeddings: Embeddings,
) -> PostgresStore:
    """Create a store with vector search enabled."""
    vector_type, distance_type, enable_ttl = request.param
    with _create_vector_store(
        vector_type, distance_type, fake_embeddings, enable_ttl=enable_ttl
    ) as store:
        yield store


def test_vector_store_initialization(
    vector_store: PostgresStore, fake_embeddings: CharacterEmbeddings
) -> None:
    """Test store initialization with embedding config."""
    # Store should be initialized with embedding config
    assert vector_store.index_config is not None
    assert vector_store.index_config["dims"] == fake_embeddings.dims
    assert vector_store.index_config["embed"] == fake_embeddings


def test_vector_insert_with_auto_embedding(vector_store: PostgresStore) -> None:
    """Test inserting items that get auto-embedded."""
    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:
        vector_store.put(("test",), key, value)

    results = vector_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


def test_vector_update_with_embedding(vector_store: PostgresStore) -> None:
    """Test that updating items properly updates their embeddings."""
    vector_store.put(("test",), "doc1", {"text": "zany zebra Xerxes"})
    vector_store.put(("test",), "doc2", {"text": "something about dogs"})
    vector_store.put(("test",), "doc3", {"text": "text about birds"})

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

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

    results_after = vector_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 = vector_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
    vector_store.put(("test",), "doc4", {"text": "new text about dogs"}, index=False)
    results_new = vector_store.search(("test",), query="new text about dogs", limit=3)
    assert not any(r.key == "doc4" for r in results_new)


@pytest.mark.parametrize("refresh_ttl", [True, False])
def test_vector_search_with_filters(
    vector_store: PostgresStore, refresh_ttl: bool
) -> None:
    """Test combining vector search with filters."""
    # 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:
        vector_store.put(("test",), key, value)

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

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

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

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


def test_vector_search_pagination(vector_store: PostgresStore) -> None:
    """Test pagination with vector search."""
    # Insert multiple similar documents
    for i in range(5):
        vector_store.put(("test",), f"doc{i}", {"text": f"test document number {i}"})

    # Test with different page sizes
    results_page1 = vector_store.search(("test",), query="test", limit=2)
    results_page2 = vector_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

    # Get all results
    all_results = vector_store.search(("test",), query="test", limit=10)
    assert len(all_results) == 5


def test_vector_search_edge_cases(vector_store: PostgresStore) -> None:
    """Test edge cases in vector search."""
    vector_store.put(("test",), "doc1", {"text": "test document"})

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

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

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

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


@pytest.mark.parametrize(
    "vector_type,distance_type",
    [
        ("vector", "cosine"),
        ("vector", "inner_product"),
        ("halfvec", "cosine"),
        ("halfvec", "inner_product"),
    ],
)
def test_embed_with_path_sync(
    request: Any,
    fake_embeddings: CharacterEmbeddings,
    vector_type: str,
    distance_type: str,
) -> None:
    """Test vector search with specific text fields in Postgres store."""
    with _create_vector_store(
        vector_type,
        distance_type,
        fake_embeddings,
        text_fields=["key0", "key1", "key3"],
    ) 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
        ascore = results[0].score
        bscore = results[1].score
        assert ascore == pytest.approx(bscore, abs=1e-3)

        # ~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
        assert ascore == pytest.approx(results[0].score, abs=1e-3)

        # ~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
        assert ascore == pytest.approx(results[0].score, abs=1e-3)

        # 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 < ascore
        assert results[1].score < ascore


@pytest.mark.parametrize(
    "vector_type,distance_type",
    [
        ("vector", "cosine"),
        ("vector", "inner_product"),
        ("halfvec", "cosine"),
        ("halfvec", "inner_product"),
    ],
)
def test_embed_with_path_operation_config(
    request: Any,
    fake_embeddings: CharacterEmbeddings,
    vector_type: str,
    distance_type: str,
) -> None:
    """Test operation-level field configuration for vector search."""

    with _create_vector_store(
        vector_type,
        distance_type,
        fake_embeddings,
        text_fields=["key17"],  # Default fields that won't match our test data
    ) 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 results[0].score == pytest.approx(results[1].score, abs=1e-3)

        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 all(r.score is None for r in results), f"{results}"
        assert any(r.key == "doc5" for r in results)

        results = store.search(("test",), query="hhh")
        # TODO: We don't currently fill in additional results if there are not enough
        # returned during vector search.
        # assert len(results) == 3
        # doc5_result = next(r for r in results if r.key == "doc5")
        # assert doc5_result.score is None


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


def _inner_product(X: list[float], Y: list[list[float]]) -> list[float]:
    """
    Compute inner product between a vector X and a matrix Y.
    Lazy import numpy for efficiency.
    """

    similarities = []
    for y in Y:
        similarity = sum(a * b for a, b in zip(X, y, strict=False))
        similarities.append(similarity)

    return similarities


def _neg_l2_distance(X: list[float], Y: list[list[float]]) -> list[float]:
    """
    Compute l2 distance between a vector X and a matrix Y.
    Lazy import numpy for efficiency.
    """

    similarities = []
    for y in Y:
        similarity = sum((a - b) ** 2 for a, b in zip(X, y, strict=False)) ** 0.5
        similarities.append(-similarity)

    return similarities


@pytest.mark.parametrize(
    "vector_type,distance_type",
    [
        ("vector", "cosine"),
        ("vector", "inner_product"),
        ("halfvec", "l2"),
    ],
)
@pytest.mark.parametrize("query", ["aaa", "bbb", "ccc", "abcd", "poisson"])
def test_scores(
    fake_embeddings: CharacterEmbeddings,
    vector_type: str,
    distance_type: str,
    query: str,
) -> None:
    """Test operation-level field configuration for vector search."""
    with _create_vector_store(
        vector_type,
        distance_type,
        fake_embeddings,
        text_fields=["key0"],
    ) 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)
        if distance_type == "cosine":
            similarities = _cosine_similarity(vec1, [vec0])
        elif distance_type == "inner_product":
            similarities = _inner_product(vec1, [vec0])
        elif distance_type == "l2":
            similarities = _neg_l2_distance(vec1, [vec0])

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


def test_nonnull_migrations() -> None:
    _leading_comment_remover = re.compile(r"^/\*.*?\*/")
    for migration in PostgresStore.MIGRATIONS:
        statement = _leading_comment_remover.sub("", migration).split()[0]
        assert statement.strip()


def test_store_ttl(store):
    # Assumes a TTL of 1 minute = 60 seconds
    ns = ("foo",)
    store.put(
        ns,
        key="item1",
        value={"foo": "bar"},
        ttl=TTL_MINUTES,  # type: ignore
    )
    time.sleep(TTL_SECONDS - 2)
    res = store.get(ns, key="item1", refresh_ttl=True)
    assert res is not None
    time.sleep(TTL_SECONDS - 2)
    results = store.search(ns, query="foo", refresh_ttl=True)
    assert len(results) == 1
    time.sleep(TTL_SECONDS - 2)
    res = store.get(ns, key="item1", refresh_ttl=False)
    assert res is not None
    time.sleep(TTL_SECONDS - 1)
    # Now has been (TTL_SECONDS-2)*2 > TTL_SECONDS + TTL_SECONDS/2
    res = store.search(ns, query="bar", refresh_ttl=False)
    assert len(res) == 0


def _expire_now(store: PostgresStore, ns: tuple[str, ...], key: str) -> None:
    """Backdate a row's expires_at into the past without deleting it (unswept)."""
    with store._cursor() as cur:
        cur.execute(
            "UPDATE store SET expires_at = NOW() - INTERVAL '1 minute' "
            "WHERE prefix = %s AND key = %s",
            (".".join(ns), key),
        )


def _row_exists(store: PostgresStore, ns: tuple[str, ...], key: str) -> bool:
    with store._cursor() as cur:
        cur.execute(
            "SELECT COUNT(*) AS n FROM store WHERE prefix = %s AND key = %s",
            (".".join(ns), key),
        )
        return cur.fetchone()["n"] == 1


def _stored_expires_at(store: PostgresStore, ns: tuple[str, ...], key: str):
    with store._cursor() as cur:
        cur.execute(
            "SELECT expires_at FROM store WHERE prefix = %s AND key = %s",
            (".".join(ns), key),
        )
        return cur.fetchone()["expires_at"]


def test_omit_expired_filters_read_paths(store: PostgresStore) -> None:
    store.stop_ttl_sweeper()  # deterministic: no background deletion
    store.ttl_config["omit_expired"] = True

    expired_ns = ("omit", "expired")
    control_ns = ("omit", "control")
    store.put(expired_ns, "e", {"data": "gone"}, ttl=TTL_MINUTES)
    store.put(control_ns, "c", {"data": "keep"}, ttl=None)
    _expire_now(store, expired_ns, "e")

    # The row is expired but physically still present (unswept).
    assert _row_exists(store, expired_ns, "e")

    # get omits it; the never-expiring control is still returned.
    assert store.get(expired_ns, "e") is None
    assert store.get(control_ns, "c") is not None

    # search omits it but returns the control.
    assert store.search(expired_ns) == []
    assert [i.key for i in store.search(control_ns)] == ["c"]

    # list_namespaces drops the expired-only namespace, keeps the control.
    namespaces = store.list_namespaces(prefix=("omit",))
    assert expired_ns not in namespaces
    assert control_ns in namespaces


@pytest.mark.parametrize("omit", [None, False], ids=["default", "explicit-false"])
def test_omit_expired_disabled_preserves_expired_rows(
    store: PostgresStore, omit
) -> None:
    store.stop_ttl_sweeper()
    if omit is not None:
        store.ttl_config["omit_expired"] = omit

    ns = ("keep",)
    store.put(ns, "k", {"data": "still-here"}, ttl=TTL_MINUTES)
    _expire_now(store, ns, "k")

    assert store.get(ns, "k", refresh_ttl=False) is not None
    assert [i.key for i in store.search(ns, refresh_ttl=False)] == ["k"]
    assert ns in store.list_namespaces(prefix=("keep",))


def test_omit_expired_refresh_ttl_only_refreshes_live_rows(
    store: PostgresStore,
) -> None:
    store.stop_ttl_sweeper()
    store.ttl_config["omit_expired"] = True

    ns = ("refresh",)
    store.put(ns, "expired", {"n": 0}, ttl=TTL_MINUTES)
    store.put(ns, "live_get", {"n": 1}, ttl=TTL_MINUTES)
    store.put(ns, "live_search", {"n": 2}, ttl=TTL_MINUTES)
    _expire_now(store, ns, "expired")

    expired_before = _stored_expires_at(store, ns, "expired")
    get_before = _stored_expires_at(store, ns, "live_get")
    search_before = _stored_expires_at(store, ns, "live_search")

    # refresh_ttl=True must NOT resurrect the expired row (via get or search)...
    assert store.get(ns, "expired", refresh_ttl=True) is None
    assert "expired" not in [i.key for i in store.search(ns, refresh_ttl=True)]
    assert _stored_expires_at(store, ns, "expired") == expired_before

    # ...but must still extend the live rows that were read.
    assert store.get(ns, "live_get", refresh_ttl=True) is not None
    assert _stored_expires_at(store, ns, "live_get") > get_before
    assert _stored_expires_at(store, ns, "live_search") > search_before


def test_omit_expired_search_pagination(store: PostgresStore) -> None:
    store.stop_ttl_sweeper()
    store.ttl_config["omit_expired"] = True

    ns = ("page",)
    for k in ("a", "b", "c"):
        store.put(ns, k, {"k": k}, ttl=TTL_MINUTES)
    store.put(ns, "expired", {"k": "x"}, ttl=TTL_MINUTES)
    _expire_now(store, ns, "expired")

    seconds_ago = {"a": 1, "expired": 2, "b": 3, "c": 4}
    # updated_at DESC orders these a, expired, b, c, so the expired row sits inside
    # the first limit=2 window. Correct (pre-LIMIT) filtering yields live pages
    # [a, b] then [c]; post-LIMIT filtering would underfill page 1 to just [a].
    with store._cursor() as cur:
        for key, secs in seconds_ago.items():
            cur.execute(
                "UPDATE store SET updated_at = NOW() - (%s * INTERVAL '1 second') "
                "WHERE prefix = %s AND key = %s",
                (secs, ".".join(ns), key),
            )

    page1 = store.search(ns, limit=2, offset=0)
    page2 = store.search(ns, limit=2, offset=2)
    assert [i.key for i in page1] == ["a", "b"]
    assert [i.key for i in page2] == ["c"]


@pytest.mark.parametrize(
    "vector_type,distance_type",
    [
        ("vector", "cosine"),
        ("vector", "inner_product"),
        ("halfvec", "cosine"),
        ("halfvec", "inner_product"),
    ],
)
def test_non_ascii(
    request: Any,
    fake_embeddings: CharacterEmbeddings,
    vector_type: str,
    distance_type: str,
) -> None:
    """Test support for non-ascii characters"""
    with _create_vector_store(vector_type, distance_type, fake_embeddings) 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"


def test_namespace_labels_with_trailing_newline(store) -> None:
    """Labels may contain newlines, and must not match a differently-named label."""
    store.put(("users", "alice"), "k", {"v": 1})
    store.put(("users", "alice\n"), "k", {"v": 2})

    assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
        ("users", "alice"),
    }
    assert set(store.list_namespaces(prefix=["users", "alice"], limit=100)) == {
        ("users", "alice"),
    }
