"""Tests for bind() and collect_params() in therismos.expr.template."""

from __future__ import annotations

import datetime

import pytest

from therismos import F
from therismos.expr._expr import TemplateParam
from therismos.expr.template import MissingParamError, bind, collect_params

# ---------------------------------------------------------------------------
# collect_params
# ---------------------------------------------------------------------------


class TestCollectParams:
    def test_no_params(self) -> None:
        expr = F("age") > 18
        assert collect_params(expr) == {}

    def test_single_eq_param(self) -> None:
        p = TemplateParam("threshold")
        expr = F("age") == p
        result = collect_params(expr)
        assert result == {"threshold": p}

    def test_ge_param(self) -> None:
        p = TemplateParam("start", datetime.date)
        expr = F("created", datetime.date) >= p
        result = collect_params(expr)
        assert result == {"start": p}

    def test_between_both_params(self) -> None:
        lower = TemplateParam("start")
        upper = TemplateParam("end")
        expr = F("score").between(lower, upper)
        result = collect_params(expr)
        assert result == {"start": lower, "end": upper}

    def test_between_one_param(self) -> None:
        lower = TemplateParam("start")
        expr = F("score").between(lower, 100)
        result = collect_params(expr)
        assert result == {"start": lower}

    def test_in_with_params(self) -> None:
        p1 = TemplateParam("a")
        p2 = TemplateParam("b")
        expr = F("x").is_in(p1, p2)
        result = collect_params(expr)
        assert result == {"a": p1, "b": p2}

    def test_composite_and(self) -> None:
        p_start = TemplateParam("start", datetime.date)
        p_end = TemplateParam("end", datetime.date)
        field = F("created", datetime.date)
        expr = (field >= p_start) & (field <= p_end)
        result = collect_params(expr)
        assert set(result) == {"start", "end"}

    def test_not_expr(self) -> None:
        p = TemplateParam("threshold")
        expr = ~(F("age") == p)
        result = collect_params(expr)
        assert result == {"threshold": p}

    def test_nested_or(self) -> None:
        p1 = TemplateParam("a")
        p2 = TemplateParam("b")
        expr = (F("x") == p1) | (F("y") == p2)
        result = collect_params(expr)
        assert set(result) == {"a", "b"}

    def test_constant_exprs_no_params(self) -> None:
        from therismos import FALSE, TRUE

        assert collect_params(TRUE) == {}
        assert collect_params(FALSE) == {}

    def test_is_null_no_params(self) -> None:
        expr = F("x").is_null()
        assert collect_params(expr) == {}


# ---------------------------------------------------------------------------
# bind — simple expressions
# ---------------------------------------------------------------------------


class TestBind:
    def test_bind_eq(self) -> None:
        p = TemplateParam("age")
        expr = F("age") == p
        bound = bind(expr, {"age": 42})
        from therismos import Eq

        assert isinstance(bound, Eq)
        assert bound.value == 42

    def test_bind_ne(self) -> None:
        p = TemplateParam("status")
        expr = F("status") != p
        bound = bind(expr, {"status": "inactive"})
        from therismos import Ne

        assert isinstance(bound, Ne)
        assert bound.value == "inactive"

    def test_bind_ge(self) -> None:
        p = TemplateParam("start", datetime.date)
        field = F("created", datetime.date)
        expr = field >= p
        bound = bind(expr, {"start": datetime.date(2026, 1, 1)})
        from therismos import Ge

        assert isinstance(bound, Ge)
        assert bound.value == datetime.date(2026, 1, 1)

    def test_bind_type_cast(self) -> None:
        """TemplateParam.type_ is applied during bind."""
        p = TemplateParam("threshold", int)
        expr = F("score") >= p
        bound = bind(expr, {"threshold": "100"})  # string → int cast
        assert bound.value == 100
        assert isinstance(bound.value, int)

    def test_bind_no_cast_when_no_type(self) -> None:
        p = TemplateParam("val")
        expr = F("x") == p
        bound = bind(expr, {"val": "hello"})
        assert bound.value == "hello"

    def test_bind_between(self) -> None:
        lower = TemplateParam("lo")
        upper = TemplateParam("hi")
        expr = F("score").between(lower, upper)
        bound = bind(expr, {"lo": 10, "hi": 20})
        from therismos import Between

        assert isinstance(bound, Between)
        assert bound.lower == 10
        assert bound.upper == 20

    def test_bind_between_partial(self) -> None:
        lower = TemplateParam("lo")
        expr = F("score").between(lower, 100)
        bound = bind(expr, {"lo": 0})
        from therismos import Between

        assert isinstance(bound, Between)
        assert bound.lower == 0
        assert bound.upper == 100

    def test_bind_in(self) -> None:
        p1 = TemplateParam("a")
        p2 = TemplateParam("b")
        expr = F("x").is_in(p1, p2)
        bound = bind(expr, {"a": "foo", "b": "bar"})
        from therismos import In

        assert isinstance(bound, In)
        assert set(bound.values) == {"foo", "bar"}

    def test_bind_composite_and(self) -> None:
        p_start = TemplateParam("start", datetime.date)
        p_end = TemplateParam("end", datetime.date)
        field = F("created", datetime.date)
        expr = (field >= p_start) & (field <= p_end)
        params = {
            "start": datetime.date(2026, 3, 11),
            "end": datetime.date(2026, 3, 18),
        }
        bound = bind(expr, params)
        # No template params remain
        assert collect_params(bound) == {}

    def test_bind_not_expr(self) -> None:
        p = TemplateParam("val")
        expr = ~(F("x") == p)
        bound = bind(expr, {"val": 5})
        assert collect_params(bound) == {}

    def test_bind_or_expr(self) -> None:
        p = TemplateParam("val")
        expr = (F("x") == p) | (F("y") > 10)
        bound = bind(expr, {"val": "hello"})
        assert collect_params(bound) == {}

    def test_bind_no_change_when_no_params(self) -> None:
        expr = F("age") > 18
        bound = bind(expr, {})
        assert bound is expr  # unchanged, same object

    def test_bind_missing_param_raises(self) -> None:
        p = TemplateParam("missing")
        expr = F("x") == p
        with pytest.raises(MissingParamError) as exc_info:
            bind(expr, {})
        assert exc_info.value.name == "missing"

    def test_bind_missing_between_lower_raises(self) -> None:
        lower = TemplateParam("lo")
        expr = F("score").between(lower, 100)
        with pytest.raises(MissingParamError) as exc_info:
            bind(expr, {})
        assert exc_info.value.name == "lo"

    def test_bind_missing_in_raises(self) -> None:
        p = TemplateParam("val")
        expr = F("x").is_in(p)
        with pytest.raises(MissingParamError) as exc_info:
            bind(expr, {})
        assert exc_info.value.name == "val"

    def test_bind_true_false_unchanged(self) -> None:
        from therismos import FALSE, TRUE

        assert bind(TRUE, {}) is TRUE
        assert bind(FALSE, {}) is FALSE

    def test_bind_is_null_unchanged(self) -> None:
        expr = F("x").is_null()
        assert bind(expr, {}) is expr

    def test_bind_correct_type_preserved_when_already_correct_type(self) -> None:
        """If TemplateParam.type_ matches the value's type, no cast is called."""
        p = TemplateParam("d", datetime.date)
        today = datetime.date.today()
        expr = F("created", datetime.date) >= p
        bound = bind(expr, {"d": today})
        assert bound.value is today
