"""Tests for the Yeelight LAN LED client + provider."""

from __future__ import annotations

import asyncio
import json
from unittest.mock import AsyncMock, MagicMock

import numpy as np
import pytest

from ledgrab.core.devices.device_config import YeelightConfig
from ledgrab.core.devices.led_client import ProviderDeps
from ledgrab.core.devices.yeelight_client import (
    YeelightClient,
    _average_color,
    _pack_rgb,
    _parse_ssdp_response,
    parse_yeelight_url,
)
from ledgrab.core.devices.yeelight_provider import YeelightDeviceProvider

# ============================================================================
# parse_yeelight_url
# ============================================================================


@pytest.mark.parametrize(
    "url,expected",
    [
        ("yeelight://192.168.1.50", "192.168.1.50"),
        ("yeelight://192.168.1.50:55443", "192.168.1.50"),
        ("192.168.1.50", "192.168.1.50"),
        ("192.168.1.50:55443", "192.168.1.50"),
        ("bulb.local", "bulb.local"),
    ],
)
def test_parse_yeelight_url(url, expected):
    assert parse_yeelight_url(url) == expected


@pytest.mark.parametrize("url", ["", "   ", "yeelight://", "://192.168.1.1"])
def test_parse_yeelight_url_rejects_empty(url):
    with pytest.raises(ValueError):
        parse_yeelight_url(url)


# ============================================================================
# Helpers
# ============================================================================


def test_pack_rgb_packs_24_bit_int():
    assert _pack_rgb(255, 0, 0) == 0xFF0000
    assert _pack_rgb(0, 255, 0) == 0x00FF00
    assert _pack_rgb(0, 0, 255) == 0x0000FF
    assert _pack_rgb(0x12, 0x34, 0x56) == 0x123456
    # Clamps to a byte
    assert _pack_rgb(300, -5, 256) & 0xFFFFFF == _pack_rgb(300 & 0xFF, -5 & 0xFF, 256 & 0xFF)


def test_average_color_numpy():
    pixels = np.array([[10, 20, 30], [40, 50, 60], [70, 80, 90]], dtype=np.uint8)
    assert _average_color(pixels) == (40, 50, 60)


def test_average_color_list():
    assert _average_color([(10, 0, 0), (20, 0, 0), (30, 0, 0)]) == (20, 0, 0)


def test_average_color_empty():
    assert _average_color([]) == (0, 0, 0)
    assert _average_color(np.array([], dtype=np.uint8)) == (0, 0, 0)


# ============================================================================
# YeelightClient (mocked transport)
# ============================================================================


def _make_connected_client(min_interval_s: float = 0.0) -> YeelightClient:
    client = YeelightClient("yeelight://127.0.0.1", led_count=10, min_interval_s=min_interval_s)
    writer = MagicMock()
    writer.write = MagicMock()
    writer.drain = AsyncMock()
    writer.close = MagicMock()
    writer.wait_closed = AsyncMock()
    client._writer = writer
    client._reader = MagicMock()
    client._connected = True
    return client


def _sent_payloads(client: YeelightClient) -> list[dict]:
    """Decode every JSON-RPC body the client has written."""
    payloads = []
    for call in client._writer.write.call_args_list:
        raw = call.args[0].decode("utf-8").strip()
        payloads.append(json.loads(raw))
    return payloads


@pytest.mark.asyncio
async def test_send_pixels_averages_and_packs_rgb():
    client = _make_connected_client()
    pixels = np.array(
        [[255, 0, 0], [0, 255, 0], [0, 0, 255]],
        dtype=np.uint8,
    )

    await client.send_pixels(pixels)

    payloads = _sent_payloads(client)
    assert len(payloads) == 1
    assert payloads[0]["method"] == "set_rgb"
    # Average of (255,0,0), (0,255,0), (0,0,255) is (85, 85, 85) → 0x555555
    assert payloads[0]["params"][0] == _pack_rgb(85, 85, 85)
    # Effect & duration: ambilight needs sudden + 0 ms
    assert payloads[0]["params"][1] == "sudden"
    assert payloads[0]["params"][2] == 0


@pytest.mark.asyncio
async def test_send_pixels_scales_for_brightness():
    client = _make_connected_client()
    pixels = np.array([[200, 100, 50]], dtype=np.uint8)

    await client.send_pixels(pixels, brightness=128)

    payloads = _sent_payloads(client)
    # Scaled: 200*0.501..→100, 100→50, 50→25
    expected = _pack_rgb(int(200 * 128 / 255), int(100 * 128 / 255), int(50 * 128 / 255))
    assert payloads[0]["params"][0] == expected


@pytest.mark.asyncio
async def test_send_pixels_full_brightness_passthrough():
    client = _make_connected_client()
    pixels = np.array([[200, 100, 50]], dtype=np.uint8)

    await client.send_pixels(pixels, brightness=255)

    payloads = _sent_payloads(client)
    assert payloads[0]["params"][0] == _pack_rgb(200, 100, 50)


@pytest.mark.asyncio
async def test_send_pixels_rate_limit_drops_subsequent_calls():
    """Within the min interval, the second call no-ops without TX."""
    client = _make_connected_client(min_interval_s=10.0)  # huge gate
    pixels = np.array([[10, 20, 30]], dtype=np.uint8)

    await client.send_pixels(pixels)
    await client.send_pixels(pixels)
    await client.send_pixels(pixels)

    payloads = _sent_payloads(client)
    assert len(payloads) == 1  # only the first one made it through


@pytest.mark.asyncio
async def test_send_pixels_zero_interval_sends_every_frame():
    client = _make_connected_client(min_interval_s=0.0)
    pixels = np.array([[10, 20, 30]], dtype=np.uint8)

    await client.send_pixels(pixels)
    await client.send_pixels(pixels)
    await client.send_pixels(pixels)

    payloads = _sent_payloads(client)
    assert len(payloads) == 3


@pytest.mark.asyncio
async def test_send_pixels_when_not_connected_raises():
    client = YeelightClient("yeelight://127.0.0.1", led_count=1)
    with pytest.raises(RuntimeError, match="not connected"):
        await client.send_pixels(np.array([[1, 2, 3]], dtype=np.uint8))


@pytest.mark.asyncio
async def test_set_power_sends_set_power_command():
    client = _make_connected_client()
    await client.set_power(True)
    await client.set_power(False)
    payloads = _sent_payloads(client)
    assert [p["method"] for p in payloads] == ["set_power", "set_power"]
    assert payloads[0]["params"][0] == "on"
    assert payloads[1]["params"][0] == "off"


@pytest.mark.asyncio
async def test_set_brightness_clamps_to_1_100():
    client = _make_connected_client()
    await client.set_brightness(0)
    await client.set_brightness(50)
    await client.set_brightness(200)
    payloads = _sent_payloads(client)
    # Yeelight bulbs reject brightness 0 (use set_power off instead) — clamp to 1.
    assert payloads[0]["params"][0] == 1
    assert payloads[1]["params"][0] == 50
    assert payloads[2]["params"][0] == 100


@pytest.mark.asyncio
async def test_close_releases_transport():
    client = _make_connected_client()
    writer = client._writer
    await client.close()
    writer.close.assert_called_once()
    assert client._writer is None
    assert client.is_connected is False


# ============================================================================
# SSDP response parsing
# ============================================================================


_SAMPLE_RESPONSE = (
    b"HTTP/1.1 200 OK\r\n"
    b"Cache-Control: max-age=3600\r\n"
    b"Date: \r\n"
    b"Ext: \r\n"
    b"Location: yeelight://192.168.1.50:55443\r\n"
    b"Server: POSIX UPnP/1.0 YGLC/1\r\n"
    b"id: 0x000000000035cb01\r\n"
    b"model: color\r\n"
    b"fw_ver: 18\r\n"
    b"support: get_prop set_default set_power set_bright\r\n"
    b"power: off\r\n"
    b"bright: 100\r\n"
    b"color_mode: 2\r\n"
    b"ct: 4000\r\n"
    b"rgb: 16711680\r\n"
)


def test_parse_ssdp_response_extracts_headers():
    headers = _parse_ssdp_response(_SAMPLE_RESPONSE)
    assert headers is not None
    assert headers["location"] == "yeelight://192.168.1.50:55443"
    assert headers["id"] == "0x000000000035cb01"
    assert headers["model"] == "color"
    assert headers["fw_ver"] == "18"


def test_parse_ssdp_response_rejects_non_yeelight():
    """A stray HTTP response from another SSDP service should be ignored."""
    other = b"HTTP/1.1 200 OK\r\nLocation: upnp://something/else\r\n"
    assert _parse_ssdp_response(other) is None


# ============================================================================
# Provider
# ============================================================================


def test_provider_device_type_and_capabilities():
    provider = YeelightDeviceProvider()
    assert provider.device_type == "yeelight"
    caps = provider.capabilities
    assert "manual_led_count" in caps
    assert "power_control" in caps
    assert "brightness_control" in caps
    assert "static_color" in caps
    assert "single_pixel" in caps


@pytest.mark.asyncio
async def test_provider_validate_device_accepts_bare_host():
    provider = YeelightDeviceProvider()
    assert await provider.validate_device("192.168.1.50") == {}


@pytest.mark.asyncio
async def test_provider_validate_device_rejects_empty():
    provider = YeelightDeviceProvider()
    with pytest.raises(ValueError, match="Invalid Yeelight URL"):
        await provider.validate_device("")


def test_provider_create_client_threads_config():
    provider = YeelightDeviceProvider()
    config = YeelightConfig(
        device_id="device_test",
        device_url="yeelight://192.168.1.50",
        led_count=30,
        yeelight_min_interval_ms=750,
    )

    client = provider.create_client(config, deps=ProviderDeps())

    assert isinstance(client, YeelightClient)
    assert client.host == "192.168.1.50"
    assert client._led_count == 30
    assert client._min_interval_s == pytest.approx(0.75)


@pytest.mark.asyncio
async def test_provider_discover_returns_empty_on_failure(monkeypatch):
    """Multicast failures (no network, firewall) must yield [], not raise."""

    async def _explode(timeout):
        raise OSError("no route to host")

    monkeypatch.setattr(
        "ledgrab.core.devices.yeelight_provider.discover_yeelight_bulbs",
        _explode,
    )
    provider = YeelightDeviceProvider()
    assert await provider.discover() == []


@pytest.mark.asyncio
async def test_provider_discover_maps_ssdp_to_discovered_device(monkeypatch):
    async def _fake(timeout):
        return [
            {
                "location": "yeelight://192.168.1.50:55443",
                "id": "0x0035cb01",
                "model": "color",
                "fw_ver": "18",
            },
            # Missing location should be skipped, not crash.
            {"id": "0xff", "model": "stripe"},
        ]

    monkeypatch.setattr(
        "ledgrab.core.devices.yeelight_provider.discover_yeelight_bulbs",
        _fake,
    )
    provider = YeelightDeviceProvider()
    results = await provider.discover()

    assert len(results) == 1
    [bulb] = results
    assert bulb.device_type == "yeelight"
    assert bulb.url == "yeelight://192.168.1.50"
    assert bulb.ip == "192.168.1.50"
    assert bulb.mac == "0x0035cb01"
    assert bulb.version == "18"
    assert "color" in bulb.name.lower()


# ============================================================================
# Device.to_config() round-trip
# ============================================================================


def test_device_to_config_round_trip_yeelight():
    from ledgrab.storage.device_store import Device

    device = Device(
        device_id="device_abc12345",
        name="Bedroom bulb",
        url="yeelight://192.168.1.42",
        led_count=30,
        device_type="yeelight",
        yeelight_min_interval_ms=750,
    )

    config = device.to_config()

    assert isinstance(config, YeelightConfig)
    assert config.device_url == "yeelight://192.168.1.42"
    assert config.led_count == 30
    assert config.yeelight_min_interval_ms == 750


def test_device_to_dict_omits_yeelight_default_interval():
    from ledgrab.storage.device_store import Device

    device = Device(
        device_id="device_abc12345",
        name="Default",
        url="yeelight://192.168.1.42",
        led_count=1,
        device_type="yeelight",
    )
    assert "yeelight_min_interval_ms" not in device.to_dict()


def test_device_to_dict_preserves_non_default_yeelight_interval():
    from ledgrab.storage.device_store import Device

    device = Device(
        device_id="device_abc12345",
        name="Custom",
        url="yeelight://192.168.1.42",
        led_count=1,
        device_type="yeelight",
        yeelight_min_interval_ms=1000,
    )
    assert device.to_dict()["yeelight_min_interval_ms"] == 1000


def test_device_from_dict_yeelight_round_trip():
    from ledgrab.storage.device_store import Device

    restored = Device.from_dict(
        {
            "id": "device_abc12345",
            "name": "Roundtrip",
            "url": "yeelight://10.0.0.1",
            "led_count": 1,
            "device_type": "yeelight",
            "yeelight_min_interval_ms": 250,
        }
    )
    assert restored.yeelight_min_interval_ms == 250


# Suppress the asyncio CancelledError that asyncio raises on
# garbage-collected mock writers — they're swallowed in close() but
# pytest-asyncio still warns about them when MagicMock is used.
@pytest.fixture(autouse=True)
def _suppress_asyncio_warnings():
    yield
    asyncio.get_event_loop_policy()
