"""Tests for SQL generation.

This module tests SQL string generation only - no database required.
"""

from typing import Any
from unittest.mock import Mock

import pytest
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.writer import MigrationWriter
from django.db.models import F, Q
from django.db.models.functions import Length, Lower

from paradedb.functions import Score, Snippet, SnippetPositions, Snippets
from paradedb.indexes import BM25Index, IndexExpression
from paradedb.search import (
    Match,
    ParadeDB,
    Parse,
    Phrase,
    PhrasePrefix,
    Proximity,
    ProxRegex,
    RangeTerm,
    Regex,
    RegexPhrase,
    Term,
)
from tests.models import Product


class DummyDatabaseOperations:
    """Minimal database operations for SQL string generation."""

    compiler_module = "django.db.backends.postgresql.compiler"
    cast_char_field_without_max_length = "varchar"

    def quote_name(self, name: str) -> str:
        return f'"{name}"'

    def max_name_length(self) -> int:
        return 63

    def autoinc_sql(self, *_args: Any, **_kwargs: Any) -> None:
        return None

    def conditional_expression_supported_in_where_clause(
        self, _expression: Any
    ) -> bool:
        return True

    def combine_expression(self, connector: str, sub_expressions: list[str]) -> str:
        return f"({f' {connector} '.join(sub_expressions)})"

    def compiler(self, _compiler_name: str) -> type:
        from django.db.backends.postgresql.compiler import SQLCompiler

        return SQLCompiler

    def check_expression_support(self, expression: Any) -> None:
        """Check if expression is supported - always pass for tests."""
        pass

    def adapt_integerfield_value(self, value: Any, _internal_type: str) -> Any:
        return value


class DummyConnection:
    """Minimal connection for SQL string generation."""

    vendor = "postgresql"
    Database = Mock()

    def __init__(self) -> None:
        self.features = Mock()
        self.features.uses_case_insensitive_names = False
        self.features.allows_group_by_select_index = True
        self.ops = DummyDatabaseOperations()
        # Set alias for the connection
        self.alias = "default"


class DummySchemaEditor(BaseDatabaseSchemaEditor):
    """Minimal schema editor for SQL string generation."""

    def __init__(self) -> None:
        connection = DummyConnection()
        super().__init__(connection, collect_sql=False)

    def quote_name(self, name: str) -> str:
        return f'"{name}"'

    def quote_value(self, value: Any) -> str:
        if isinstance(value, bool):
            return "true" if value else "false"
        if isinstance(value, int | float):
            return str(value)
        if value is None:
            return "null"
        if isinstance(value, str):
            escaped = value.replace("'", "''")
            return f"'{escaped}'"
        raise TypeError(f"Unsupported quoted value type: {type(value).__name__}")


class TestParadeDBLookup:
    """Test ParadeDB lookup SQL generation."""

    def test_single_term_search(self) -> None:
        """Single term generates: WHERE description &&& 'shoes'."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", operator="AND"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\''
        )

    def test_and_search_multiple_terms(self) -> None:
        """Multiple terms generate: WHERE description &&& ARRAY[...]"""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("running", "shoes", operator="AND"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& ARRAY[\'running\', \'shoes\']'
        )

    def test_and_search_three_terms(self) -> None:
        """Edge case: three terms for AND search."""
        queryset = Product.objects.filter(
            description=ParadeDB(
                Match("running", "shoes", "lightweight", operator="AND")
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& ARRAY[\'running\', \'shoes\', \'lightweight\']'
        )


class TestExactLiteralDisjunction:
    """Test exact literal OR search SQL generation."""

    def test_single_string_or(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("running shoes", operator="OR"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ||| \'running shoes\''
        )

    def test_multiple_strings_or(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", "boots", operator="OR"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ||| ARRAY[\'shoes\', \'boots\']'
        )

    def test_term_operator_single(self) -> None:
        with pytest.raises(
            ValueError,
            match=r"Match operator must be 'AND' or 'OR'\.",
        ):
            _ = ParadeDB(Match("shoes", operator="TERM"))  # type: ignore[arg-type]

    def test_term_operator_multiple(self) -> None:
        with pytest.raises(
            ValueError,
            match=r"Match operator must be 'AND' or 'OR'\.",
        ):
            _ = ParadeDB(
                Match("shoes", "boots", operator="TERM")  # type: ignore[arg-type]
            )

    def test_operator_invalid_with_phrase(self) -> None:
        with pytest.raises(
            TypeError,
            match=r"unexpected keyword argument 'operator'",
        ):
            _ = ParadeDB(Phrase("text"), operator="OR")

    def test_phrase_without_operator_is_allowed(self) -> None:
        queryset = Product.objects.filter(description=ParadeDB(Phrase("text")))
        assert "### 'text'" in str(queryset.query)

    def test_operator_invalid_value(self) -> None:
        with pytest.raises(
            ValueError,
            match=r"Match operator must be 'AND' or 'OR'\.",
        ):
            _ = ParadeDB(Match("shoes", operator="BAD"))  # type: ignore[arg-type]

    def test_match_and_paradedb_operator_cannot_be_mixed(self) -> None:
        with pytest.raises(
            TypeError,
            match=r"unexpected keyword argument 'operator'",
        ):
            _ = ParadeDB(Match("shoes", operator="OR"), operator="AND")


class TestPhraseSearch:
    """Test Phrase search SQL generation."""

    def test_phrase_search(self) -> None:
        """Phrase generates: WHERE description ### 'wireless bluetooth'."""
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("wireless bluetooth"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'wireless bluetooth\''
        )

    def test_phrase_with_slop(self) -> None:
        """Phrase with slop: WHERE description ### 'running shoes'::pdb.slop(1)."""
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("running shoes", slop=1))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'running shoes\'::pdb.slop(1)'
        )

    def test_phrase_with_multiple_terms(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("running shoes", "sneakers"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### ARRAY[\'running shoes\', \'sneakers\']'
        )

    def test_phrase_with_multiple_terms_and_slop(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("running shoes", "sneakers", slop=7))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### ARRAY[\'running shoes\', \'sneakers\']::pdb.slop(7)'
        )

    def test_phrase_with_multiple_terms_and_slop_and_boost(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("running shoes", "sneakers", slop=7, boost=5))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### ARRAY[\'running shoes\', \'sneakers\']::pdb.slop(7)::pdb.boost(5)'
        )

    def test_phrase_with_multiple_terms_and_slop_and_const(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("running shoes", "sneakers", slop=7, const=5))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### ARRAY[\'running shoes\', \'sneakers\']::pdb.slop(7)::pdb.query::pdb.const(5)'
        )


class TestProximitySearch:
    """Test Proximity search SQL generation."""

    def test_proximity_unordered(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Proximity("running").within(2, "shoes"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 2 ## \'shoes\')'
        )

    def test_proximity_ordered(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Proximity("running").within(2, "shoes", ordered=True))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ##> 2 ##> \'shoes\')'
        )

    def test_proximity_with_boost(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Proximity("running").within(2, "shoes").boost(1.5))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 2 ## \'shoes\')::pdb.boost(1.5)'
        )

    def test_proximity_with_const(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Proximity("running").within(2, "shoes").const(1.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 2 ## \'shoes\')::pdb.const(1.0)'
        )

    def test_proximity_three_terms_chain(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Proximity("running").within(2, "shoes").within(2, "lightweight")
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 2 ## \'shoes\' ## 2 ## \'lightweight\')'
        )

    def test_proximity_distance_negative_rejected(self) -> None:
        with pytest.raises(
            ValueError, match=r"Proximity distance must be zero or positive\."
        ):
            Proximity("running").within(-1, "shoes")

    def test_proximity_without_nodes_rejected(self) -> None:
        queryset = Product.objects.filter(description=ParadeDB(Proximity("running")))
        with pytest.raises(
            TypeError,
            match=r"Unsupported ParadeDB term type",
        ):
            _ = str(queryset.query)


class TestDistanceOption:
    def test_match_distance_single(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("sheos", operator="OR", distance=1))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ||| \'sheos\'::pdb.fuzzy(1)'
        )

    def test_match_distance_multiple_terms(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("runnning", "shoez", operator="OR", distance=1))
        )
        # Fuzzy is applied to the whole array, not per-element
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ||| ARRAY[\'runnning\', \'shoez\']::pdb.fuzzy(1)'
        )

    def test_term_distance(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Term("shose", distance=2))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.term(\'shose\')::pdb.fuzzy(2)'
        )

    def test_match_distance_invalid(self) -> None:
        with pytest.raises(
            ValueError, match=r"Distance must be between 0 and 2, inclusive\."
        ):
            Match("x", operator="AND", distance=3)

    def test_match_tokenizer_and_distance_rejected(self) -> None:
        with pytest.raises(
            ValueError, match="Match tokenizer cannot be combined with fuzzy options"
        ):
            Match(
                "running shoes",
                operator="AND",
                tokenizer="whitespace",
                distance=1,
            )

    def test_multi_term_fuzzy_boost_rejected(self) -> None:
        with pytest.raises(
            ValueError, match="Multi-term fuzzy Match does not support boost or const"
        ):
            Match("a", "b", operator="OR", distance=1, boost=2.0)

    def test_multi_term_fuzzy_const_rejected(self) -> None:
        with pytest.raises(
            ValueError, match="Multi-term fuzzy Match does not support boost or const"
        ):
            Match("a", "b", operator="OR", distance=1, const=1.0)


class TestTokenizerOverride:
    def test_match_with_tokenizer(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Match("running shoes", operator="AND", tokenizer="whitespace")
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& \'running shoes\'::pdb.whitespace'
        )

    def test_match_or_with_tokenizer(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Match("running shoes", operator="OR", tokenizer="whitespace")
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ||| \'running shoes\'::pdb.whitespace'
        )

    def test_phrase_with_tokenizer(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("running shoes", tokenizer="whitespace"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'running shoes\'::pdb.whitespace'
        )

    def test_phrase_with_slop_and_tokenizer(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Phrase("running shoes", slop=2, tokenizer="whitespace")
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'running shoes\'::pdb.slop(2)::pdb.whitespace'
        )

    def test_match_with_tokenizer_args(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Match(
                    "running shoes",
                    operator="AND",
                    tokenizer="whitespace('lowercase=false')",
                )
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& \'running shoes\'::pdb.whitespace(\'lowercase=false\')'
        )

    def test_match_with_tokenizer_multi_args(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Match(
                    "wireless keyboard",
                    operator="OR",
                    tokenizer="simple('lowercase=false', 'remove_long=20')",
                )
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ||| \'wireless keyboard\'::pdb.simple(\'lowercase=false\', \'remove_long=20\')'
        )

    def test_phrase_with_tokenizer_args(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Phrase("running shoes", tokenizer="raw('lowercase=false')")
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'running shoes\'::pdb.raw(\'lowercase=false\')'
        )

    def test_phrase_with_slop_and_tokenizer_multi_args(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Phrase(
                    "wireless mouse",
                    slop=2,
                    tokenizer="ngram('min_gram=3', 'max_gram=8')",
                )
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'wireless mouse\'::pdb.slop(2)::pdb.ngram(\'min_gram=3\', \'max_gram=8\')'
        )

    def test_tokenizer_with_boost(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Match("shoes", operator="AND", tokenizer="whitespace", boost=2.0)
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\'::pdb.whitespace::pdb.boost(2.0)'
        )


class TestParseQuery:
    """Test Parse query SQL generation."""

    def test_parse_query(self) -> None:
        """Parse generates: WHERE description @@@ pdb.parse(..., lenient => true)."""
        queryset = Product.objects.filter(
            description=ParadeDB(Parse("running AND shoes", lenient=True))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.parse(\'running AND shoes\', lenient => true)'
        )

    def test_parse_query_with_conjunction_mode(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Parse("running shoes", conjunction_mode=True))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.parse(\'running shoes\', conjunction_mode => true)'
        )


class TestPhrasePrefixQuery:
    """Test phrase_prefix query SQL generation."""

    def test_phrase_prefix_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(PhrasePrefix("running", "sh"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.phrase_prefix(ARRAY[\'running\', \'sh\'])'
        )

    def test_phrase_prefix_with_max_expansion(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(PhrasePrefix("running", "sh", max_expansion=50))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.phrase_prefix(ARRAY[\'running\', \'sh\'], max_expansion => 50)'
        )

    def test_phrase_prefix_with_boost(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(PhrasePrefix("running", "sh", boost=2.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.phrase_prefix(ARRAY[\'running\', \'sh\'])::pdb.boost(2.0)'
        )

    def test_phrase_prefix_with_const(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(PhrasePrefix("running", "sh", const=1.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.phrase_prefix(ARRAY[\'running\', \'sh\'])::pdb.const(1.0)'
        )

    def test_phrase_prefix_requires_terms(self) -> None:
        with pytest.raises(
            ValueError, match=r"PhrasePrefix requires at least one phrase term\."
        ):
            PhrasePrefix()


class TestRegexPhraseQuery:
    def test_regex_phrase_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(RegexPhrase("run.*", "sho.*"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.regex_phrase(ARRAY[\'run.*\', \'sho.*\'])'
        )

    def test_regex_phrase_with_options(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                RegexPhrase("run.*", "sho.*", slop=2, max_expansions=100)
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.regex_phrase(ARRAY[\'run.*\', \'sho.*\'], slop => 2, max_expansions => 100)'
        )


class TestProximityAdvancedQuery:
    def test_proximity_regex_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Proximity("running").within(1, ProxRegex("sho.*")))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 1 ## pdb.prox_regex(\'sho.*\'))'
        )

    def test_proximity_array_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Proximity(["sleek", "running"]).within(1, "shoes"))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (pdb.prox_array(\'sleek\', \'running\') ## 1 ## \'shoes\')'
        )

    def test_proximity_array_with_prox_regex_items(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Proximity(["chicken", ProxRegex("r..s")]).within(1, "delicious")
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (pdb.prox_array(\'chicken\', pdb.prox_regex(\'r..s\')) ## 1 ## \'delicious\')'
        )

    def test_proximity_array_with_prox_regex_custom_expansions(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Proximity([ProxRegex("sl.*", max_expansions=100), "white"]).within(
                    1,
                    "shoes",
                )
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (pdb.prox_array(pdb.prox_regex(\'sl.*\', 100), \'white\') ## 1 ## \'shoes\')'
        )

    def test_proximity_array_regex_rhs_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Proximity("running").within(
                    1,
                    ProxRegex("sho.*", max_expansions=80),
                )
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 1 ## pdb.prox_regex(\'sho.*\', 80))'
        )

    def test_proximity_array_list_rhs_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Proximity("running").within(
                    1,
                    ["shoes", ProxRegex("boot.*", max_expansions=80)],
                )
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 1 ## pdb.prox_array(\'shoes\', pdb.prox_regex(\'boot.*\', 80)))'
        )

    def test_proximity_chain_mixed_ordering_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Proximity(ProxRegex("sho.*"))
                .within(1, ["history", "science"], ordered=True)
                .within(5, "hardcover")
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (pdb.prox_regex(\'sho.*\') ##> 1 ##> pdb.prox_array(\'history\', \'science\') ## 5 ## \'hardcover\')'
        )

    def test_proximity_nested_child_constructor_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Proximity("running")
                .within(
                    2,
                    [ProxRegex("sho.*", max_expansions=80), "boots"],
                )
                .within(4, "hardcover", ordered=True)
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 2 ## pdb.prox_array(pdb.prox_regex(\'sho.*\', 80), \'boots\') ##> 4 ##> \'hardcover\')'
        )

    def test_proximity_associativity_parenthesizes_grouped_rhs(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Proximity("running").within(
                    2, Proximity("shoes").within(4, "hardcover", ordered=True)
                )
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 2 ## (\'shoes\' ##> 4 ##> \'hardcover\'))'
        )

    def test_deeply_nested_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                Proximity("running")
                .within(
                    2,
                    Proximity(
                        ["shoes", ProxRegex("sho.*"), ["shoe", [ProxRegex("shoe")]]]
                    )
                    .within(4, "hardcover", ordered=True)
                    .within(100, "foo"),
                )
                .within(2, ProxRegex("bar"))
                .boost(1.2)
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ (\'running\' ## 2 ## (pdb.prox_array(\'shoes\', pdb.prox_regex(\'sho.*\'), pdb.prox_array(\'shoe\', pdb.prox_array(pdb.prox_regex(\'shoe\')))) ##> 4 ##> \'hardcover\' ## 100 ## \'foo\') ## 2 ## pdb.prox_regex(\'bar\'))::pdb.boost(1.2)'
        )


class TestRangeTermQuery:
    def test_range_term_scalar_query(self) -> None:
        queryset = Product.objects.filter(description=ParadeDB(RangeTerm(10)))
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.range_term(10)'
        )

    def test_range_term_relation_query(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(
                RangeTerm("(10, 12]", relation="Intersects", range_type="int4range")
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.range_term(\'(10, 12]\'::int4range, \'Intersects\')'
        )

    def test_range_term_relation_requires_range_type(self) -> None:
        with pytest.raises(ValueError, match=r"RangeTerm relation requires range_type"):
            RangeTerm("(10, 12]", relation="Intersects")

    def test_range_term_range_type_requires_relation(self) -> None:
        with pytest.raises(
            ValueError, match=r"RangeTerm range_type is only valid when relation"
        ):
            RangeTerm("(10, 12]", range_type="int4range")

    def test_range_term_invalid_range_type(self) -> None:
        with pytest.raises(ValueError, match=r"Range type must be one of"):
            RangeTerm("(10, 12]", relation="Intersects", range_type="badtype")


class TestTermQuery:
    """Test Term query SQL generation."""

    def test_term_query(self) -> None:
        """Term generates: WHERE description @@@ pdb.term('shoes')."""
        queryset = Product.objects.filter(description=ParadeDB(Term("shoes")))
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.term(\'shoes\')'
        )


class TestRegexQuery:
    """Test Regex query SQL generation."""

    def test_regex_query(self) -> None:
        """Regex generates: WHERE description @@@ pdb.regex('run.*shoes')."""
        queryset = Product.objects.filter(description=ParadeDB(Regex("run.*shoes")))
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.regex(\'run.*shoes\')'
        )


class TestBoosting:
    """Test boost SQL generation and validation."""

    def test_boost_plain_string(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", operator="AND", boost=2.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\'::pdb.boost(2.0)'
        )

    def test_boost_match_distance(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shose", operator="OR", distance=2, boost=2.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ||| \'shose\'::pdb.fuzzy(2)::pdb.boost(2.0)'
        )

    def test_boost_phrase(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("running shoes", boost=1.5))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'running shoes\'::pdb.boost(1.5)'
        )

    def test_boost_regex(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Regex("key.*", boost=2.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.regex(\'key.*\')::pdb.boost(2.0)'
        )

    def test_boost_parse(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Parse("shoes", boost=3.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.parse(\'shoes\')::pdb.boost(3.0)'
        )

    def test_boost_term(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Term("shoes", boost=2.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.term(\'shoes\')::pdb.boost(2.0)'
        )

    def test_boost_none_default(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("x", operator="AND"))
        )
        assert "::pdb.boost" not in str(queryset.query)


class TestConstantScoring:
    """Test constant scoring SQL generation."""

    def test_const_plain_string(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", operator="AND", const=1.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\'::pdb.const(1.0)'
        )

    def test_const_match_distance(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shose", operator="OR", distance=2, const=5.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ||| \'shose\'::pdb.fuzzy(2)::pdb.query::pdb.const(5.0)'
        )

    def test_const_phrase(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("running shoes", const=1.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'running shoes\'::pdb.const(1.0)'
        )

    def test_const_phrase_with_slop(self) -> None:
        # pdb.slop has no direct cast to pdb.const; must bridge via pdb.query.
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("running shoes", slop=2, const=1.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'running shoes\'::pdb.slop(2)::pdb.query::pdb.const(1.0)'
        )

    def test_const_regex(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Regex("key.*", const=1.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.regex(\'key.*\')::pdb.const(1.0)'
        )

    def test_const_term(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Term("shoes", const=2.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" @@@ pdb.term(\'shoes\')::pdb.const(2.0)'
        )

    def test_const_none_default(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("x", operator="AND"))
        )
        assert "::pdb.const" not in str(queryset.query)

    def test_boost_and_const_phrase_sql_is_generated(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Phrase("x", boost=2.0, const=1.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ### \'x\'::pdb.boost(2.0)::pdb.const(1.0)'
        )

    def test_boost_and_const_plain_sql_is_generated(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", operator="AND", boost=2.0, const=1.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\'::pdb.boost(2.0)::pdb.const(1.0)'
        )

    def test_boost_multi_term_plain_strings(self) -> None:
        # boost must be cast on the ARRAY as a whole, not per-element;
        # ARRAY['a'::pdb.boost(N), 'b'::pdb.boost(N)] produces pdb.boost[] which
        # has no matching &&& operator.
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", "boots", operator="AND", boost=2.0))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" &&& ARRAY[\'shoes\', \'boots\']::pdb.boost(2.0)'
        )

    def test_const_multi_term_plain_strings(self) -> None:
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", "boots", operator="OR", const=1.5))
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata" FROM "tests_product" WHERE "tests_product"."description" ||| ARRAY[\'shoes\', \'boots\']::pdb.const(1.5)'
        )


class TestScoreAnnotation:
    """Test Score annotation SQL generation."""

    def test_score_annotation(self) -> None:
        """Score generates: SELECT ..., pdb.score(id) AS search_score."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("running", "shoes", operator="AND"))
        ).annotate(search_score=Score())
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.score("tests_product"."id") AS "search_score" FROM "tests_product" WHERE "tests_product"."description" &&& ARRAY[\'running\', \'shoes\']'
        )

    def test_score_with_ordering(self) -> None:
        """Score with ORDER BY search_score DESC."""
        queryset = (
            Product.objects.filter(description=ParadeDB(Match("shoes", operator="AND")))
            .annotate(search_score=Score())
            .order_by("-search_score")
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.score("tests_product"."id") AS "search_score" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\' ORDER BY 9 DESC'
        )

    def test_score_filter(self) -> None:
        """Filter by score: WHERE pdb.score(id) > 0."""
        queryset = (
            Product.objects.filter(description=ParadeDB(Match("shoes", operator="AND")))
            .annotate(search_score=Score())
            .filter(search_score__gt=0)
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.score("tests_product"."id") AS "search_score" FROM "tests_product" WHERE ("tests_product"."description" &&& \'shoes\' AND pdb.score("tests_product"."id") > 0.0)'
        )


class TestSnippetAnnotation:
    """Test Snippet annotation SQL generation."""

    def test_snippet_annotation(self) -> None:
        """Snippet generates: pdb.snippet(description) AS snippet."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("wireless", "bluetooth", operator="OR"))
        ).annotate(snippet=Snippet("description"))
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.snippet("tests_product"."description") AS "snippet" FROM "tests_product" WHERE "tests_product"."description" ||| ARRAY[\'wireless\', \'bluetooth\']'
        )

    def test_snippet_with_custom_formatting(self) -> None:
        """Custom snippet: pdb.snippet(description, '<mark>', '</mark>', 100)."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", operator="AND"))
        ).annotate(
            snippet=Snippet(
                "description",
                start_sel="<mark>",
                stop_sel="</mark>",
                max_num_chars=100,
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.snippet("tests_product"."description", \'<mark>\', \'</mark>\', 100) AS "snippet" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\''
        )


class TestSnippetsAnnotation:
    """Test Snippets annotation SQL generation."""

    def test_snippets_basic(self) -> None:
        """Snippets generates: pdb.snippets(description) AS snippets."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("artistic", "vase", operator="AND"))
        ).annotate(snippets=Snippets("description"))
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.snippets("tests_product"."description") AS "snippets" FROM "tests_product" WHERE "tests_product"."description" &&& ARRAY[\'artistic\', \'vase\']'
        )

    def test_snippets_with_max_num_chars(self) -> None:
        """Snippets with max_num_chars only."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("artistic", "vase", operator="AND"))
        ).annotate(snippets=Snippets("description", max_num_chars=15))
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.snippets("tests_product"."description", max_num_chars => 15) AS "snippets" FROM "tests_product" WHERE "tests_product"."description" &&& ARRAY[\'artistic\', \'vase\']'
        )

    def test_snippets_with_limit_and_offset(self) -> None:
        """Snippets with limit and offset uses double-quoted SQL reserved words."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("running", operator="AND"))
        ).annotate(
            snippets=Snippets("description", max_num_chars=15, limit=1, offset=1)
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.snippets("tests_product"."description", max_num_chars => 15, "limit" => 1, "offset" => 1) AS "snippets" FROM "tests_product" WHERE "tests_product"."description" &&& \'running\''
        )

    def test_snippets_with_sort_by(self) -> None:
        """Snippets with sort_by parameter."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("artistic", "vase", operator="AND"))
        ).annotate(
            snippets=Snippets("description", max_num_chars=15, sort_by="position")
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.snippets("tests_product"."description", max_num_chars => 15, sort_by => \'position\') AS "snippets" FROM "tests_product" WHERE "tests_product"."description" &&& ARRAY[\'artistic\', \'vase\']'
        )

    def test_snippets_with_all_params(self) -> None:
        """Snippets with all named parameters."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", operator="AND"))
        ).annotate(
            snippets=Snippets(
                "description",
                start_tag="<mark>",
                end_tag="</mark>",
                max_num_chars=30,
                limit=2,
                offset=0,
                sort_by="score",
            )
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.snippets("tests_product"."description", start_tag => \'<mark>\', end_tag => \'</mark>\', max_num_chars => 30, "limit" => 2, "offset" => 0, sort_by => \'score\') AS "snippets" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\''
        )


class TestSnippetPositionsAnnotation:
    """Test SnippetPositions annotation SQL generation."""

    def test_snippet_positions_basic(self) -> None:
        """SnippetPositions generates: pdb.snippet_positions(description)."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", operator="AND"))
        ).annotate(positions=SnippetPositions("description"))
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.snippet_positions("tests_product"."description") AS "positions" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\''
        )

    def test_snippet_positions_with_snippet(self) -> None:
        """SnippetPositions alongside Snippet."""
        queryset = Product.objects.filter(
            description=ParadeDB(Match("shoes", operator="AND"))
        ).annotate(
            snippet=Snippet("description"),
            positions=SnippetPositions("description"),
        )
        assert (
            str(queryset.query)
            == 'SELECT "tests_product"."id", "tests_product"."description", "tests_product"."category", "tests_product"."rating", "tests_product"."in_stock", "tests_product"."price", "tests_product"."created_at", "tests_product"."metadata", pdb.snippet("tests_product"."description") AS "snippet", pdb.snippet_positions("tests_product"."description") AS "positions" FROM "tests_product" WHERE "tests_product"."description" &&& \'shoes\''
        )


class TestBM25Index:
    """Test BM25 index SQL generation."""

    def test_basic_index_sql(self) -> None:
        """Basic BM25 index DDL generation."""
        index = BM25Index(
            fields={"id": {}, "description": {}},
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    "description"\n)\nWITH (key_field=\'id\')'
        )

    def test_index_with_tokenizer(self) -> None:
        """Index with tokenizer configuration."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizer": "simple",
                    "filters": ["lowercase", "stemmer"],
                    "stemmer": "english",
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ("description"::pdb.simple(\'lowercase=true,stemmer=english\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_index_with_tokenizer_only(self) -> None:
        """Index with tokenizer only."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {"tokenizer": "simple"},
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ("description"::pdb.simple)\n)\nWITH (key_field=\'id\')'
        )

    def test_json_field_index(self) -> None:
        """JSON field with json_keys configuration."""
        index = BM25Index(
            fields={
                "id": {},
                "metadata": {
                    "json_keys": {
                        "title": {"tokenizer": "simple", "filters": ["lowercase"]},
                        "brand": {"tokenizer": "simple"},
                    }
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == "CREATE INDEX \"product_search_idx\" ON \"tests_product\"\nUSING bm25 (\n    \"id\",\n    ((\"metadata\"->>'title')::pdb.simple('alias=metadata_title,lowercase=true')),\n    ((\"metadata\"->>'brand')::pdb.simple('alias=metadata_brand'))\n)\nWITH (key_field='id')"
        )

    def test_json_field_native_json_fields(self) -> None:
        """Native json_fields config is emitted via WITH (...) and indexes the column."""
        index = BM25Index(
            fields={
                "id": {},
                "metadata": {
                    "json_fields": {
                        "fast": True,
                        "expand_dots": False,
                    }
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    "metadata"\n)\nWITH (key_field=\'id\', json_fields=\'{"metadata":{"expand_dots":false,"fast":true}}\')'
        )

    def test_field_filters_without_tokenizer_raises(self) -> None:
        """Specifying filters or stemmer without a tokenizer raises ValueError."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {"stemmer": "english"},
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        with pytest.raises(ValueError, match="no tokenizer"):
            index.create_sql(model=Product, schema_editor=schema_editor)

    def test_stemmer_filter_requires_stemmer_language(self) -> None:
        """The stemmer filter must be paired with an explicit stemmer language."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizer": "simple",
                    "filters": ["stemmer"],
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        with pytest.raises(ValueError, match="requires a stemmer language"):
            index.create_sql(model=Product, schema_editor=schema_editor)

    def test_field_filters_only_without_tokenizer_raises(self) -> None:
        """Specifying only filters without a tokenizer raises ValueError."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {"filters": ["lowercase"]},
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        with pytest.raises(ValueError, match="no tokenizer"):
            index.create_sql(model=Product, schema_editor=schema_editor)

    def test_json_key_without_tokenizer_raises(self) -> None:
        """JSON keys without an explicit tokenizer raise ValueError."""
        index = BM25Index(
            fields={
                "id": {},
                "metadata": {
                    "json_keys": {
                        "color": {},
                    }
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        with pytest.raises(ValueError, match="requires an explicit tokenizer"):
            index.create_sql(model=Product, schema_editor=schema_editor)

    def test_json_field_literal_alias(self) -> None:
        """JSON subfields can be indexed with literal tokenizer aliases."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {},
                "metadata": {
                    "json_keys": {
                        "color": {"tokenizer": "literal"},
                        "location": {"tokenizer": "literal"},
                    }
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    "description",\n    (("metadata"->>\'color\')::pdb.literal(\'alias=metadata_color\')),\n    (("metadata"->>\'location\')::pdb.literal(\'alias=metadata_location\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_field_with_multiple_tokenizers(self) -> None:
        """A field can include multiple tokenizer expressions."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizers": [
                        {"tokenizer": "literal"},
                        {
                            "tokenizer": "simple",
                            "filters": ["lowercase"],
                            "alias": "description_simple",
                        },
                    ]
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ("description"::pdb.literal),\n    ("description"::pdb.simple(\'alias=description_simple,lowercase=true\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_multiple_tokenizers_allows_secondary_entries_without_alias(self) -> None:
        """Thin wrapper mode allows tokenizer entries without alias."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizers": [
                        {"tokenizer": "literal"},
                        {"tokenizer": "simple"},
                    ]
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ("description"::pdb.literal),\n    ("description"::pdb.simple)\n)\nWITH (key_field=\'id\')'
        )

    def test_multiple_tokenizers_cannot_mix_with_single_tokenizer_keys(self) -> None:
        """The list syntax cannot be combined with top-level tokenizer keys."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizers": [{"tokenizer": "literal"}],
                    "tokenizer": "simple",
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        with pytest.raises(ValueError, match="cannot mix 'tokenizers'"):
            index.create_sql(model=Product, schema_editor=schema_editor)

    def test_structured_ngram_args_and_named_args_in_multi_tokenizer_dsl(self) -> None:
        """Supports positional ngram args plus named args in DSL."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizers": [
                        {"tokenizer": "literal"},
                        {
                            "tokenizer": "ngram",
                            "args": [3, 3],
                            "named_args": {
                                "prefix_only": True,
                                "positions": True,
                            },
                            "alias": "description_ngram",
                        },
                    ]
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ("description"::pdb.literal),\n    ("description"::pdb.ngram(3,3,\'alias=description_ngram,prefix_only=true,positions=true\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_structured_regex_pattern_and_alias_in_multi_tokenizer_dsl(self) -> None:
        """Supports regex_pattern positional args with alias in DSL."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizers": [
                        {"tokenizer": "literal"},
                        {
                            "tokenizer": "regex_pattern",
                            "args": [r"(?i)\bh\w*"],
                            "alias": "description_regex",
                        },
                    ]
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ("description"::pdb.literal),\n    ("description"::pdb.regex_pattern(\'(?i)\\bh\\w*\',\'alias=description_regex\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_structured_lindera_dictionary_argument_in_multi_tokenizer_dsl(
        self,
    ) -> None:
        """Supports lindera dictionary positional arg in DSL."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizers": [
                        {"tokenizer": "literal"},
                        {
                            "tokenizer": "lindera",
                            "args": ["japanese"],
                            "alias": "description_jp",
                        },
                    ]
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ("description"::pdb.literal),\n    ("description"::pdb.lindera(\'japanese\',\'alias=description_jp\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_value_based_token_filter_named_args(self) -> None:
        """Supports non-boolean token filter named args in DSL."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizer": "simple",
                    "named_args": {
                        "lowercase": False,
                        "stopwords_language": "English,French",
                        "remove_long": 20,
                        "remove_short": 2,
                    },
                    "stemmer": "english",
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ("description"::pdb.simple(\'lowercase=false,stopwords_language=English,French,remove_long=20,remove_short=2,stemmer=english\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_legacy_options_key_raises(self) -> None:
        """Legacy 'options' key is no longer accepted."""
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizer": "simple",
                    "options": {"remove_long": 20},
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        with pytest.raises(ValueError, match="deprecated 'options'"):
            index.create_sql(model=Product, schema_editor=schema_editor)

    def test_named_args_invalid_key_raises(self) -> None:
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizer": "simple",
                    "named_args": {"bad-key": True},
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        with pytest.raises(ValueError, match="Tokenizer named arg keys must match"):
            index.create_sql(model=Product, schema_editor=schema_editor)

    def test_named_args_value_with_equals_raises(self) -> None:
        index = BM25Index(
            fields={
                "id": {},
                "description": {
                    "tokenizer": "simple",
                    "named_args": {"language": "en=us"},
                },
            },
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        with pytest.raises(
            ValueError, match="named arg string values cannot contain '='"
        ):
            index.create_sql(model=Product, schema_editor=schema_editor)

    def test_create_sql_concurrently(self) -> None:
        """create_sql with concurrently=True emits CREATE INDEX CONCURRENTLY."""
        index = BM25Index(
            fields={"id": {}, "description": {"tokenizer": "simple"}},
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(
            index.create_sql(
                model=Product, schema_editor=schema_editor, concurrently=True
            )
        )
        assert sql.startswith('CREATE INDEX CONCURRENTLY "product_search_idx"')
        assert "USING bm25" in sql

    def test_create_sql_without_concurrently(self) -> None:
        """create_sql without concurrently does not emit CONCURRENTLY."""
        index = BM25Index(
            fields={"id": {}, "description": {"tokenizer": "simple"}},
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert sql.startswith('CREATE INDEX "product_search_idx"')
        assert "CONCURRENTLY" not in sql

    def test_create_sql_with_condition(self) -> None:
        """create_sql with condition appends a WHERE clause."""
        index = BM25Index(
            fields={"id": {}, "description": {"tokenizer": "simple"}},
            key_field="id",
            name="product_search_idx",
            condition=Q(description__isnull=False),
        )
        schema_editor = DummySchemaEditor()
        # Mock _get_condition_sql to avoid needing a real DB connection
        index._get_condition_sql = Mock(return_value='"description" IS NOT NULL')
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert sql.endswith('WHERE "description" IS NOT NULL')
        assert "USING bm25" in sql

    def test_create_sql_with_condition_and_concurrently(self) -> None:
        """create_sql with both condition and concurrently emits both."""
        index = BM25Index(
            fields={"id": {}, "description": {"tokenizer": "simple"}},
            key_field="id",
            name="product_search_idx",
            condition=Q(description__isnull=False),
        )
        schema_editor = DummySchemaEditor()
        index._get_condition_sql = Mock(return_value='"description" IS NOT NULL')
        sql = str(
            index.create_sql(
                model=Product, schema_editor=schema_editor, concurrently=True
            )
        )
        assert sql.startswith('CREATE INDEX CONCURRENTLY "product_search_idx"')
        assert sql.endswith('WHERE "description" IS NOT NULL')

    def test_create_sql_with_native_json_fields_and_condition(self) -> None:
        """json_fields and condition can both be emitted in the same CREATE INDEX."""
        index = BM25Index(
            fields={"id": {}, "metadata": {"json_fields": {"fast": True}}},
            key_field="id",
            name="product_search_idx",
            condition=Q(description__isnull=False),
        )
        schema_editor = DummySchemaEditor()
        index._get_condition_sql = Mock(return_value='"description" IS NOT NULL')
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert 'json_fields=\'{"metadata":{"fast":true}}\'' in sql
        assert sql.endswith('WHERE "description" IS NOT NULL')

    def test_create_sql_without_condition_no_where(self) -> None:
        """create_sql without condition does not append WHERE clause."""
        index = BM25Index(
            fields={"id": {}, "description": {"tokenizer": "simple"}},
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert "WHERE" not in sql

    def test_index_expression_with_lower_and_tokenizer(self) -> None:
        """IndexExpression with Lower() and tokenizer generates correct SQL."""
        index = BM25Index(
            fields={"id": {}, "description": {}},
            expressions=[
                IndexExpression(
                    Lower("description"),
                    alias="description_lower",
                    tokenizer="simple",
                ),
            ],
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    "description",\n    ((LOWER("tests_product"."description"))::pdb.simple(\'alias=description_lower\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_index_expression_non_text_with_pdb_alias(self) -> None:
        """IndexExpression without tokenizer uses pdb.alias for non-text."""
        index = BM25Index(
            fields={"id": {}, "description": {}},
            expressions=[
                IndexExpression(
                    F("rating"),
                    alias="rating_indexed",
                ),
            ],
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    "description",\n    (("tests_product"."rating")::pdb.alias(\'rating_indexed\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_index_expression_with_tokenizer_and_filters(self) -> None:
        """IndexExpression with tokenizer, filters, and stemmer."""
        index = BM25Index(
            fields={"id": {}},
            expressions=[
                IndexExpression(
                    Lower("description"),
                    alias="desc_processed",
                    tokenizer="simple",
                    filters=["lowercase", "stemmer"],
                    stemmer="english",
                ),
            ],
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ((LOWER("tests_product"."description"))::pdb.simple(\'alias=desc_processed,lowercase=true,stemmer=english\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_index_expression_with_arithmetic(self) -> None:
        """IndexExpression with arithmetic constant is inlined into SQL."""
        index = BM25Index(
            fields={"id": {}},
            expressions=[
                IndexExpression(
                    F("rating") + 1,
                    alias="rating_plus_one",
                ),
            ],
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    (((("tests_product"."rating" + 1)))::pdb.alias(\'rating_plus_one\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_index_expression_non_text_transform_from_text_source_uses_alias(
        self,
    ) -> None:
        """Non-text outputs from text fields should use pdb.alias without a tokenizer."""
        index = BM25Index(
            fields={"id": {}},
            expressions=[
                IndexExpression(
                    Length("description"),
                    alias="description_length",
                ),
            ],
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    ((LENGTH("tests_product"."description"))::pdb.alias(\'description_length\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_index_expression_with_json_path_reference(self) -> None:
        """JSON path expressions require a tokenizer on the source expression."""
        index = BM25Index(
            fields={"id": {}},
            expressions=[
                IndexExpression(
                    F("metadata__word_count"),
                    alias="word_count",
                ),
            ],
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        with pytest.raises(ValueError, match="resolves to a text or JSON value"):
            index.create_sql(model=Product, schema_editor=schema_editor)

    def test_index_expression_with_string_field_reference(self) -> None:
        """IndexExpression with string field reference (converted to F())."""
        index = BM25Index(
            fields={"id": {}},
            expressions=[
                IndexExpression(
                    "rating",
                    alias="rating_alias",
                ),
            ],
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert (
            sql
            == 'CREATE INDEX "product_search_idx" ON "tests_product"\nUSING bm25 (\n    "id",\n    (("tests_product"."rating")::pdb.alias(\'rating_alias\'))\n)\nWITH (key_field=\'id\')'
        )

    def test_index_expression_with_ngram_tokenizer_and_args(self) -> None:
        """IndexExpression with ngram tokenizer and positional args."""
        index = BM25Index(
            fields={"id": {}},
            expressions=[
                IndexExpression(
                    Lower("description"),
                    alias="desc_ngram",
                    tokenizer="ngram",
                    args=[3, 3],
                    named_args={"prefix_only": True},
                ),
            ],
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert "pdb.ngram(3,3,'alias=desc_ngram,prefix_only=true')" in sql

    def test_multiple_index_expressions(self) -> None:
        """Multiple IndexExpressions in a single index."""
        index = BM25Index(
            fields={"id": {}},
            expressions=[
                IndexExpression(
                    Lower("description"),
                    alias="desc_lower",
                    tokenizer="simple",
                ),
                IndexExpression(
                    F("rating"),
                    alias="rating_idx",
                ),
            ],
            key_field="id",
            name="product_search_idx",
        )
        schema_editor = DummySchemaEditor()
        sql = str(index.create_sql(model=Product, schema_editor=schema_editor))
        assert "pdb.simple('alias=desc_lower')" in sql
        assert "pdb.alias('rating_idx')" in sql

    def test_index_expression_deconstruct(self) -> None:
        """BM25Index with expressions deconstructs correctly for migrations."""
        expr = IndexExpression(
            Lower("description"),
            alias="desc_lower",
            tokenizer="simple",
        )
        index = BM25Index(
            fields={"id": {}},
            expressions=[expr],
            key_field="id",
            name="product_search_idx",
        )
        _path, _args, kwargs = index.deconstruct()
        assert "expressions" in kwargs
        assert len(kwargs["expressions"]) == 1
        assert kwargs["expressions"][0].alias == "desc_lower"

    def test_index_expression_is_migration_serializable(self) -> None:
        """BM25Index with IndexExpression serializes through MigrationWriter."""
        index = BM25Index(
            fields={"id": {}},
            expressions=[
                IndexExpression(
                    Lower("description"),
                    alias="desc_lower",
                    tokenizer="simple",
                )
            ],
            key_field="id",
            name="product_search_idx",
        )
        serialized, imports = MigrationWriter.serialize(index)
        assert "IndexExpression(" in serialized
        assert "Lower('description')" in serialized
        assert "import django.db.models.functions.text" in imports
        assert "import paradedb.indexes" in imports

    def test_index_expression_without_expressions_no_key_in_deconstruct(self) -> None:
        """BM25Index without expressions does not include key in deconstruct."""
        index = BM25Index(
            fields={"id": {}, "description": {}},
            key_field="id",
            name="product_search_idx",
        )
        _path, _args, kwargs = index.deconstruct()
        assert "expressions" not in kwargs
