"""Tests for the Between expression operator."""

import dataclasses

import pytest

from therismos import FALSE, Between, F, optimize, unwind_data
from therismos.expr.serializer import Serializer
from therismos.expr.visitors._visitors import (
    CountVisitor,
    DictVisitor,
    FieldGathererVisitor,
    StringVisitor,
)
from therismos.expr.visitors.mongo import MongoVisitor

# ---------------------------------------------------------------------------
# Construction
# ---------------------------------------------------------------------------


class TestBetweenConstruction:
    """Test Between expression construction."""

    def test_direct_construction(self) -> None:
        """Test direct construction of Between."""
        field = F("age")
        expr = Between(field, 10, 20)
        assert expr.field.name == "age"
        assert expr.lower == 10
        assert expr.upper == 20

    def test_field_helper_method(self) -> None:
        """Test creating Between via Field.between()."""
        field = F("age")
        expr = field.between(10, 20)
        assert isinstance(expr, Between)
        assert expr.field.name == "age"
        assert expr.lower == 10
        assert expr.upper == 20

    def test_between_is_immutable(self) -> None:
        """Test that Between instances are immutable (frozen dataclass)."""
        field = F("age")
        expr = Between(field, 10, 20)
        with pytest.raises((AttributeError, TypeError, dataclasses.FrozenInstanceError)):
            expr.lower = 5  # type: ignore[misc]

    def test_between_equality(self) -> None:
        """Test that two Between instances with the same values are equal."""
        field = F("age")
        assert Between(field, 10, 20) == Between(field, 10, 20)
        assert Between(field, 10, 20) != Between(field, 10, 30)

    def test_list_lower_bound_rejected(self) -> None:
        """Test that a list lower bound raises TypeError."""
        with pytest.raises(TypeError):
            Between(F("age"), [1, 2], 20)

    def test_tuple_upper_bound_rejected(self) -> None:
        """Test that a tuple upper bound raises TypeError."""
        with pytest.raises(TypeError):
            Between(F("age"), 10, (5, 6))

    def test_valid_bounds_accepted(self) -> None:
        """Test that scalar bounds are accepted without error."""
        expr = Between(F("age"), 10, 20)
        assert expr.lower == 10
        assert expr.upper == 20

    def test_typed_field_casted_bounds(self) -> None:
        """Test that casted_lower/casted_upper apply the field's type."""
        field = F("age", int)
        expr = Between(field, "10", "20")
        assert expr.casted_lower() == 10
        assert expr.casted_upper() == 20

    def test_str_representation(self) -> None:
        """Test __str__ produces a readable representation."""
        expr = Between(F("age"), 10, 20)
        assert str(expr) == "age BETWEEN [10, 20)"


# ---------------------------------------------------------------------------
# Evaluation
# ---------------------------------------------------------------------------


class TestBetweenEvaluation:
    """Test Between expression evaluation (lower <= x < upper)."""

    @pytest.mark.parametrize(
        ("field_value", "lower", "upper", "expected"),
        [
            # Within range
            (15, 10, 20, True),
            # At lower bound (inclusive)
            (10, 10, 20, True),
            # At upper bound (exclusive)
            (20, 10, 20, False),
            # Below lower bound
            (9, 10, 20, False),
            # Above upper bound
            (21, 10, 20, False),
            # Float bounds
            (1.5, 1.0, 2.0, True),
            (2.0, 1.0, 2.0, False),
            # String lexicographic ordering
            ("b", "a", "c", True),
            ("a", "a", "c", True),
            ("c", "a", "c", False),
        ],
    )
    def test_between_range(
        self,
        field_value: int | float | str,
        lower: int | float | str,
        upper: int | float | str,
        expected: bool,
    ) -> None:
        """Test evaluation with various boundary conditions."""
        field = F("value")
        expr = field.between(lower, upper)
        data = unwind_data({"value": field_value})
        assert expr.evaluate(data) is expected

    def test_missing_field_raises_key_error(self) -> None:
        """Test that evaluating against data without the field raises KeyError."""
        expr = F("age").between(10, 20)
        with pytest.raises(KeyError, match="age"):
            expr.evaluate(unwind_data({"name": "Alice"}))

    @pytest.mark.parametrize(
        ("field_values", "lower", "upper", "expected"),
        [
            # Any value in range → True
            ([5, 15, 25], 10, 20, True),
            # All values out of range → False
            ([1, 2, 3], 10, 20, False),
            # One value at lower bound → True
            ([10, 5], 10, 20, True),
            # Only values at upper bound (exclusive) or above → False
            ([20, 25], 10, 20, False),
        ],
    )
    def test_multi_valued_field(
        self,
        field_values: list[int],
        lower: int,
        upper: int,
        expected: bool,
    ) -> None:
        """Test evaluation on multi-valued (list) fields uses any-semantics."""
        field = F("scores")
        expr = field.between(lower, upper)
        assert expr.evaluate(unwind_data({"scores": field_values})) is expected

    def test_typed_field_casts_on_range_check(self) -> None:
        """Test that field type casting is applied during evaluation."""
        field = F("age", int)
        expr = field.between("10", "20")
        assert expr.evaluate(unwind_data({"age": 15})) is True
        assert expr.evaluate(unwind_data({"age": 25})) is False


# ---------------------------------------------------------------------------
# Optimizer
# ---------------------------------------------------------------------------


class TestBetweenOptimizer:
    """Test optimizer rules for Between expressions."""

    def test_empty_range_becomes_false(self) -> None:
        """Test Between with lower > upper → FALSE."""
        expr = F("age").between(20, 10)
        result, records = optimize(expr)
        assert result == FALSE
        assert any("lower >= upper" in r.reason for r in records)

    def test_equal_bounds_becomes_false(self) -> None:
        """Test Between with lower == upper → FALSE (empty half-open range)."""
        expr = F("age").between(10, 10)
        result, records = optimize(expr)
        assert result == FALSE
        assert any("lower >= upper" in r.reason for r in records)

    def test_valid_range_unchanged(self) -> None:
        """Test that a valid Between range is not simplified."""
        expr = F("age").between(10, 20)
        result, _ = optimize(expr)
        assert result == expr

    def test_cross_range_contradiction(self) -> None:
        """Test Between(f, 5, 10) AND Between(f, 15, 20) → FALSE (non-overlapping)."""
        f = F("age")
        expr = f.between(5, 10) & f.between(15, 20)
        result, records = optimize(expr)
        assert result == FALSE
        assert any("intersection" in r.reason or "Contradiction" in r.reason for r in records)

    def test_between_and_gt_contradiction(self) -> None:
        """Test Between(f, 5, 10) AND f > 15 → FALSE."""
        f = F("age")
        expr = f.between(5, 10) & (f > 15)
        result, _ = optimize(expr)
        assert result == FALSE

    def test_between_in_all_expr_with_false(self) -> None:
        """Test AND with FALSE still simplifies."""
        f = F("age")
        expr = f.between(10, 20) & FALSE
        result, _ = optimize(expr)
        assert result == FALSE

    def test_and_intersection_overlapping(self) -> None:
        """Test Between(f, 5, 15) AND Between(f, 10, 20) → Between(f, 10, 15)."""
        f = F("age")
        expr = f.between(5, 15) & f.between(10, 20)
        result, records = optimize(expr)
        assert result == Between(f, 10, 15)
        assert any("intersection" in r.reason for r in records)

    def test_and_intersection_adjacent_bounds_is_false(self) -> None:
        """Test Between(f, 5, 10) AND Between(f, 10, 20) → FALSE (empty [10, 10))."""
        f = F("age")
        expr = f.between(5, 10) & f.between(10, 20)
        result, records = optimize(expr)
        assert result == FALSE
        assert any("intersection" in r.reason for r in records)

    def test_and_intersection_three_way(self) -> None:
        """Test three-way AND intersection → Between(f, 5, 6)."""
        f = F("age")
        expr = f.between(3, 7) & f.between(5, 9) & f.between(4, 6)
        result, _ = optimize(expr)
        assert result == Between(f, 5, 6)

    def test_or_union_overlapping(self) -> None:
        """Test Between(f, 5, 10) OR Between(f, 8, 15) → Between(f, 5, 15)."""
        f = F("age")
        expr = f.between(5, 10) | f.between(8, 15)
        result, records = optimize(expr)
        assert result == Between(f, 5, 15)
        assert any("union" in r.reason for r in records)

    def test_or_union_adjacent(self) -> None:
        """Test Between(f, 5, 10) OR Between(f, 10, 15) → Between(f, 5, 15) (c == b)."""
        f = F("age")
        expr = f.between(5, 10) | f.between(10, 15)
        result, records = optimize(expr)
        assert result == Between(f, 5, 15)
        assert any("union" in r.reason for r in records)

    def test_or_union_gap_unchanged(self) -> None:
        """Test Between(f, 5, 8) OR Between(f, 10, 15) → unchanged (gap between 8 and 10)."""
        f = F("age")
        expr = f.between(5, 8) | f.between(10, 15)
        result, _ = optimize(expr)
        assert result == expr

    def test_or_union_three_way(self) -> None:
        """Test three-way OR union merges all overlapping ranges → Between(f, 5, 15)."""
        f = F("age")
        expr = f.between(5, 10) | f.between(7, 9) | f.between(8, 15)
        result, _ = optimize(expr)
        assert result == Between(f, 5, 15)


# ---------------------------------------------------------------------------
# Serializer
# ---------------------------------------------------------------------------


class TestBetweenSerializer:
    """Test serialization and deserialization of Between expressions."""

    @pytest.mark.parametrize(
        "expr,expected",
        [
            (Between(F("age"), 10, 20), "age=between=(10,20)"),
            (Between(F("price"), 1.5, 9.99), "price=between=(1.5,9.99)"),
            (Between(F("name"), "a", "z"), 'name=between=("a","z")'),
        ],
    )
    def test_serialization(self, expr: Between, expected: str) -> None:
        """Test serialization produces the expected string."""
        serializer = Serializer()
        assert serializer.serialize(expr) == expected

    @pytest.mark.parametrize(
        "s,expected_lower,expected_upper",
        [
            ("age=between=(10,20)", 10, 20),
            ("price=between=(1.5,9.99)", 1.5, 9.99),
            ('name=between=("a","z")', "a", "z"),
        ],
    )
    def test_deserialization(self, s: str, expected_lower: object, expected_upper: object) -> None:
        """Test deserialization produces the correct Between expression."""
        serializer = Serializer()
        expr = serializer.deserialize(s)
        assert isinstance(expr, Between)
        assert expr.lower == expected_lower
        assert expr.upper == expected_upper

    def test_roundtrip(self) -> None:
        """Test that serialize → deserialize → serialize is stable."""
        serializer = Serializer()
        original = Between(F("score"), 50, 100)
        roundtripped = serializer.deserialize(serializer.serialize(original))
        assert isinstance(roundtripped, Between)
        assert roundtripped.lower == original.lower
        assert roundtripped.upper == original.upper

    def test_roundtrip_typed_field(self) -> None:
        """Test roundtrip with implicit field type."""
        serializer = Serializer(implicit_field_types={"age": int})
        original = Between(F("age", int), 10, 20)
        s = serializer.serialize(original)
        result = serializer.deserialize(s)
        assert isinstance(result, Between)
        assert result.lower == 10
        assert result.upper == 20

    def test_url_encoding(self) -> None:
        """Test that URL encoding is applied when configured."""
        serializer = Serializer(url_encode=True)
        expr = Between(F("age"), 10, 20)
        s = serializer.serialize(expr)
        assert "%" in s
        result = serializer.deserialize(s)
        assert isinstance(result, Between)
        assert result.lower == 10
        assert result.upper == 20


# ---------------------------------------------------------------------------
# Built-in visitors
# ---------------------------------------------------------------------------


class TestBetweenBuiltinVisitors:
    """Test built-in visitor implementations for Between."""

    def test_string_visitor(self) -> None:
        """Test StringVisitor produces a human-readable representation."""
        visitor = StringVisitor()
        expr = F("age").between(10, 20)
        result = expr.accept(visitor)
        assert result == "age BETWEEN [10, 20)"

    def test_count_visitor(self) -> None:
        """Test CountVisitor counts Between as one node."""
        visitor = CountVisitor()
        expr = F("age").between(10, 20)
        assert expr.accept(visitor) == 1

    def test_dict_visitor(self) -> None:
        """Test DictVisitor produces the expected dictionary."""
        visitor = DictVisitor()
        expr = F("age").between(10, 20)
        result = expr.accept(visitor)
        assert result == {"type": "between", "field": "age", "lower": 10, "upper": 20}

    def test_field_gatherer_visitor(self) -> None:
        """Test FieldGathererVisitor collects the field name."""
        visitor = FieldGathererVisitor()
        expr = F("age").between(10, 20)
        expr.accept(visitor)
        assert "age" in visitor.field_names


# ---------------------------------------------------------------------------
# MongoDB visitor
# ---------------------------------------------------------------------------


class TestBetweenMongoVisitor:
    """Test MongoVisitor for Between expressions."""

    def test_mongo_between(self) -> None:
        """Test MongoDB filter uses $gte and $lt for half-open range."""
        visitor = MongoVisitor()
        expr = F("age").between(10, 20)
        result = expr.accept(visitor)
        assert result == {"age": {"$gte": 10, "$lt": 20}}

    def test_mongo_between_float_bounds(self) -> None:
        """Test MongoDB filter with float bounds."""
        visitor = MongoVisitor()
        expr = F("price").between(1.5, 9.99)
        result = expr.accept(visitor)
        assert result == {"price": {"$gte": 1.5, "$lt": 9.99}}

    def test_mongo_between_typed_field(self) -> None:
        """Test MongoDB filter applies field type casting."""
        visitor = MongoVisitor()
        expr = Between(F("age", int), "10", "20")
        result = expr.accept(visitor)
        assert result == {"age": {"$gte": 10, "$lt": 20}}


# ---------------------------------------------------------------------------
# Polars visitor
# ---------------------------------------------------------------------------


class TestBetweenPolarsVisitor:
    """Test PolarsExprVisitor for Between expressions."""

    def test_polars_between_filters_correctly(self) -> None:
        """Test that the Polars visitor produces a working expression."""
        pl = pytest.importorskip("polars")
        from therismos.expr.visitors.polars import PolarsExprVisitor

        visitor = PolarsExprVisitor()
        expr = F("age").between(18, 30)
        pl_expr = expr.accept(visitor)

        df = pl.DataFrame({"age": [15, 18, 25, 30, 35]})
        result = df.filter(pl_expr)["age"].to_list()
        assert result == [18, 25]

    def test_polars_between_boundary_semantics(self) -> None:
        """Test boundary semantics: lower is inclusive, upper is exclusive."""
        pl = pytest.importorskip("polars")
        from therismos.expr.visitors.polars import PolarsExprVisitor

        visitor = PolarsExprVisitor()
        expr = F("x").between(10, 11)
        pl_expr = expr.accept(visitor)
        df = pl.DataFrame({"x": [9, 10, 11, 12]})
        result = df.filter(pl_expr)["x"].to_list()
        assert result == [10]


# ---------------------------------------------------------------------------
# Pandas visitor
# ---------------------------------------------------------------------------


class TestBetweenPandasVisitor:
    """Test PandasExprVisitor for Between expressions."""

    def test_pandas_between_filters_correctly(self) -> None:
        """Test that the Pandas visitor produces a working callable filter."""
        pd = pytest.importorskip("pandas")
        from therismos.expr.visitors.pandas import PandasExprVisitor

        visitor = PandasExprVisitor()
        expr = F("age").between(18, 30)
        filter_fn = expr.accept(visitor)
        assert callable(filter_fn)

        df = pd.DataFrame({"age": [15, 18, 25, 30, 35]})
        result = df[filter_fn(df)]["age"].to_list()
        assert result == [18, 25]

    def test_pandas_between_boundary_semantics(self) -> None:
        """Test boundary semantics: lower is inclusive, upper is exclusive."""
        pd = pytest.importorskip("pandas")
        from therismos.expr.visitors.pandas import PandasExprVisitor

        visitor = PandasExprVisitor()
        expr = F("x").between(10, 11)
        filter_fn = expr.accept(visitor)

        df = pd.DataFrame({"x": [9, 10, 11, 12]})
        result = df[filter_fn(df)]["x"].to_list()
        assert result == [10]
