"""Tests for GameEventValueSource (storage model) and GameEventValueStream (runtime)."""

import time
import threading

import pytest

from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.core.value_sources.game_event_value_source import GameEventValueStream
from wled_controller.storage.value_source import (
    GameEventValueSource,
    ValueSource,
)


# ---------------------------------------------------------------------------
# GameEventValueSource model tests (Task 5)
# ---------------------------------------------------------------------------


class TestGameEventValueSourceModel:
    """Serialization round-trips and defaults for the storage dataclass."""

    def test_round_trip(self):
        data = {
            "id": "vs_game1",
            "name": "Health Monitor",
            "source_type": "game_event",
            "game_integration_id": "gi_abc123",
            "event_type": "health",
            "min_game_value": 0.0,
            "max_game_value": 100.0,
            "smoothing": 0.3,
            "default_value": 0.5,
            "timeout": 10.0,
            "created_at": "2025-01-01T00:00:00+00:00",
            "updated_at": "2025-01-01T00:00:00+00:00",
        }
        src = ValueSource.from_dict(data)
        assert isinstance(src, GameEventValueSource)
        assert src.game_integration_id == "gi_abc123"
        assert src.event_type == "health"
        assert src.min_game_value == 0.0
        assert src.max_game_value == 100.0
        assert src.smoothing == 0.3
        assert src.default_value == 0.5
        assert src.timeout == 10.0

        # Round-trip through to_dict -> from_dict
        restored = ValueSource.from_dict(src.to_dict())
        assert isinstance(restored, GameEventValueSource)
        assert restored.game_integration_id == "gi_abc123"
        assert restored.event_type == "health"
        assert restored.smoothing == 0.3
        assert restored.default_value == 0.5
        assert restored.timeout == 10.0

    def test_defaults(self):
        data = {
            "id": "vs_game2",
            "name": "Default Test",
            "source_type": "game_event",
            "created_at": "2025-01-01T00:00:00+00:00",
            "updated_at": "2025-01-01T00:00:00+00:00",
        }
        src = ValueSource.from_dict(data)
        assert isinstance(src, GameEventValueSource)
        assert src.game_integration_id == ""
        assert src.event_type == "health"
        assert src.min_game_value == 0.0
        assert src.max_game_value == 100.0
        assert src.smoothing == 0.0
        assert src.default_value == 0.5
        assert src.timeout == 5.0

    def test_to_dict_includes_all_fields(self):
        data = {
            "id": "vs_game3",
            "name": "Full",
            "source_type": "game_event",
            "game_integration_id": "gi_xyz",
            "event_type": "ammo",
            "min_game_value": 0.0,
            "max_game_value": 30.0,
            "smoothing": 0.5,
            "default_value": 0.0,
            "timeout": 3.0,
            "created_at": "2025-01-01T00:00:00+00:00",
            "updated_at": "2025-01-01T00:00:00+00:00",
        }
        src = ValueSource.from_dict(data)
        d = src.to_dict()
        assert d["source_type"] == "game_event"
        assert d["game_integration_id"] == "gi_xyz"
        assert d["event_type"] == "ammo"
        assert d["max_game_value"] == 30.0
        assert d["smoothing"] == 0.5
        assert d["default_value"] == 0.0
        assert d["timeout"] == 3.0

    def test_from_dict_with_zero_values(self):
        """Ensure 0.0 values are preserved, not replaced by defaults."""
        data = {
            "id": "vs_game4",
            "name": "Zeros",
            "source_type": "game_event",
            "min_game_value": 0.0,
            "max_game_value": 0.0,
            "smoothing": 0.0,
            "default_value": 0.0,
            "timeout": 0.0,
            "created_at": "2025-01-01T00:00:00+00:00",
            "updated_at": "2025-01-01T00:00:00+00:00",
        }
        src = ValueSource.from_dict(data)
        assert isinstance(src, GameEventValueSource)
        assert src.max_game_value == 0.0
        assert src.default_value == 0.0
        assert src.timeout == 0.0


# ---------------------------------------------------------------------------
# GameEventValueStream runtime tests (Task 6)
# ---------------------------------------------------------------------------


def _make_event(event_type: str, value: float) -> GameEvent:
    return GameEvent(
        adapter_id="test_adapter",
        event_type=event_type,
        value=value,
    )


class TestGameEventValueStreamNormalization:
    """Tests for value normalization (min/max mapping)."""

    def test_mid_value(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 50.0))
        assert stream.get_value() == pytest.approx(0.5)
        stream.stop()

    def test_min_value(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 0.0))
        assert stream.get_value() == pytest.approx(0.0)
        stream.stop()

    def test_max_value(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 100.0))
        assert stream.get_value() == pytest.approx(1.0)
        stream.stop()

    def test_clamp_above_max(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 150.0))
        assert stream.get_value() == pytest.approx(1.0)
        stream.stop()

    def test_clamp_below_min(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", -10.0))
        assert stream.get_value() == pytest.approx(0.0)
        stream.stop()

    def test_custom_range(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="ammo",
            min_game_value=10.0,
            max_game_value=60.0,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("ammo", 35.0))
        assert stream.get_value() == pytest.approx(0.5)
        stream.stop()

    def test_zero_range_returns_midpoint(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=50.0,
            max_game_value=50.0,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 50.0))
        assert stream.get_value() == pytest.approx(0.5)
        stream.stop()


class TestGameEventValueStreamSmoothing:
    """Tests for EMA smoothing behavior."""

    def test_no_smoothing(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            smoothing=0.0,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 100.0))
        assert stream.get_value() == pytest.approx(1.0)
        bus.publish(_make_event("health", 0.0))
        assert stream.get_value() == pytest.approx(0.0)
        stream.stop()

    def test_smoothing_dampens_change(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            smoothing=0.8,
            event_bus=bus,
        )
        stream.start()
        # First event: no smoothing applied (no previous value)
        bus.publish(_make_event("health", 100.0))
        assert stream.get_value() == pytest.approx(1.0)

        # Second event: smoothing kicks in
        # alpha = 1 - 0.8 = 0.2
        # smoothed = 0.2 * 0.0 + 0.8 * 1.0 = 0.8
        bus.publish(_make_event("health", 0.0))
        assert stream.get_value() == pytest.approx(0.8)

        # Third event: continues smoothing
        # smoothed = 0.2 * 0.0 + 0.8 * 0.8 = 0.64
        bus.publish(_make_event("health", 0.0))
        assert stream.get_value() == pytest.approx(0.64)
        stream.stop()

    def test_smoothing_converges(self):
        """With many events at the same value, output should approach that value."""
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            smoothing=0.5,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 100.0))
        for _ in range(20):
            bus.publish(_make_event("health", 50.0))
        assert stream.get_value() == pytest.approx(0.5, abs=0.01)
        stream.stop()


class TestGameEventValueStreamTimeout:
    """Tests for timeout / revert to default."""

    def test_default_before_any_event(self):
        stream = GameEventValueStream(
            event_type="health",
            default_value=0.5,
            event_bus=GameEventBus(),
        )
        stream.start()
        assert stream.get_value() == pytest.approx(0.5)
        stream.stop()

    def test_timeout_reverts_to_default(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            default_value=0.5,
            timeout=0.05,  # 50ms timeout for fast test
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 80.0))
        assert stream.get_value() == pytest.approx(0.8)

        # Wait for timeout
        time.sleep(0.1)
        assert stream.get_value() == pytest.approx(0.5)
        stream.stop()

    def test_no_timeout_when_zero(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            default_value=0.5,
            timeout=0.0,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 80.0))
        time.sleep(0.05)
        # With timeout=0.0, should never revert
        assert stream.get_value() == pytest.approx(0.8)
        stream.stop()

    def test_event_resets_timeout(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            default_value=0.5,
            timeout=0.1,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 80.0))
        time.sleep(0.06)
        # Send another event before timeout
        bus.publish(_make_event("health", 80.0))
        time.sleep(0.06)
        # Should still be live (timeout reset)
        assert stream.get_value() == pytest.approx(0.8)
        stream.stop()


class TestGameEventValueStreamLifecycle:
    """Tests for start/stop and EventBus subscription management."""

    def test_stop_unsubscribes(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            default_value=0.5,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("health", 100.0))
        assert stream.get_value() == pytest.approx(1.0)

        stream.stop()
        # After stop, events should not affect the stream
        assert stream.get_value() == pytest.approx(0.5)

    def test_ignores_other_event_types(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            default_value=0.5,
            event_bus=bus,
        )
        stream.start()
        bus.publish(_make_event("ammo", 100.0))
        # Should still be at default since event_type doesn't match
        assert stream.get_value() == pytest.approx(0.5)
        stream.stop()

    def test_no_event_bus(self):
        """Stream without event bus should return default value."""
        stream = GameEventValueStream(
            event_type="health",
            default_value=0.7,
            event_bus=None,
        )
        stream.start()
        assert stream.get_value() == pytest.approx(0.7)
        stream.stop()

    def test_get_color_raises(self):
        stream = GameEventValueStream(
            event_type="health",
            event_bus=None,
        )
        with pytest.raises(NotImplementedError):
            stream.get_color()


class TestGameEventValueStreamThreadSafety:
    """Tests for concurrent access from multiple threads."""

    def test_concurrent_publish_and_read(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            default_value=0.5,
            smoothing=0.0,
            timeout=5.0,
            event_bus=bus,
        )
        stream.start()

        errors = []

        def publisher():
            try:
                for i in range(100):
                    bus.publish(_make_event("health", float(i)))
            except Exception as e:
                errors.append(e)

        def reader():
            try:
                for _ in range(100):
                    val = stream.get_value()
                    assert 0.0 <= val <= 1.0
            except Exception as e:
                errors.append(e)

        threads = [
            threading.Thread(target=publisher),
            threading.Thread(target=reader),
            threading.Thread(target=reader),
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=5.0)

        stream.stop()
        assert len(errors) == 0, f"Thread errors: {errors}"


class TestGameEventValueStreamHotUpdate:
    """Tests for update_source hot-update functionality."""

    def test_update_source_changes_params(self):
        bus = GameEventBus()
        stream = GameEventValueStream(
            event_type="health",
            min_game_value=0.0,
            max_game_value=100.0,
            default_value=0.5,
            timeout=5.0,
            event_bus=bus,
        )
        stream.start()

        # Create an updated source config
        source = GameEventValueSource(
            id="vs_test",
            name="Test",
            source_type="game_event",
            min_game_value=0.0,
            max_game_value=200.0,
            smoothing=0.5,
            default_value=0.3,
            timeout=10.0,
            created_at="2025-01-01T00:00:00+00:00",
            updated_at="2025-01-01T00:00:00+00:00",
        )
        stream.update_source(source)

        # Verify hot-updated params take effect
        bus.publish(_make_event("health", 100.0))
        # With max_game_value=200, 100 normalizes to 0.5
        assert stream.get_value() == pytest.approx(0.5)
        stream.stop()
