"""Tests for the Open Pixel Control LED client + provider."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock

import numpy as np
import pytest

from ledgrab.core.devices.device_config import OPCConfig
from ledgrab.core.devices.led_client import ProviderDeps
from ledgrab.core.devices.opc_client import (
    OPC_CMD_SET_PIXELS,
    OPC_PORT,
    OPCClient,
    _build_set_pixels_header,
    parse_opc_url,
)
from ledgrab.core.devices.opc_provider import OPCDeviceProvider


# ============================================================================
# URL parsing
# ============================================================================


@pytest.mark.parametrize(
    "url,expected",
    [
        ("opc://192.168.1.50", ("192.168.1.50", OPC_PORT)),
        ("opc://192.168.1.50:7890", ("192.168.1.50", 7890)),
        ("opc://192.168.1.50:9000", ("192.168.1.50", 9000)),
        ("192.168.1.50", ("192.168.1.50", OPC_PORT)),
        ("192.168.1.50:7890", ("192.168.1.50", 7890)),
        ("fadecandy.local", ("fadecandy.local", OPC_PORT)),
    ],
)
def test_parse_opc_url(url, expected):
    assert parse_opc_url(url) == expected


@pytest.mark.parametrize("url", ["", "   ", "opc://", "://192.168.1.1"])
def test_parse_opc_url_rejects_empty(url):
    with pytest.raises(ValueError):
        parse_opc_url(url)


# ============================================================================
# Header construction
# ============================================================================


def test_build_set_pixels_header_layout():
    """4-byte header: [channel, command, length_hi, length_lo]"""
    header = _build_set_pixels_header(channel=1, body_len=300)
    assert len(header) == 4
    assert header[0] == 1  # channel
    assert header[1] == OPC_CMD_SET_PIXELS  # command
    assert header[2] == 1  # length_hi (300 >> 8)
    assert header[3] == 44  # length_lo (300 & 0xFF)


def test_build_set_pixels_header_broadcast_channel():
    header = _build_set_pixels_header(channel=0, body_len=3)
    assert header[0] == 0


def test_build_set_pixels_header_clamps_channel_to_byte():
    header = _build_set_pixels_header(channel=300, body_len=3)
    assert header[0] == 300 & 0xFF  # 44


def test_build_set_pixels_header_encodes_large_body_length():
    header = _build_set_pixels_header(channel=0, body_len=0xFFFF)
    assert header[2] == 0xFF
    assert header[3] == 0xFF


# ============================================================================
# OPCClient (mocked transport)
# ============================================================================


def _make_connected_client(channel: int = 0) -> OPCClient:
    client = OPCClient("opc://127.0.0.1", led_count=10, channel=channel)
    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_bytes(client: OPCClient) -> bytes:
    """Concatenate every write made to the mock writer into one byte string."""
    return b"".join(call.args[0] for call in client._writer.write.call_args_list)


@pytest.mark.asyncio
async def test_send_pixels_emits_header_then_body():
    client = _make_connected_client(channel=1)
    pixels = np.array([[10, 20, 30], [40, 50, 60]], dtype=np.uint8)

    await client.send_pixels(pixels)

    data = _sent_bytes(client)
    assert len(data) == 4 + 6  # header + 2 pixels * 3 bytes
    assert data[0] == 1  # channel
    assert data[1] == OPC_CMD_SET_PIXELS
    assert data[2:4] == bytes([0, 6])  # body length 6
    assert data[4:] == bytes([10, 20, 30, 40, 50, 60])


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

    await client.send_pixels(pixels, brightness=128)

    data = _sent_bytes(client)
    body = data[4:]
    # Each channel scales as int(x * 128 / 255)
    expected = bytes([int(200 * 128 / 255), int(100 * 128 / 255), int(50 * 128 / 255)])
    assert body == expected


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

    await client.send_pixels(pixels, brightness=0)

    body = _sent_bytes(client)[4:]
    assert body == bytes(6)  # all zeros


@pytest.mark.asyncio
async def test_send_pixels_accepts_list():
    client = _make_connected_client()

    await client.send_pixels([(1, 2, 3), (4, 5, 6)])

    body = _sent_bytes(client)[4:]
    assert body == bytes([1, 2, 3, 4, 5, 6])


@pytest.mark.asyncio
async def test_send_pixels_reshapes_flat_array():
    client = _make_connected_client()
    flat = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint8)

    await client.send_pixels(flat)

    body = _sent_bytes(client)[4:]
    assert body == bytes([1, 2, 3, 4, 5, 6])


@pytest.mark.asyncio
async def test_send_pixels_drains_after_write():
    client = _make_connected_client()

    await client.send_pixels(np.array([[1, 2, 3]], dtype=np.uint8))

    client._writer.drain.assert_awaited()


@pytest.mark.asyncio
async def test_send_pixels_when_not_connected_raises():
    client = OPCClient("opc://127.0.0.1", led_count=1)
    with pytest.raises(RuntimeError, match="not connected"):
        await client.send_pixels([(1, 2, 3)])


def test_send_pixels_fast_writes_synchronously_without_drain():
    """Hot path skips drain so OS write-buffer flushes on its own schedule."""
    client = _make_connected_client()

    client.send_pixels_fast(np.array([[1, 2, 3]], dtype=np.uint8))

    data = _sent_bytes(client)
    assert data == bytes([0, OPC_CMD_SET_PIXELS, 0, 3, 1, 2, 3])
    client._writer.drain.assert_not_called()


def test_send_pixels_fast_when_not_connected_raises():
    client = OPCClient("opc://127.0.0.1", led_count=1)
    with pytest.raises(RuntimeError, match="not connected"):
        client.send_pixels_fast([(1, 2, 3)])


def test_supports_fast_send_is_true():
    assert OPCClient("opc://127.0.0.1", led_count=1).supports_fast_send is True


def test_channel_clamps_to_byte():
    client = OPCClient("opc://127.0.0.1", led_count=1, channel=300)
    assert client.channel == 300 & 0xFF


def test_default_channel_is_broadcast():
    client = OPCClient("opc://127.0.0.1", led_count=1)
    assert client.channel == 0


@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


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


def test_provider_device_type_and_capabilities():
    provider = OPCDeviceProvider()
    assert provider.device_type == "opc"
    caps = provider.capabilities
    assert "manual_led_count" in caps
    assert "health_check" in caps
    # OPC has no reply channel; no native power/brightness control
    assert "power_control" not in caps
    assert "brightness_control" not in caps


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


@pytest.mark.asyncio
async def test_provider_validate_rejects_empty():
    provider = OPCDeviceProvider()
    with pytest.raises(ValueError, match="Invalid OPC URL"):
        await provider.validate_device("")


@pytest.mark.asyncio
async def test_provider_discover_returns_empty():
    """OPC has no native discovery — provider must return [], not raise."""
    provider = OPCDeviceProvider()
    assert await provider.discover() == []


def test_provider_create_client_threads_config():
    provider = OPCDeviceProvider()
    config = OPCConfig(
        device_id="device_test",
        device_url="opc://192.168.1.50:9000",
        led_count=144,
        opc_channel=3,
    )

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

    assert isinstance(client, OPCClient)
    assert client.host == "192.168.1.50"
    assert client.port == 9000
    assert client._led_count == 144
    assert client.channel == 3


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


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

    device = Device(
        device_id="device_abc12345",
        name="Fadecandy 1",
        url="opc://192.168.1.42",
        led_count=512,
        device_type="opc",
        opc_channel=2,
    )

    config = device.to_config()

    assert isinstance(config, OPCConfig)
    assert config.device_url == "opc://192.168.1.42"
    assert config.led_count == 512
    assert config.opc_channel == 2


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

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


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

    device = Device(
        device_id="device_abc12345",
        name="Channel 3",
        url="opc://192.168.1.42",
        led_count=1,
        device_type="opc",
        opc_channel=3,
    )
    assert device.to_dict()["opc_channel"] == 3


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

    restored = Device.from_dict(
        {
            "id": "device_abc12345",
            "name": "Roundtrip",
            "url": "opc://10.0.0.1",
            "led_count": 64,
            "device_type": "opc",
            "opc_channel": 7,
        }
    )
    assert restored.opc_channel == 7
