"""Unit tests for HALightTargetProcessor — covers CSS and color_vs modes."""

import asyncio
from dataclasses import dataclass
from typing import Any, Dict, List, Tuple

import numpy as np
import pytest

from ledgrab.core.processing.ha_light_target_processor import HALightTargetProcessor
from ledgrab.core.processing.target_processor import TargetContext
from ledgrab.storage.bindable import BindableFloat
from ledgrab.storage.ha_light_output_target import HALightMapping

# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------


@dataclass
class _RecordedCall:
    domain: str
    service: str
    service_data: dict
    target: dict


class _FakeHARuntime:
    is_connected = True

    def __init__(self) -> None:
        self.calls: List[_RecordedCall] = []
        self._states: Dict[str, Any] = {}

    async def call_service(self, *, domain, service, service_data, target):
        self.calls.append(_RecordedCall(domain, service, service_data, target))

    def get_state(self, _entity_id: str):
        return self._states.get(_entity_id)


class _FakeHAManager:
    def __init__(self, runtime: _FakeHARuntime) -> None:
        self._runtime = runtime
        self.acquired_for: List[str] = []
        self.released_for: List[str] = []

    async def acquire(self, source_id: str) -> _FakeHARuntime:
        self.acquired_for.append(source_id)
        return self._runtime

    async def release(self, source_id: str) -> None:
        self.released_for.append(source_id)


class _FakeColorStream:
    """A ValueStream that returns a constant RGB triple."""

    def __init__(self, color: Tuple[int, int, int]) -> None:
        self._color = color
        self.get_color_calls = 0

    def get_value(self) -> float:
        r, g, b = self._color
        return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0

    def get_color(self) -> Tuple[int, int, int]:
        self.get_color_calls += 1
        return self._color


class _FakeCSSStream:
    """A CSS stream that returns a fixed numpy LED frame."""

    def __init__(self, frame: np.ndarray) -> None:
        self._frame = frame

    def get_latest_colors(self) -> np.ndarray:
        return self._frame


class _FakeVSManager:
    def __init__(self, stream) -> None:
        self._stream = stream
        self.acquired: List[str] = []
        self.released: List[str] = []

    def acquire(self, vs_id: str):
        self.acquired.append(vs_id)
        return self._stream

    def release(self, vs_id: str) -> None:
        self.released.append(vs_id)


class _FakeCSSManager:
    def __init__(self, stream) -> None:
        self._stream = stream
        self.acquired: List[Tuple[str, str]] = []
        self.released: List[Tuple[str, str]] = []

    def acquire(self, css_id: str, target_id: str):
        self.acquired.append((css_id, target_id))
        return self._stream

    def release(self, css_id: str, target_id: str) -> None:
        self.released.append((css_id, target_id))


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_ctx(
    *,
    ha_manager: _FakeHAManager | None = None,
    css_manager: _FakeCSSManager | None = None,
    vs_manager: _FakeVSManager | None = None,
) -> TargetContext:
    return TargetContext(
        live_stream_manager=None,  # type: ignore[arg-type]
        overlay_manager=None,  # type: ignore[arg-type]
        color_strip_stream_manager=css_manager,
        value_stream_manager=vs_manager,
        ha_manager=ha_manager,
    )


def _mapping(entity_id: str, *, scale: float = 1.0) -> HALightMapping:
    return HALightMapping(
        entity_id=entity_id,
        led_start=0,
        led_end=-1,
        brightness_scale=BindableFloat(scale),
    )


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_color_vs_mode_broadcasts_same_color_to_all_mappings():
    runtime = _FakeHARuntime()
    ha_mgr = _FakeHAManager(runtime)
    color_stream = _FakeColorStream((128, 64, 200))
    vs_mgr = _FakeVSManager(color_stream)

    proc = HALightTargetProcessor(
        target_id="t_color_vs",
        ha_source_id="ha_1",
        source_kind="color_vs",
        color_value_source_id="vs_purple",
        light_mappings=[_mapping("light.a"), _mapping("light.b"), _mapping("light.c")],
        color_tolerance=0,
        ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=vs_mgr),
    )

    await proc.start()
    # Wait until each entity has been called at least once.
    for _ in range(40):
        await asyncio.sleep(0.05)
        entities = {c.target["entity_id"] for c in runtime.calls}
        if {"light.a", "light.b", "light.c"} <= entities:
            break
    await proc.stop()

    assert vs_mgr.acquired == ["vs_purple"], "color VS must be acquired in color_vs mode"
    assert vs_mgr.released == ["vs_purple"], "color VS must be released on stop"

    # Each entity received the same RGB triple
    by_entity: Dict[str, set] = {}
    for c in runtime.calls:
        eid = c.target["entity_id"]
        by_entity.setdefault(eid, set()).add(tuple(c.service_data["rgb_color"]))
    assert {"light.a", "light.b", "light.c"} <= set(by_entity.keys())
    for colors in by_entity.values():
        assert colors == {(128, 64, 200)}


@pytest.mark.asyncio
async def test_css_mode_still_uses_per_segment_average():
    runtime = _FakeHARuntime()
    ha_mgr = _FakeHAManager(runtime)
    # 6-LED frame; light.a covers 0..2 (red), light.b covers 3..5 (blue)
    frame = np.array(
        [
            [255, 0, 0],
            [255, 0, 0],
            [255, 0, 0],
            [0, 0, 255],
            [0, 0, 255],
            [0, 0, 255],
        ],
        dtype=np.int32,
    )
    css_mgr = _FakeCSSManager(_FakeCSSStream(frame))
    proc = HALightTargetProcessor(
        target_id="t_css",
        ha_source_id="ha_1",
        source_kind="css",
        color_strip_source_id="css_1",
        light_mappings=[
            HALightMapping(
                entity_id="light.a",
                led_start=0,
                led_end=3,
                brightness_scale=BindableFloat(1.0),
            ),
            HALightMapping(
                entity_id="light.b",
                led_start=3,
                led_end=6,
                brightness_scale=BindableFloat(1.0),
            ),
        ],
        color_tolerance=0,
        ctx=_make_ctx(ha_manager=ha_mgr, css_manager=css_mgr),
    )

    await proc.start()
    for _ in range(40):
        await asyncio.sleep(0.05)
        if {c.target["entity_id"] for c in runtime.calls} >= {"light.a", "light.b"}:
            break
    await proc.stop()

    by_entity = {c.target["entity_id"]: tuple(c.service_data["rgb_color"]) for c in runtime.calls}
    assert by_entity["light.a"] == (255, 0, 0)
    assert by_entity["light.b"] == (0, 0, 255)
    assert css_mgr.released == [("css_1", "t_css")]


@pytest.mark.asyncio
async def test_brightness_scale_applied_exactly_once():
    """Regression: brightness_scale must not be double-applied.

    Past bug: brightness was first multiplied by (bs * vs_multiplier) when
    eff_scale < 1.0, then multiplied by `bs` again before being sent. With
    bs=0.5 and vs=1.0 the user got quarter brightness instead of half.
    """
    runtime = _FakeHARuntime()
    ha_mgr = _FakeHAManager(runtime)
    color_stream = _FakeColorStream((200, 100, 50))

    proc = HALightTargetProcessor(
        target_id="t_b",
        ha_source_id="ha_1",
        source_kind="color_vs",
        color_value_source_id="vs_c",
        light_mappings=[_mapping("light.a", scale=0.5)],
        color_tolerance=0,
        ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(color_stream)),
    )

    await proc.start()
    for _ in range(40):
        await asyncio.sleep(0.05)
        if any(c.target["entity_id"] == "light.a" for c in runtime.calls):
            break
    await proc.stop()

    turn_on = next(c for c in runtime.calls if c.service == "turn_on")
    # max(200,100,50)=200, scaled by bs=0.5 → 100. Pre-fix bug produced 50.
    assert turn_on.service_data["brightness"] == 100
    assert turn_on.service_data["rgb_color"] == [200, 100, 50]


@pytest.mark.asyncio
async def test_brightness_boost_via_value_stream_multiplier():
    """vs_multiplier > 1.0 must boost brightness up to the 255 cap.

    Past bug: scaling was gated behind `if eff_scale < 1.0`, so any boost
    direction (vs > 1.0 with bs <= 1.0) was silently ignored.
    """

    class _BoostingStream:
        def get_value(self) -> float:
            return 2.0  # 2× boost

        def get_color(self):
            return (50, 30, 10)

    runtime = _FakeHARuntime()
    ha_mgr = _FakeHAManager(runtime)
    boosting_stream = _BoostingStream()

    proc = HALightTargetProcessor(
        target_id="t_boost",
        ha_source_id="ha_1",
        source_kind="color_vs",
        color_value_source_id="vs_color",
        # Brightness VS is a separate stream — wire it through bfloat source_id.
        brightness=BindableFloat(1.0, source_id="vs_brightness"),
        light_mappings=[_mapping("light.a", scale=1.0)],
        color_tolerance=0,
        ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(boosting_stream)),
    )

    await proc.start()
    for _ in range(40):
        await asyncio.sleep(0.05)
        if any(c.target["entity_id"] == "light.a" for c in runtime.calls):
            break
    await proc.stop()

    turn_on = next(c for c in runtime.calls if c.service == "turn_on")
    # max(50,30,10)=50, vs_multiplier=2.0 → 100. Pre-fix bug produced 50.
    assert turn_on.service_data["brightness"] == 100


@pytest.mark.asyncio
async def test_brightness_clamped_at_255():
    color_stream = _FakeColorStream((200, 0, 0))
    runtime = _FakeHARuntime()
    ha_mgr = _FakeHAManager(runtime)

    proc = HALightTargetProcessor(
        target_id="t_clamp",
        ha_source_id="ha_1",
        source_kind="color_vs",
        color_value_source_id="vs_c",
        # bs=4.0 with channel max 200 → 800, must be clamped to 255.
        light_mappings=[_mapping("light.a", scale=4.0)],
        color_tolerance=0,
        ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(color_stream)),
    )

    await proc.start()
    for _ in range(40):
        await asyncio.sleep(0.05)
        if any(c.target["entity_id"] == "light.a" for c in runtime.calls):
            break
    await proc.stop()

    turn_on = next(c for c in runtime.calls if c.service == "turn_on")
    assert turn_on.service_data["brightness"] == 255


@pytest.mark.asyncio
async def test_turn_off_lights_when_idle_borrows_runtime_from_manager():
    """Manual turn-off must work even when the processor isn't running."""
    runtime = _FakeHARuntime()
    ha_mgr = _FakeHAManager(runtime)

    proc = HALightTargetProcessor(
        target_id="t_off_idle",
        ha_source_id="ha_1",
        source_kind="color_vs",
        color_value_source_id="vs_x",
        light_mappings=[_mapping("light.a"), _mapping("light.b"), _mapping("light.a")],
        ctx=_make_ctx(ha_manager=ha_mgr),
    )

    count = await proc.turn_off_lights()

    assert count == 2  # duplicates collapsed
    assert ha_mgr.acquired_for == ["ha_1"]
    assert ha_mgr.released_for == ["ha_1"]
    services = [(c.service, c.target["entity_id"]) for c in runtime.calls]
    assert ("turn_off", "light.a") in services
    assert ("turn_off", "light.b") in services
    assert all(c.service == "turn_off" for c in runtime.calls)


@pytest.mark.asyncio
async def test_turn_off_lights_while_running_uses_existing_runtime():
    """If running, turn_off must reuse self._ha_runtime — not borrow another."""
    runtime = _FakeHARuntime()
    ha_mgr = _FakeHAManager(runtime)
    color_stream = _FakeColorStream((10, 20, 30))

    proc = HALightTargetProcessor(
        target_id="t_off_run",
        ha_source_id="ha_1",
        source_kind="color_vs",
        color_value_source_id="vs_x",
        light_mappings=[_mapping("light.a")],
        color_tolerance=0,
        ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(color_stream)),
    )

    await proc.start()
    try:
        # One acquire from start(); turn_off_lights must NOT add another.
        before_acquires = list(ha_mgr.acquired_for)
        before_releases = list(ha_mgr.released_for)
        count = await proc.turn_off_lights()
    finally:
        await proc.stop()

    assert count == 1
    # No extra acquire/release pairs while running.
    assert len(ha_mgr.acquired_for) == len(before_acquires)
    assert len(ha_mgr.released_for) == len(before_releases) + 1  # +1 from stop()
    assert any(
        c.service == "turn_off" and c.target["entity_id"] == "light.a" for c in runtime.calls
    )


@pytest.mark.asyncio
async def test_turn_off_lights_no_mappings_returns_zero():
    runtime = _FakeHARuntime()
    ha_mgr = _FakeHAManager(runtime)

    proc = HALightTargetProcessor(
        target_id="t_off_empty",
        ha_source_id="ha_1",
        source_kind="color_vs",
        color_value_source_id="vs_x",
        light_mappings=[],
        ctx=_make_ctx(ha_manager=ha_mgr),
    )

    count = await proc.turn_off_lights()

    assert count == 0
    # No need to acquire when there's nothing to do.
    assert ha_mgr.acquired_for == []
    assert runtime.calls == []


@pytest.mark.asyncio
async def test_get_state_reports_source_kind():
    runtime = _FakeHARuntime()
    ha_mgr = _FakeHAManager(runtime)
    proc = HALightTargetProcessor(
        target_id="t_state",
        ha_source_id="ha_1",
        source_kind="color_vs",
        color_value_source_id="vs_x",
        light_mappings=[_mapping("light.a")],
        ctx=_make_ctx(ha_manager=ha_mgr, vs_manager=_FakeVSManager(_FakeColorStream((0, 0, 0)))),
    )
    state = proc.get_state()
    assert state["source_kind"] == "color_vs"
    assert state["color_value_source_id"] == "vs_x"
    assert state["css_id"] == ""
