"""Tests for the LVGL animation schema and configuration validation."""

from __future__ import annotations

import pytest
from voluptuous import Invalid, MultipleInvalid

from esphome.components.lvgl.animation import (
    ANIMABLE_STYLES,
    ANIMATION_SCHEMA,
    TIMING_SCHEMA,
    from_to,
    literal_color,
)
from esphome.components.lvgl.defines import LValidator
from esphome.core import Lambda


def _animation(**overrides) -> dict:
    """A minimal valid animation config, with optional overrides applied."""
    config = {
        "id": "anim_id",
        "widgets": [{"id": "widget_id", "x": {"from": 0, "to": 100}}],
    }
    config.update(overrides)
    return config


# ---------------------------------------------------------------------------
# Animatable property set
# ---------------------------------------------------------------------------


class TestAnimableStyles:
    def test_all_entries_are_animatable_validators(self) -> None:
        """Every animatable style must be an LValidator marked animatable."""
        assert ANIMABLE_STYLES
        assert all(
            isinstance(v, LValidator) and v.animatable for v in ANIMABLE_STYLES.values()
        )

    def test_known_animatable_present(self) -> None:
        for prop in ("x", "y", "opa", "bg_color", "transform_rotation"):
            assert prop in ANIMABLE_STYLES

    def test_non_animatable_absent(self) -> None:
        # width/height set size but are not animatable; layout/padding never are.
        for prop in ("width", "height", "radius", "pad_all", "align"):
            assert prop not in ANIMABLE_STYLES


# ---------------------------------------------------------------------------
# Animation schema
# ---------------------------------------------------------------------------


class TestAnimationSchema:
    def test_defaults(self) -> None:
        config = ANIMATION_SCHEMA(_animation())
        assert config["duration"].total_milliseconds == 5000
        assert config["start_delay"].total_milliseconds == 0
        assert config["auto_start"] is False
        assert config["loop"] is False
        assert config["timing"] == []

    def test_values_preserved(self) -> None:
        config = ANIMATION_SCHEMA(
            _animation(duration="2s", start_delay="250ms", auto_start=True, loop=True)
        )
        assert config["duration"].total_milliseconds == 2000
        assert config["start_delay"].total_milliseconds == 250
        assert config["auto_start"] is True
        assert config["loop"] is True

    def test_id_required(self) -> None:
        with pytest.raises((Invalid, MultipleInvalid)):
            ANIMATION_SCHEMA({"widgets": [{"id": "widget_id"}]})

    def test_widgets_required(self) -> None:
        with pytest.raises((Invalid, MultipleInvalid)):
            ANIMATION_SCHEMA({"id": "anim_id"})

    def test_multiple_properties_and_widgets(self) -> None:
        config = ANIMATION_SCHEMA(
            _animation(
                widgets=[
                    {
                        "id": "w1",
                        "x": {"from": 0, "to": 100},
                        "opa": {"from": "0%", "to": "100%"},
                    },
                    {"id": "w2", "y": {"from": 10, "to": 50}},
                ]
            )
        )
        assert len(config["widgets"]) == 2

    def test_unknown_property_rejected(self) -> None:
        with pytest.raises((Invalid, MultipleInvalid)):
            ANIMATION_SCHEMA(
                _animation(widgets=[{"id": "w1", "not_a_style": {"from": 0, "to": 1}}])
            )


class TestAnimatedColorLiteral:
    """A color animated via from/to must be a literal, not a lambda."""

    def test_color_lambda_rejected_directly(self) -> None:
        with pytest.raises(Invalid, match="lambda"):
            literal_color(Lambda("return lv_color_hex(0xFF0000);"))

    def test_color_literal_accepted_directly(self) -> None:
        # A literal color value validates without error.
        literal_color(0xFF0000)

    def test_color_lambda_rejected_in_animation(self) -> None:
        with pytest.raises((Invalid, MultipleInvalid), match="lambda"):
            ANIMATION_SCHEMA(
                _animation(
                    widgets=[
                        {
                            "id": "w1",
                            "text_color": {
                                "from": Lambda("return lv_color_hex(0xFF0000);"),
                                "to": 0x00FF00,
                            },
                        }
                    ]
                )
            )

    def test_color_literals_accepted_in_animation(self) -> None:
        config = ANIMATION_SCHEMA(
            _animation(
                widgets=[{"id": "w1", "text_color": {"from": 0xFF0000, "to": 0x00FF00}}]
            )
        )
        assert config["widgets"][0]["id"].id == "w1"

    def test_non_color_property_allows_lambda(self) -> None:
        # Only colors are restricted; numeric properties may use lambdas.
        config = ANIMATION_SCHEMA(
            _animation(
                widgets=[{"id": "w1", "x": {"from": Lambda("return 5;"), "to": 100}}]
            )
        )
        assert config["widgets"][0]["id"].id == "w1"


class TestFromTo:
    def test_requires_both(self) -> None:
        validator = from_to(lambda value: value)
        with pytest.raises((Invalid, MultipleInvalid)):
            validator({"from": 1})
        with pytest.raises((Invalid, MultipleInvalid)):
            validator({"to": 1})

    def test_accepts_both(self) -> None:
        validator = from_to(lambda value: value)
        assert validator({"from": 1, "to": 2}) == {"from": 1, "to": 2}


# ---------------------------------------------------------------------------
# Timing schema
# ---------------------------------------------------------------------------


class TestTimingSchema:
    def test_round_trip_string(self) -> None:
        assert TIMING_SCHEMA("round_trip")["type"] == "round_trip"

    def test_round_trip_default_pause(self) -> None:
        # Back-compat default: no pause, matching the pre-existing round_trip behavior.
        assert TIMING_SCHEMA("round_trip")["pause"] == pytest.approx(0.0)

    def test_round_trip_pause_percentage_string(self) -> None:
        result = TIMING_SCHEMA({"type": "round_trip", "pause": "50%"})
        assert result["pause"] == pytest.approx(0.5)

    def test_round_trip_pause_rejects_one(self) -> None:
        # pause == 1.0 would make moving_length_ zero and divide by zero in map_progress.
        with pytest.raises((Invalid, MultipleInvalid)):
            TIMING_SCHEMA({"type": "round_trip", "pause": 1.0})

    def test_ease_in_out_default_weight(self) -> None:
        result = TIMING_SCHEMA("ease_in_out")
        assert result["type"] == "ease_in_out"
        assert result["weight"] == pytest.approx(1.0)

    def test_ease_in_out_custom_weight(self) -> None:
        result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 0.5})
        assert result["weight"] == pytest.approx(0.5)

    def test_gravity_defaults(self) -> None:
        result = TIMING_SCHEMA("gravity")
        assert result["type"] == "gravity"
        assert result["bounce"] == pytest.approx(0.5)
        assert result["acceleration"] == pytest.approx(0.5)

    def test_gravity_custom(self) -> None:
        result = TIMING_SCHEMA({"type": "gravity", "bounce": 0.3, "acceleration": 0.8})
        assert result["bounce"] == pytest.approx(0.3)
        assert result["acceleration"] == pytest.approx(0.8)

    def test_unknown_type_rejected(self) -> None:
        with pytest.raises((Invalid, MultipleInvalid)):
            TIMING_SCHEMA({"type": "not_a_timing"})

    def test_timing_list_in_animation(self) -> None:
        config = ANIMATION_SCHEMA(
            _animation(timing=["round_trip", {"type": "gravity", "bounce": 0.3}])
        )
        types = [t["type"] for t in config["timing"]]
        assert types == ["round_trip", "gravity"]
