"""Tests for DDP client packet construction (the LED hot path)."""

import struct
from unittest.mock import MagicMock

import numpy as np
import pytest

from ledgrab.core.devices.ddp_client import DDPClient


def _make_client(rgbw: bool = False) -> DDPClient:
    """Build a DDPClient with a mocked transport that captures sendto bytes."""
    client = DDPClient(host="127.0.0.1", rgbw=rgbw)
    transport = MagicMock()
    captured: list[bytes] = []

    def _sendto(data, addr=None):  # noqa: ARG001
        # asyncio's sendto accepts bytes-like; copy to bytes for assertion stability
        captured.append(bytes(data))

    transport.sendto.side_effect = _sendto
    client._transport = transport
    client._captured = captured  # type: ignore[attr-defined]
    return client


def _parse_header(packet: bytes) -> dict:
    """Parse a 10-byte DDP header back into named fields."""
    flags, seq, dtype, src, offset, dlen = struct.unpack("!BBB B I H", packet[:10])
    return {
        "flags": flags,
        "seq": seq,
        "type": dtype,
        "src": src,
        "offset": offset,
        "data_len": dlen,
        "payload": packet[10:],
    }


def test_send_pixels_numpy_single_packet_rgb():
    client = _make_client(rgbw=False)
    pixels = np.array(
        [[10, 20, 30], [40, 50, 60], [70, 80, 90]],
        dtype=np.uint8,
    )

    client.send_pixels_numpy(pixels)

    assert len(client._captured) == 1
    parsed = _parse_header(client._captured[0])
    # PUSH on the only packet, VER=1
    assert parsed["flags"] == 0x40 | 0x01
    assert parsed["type"] == 0x01
    assert parsed["offset"] == 0
    assert parsed["data_len"] == 9
    assert parsed["payload"] == bytes([10, 20, 30, 40, 50, 60, 70, 80, 90])


def test_send_pixels_numpy_increments_sequence():
    client = _make_client(rgbw=False)
    pixels = np.array([[1, 2, 3]], dtype=np.uint8)

    client.send_pixels_numpy(pixels)
    client.send_pixels_numpy(pixels)
    client.send_pixels_numpy(pixels)

    seqs = [_parse_header(p)["seq"] for p in client._captured]
    assert seqs == [1, 2, 3]


def test_send_pixels_numpy_chunks_large_payload():
    """A 1000-LED payload (3000 bytes) splits into multiple packets."""
    client = _make_client(rgbw=False)
    n = 1000
    pixels = np.arange(n * 3, dtype=np.uint8).reshape(n, 3)

    client.send_pixels_numpy(pixels, max_packet_size=1400)

    # max_payload = 1390, bytes_per_packet = 1389 (multiple of 3) → 3 packets
    assert len(client._captured) >= 2
    headers = [_parse_header(p) for p in client._captured]

    # Only the last packet has PUSH
    assert (headers[-1]["flags"] & 0x01) == 0x01
    for h in headers[:-1]:
        assert (h["flags"] & 0x01) == 0

    # Offsets are contiguous and lengths sum to total
    total = sum(h["data_len"] for h in headers)
    assert total == n * 3
    expected_offset = 0
    for h in headers:
        assert h["offset"] == expected_offset
        expected_offset += h["data_len"]


def test_send_pixels_numpy_rgbw_pads_alpha():
    client = _make_client(rgbw=True)
    pixels = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint8)

    client.send_pixels_numpy(pixels)

    parsed = _parse_header(client._captured[0])
    # bpp=4 → payload is 8 bytes, alpha=0
    assert parsed["data_len"] == 8
    expected = bytes([1, 2, 3, 0, 4, 5, 6, 0])
    assert parsed["payload"] == expected


def test_send_pixels_numpy_reuses_send_buffer():
    """The internal send_buf must be allocated lazily and reused — no fresh
    bytearray per call."""
    client = _make_client(rgbw=False)
    pixels = np.array([[1, 2, 3]], dtype=np.uint8)

    assert client._send_buf is None
    client.send_pixels_numpy(pixels)
    first_buf_id = id(client._send_buf)
    assert first_buf_id is not None

    client.send_pixels_numpy(pixels)
    assert id(client._send_buf) == first_buf_id


def test_send_pixels_numpy_growing_payload_grows_buffer():
    client = _make_client(rgbw=False)
    small = np.array([[1, 2, 3]], dtype=np.uint8)
    big = np.zeros((600, 3), dtype=np.uint8)

    client.send_pixels_numpy(small)
    small_capacity = len(client._send_buf)

    client.send_pixels_numpy(big, max_packet_size=2048)
    big_capacity = len(client._send_buf)

    assert big_capacity >= small_capacity


def test_send_pixels_numpy_handles_non_contiguous():
    """Slicing a wider array yields a non-contiguous view; payload must
    still serialise to the same bytes as a contiguous copy."""
    client = _make_client(rgbw=False)
    wide = np.arange(60, dtype=np.uint8).reshape(5, 4, 3)
    # take the middle pixel column → (5, 3) but possibly non-contiguous
    pixels = wide[:, 1, :]

    client.send_pixels_numpy(pixels)
    payload = _parse_header(client._captured[0])["payload"]
    assert payload == np.ascontiguousarray(pixels).tobytes()


@pytest.mark.asyncio
async def test_send_pixels_async_delegates_to_numpy():
    client = _make_client(rgbw=False)
    pixels = [(10, 20, 30), (40, 50, 60)]

    result = await client.send_pixels(pixels)
    assert result is True

    payload = _parse_header(client._captured[0])["payload"]
    assert payload == bytes([10, 20, 30, 40, 50, 60])


def test_send_pixels_numpy_raises_when_disconnected():
    client = DDPClient(host="127.0.0.1")
    pixels = np.array([[1, 2, 3]], dtype=np.uint8)
    with pytest.raises(RuntimeError):
        client.send_pixels_numpy(pixels)
