"""Additional coverage tests for icom_lan.web.server without real sockets."""

from __future__ import annotations

import asyncio
import pathlib
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from icom_lan.web.radio_poller import EnableScope
from icom_lan.web.server import WebConfig, WebServer, _send_response, run_web_server


class _FakeSocket:
    def __init__(self, host: str = "127.0.0.1", port: int = 4242) -> None:
        self._host = host
        self._port = port

    def getsockname(self):
        return (self._host, self._port)


class _FakeAsyncServer:
    def __init__(self) -> None:
        self.sockets = [_FakeSocket()]
        self.closed = False

    def close(self) -> None:
        self.closed = True

    async def wait_closed(self) -> None:
        return None


class _FakeWriter:
    def __init__(self) -> None:
        self.buffer = bytearray()
        self.closed = False
        self.wait_closed_called = False

    def write(self, data: bytes) -> None:
        self.buffer.extend(data)

    async def drain(self) -> None:
        return None

    def close(self) -> None:
        self.closed = True

    async def wait_closed(self) -> None:
        self.wait_closed_called = True

    def get_extra_info(self, name: str, default=None):
        if name == "peername":
            return ("127.0.0.1", 55555)
        return default


def _reader_with(data: bytes) -> asyncio.StreamReader:
    reader = asyncio.StreamReader()
    reader.feed_data(data)
    reader.feed_eof()
    return reader


def _scope_radio(*, ready: bool = True, connected: bool = True) -> MagicMock:
    radio = MagicMock()
    radio.radio_ready = ready
    radio.connected = connected
    radio.capabilities = {"scope"}
    radio.on_scope_data = MagicMock()
    radio.enable_scope = AsyncMock()
    radio.disable_scope = AsyncMock()
    radio.capture_scope_frame = AsyncMock()
    radio.capture_scope_frames = AsyncMock()
    radio.set_scope_during_tx = AsyncMock()
    radio.set_scope_center_type = AsyncMock()
    radio.set_scope_fixed_edge = AsyncMock()
    return radio


class _StateNotifyRadio(MagicMock):
    """Minimal mock that satisfies StateNotifyCapable so server registers callbacks."""

    def set_state_change_callback(self, callback: object) -> None:
        self._state_change_callback = callback

    def set_reconnect_callback(self, callback: object) -> None:
        self._reconnect_callback = callback


@pytest.mark.asyncio
async def test_start_and_stop_with_radio_sets_callbacks() -> None:
    radio = _StateNotifyRadio()
    radio.state_cache = MagicMock()
    radio.disconnect = AsyncMock()
    fake_server = _FakeAsyncServer()
    fake_poller = MagicMock()

    srv = WebServer(radio, WebConfig(host="127.0.0.1", port=0))
    with (
        patch(
            "icom_lan.web.server.asyncio.start_server",
            new=AsyncMock(return_value=fake_server),
        ),
        patch("icom_lan.web.server.RadioPoller", return_value=fake_poller),
    ):
        await srv.start()
        assert srv.port == 4242
        assert radio._state_change_callback is not None
        assert radio._state_change_callback == srv._on_radio_state_change
        assert radio._reconnect_callback is not None
        assert radio._reconnect_callback == srv._on_radio_reconnect
        fake_poller.start.assert_called_once()
        await srv.stop()

    fake_poller.stop.assert_called_once()
    radio.disconnect.assert_awaited_once()
    assert fake_server.closed is True


@pytest.mark.asyncio
async def test_stop_handles_disconnect_failure_and_cancels_client_tasks() -> None:
    radio = MagicMock()
    radio.disconnect = AsyncMock(side_effect=RuntimeError("disconnect failed"))
    srv = WebServer(radio)
    srv._server = _FakeAsyncServer()
    srv._radio_poller = MagicMock()

    blocker = asyncio.Event()

    async def slow_client() -> None:
        await blocker.wait()

    task = asyncio.create_task(slow_client())
    srv._client_tasks.add(task)
    await srv.stop()
    assert task.cancelled() or task.done()


@pytest.mark.asyncio
async def test_accept_client_max_clients_and_normal_path() -> None:
    srv = WebServer(None, WebConfig(max_clients=0))
    writer = _FakeWriter()
    srv._accept_client(_reader_with(b""), writer)
    assert writer.closed is True

    srv2 = WebServer(None, WebConfig(max_clients=10))
    with patch.object(srv2, "_handle_connection", new=AsyncMock()) as handle_conn:
        writer2 = _FakeWriter()
        srv2._accept_client(_reader_with(b""), writer2)
        await asyncio.sleep(0)
    handle_conn.assert_awaited()


@pytest.mark.asyncio
async def test_read_request_parses_and_handles_invalid_cases() -> None:
    srv = WebServer(None)
    reader = _reader_with(
        b"GET /x%20y?q=1 HTTP/1.1\r\nHost: localhost\r\nX-Test: abc\r\n\r\n"
    )
    method, path, headers, query = await srv._read_request(reader)  # noqa: SLF001
    assert method == "GET"
    assert path == "/x y"
    assert headers["host"] == "localhost"
    assert headers["x-test"] == "abc"
    assert query == {"q": ["1"]}

    bad = _reader_with(b"BROKEN\r\n\r\n")
    assert await srv._read_request(bad) is None  # noqa: SLF001

    async def timeout_wait_for(coro, timeout):
        del timeout
        if hasattr(coro, "close"):
            coro.close()
        raise asyncio.TimeoutError

    with patch("icom_lan.web.server.asyncio.wait_for", side_effect=timeout_wait_for):
        assert await srv._read_request(_reader_with(b"GET / HTTP/1.1\r\n")) is None  # noqa: SLF001


@pytest.mark.asyncio
async def test_handle_http_routes_and_405_404() -> None:
    srv = WebServer(None)
    writer = _FakeWriter()
    with patch("icom_lan.web.server._send_response", new=AsyncMock()) as send_resp:
        await srv._handle_http(writer, "POST", "/")  # noqa: SLF001
    send_resp.assert_awaited_once()

    srv2 = WebServer(None)
    writer2 = _FakeWriter()
    srv2._serve_info = AsyncMock()
    srv2._serve_state = AsyncMock()
    srv2._serve_capabilities = AsyncMock()
    srv2._serve_static = AsyncMock()
    with patch("icom_lan.web.server._send_response", new=AsyncMock()) as send_resp2:
        await srv2._handle_http(writer2, "GET", "/api/v1/info")  # noqa: SLF001
        await srv2._handle_http(writer2, "GET", "/api/v1/state")  # noqa: SLF001
        await srv2._handle_http(writer2, "GET", "/api/v1/capabilities")  # noqa: SLF001
        await srv2._handle_http(writer2, "GET", "/")  # noqa: SLF001
        await srv2._handle_http(writer2, "GET", "/file.js")  # noqa: SLF001
        await srv2._handle_http(writer2, "GET", "relative-path")  # noqa: SLF001
    assert srv2._serve_info.await_count == 1
    assert srv2._serve_state.await_count == 1
    assert srv2._serve_capabilities.await_count == 1
    assert srv2._serve_static.await_count == 2
    send_resp2.assert_awaited_once()


@pytest.mark.asyncio
async def test_serve_static_forbidden_missing_read_error_and_success(tmp_path) -> None:
    static_dir = tmp_path / "static"
    static_dir.mkdir()
    index = static_dir / "index.html"
    index.write_text("<html>ok</html>", encoding="utf-8")

    srv = WebServer(None, WebConfig(static_dir=static_dir))
    writer = _FakeWriter()

    await srv._serve_static(writer, "../secret")  # noqa: SLF001
    assert b"403 Forbidden" in writer.buffer

    writer = _FakeWriter()
    await srv._serve_static(writer, "missing.txt")  # noqa: SLF001
    assert b"404 Not Found" in writer.buffer

    writer = _FakeWriter()
    with patch.object(pathlib.Path, "read_bytes", side_effect=OSError("read fail")):
        await srv._serve_static(writer, "index.html")  # noqa: SLF001
    assert b"500 Internal Server Error" in writer.buffer

    writer = _FakeWriter()
    await srv._serve_static(writer, "index.html")  # noqa: SLF001
    text = writer.buffer.decode("ascii", errors="replace")
    assert "200 OK" in text
    assert "Cache-Control: no-cache, no-store, must-revalidate" in text


@pytest.mark.asyncio
async def test_handle_websocket_missing_key_unknown_channel_and_control_handler() -> (
    None
):
    srv = WebServer(None)
    writer = _FakeWriter()
    with patch("icom_lan.web.server._send_response", new=AsyncMock()) as send_resp:
        await srv._handle_websocket(_reader_with(b""), writer, "/api/v1/ws", {})  # noqa: SLF001
    send_resp.assert_awaited_once()

    ws_unknown = MagicMock()
    ws_unknown.close = AsyncMock()
    ws_unknown.keepalive_loop = AsyncMock()
    with patch("icom_lan.web.server.WebSocketConnection", return_value=ws_unknown):
        await srv._handle_websocket(  # noqa: SLF001
            _reader_with(b""),
            _FakeWriter(),
            "/api/v1/unknown",
            {"sec-websocket-key": "abc"},
        )
    ws_unknown.close.assert_awaited_once_with(1008, "unknown channel")

    ws_ok = MagicMock()

    async def keepalive_loop(_interval: float) -> None:
        await asyncio.sleep(3600)

    ws_ok.keepalive_loop = keepalive_loop
    ws_ok.close = AsyncMock()
    handler = MagicMock()
    handler.run = AsyncMock(side_effect=RuntimeError("handler failed"))
    writer_ok = _FakeWriter()
    with (
        patch("icom_lan.web.server.WebSocketConnection", return_value=ws_ok),
        patch("icom_lan.web.server.ControlHandler", return_value=handler),
    ):
        await srv._handle_websocket(  # noqa: SLF001
            _reader_with(b""),
            writer_ok,
            "/api/v1/ws",
            {"sec-websocket-key": "abc"},
        )
    assert b"101 Switching Protocols" in writer_ok.buffer
    handler.run.assert_awaited_once()


@pytest.mark.asyncio
async def test_handle_connection_none_http_ws_and_exception() -> None:
    srv = WebServer(None)
    writer = _FakeWriter()

    srv._read_request = AsyncMock(return_value=None)
    await srv._handle_connection(_reader_with(b""), writer)  # noqa: SLF001
    assert writer.closed is True

    writer2 = _FakeWriter()
    srv._read_request = AsyncMock(return_value=("GET", "/api/v1/info", {}, {}))
    srv._handle_http = AsyncMock()
    await srv._handle_connection(_reader_with(b""), writer2)  # noqa: SLF001
    srv._handle_http.assert_awaited_once()

    writer3 = _FakeWriter()
    srv._read_request = AsyncMock(
        return_value=(
            "GET",
            "/api/v1/ws",
            {"upgrade": "websocket", "connection": "Upgrade"},
            {},
        )
    )
    srv._handle_websocket = AsyncMock()
    await srv._handle_connection(_reader_with(b""), writer3)  # noqa: SLF001
    srv._handle_websocket.assert_awaited_once()

    writer4 = _FakeWriter()
    srv._read_request = AsyncMock(side_effect=RuntimeError("boom"))
    await srv._handle_connection(_reader_with(b""), writer4)  # noqa: SLF001
    assert writer4.closed is True


@pytest.mark.asyncio
async def test_scope_health_and_radio_state_event_paths() -> None:
    radio = _scope_radio()
    srv = WebServer(radio)

    frame = SimpleNamespace(pixels=b"\x00\x01")
    before = srv._scope_last_nonzero
    srv._scope_health_check(frame)  # noqa: SLF001
    assert srv._scope_last_nonzero >= before

    bad = SimpleNamespace(pixels=123)
    srv._scope_health_check(bad)  # noqa: SLF001

    # Meter state change still triggers broadcast_state_update
    srv._on_radio_state_change("meter", {"type": "power", "raw": 77})  # noqa: SLF001

    scope_handler = MagicMock()
    srv._scope_handlers.add(scope_handler)
    srv._on_radio_reconnect()  # noqa: SLF001
    cmds = srv.command_queue.drain
    assert any(isinstance(c, EnableScope) for c in cmds())


@pytest.mark.asyncio
async def test_on_radio_reconnect_waits_until_radio_ready() -> None:
    radio = _scope_radio(ready=False)
    srv = WebServer(radio)
    srv._scope_handlers.add(MagicMock())
    srv._scope_reenable_poll_interval = 0.01  # noqa: SLF001
    srv._scope_reenable_timeout = 0.2  # noqa: SLF001

    srv._on_radio_reconnect()  # noqa: SLF001
    assert not any(isinstance(c, EnableScope) for c in srv.command_queue.drain())

    radio.radio_ready = True
    await asyncio.sleep(0.03)
    assert any(isinstance(c, EnableScope) for c in srv.command_queue.drain())


@pytest.mark.asyncio
async def test_ensure_scope_enabled_defers_enable_when_radio_not_ready() -> None:
    radio = _scope_radio(ready=False)
    srv = WebServer(radio)
    srv._scope_reenable_poll_interval = 0.01  # noqa: SLF001
    srv._scope_reenable_timeout = 0.2  # noqa: SLF001

    await srv.ensure_scope_enabled(MagicMock())
    assert not any(isinstance(c, EnableScope) for c in srv.command_queue.drain())

    radio.radio_ready = True
    await asyncio.sleep(0.03)
    assert any(isinstance(c, EnableScope) for c in srv.command_queue.drain())


@pytest.mark.asyncio
async def test_ensure_scope_enabled_skips_when_scope_capability_absent() -> None:
    radio = MagicMock()
    radio.connected = True
    radio.radio_ready = True
    radio.capabilities = set()
    srv = WebServer(radio)

    await srv.ensure_scope_enabled(MagicMock())

    assert not any(isinstance(c, EnableScope) for c in srv.command_queue.drain())
    assert srv._scope_enabled is False


@pytest.mark.asyncio
async def test_scope_health_monitor_disconnected_and_reenable() -> None:
    radio = _scope_radio(connected=False)
    srv = WebServer(radio)
    srv._scope_handlers.add(MagicMock())
    srv._scope_health_interval = 0.01
    srv._scope_last_nonzero = 0.0

    task = asyncio.create_task(srv._scope_health_monitor())  # noqa: SLF001
    await asyncio.sleep(0.03)
    task.cancel()
    await asyncio.gather(task, return_exceptions=True)
    assert srv._scope_last_nonzero > 0

    radio.connected = True
    srv._scope_last_nonzero = time.monotonic() - 1.0
    task = asyncio.create_task(srv._scope_health_monitor())  # noqa: SLF001
    await asyncio.sleep(0.03)
    task.cancel()
    await asyncio.gather(task, return_exceptions=True)
    cmds = srv.command_queue.drain()
    assert any(isinstance(c, EnableScope) for c in cmds)


@pytest.mark.asyncio
async def test_send_response_and_run_web_server() -> None:
    writer = _FakeWriter()
    await _send_response(writer, 200, "OK", b"abc", {"Content-Type": "text/plain"})
    assert b"HTTP/1.1 200 OK" in writer.buffer
    assert b"Content-Length: 3" in writer.buffer

    fake_server = MagicMock()
    fake_server.serve_forever = AsyncMock()
    with patch("icom_lan.web.server.WebServer", return_value=fake_server):
        await run_web_server(None, host="127.0.0.1", port=8000)
    fake_server.serve_forever.assert_awaited_once()


@pytest.mark.asyncio
async def test_broadcast_notification_puts_to_all_queues() -> None:
    """broadcast_notification pushes notification dict to all registered queues."""
    srv = WebServer()
    q1: asyncio.Queue[dict] = asyncio.Queue()
    q2: asyncio.Queue[dict] = asyncio.Queue()
    srv.register_control_event_queue(q1)
    srv.register_control_event_queue(q2)

    srv.broadcast_notification("success", "Radio connected", "connection")

    assert not q1.empty()
    assert not q2.empty()
    n1 = q1.get_nowait()
    n2 = q2.get_nowait()
    assert n1["type"] == "notification"
    assert n1["level"] == "success"
    assert n1["message"] == "Radio connected"
    assert n1["category"] == "connection"
    assert n1 == n2


@pytest.mark.asyncio
async def test_broadcast_notification_default_category() -> None:
    """broadcast_notification uses 'system' as default category."""
    srv = WebServer()
    q: asyncio.Queue[dict] = asyncio.Queue()
    srv.register_control_event_queue(q)

    srv.broadcast_notification("info", "Hello")

    n = q.get_nowait()
    assert n["category"] == "system"


@pytest.mark.asyncio
async def test_broadcast_notification_full_queue_no_crash() -> None:
    """broadcast_notification silently skips full queues (dead clients)."""
    srv = WebServer()
    q: asyncio.Queue[dict] = asyncio.Queue(maxsize=1)
    q.put_nowait({"type": "other"})  # fill the queue
    srv.register_control_event_queue(q)

    # Should not raise even though queue is full
    srv.broadcast_notification("warning", "Test")
    assert q.qsize() == 1  # queue still has the old item, notification was dropped


# ---------------------------------------------------------------------------
# Sprint 0B: revision counter + updatedAt (#158)
# ---------------------------------------------------------------------------


def test_radio_poller_revision_starts_at_zero() -> None:
    """RadioPoller.revision is 0 before any state changes."""
    from icom_lan.web.radio_poller import CommandQueue, RadioPoller
    from icom_lan.rigctld.state_cache import StateCache

    radio = MagicMock()
    radio.capabilities = set()
    radio.profile = MagicMock()
    radio.profile.receiver_count = 1
    radio.profile.model = "IC-7300"
    radio.profile.supports_receiver = MagicMock(return_value=True)
    radio.profile.supports_cmd29 = MagicMock(return_value=False)
    radio.profile.vfo_sub_code = None
    radio.profile.vfo_main_code = None
    radio.profile.vfo_swap_code = None

    poller = RadioPoller(radio, StateCache(), CommandQueue())
    assert poller.revision == 0


def test_radio_poller_revision_increments() -> None:
    """bump_revision() monotonically increments revision."""
    from icom_lan.web.radio_poller import CommandQueue, RadioPoller
    from icom_lan.rigctld.state_cache import StateCache

    radio = MagicMock()
    radio.capabilities = set()
    radio.profile = MagicMock()
    radio.profile.receiver_count = 1
    radio.profile.model = "IC-7300"
    radio.profile.supports_receiver = MagicMock(return_value=True)
    radio.profile.supports_cmd29 = MagicMock(return_value=False)
    radio.profile.vfo_sub_code = None
    radio.profile.vfo_main_code = None
    radio.profile.vfo_swap_code = None

    poller = RadioPoller(radio, StateCache(), CommandQueue())
    assert poller.revision == 0
    poller.bump_revision()
    assert poller.revision == 1
    poller.bump_revision()
    assert poller.revision == 2


def test_radio_poller_revision_never_decreases() -> None:
    """After many bump_revision calls, revision is always >= previous value."""
    from icom_lan.web.radio_poller import CommandQueue, RadioPoller
    from icom_lan.rigctld.state_cache import StateCache

    radio = MagicMock()
    radio.capabilities = set()
    radio.profile = MagicMock()
    radio.profile.receiver_count = 1
    radio.profile.model = "IC-7300"
    radio.profile.supports_receiver = MagicMock(return_value=True)
    radio.profile.supports_cmd29 = MagicMock(return_value=False)
    radio.profile.vfo_sub_code = None
    radio.profile.vfo_main_code = None
    radio.profile.vfo_swap_code = None

    poller = RadioPoller(radio, StateCache(), CommandQueue())
    prev = poller.revision
    for _ in range(10):
        poller.bump_revision()
        assert poller.revision >= prev
        prev = poller.revision


@pytest.mark.asyncio
async def test_state_response_includes_revision_and_updated_at() -> None:
    """_serve_state adds revision (int) and updatedAt (ISO 8601) to JSON."""
    import datetime as _dt
    import json as _json

    srv = WebServer(None)
    writer = _FakeWriter()
    await srv._serve_state(writer)  # noqa: SLF001

    text = writer.buffer.decode("ascii", errors="replace")
    assert "200 OK" in text
    # Extract JSON body (after blank line)
    body_start = text.index("\r\n\r\n") + 4
    data = _json.loads(text[body_start:])
    assert "revision" in data
    assert isinstance(data["revision"], int)
    assert "updatedAt" in data
    # Must parse as valid ISO 8601 UTC datetime
    ts = _dt.datetime.fromisoformat(data["updatedAt"])
    assert ts.tzinfo is not None


@pytest.mark.asyncio
async def test_state_response_revision_zero_without_poller() -> None:
    """revision is 0 when no RadioPoller is attached."""
    import json as _json

    srv = WebServer(None)
    assert srv._radio_poller is None  # noqa: SLF001
    writer = _FakeWriter()
    await srv._serve_state(writer)  # noqa: SLF001
    text = writer.buffer.decode("ascii", errors="replace")
    body_start = text.index("\r\n\r\n") + 4
    data = _json.loads(text[body_start:])
    assert data["revision"] == 0


@pytest.mark.asyncio
async def test_on_radio_state_change_bumps_revision() -> None:
    """_on_radio_state_change increments poller.revision."""
    from icom_lan.web.radio_poller import CommandQueue, RadioPoller
    from icom_lan.rigctld.state_cache import StateCache

    radio = MagicMock()
    radio.capabilities = set()
    radio.profile = MagicMock()
    radio.profile.receiver_count = 1
    radio.profile.model = "IC-7300"
    radio.profile.supports_receiver = MagicMock(return_value=True)
    radio.profile.supports_cmd29 = MagicMock(return_value=False)
    radio.profile.vfo_sub_code = None
    radio.profile.vfo_main_code = None
    radio.profile.vfo_swap_code = None

    srv = WebServer(None)
    poller = RadioPoller(radio, StateCache(), CommandQueue())
    srv._radio_poller = poller  # noqa: SLF001
    assert poller.revision == 0

    srv._on_radio_state_change("freq_changed", {"freq": 14074000})  # noqa: SLF001
    assert poller.revision == 1

    srv._on_radio_state_change("mode_changed", {"mode": "USB"})  # noqa: SLF001
    assert poller.revision == 2


@pytest.mark.asyncio
async def test_info_endpoint_returns_structured_capabilities() -> None:
    """/api/v1/info returns model, capabilities, and connection objects."""
    import json as _json

    from icom_lan.radio_protocol import AudioCapable, DualReceiverCapable, ScopeCapable

    class _FakeRadio(ScopeCapable, AudioCapable, DualReceiverCapable):
        def __init__(self) -> None:
            self.model = "IC-7610"
            self.connected = True
            self.control_connected = False
            self.radio_ready = True
            self.capabilities = {"scope", "audio", "dual_rx"}

    radio = _FakeRadio()

    srv = WebServer(radio)
    writer = _FakeWriter()
    await srv._serve_info(writer)  # noqa: SLF001

    text = writer.buffer.decode("ascii", errors="replace")
    assert "200 OK" in text
    body_start = text.index("\r\n\r\n") + 4
    data = _json.loads(text[body_start:])

    # Legacy fields still present (backward compat)
    assert data["server"] == "icom-lan"
    assert data["proto"] == 1
    assert data["radio"] == "IC-7610"

    # New structured fields
    assert data["model"] == "IC-7610"
    caps = data["capabilities"]
    assert caps["hasSpectrum"] is True
    assert caps["hasAudio"] is True
    assert caps["hasDualReceiver"] is True
    assert caps["maxReceivers"] == 2
    assert isinstance(caps["tags"], list)
    assert isinstance(caps["modes"], list)
    assert isinstance(caps["filters"], list)

    conn = data["connection"]
    assert conn["rigConnected"] is True
    assert conn["controlConnected"] is False
    assert isinstance(conn["wsClients"], int)


@pytest.mark.asyncio
async def test_info_endpoint_no_radio() -> None:
    """/api/v1/info works without a radio (all capabilities false)."""
    import json as _json

    srv = WebServer(None)
    writer = _FakeWriter()
    await srv._serve_info(writer)  # noqa: SLF001

    text = writer.buffer.decode("ascii", errors="replace")
    body_start = text.index("\r\n\r\n") + 4
    data = _json.loads(text[body_start:])
    caps = data["capabilities"]
    assert caps["hasSpectrum"] is False
    assert caps["hasAudio"] is False
    assert caps["hasDualReceiver"] is False
    assert caps["maxReceivers"] == 1
    assert caps["tags"] == []
    conn = data["connection"]
    assert conn["rigConnected"] is False
    assert conn["radioReady"] is False


# ---------------------------------------------------------------------------
# Sprint 0 fixes: _camel_case_state transform (C2)
# ---------------------------------------------------------------------------


from icom_lan.web.runtime_helpers import _camel_case_state  # noqa: E402


class TestCamelCaseState:
    """Unit tests for the _camel_case_state serialisation helper."""

    def _minimal_dict(self) -> dict:
        """Return the smallest valid state dict for testing."""
        return {
            "active": "MAIN",
            "dual_watch": False,
            "tuner_status": 0,
            "connected": False,
            "radio_ready": False,
            "control_connected": False,
            "revision": 1,
            "updatedAt": "2026-01-01T00:00:00+00:00",
            "main": {
                "freq": 14074000,
                "data_mode": False,
                "s_meter": 10,
                "af_level": 128,
                "rf_gain": 200,
            },
            "sub": {
                "freq": 7100000,
                "data_mode": True,
                "s_meter": 0,
                "af_level": 64,
                "rf_gain": 100,
            },
            "scope_controls": {"ref_db": -13.5, "vbw_narrow": False},
        }

    def test_snake_case_keys_become_camel_case(self) -> None:
        result = _camel_case_state(self._minimal_dict())
        assert "dualWatch" in result
        assert "dual_watch" not in result
        assert "tunerStatus" in result
        assert "tuner_status" not in result

    def test_freq_renamed_to_freq_hz_in_receiver_dicts(self) -> None:
        result = _camel_case_state(self._minimal_dict())
        assert "freqHz" in result["main"]
        assert "freq" not in result["main"]
        assert result["main"]["freqHz"] == 14074000
        assert "freqHz" in result["sub"]
        assert result["sub"]["freqHz"] == 7100000

    def test_receiver_snake_keys_become_camel_case(self) -> None:
        result = _camel_case_state(self._minimal_dict())
        assert "dataMode" in result["main"]
        assert "data_mode" not in result["main"]
        assert "sMeter" in result["main"]
        assert "afLevel" in result["main"]
        assert "rfGain" in result["main"]

    def test_connection_fields_wrapped_into_object(self) -> None:
        d = self._minimal_dict()
        d["connected"] = True
        d["radio_ready"] = True
        d["control_connected"] = False
        result = _camel_case_state(d)
        assert "connected" not in result
        assert "radio_ready" not in result
        assert "control_connected" not in result
        conn = result["connection"]
        assert conn["rigConnected"] is True
        assert conn["radioReady"] is True
        assert conn["controlConnected"] is False

    def test_revision_and_updated_at_preserved(self) -> None:
        result = _camel_case_state(self._minimal_dict())
        assert result["revision"] == 1
        assert result["updatedAt"] == "2026-01-01T00:00:00+00:00"

    def test_scope_controls_keys_converted(self) -> None:
        result = _camel_case_state(self._minimal_dict())
        sc = result["scopeControls"]
        assert "refDb" in sc
        assert "vbwNarrow" in sc
        assert "ref_db" not in sc

    @pytest.mark.asyncio
    async def test_state_response_is_camel_case(self) -> None:
        """Integration: _serve_state HTTP response body is camelCase."""
        import json as _json

        srv = WebServer(None)
        writer = _FakeWriter()
        await srv._serve_state(writer)  # noqa: SLF001
        text = writer.buffer.decode("ascii", errors="replace")
        body_start = text.index("\r\n\r\n") + 4
        data = _json.loads(text[body_start:])
        assert "dualWatch" in data
        assert "dual_watch" not in data
        assert "tunerStatus" in data
        assert "connection" in data
        assert "rigConnected" in data["connection"]
