"""Tests for GameEventColorStripSource and GameEventColorStripStream."""

import time
from datetime import datetime, timezone

import numpy as np

from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.core.processing.game_event_stream import GameEventColorStripStream
from wled_controller.storage.bindable import BindableColor
from wled_controller.storage.color_strip_source import (
    ColorStripSource,
    GameEventColorStripSource,
)


# ── Helpers ──────────────────────────────────────────────────────────


def _make_source(**overrides) -> GameEventColorStripSource:
    """Create a GameEventColorStripSource with sensible defaults."""
    defaults = dict(
        id="css_test01",
        name="Test Game Event Source",
        source_type="game_event",
        created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
        updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
        game_integration_id="gi_abc12345",
        idle_color=BindableColor([0, 0, 0]),
        event_mappings=[
            {
                "event_type": "kill",
                "effect": "flash",
                "color": [255, 0, 0],
                "duration_ms": 500,
                "intensity": 1.0,
                "priority": 1,
            },
            {
                "event_type": "death",
                "effect": "pulse",
                "color": [0, 0, 255],
                "duration_ms": 800,
                "intensity": 0.8,
                "priority": 2,
            },
        ],
        led_count=10,
    )
    defaults.update(overrides)
    return GameEventColorStripSource(**defaults)


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


# ── GameEventColorStripSource serialization tests ────────────────────


class TestGameEventColorStripSourceSerialization:
    def test_to_dict_roundtrip(self):
        source = _make_source()
        d = source.to_dict()

        assert d["source_type"] == "game_event"
        assert d["game_integration_id"] == "gi_abc12345"
        assert d["idle_color"] == [0, 0, 0]
        assert len(d["event_mappings"]) == 2
        assert d["led_count"] == 10

        restored = GameEventColorStripSource.from_dict(d)
        assert restored.id == source.id
        assert restored.name == source.name
        assert restored.source_type == "game_event"
        assert restored.game_integration_id == "gi_abc12345"
        assert restored.idle_color.color == [0, 0, 0]
        assert len(restored.event_mappings) == 2
        assert restored.led_count == 10

    def test_from_dict_defaults(self):
        data = {
            "id": "css_min01",
            "name": "Minimal",
            "source_type": "game_event",
            "created_at": "2026-01-01T00:00:00+00:00",
            "updated_at": "2026-01-01T00:00:00+00:00",
        }
        source = GameEventColorStripSource.from_dict(data)
        assert source.game_integration_id == ""
        assert source.idle_color.color == [0, 0, 0]
        assert source.event_mappings == []
        assert source.led_count == 0

    def test_factory_dispatch(self):
        """ColorStripSource.from_dict dispatches to GameEventColorStripSource."""
        data = {
            "id": "css_fac01",
            "name": "Factory Test",
            "source_type": "game_event",
            "created_at": "2026-01-01T00:00:00+00:00",
            "updated_at": "2026-01-01T00:00:00+00:00",
            "game_integration_id": "gi_xyz",
        }
        source = ColorStripSource.from_dict(data)
        assert isinstance(source, GameEventColorStripSource)
        assert source.game_integration_id == "gi_xyz"

    def test_create_from_kwargs(self):
        source = GameEventColorStripSource.create_from_kwargs(
            id="css_kw01",
            name="KW Test",
            source_type="game_event",
            created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            game_integration_id="gi_123",
            idle_color=[10, 20, 30],
            event_mappings=[{"event_type": "kill", "effect": "flash"}],
            led_count=20,
        )
        assert source.game_integration_id == "gi_123"
        assert source.idle_color.color == [10, 20, 30]
        assert len(source.event_mappings) == 1
        assert source.led_count == 20

    def test_create_instance_factory(self):
        source = ColorStripSource.create_instance(
            source_type="game_event",
            id="css_ci01",
            name="CI Test",
            created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            game_integration_id="gi_ci",
        )
        assert isinstance(source, GameEventColorStripSource)

    def test_sharable_is_false(self):
        source = _make_source()
        assert source.sharable is False


class TestGameEventColorStripSourceUpdate:
    def test_apply_update_game_integration_id(self):
        source = _make_source()
        source.apply_update(game_integration_id="gi_new")
        assert source.game_integration_id == "gi_new"

    def test_apply_update_idle_color(self):
        source = _make_source()
        source.apply_update(idle_color=[100, 100, 100])
        assert source.idle_color.color == [100, 100, 100]

    def test_apply_update_event_mappings(self):
        source = _make_source()
        new_mappings = [{"event_type": "health", "effect": "breathing"}]
        source.apply_update(event_mappings=new_mappings)
        assert len(source.event_mappings) == 1
        assert source.event_mappings[0]["event_type"] == "health"

    def test_apply_update_led_count(self):
        source = _make_source()
        source.apply_update(led_count=50)
        assert source.led_count == 50

    def test_apply_update_ignores_none(self):
        source = _make_source()
        original_id = source.game_integration_id
        source.apply_update(game_integration_id=None)
        assert source.game_integration_id == original_id


# ── GameEventColorStripStream tests ──────────────────────────────────


class TestGameEventColorStripStreamLifecycle:
    def test_start_stop(self):
        source = _make_source()
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)

        stream.start()
        assert stream._running is True
        assert stream._thread is not None

        stream.stop()
        assert stream._running is False
        assert stream._thread is None
        # Subscriptions should be cleaned up
        assert len(stream._subscription_ids) == 0

    def test_double_start_is_safe(self):
        source = _make_source()
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)

        stream.start()
        thread1 = stream._thread
        stream.start()  # Should not create a second thread
        assert stream._thread is thread1

        stream.stop()

    def test_subscriptions_registered(self):
        source = _make_source()
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)

        stream.start()
        # Should have subscribed to "kill" and "death"
        assert len(stream._subscription_ids) == 2

        stream.stop()

    def test_no_event_bus_still_starts(self):
        """Stream should start even without an EventBus (just no events)."""
        source = _make_source()
        stream = GameEventColorStripStream(source, event_bus=None)
        stream.start()
        assert stream._running is True
        stream.stop()


class TestGameEventColorStripStreamRendering:
    def test_idle_outputs_idle_color(self):
        source = _make_source(idle_color=BindableColor([50, 100, 150]))
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)
        stream.start()

        # Wait for at least one render frame
        time.sleep(0.1)

        colors = stream.get_latest_colors()
        assert colors is not None
        assert colors.shape == (10, 3)
        # Should be idle color
        assert colors[0, 0] == 50
        assert colors[0, 1] == 100
        assert colors[0, 2] == 150

        stream.stop()

    def test_event_triggers_effect(self):
        source = _make_source(
            idle_color=BindableColor([0, 0, 0]),
            event_mappings=[
                {
                    "event_type": "kill",
                    "effect": "flash",
                    "color": [255, 0, 0],
                    "duration_ms": 2000,
                    "intensity": 1.0,
                    "priority": 1,
                }
            ],
        )
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)
        stream.start()
        time.sleep(0.05)

        # Fire a kill event
        bus.publish(_make_event("kill"))
        time.sleep(0.1)

        colors = stream.get_latest_colors()
        assert colors is not None
        # Should have non-zero red (flash effect in progress)
        assert colors[0, 0] > 0

        stream.stop()

    def test_unmatched_event_ignored(self):
        source = _make_source(
            idle_color=BindableColor([10, 10, 10]),
            event_mappings=[
                {
                    "event_type": "kill",
                    "effect": "flash",
                    "color": [255, 0, 0],
                    "duration_ms": 500,
                    "intensity": 1.0,
                    "priority": 1,
                }
            ],
        )
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)
        stream.start()
        time.sleep(0.05)

        # Fire an event type not in mappings
        bus.publish(_make_event("health", value=0.5))
        time.sleep(0.1)

        colors = stream.get_latest_colors()
        # Should still be idle color (no matching mapping)
        assert colors[0, 0] == 10
        assert colors[0, 1] == 10
        assert colors[0, 2] == 10

        stream.stop()

    def test_priority_based_layering(self):
        """Higher priority effects override lower ones."""
        source = _make_source(
            idle_color=BindableColor([0, 0, 0]),
            event_mappings=[
                {
                    "event_type": "kill",
                    "effect": "flash",
                    "color": [255, 0, 0],
                    "duration_ms": 3000,
                    "intensity": 1.0,
                    "priority": 1,
                },
                {
                    "event_type": "death",
                    "effect": "flash",
                    "color": [0, 0, 255],
                    "duration_ms": 3000,
                    "intensity": 1.0,
                    "priority": 5,
                },
            ],
        )
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)
        stream.start()
        time.sleep(0.05)

        # Fire low-priority kill event
        bus.publish(_make_event("kill"))
        time.sleep(0.05)
        # Fire high-priority death event — should override
        bus.publish(_make_event("death"))
        time.sleep(0.1)

        colors = stream.get_latest_colors()
        # Should be blue (death effect), not red (kill effect)
        assert colors[0, 2] > colors[0, 0]

        stream.stop()

    def test_effect_returns_to_idle(self):
        """After effect completes, output returns to idle_color."""
        source = _make_source(
            idle_color=BindableColor([42, 42, 42]),
            event_mappings=[
                {
                    "event_type": "kill",
                    "effect": "flash",
                    "color": [255, 0, 0],
                    "duration_ms": 50,  # Very short effect
                    "intensity": 1.0,
                    "priority": 1,
                }
            ],
        )
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)
        stream.start()
        time.sleep(0.05)

        bus.publish(_make_event("kill"))
        # Wait for effect to complete
        time.sleep(0.2)

        colors = stream.get_latest_colors()
        # Should be back to idle color
        assert colors[0, 0] == 42
        assert colors[0, 1] == 42
        assert colors[0, 2] == 42

        stream.stop()


class TestGameEventColorStripStreamEffects:
    """Test individual effect types produce non-zero output at mid-progress."""

    def _fire_and_sample(self, effect: str, color: list) -> np.ndarray:
        source = _make_source(
            idle_color=BindableColor([0, 0, 0]),
            event_mappings=[
                {
                    "event_type": "kill",
                    "effect": effect,
                    "color": color,
                    "duration_ms": 2000,
                    "intensity": 1.0,
                    "priority": 1,
                }
            ],
        )
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)
        stream.start()
        time.sleep(0.05)

        bus.publish(_make_event("kill"))
        # Sample in the first quarter of the effect
        time.sleep(0.15)

        colors = stream.get_latest_colors()
        stream.stop()
        return colors

    def test_flash_produces_output(self):
        colors = self._fire_and_sample("flash", [255, 0, 0])
        assert colors[0, 0] > 0

    def test_pulse_produces_output(self):
        colors = self._fire_and_sample("pulse", [0, 255, 0])
        assert colors[0, 1] > 0

    def test_sweep_produces_output(self):
        colors = self._fire_and_sample("sweep", [0, 0, 255])
        # At least some LEDs should have non-zero blue
        assert np.any(colors[:, 2] > 0)

    def test_color_shift_produces_output(self):
        colors = self._fire_and_sample("color_shift", [255, 128, 0])
        # Should have some non-zero pixels
        assert np.any(colors > 0)

    def test_breathing_produces_output(self):
        colors = self._fire_and_sample("breathing", [128, 0, 255])
        # Breathing may start dim, but should have some output by 0.15s into a 2s effect
        assert np.any(colors > 0)


class TestGameEventColorStripStreamConfigure:
    def test_auto_size(self):
        source = _make_source(led_count=0)  # auto-size
        stream = GameEventColorStripStream(source)
        assert stream.led_count == 1  # minimum

        stream.configure(60)
        assert stream.led_count == 60

    def test_fixed_size_not_overridden(self):
        source = _make_source(led_count=30)
        stream = GameEventColorStripStream(source)
        assert stream.led_count == 30

        stream.configure(60)
        # Should NOT change because auto_size is False
        assert stream.led_count == 30


class TestGameEventColorStripStreamUpdate:
    def test_update_source_hot_reloads(self):
        source = _make_source(
            event_mappings=[
                {
                    "event_type": "kill",
                    "effect": "flash",
                    "color": [255, 0, 0],
                    "duration_ms": 500,
                    "intensity": 1.0,
                    "priority": 1,
                }
            ],
        )
        bus = GameEventBus()
        stream = GameEventColorStripStream(source, event_bus=bus)
        stream.start()

        # Update with new mappings
        updated_source = _make_source(
            event_mappings=[
                {
                    "event_type": "health",
                    "effect": "breathing",
                    "color": [0, 255, 0],
                    "duration_ms": 1000,
                    "intensity": 0.5,
                    "priority": 0,
                }
            ],
        )
        stream.update_source(updated_source)

        assert "health" in stream._mapping_lookup
        assert "kill" not in stream._mapping_lookup

        stream.stop()
