"""Tests for the subscription module.

This module contains comprehensive tests for the AppSync WebSocket
subscription support, including AppSyncConnection and SubscriptionManager.
"""

from __future__ import annotations

import asyncio
import contextlib
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import aiohttp
import pytest

from aiokwikset.errors import (
    ConnectionError as KwiksetConnectionError,
)
from aiokwikset.errors import (
    SubscriptionError,
    SubscriptionTimeout,
)
from aiokwikset.subscription import (
    AppSyncConnection,
    SubscriptionManager,
    _build_ws_url,
)

# ============================================================================
# Constants
# ============================================================================

MOCK_API_HOST = "test.appsync-api.us-east-1.amazonaws.com"
MOCK_REALTIME_HOST = "test.appsync-realtime-api.us-east-1.amazonaws.com"
MOCK_ID_TOKEN = "mock_id_token_value"

# ============================================================================
# Helper functions
# ============================================================================


class _AsyncWSMessages:
    """Async iterator wrapper for mock WebSocket messages.

    Enables ``async for msg in ws:`` in ``_receive_loop`` tests.
    """

    def __init__(self, messages: list[Any]) -> None:
        self._iter = iter(messages)

    def __aiter__(self) -> _AsyncWSMessages:
        return self

    async def __anext__(self) -> Any:
        try:
            return next(self._iter)
        except StopIteration:
            raise StopAsyncIteration  # noqa: B904


def _make_credentials_provider(token: str = MOCK_ID_TOKEN) -> AsyncMock:
    """Create a mock credentials provider returning a fixed token."""
    provider = AsyncMock(return_value=token)
    return provider


def _make_mock_ws(
    receive_messages: list[dict[str, Any]] | None = None,
    closed: bool = False,
) -> MagicMock:
    """Create a mock WebSocket response object.

    :param receive_messages: List of messages ``receive_json`` returns in order.
    :param closed: Whether the WS starts closed.
    """
    ws = AsyncMock(spec=aiohttp.ClientWebSocketResponse)
    ws.closed = closed
    ws.send_json = AsyncMock()
    ws.close = AsyncMock()

    if receive_messages:
        ws.receive_json = AsyncMock(side_effect=receive_messages)
    else:
        ws.receive_json = AsyncMock(
            return_value={"type": "connection_ack", "payload": {"connectionTimeoutMs": 300000}}
        )

    return ws


def _make_mock_session(ws: MagicMock | None = None) -> MagicMock:
    """Create a mock aiohttp.ClientSession with ws_connect."""
    session = MagicMock(spec=aiohttp.ClientSession)
    if ws is not None:
        session.ws_connect = AsyncMock(return_value=ws)
    else:
        default_ws = _make_mock_ws()
        session.ws_connect = AsyncMock(return_value=default_ws)
    return session


# ============================================================================
# _build_ws_url Tests
# ============================================================================


class TestBuildWsUrl:
    """Tests for the _build_ws_url helper function."""

    def test_url_structure(self) -> None:
        """Test that the URL has the correct structure."""
        url = _build_ws_url(MOCK_REALTIME_HOST, MOCK_API_HOST, MOCK_ID_TOKEN)

        assert url.startswith(f"wss://{MOCK_REALTIME_HOST}/%2Fgraphql?")
        assert "header=" in url
        assert "payload=" in url

    def test_payload_is_empty_json(self) -> None:
        """Test that the payload param is base64 of '{}'."""
        url = _build_ws_url(MOCK_REALTIME_HOST, MOCK_API_HOST, MOCK_ID_TOKEN)

        # e30= is base64('{}')
        assert "payload=e30%3D" in url

    def test_header_contains_auth(self) -> None:
        """Test that the header param encodes host and Authorization."""
        import base64
        from urllib.parse import parse_qs, urlparse

        url = _build_ws_url(MOCK_REALTIME_HOST, MOCK_API_HOST, MOCK_ID_TOKEN)
        parsed = urlparse(url)
        qs = parse_qs(parsed.query)

        header_b64 = qs["header"][0]
        header_json = json.loads(base64.b64decode(header_b64))

        assert header_json["host"] == MOCK_API_HOST
        assert header_json["Authorization"] == MOCK_ID_TOKEN


# ============================================================================
# AppSyncConnection Tests
# ============================================================================


class TestAppSyncConnection:
    """Tests for AppSyncConnection."""

    def test_init(self) -> None:
        """Test connection initialization."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            callback=callback,
        )

        assert conn._api_host == MOCK_API_HOST
        assert conn._realtime_host == MOCK_REALTIME_HOST
        assert not conn.is_connected
        assert conn.subscription_count == 0

    async def test_connect_success(self) -> None:
        """Test successful WebSocket connection."""
        creds = _make_credentials_provider()
        ws = _make_mock_ws()
        session = _make_mock_session(ws)

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        # Patch at the class level to avoid __slots__ issues
        with (
            patch.object(AppSyncConnection, "_receive_loop", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "_ka_monitor", new_callable=AsyncMock),
        ):
            await conn.async_connect()

        assert conn.is_connected
        session.ws_connect.assert_called_once()
        ws.send_json.assert_called_once_with({"type": "connection_init"})

    async def test_connect_already_connected(self) -> None:
        """Test that connecting when already connected is a no-op."""
        creds = _make_credentials_provider()
        ws = _make_mock_ws()
        session = _make_mock_session(ws)

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "_receive_loop", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "_ka_monitor", new_callable=AsyncMock),
        ):
            await conn.async_connect()
            # Second call should be a no-op
            await conn.async_connect()

        assert session.ws_connect.call_count == 1

    async def test_connect_bad_ack(self) -> None:
        """Test that a non-ack response raises SubscriptionError."""
        creds = _make_credentials_provider()
        ws = _make_mock_ws()
        ws.receive_json = AsyncMock(return_value={"type": "error", "payload": {}})
        session = _make_mock_session(ws)

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        with pytest.raises(SubscriptionError, match="Expected connection_ack"):
            await conn.async_connect()

        assert not conn.is_connected

    async def test_connect_timeout(self) -> None:
        """Test that a connection timeout raises SubscriptionTimeout."""
        creds = _make_credentials_provider()
        ws = _make_mock_ws()
        ws.receive_json = AsyncMock(side_effect=asyncio.TimeoutError)
        session = _make_mock_session(ws)

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        with pytest.raises(SubscriptionTimeout):
            await conn.async_connect()

        assert not conn.is_connected

    async def test_connect_ws_error(self) -> None:
        """Test that a WS connection error raises KwiksetConnectionError."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        session.ws_connect = AsyncMock(side_effect=OSError("Connection refused"))

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        with pytest.raises(KwiksetConnectionError, match="Failed to connect"):
            await conn.async_connect()

    async def test_subscribe_success(self) -> None:
        """Test successful subscription registration."""
        creds = _make_credentials_provider()
        ws = _make_mock_ws()
        session = _make_mock_session(ws)

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "_receive_loop", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "_ka_monitor", new_callable=AsyncMock),
        ):
            await conn.async_connect()

        # Patch async_subscribe at the class level to simulate start_ack

        async def _patched_subscribe(
            self_inner: Any, name: str, query: str, variables: dict[str, Any]
        ) -> str:
            import uuid as _uuid

            sub_id = str(_uuid.uuid4())
            ack_event = asyncio.Event()
            self_inner._pending_acks[sub_id] = ack_event
            self_inner._subscriptions[sub_id] = {
                "name": name,
                "query": query,
                "variables": variables,
            }

            id_token = await creds()
            payload = {
                "data": json.dumps({"query": query, "variables": variables}),
                "extensions": {
                    "authorization": {
                        "host": MOCK_API_HOST,
                        "Authorization": id_token,
                    }
                },
            }
            await ws.send_json({"id": sub_id, "type": "start", "payload": payload})
            ack_event.set()
            self_inner._pending_acks.pop(sub_id, None)
            return sub_id

        with patch.object(AppSyncConnection, "async_subscribe", _patched_subscribe):
            sub_id = await conn.async_subscribe(
                "onManageDevice",
                "subscription { ... }",
                {"email": "test@example.com"},
            )

        assert sub_id is not None
        assert conn.subscription_count == 1

    async def test_subscribe_not_connected(self) -> None:
        """Test that subscribing without connection raises KwiksetConnectionError."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        with pytest.raises(KwiksetConnectionError, match="not connected"):
            await conn.async_subscribe(
                "onManageDevice",
                "subscription { ... }",
                {"email": "test@example.com"},
            )

    async def test_unsubscribe(self) -> None:
        """Test unsubscribing removes the subscription."""
        creds = _make_credentials_provider()
        ws = _make_mock_ws()
        session = _make_mock_session(ws)

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "_receive_loop", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "_ka_monitor", new_callable=AsyncMock),
        ):
            await conn.async_connect()

        # Manually register a subscription
        conn._subscriptions["test-id-123"] = {
            "name": "onManageDevice",
            "query": "subscription { ... }",
            "variables": {"email": "test@example.com"},
        }

        await conn.async_unsubscribe("test-id-123")

        assert "test-id-123" not in conn._subscriptions
        # Check that stop was sent
        ws.send_json.assert_called_with({"id": "test-id-123", "type": "stop"})

    async def test_disconnect(self) -> None:
        """Test that disconnect cleans up everything."""
        creds = _make_credentials_provider()
        ws = _make_mock_ws()
        session = _make_mock_session(ws)

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "_receive_loop", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "_ka_monitor", new_callable=AsyncMock),
        ):
            await conn.async_connect()

        conn._subscriptions["sub-1"] = {
            "name": "test",
            "query": "subscription { ... }",
            "variables": {},
        }

        await conn.async_disconnect()

        assert not conn.is_connected
        assert conn.subscription_count == 0
        ws.close.assert_called_once()

    def test_handle_data_with_callback(self) -> None:
        """Test that _handle_data invokes the callback correctly."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            callback=callback,
        )

        conn._subscriptions["sub-123"] = {
            "name": "onManageDevice",
            "query": "subscription { ... }",
            "variables": {"email": "test@example.com"},
        }

        payload = {"data": {"deviceid": "dev-001", "devicestatus": "locked"}}
        conn._handle_data("sub-123", payload)

        callback.assert_called_once_with(
            "onManageDevice",
            {"deviceid": "dev-001", "devicestatus": "locked"},
        )

    def test_handle_data_unknown_subscription(self) -> None:
        """Test that _handle_data ignores unknown subscription IDs."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            callback=callback,
        )

        conn._handle_data("unknown-id", {"data": {}})

        callback.assert_not_called()

    def test_handle_data_no_callback(self) -> None:
        """Test that _handle_data does nothing without a callback."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            callback=None,
        )

        conn._subscriptions["sub-123"] = {
            "name": "onManageDevice",
            "query": "subscription { ... }",
            "variables": {},
        }
        # Should not raise
        conn._handle_data("sub-123", {"data": {}})

    def test_handle_data_callback_exception(self) -> None:
        """Test that _handle_data catches callback exceptions."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock(side_effect=RuntimeError("callback error"))

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            callback=callback,
        )

        conn._subscriptions["sub-123"] = {
            "name": "onManageDevice",
            "query": "subscription { ... }",
            "variables": {},
        }
        # Should not raise even if callback raises
        conn._handle_data("sub-123", {"data": {}})

    def test_handle_start_ack(self) -> None:
        """Test that _handle_start_ack resolves pending events."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        event = asyncio.Event()
        conn._pending_acks["sub-999"] = event

        conn._handle_start_ack("sub-999")

        assert event.is_set()

    def test_handle_start_ack_none_id(self) -> None:
        """Test that _handle_start_ack ignores None IDs."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        # Should not raise
        conn._handle_start_ack(None)

    def test_handle_start_ack_unknown_id(self) -> None:
        """Test that _handle_start_ack ignores unknown IDs."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        # Should not raise
        conn._handle_start_ack("nonexistent-id")

    def test_is_connected_property(self) -> None:
        """Test the is_connected property under various states."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        assert not conn.is_connected

        # Simulate connected state
        conn._state = "connected"
        conn._ws = MagicMock()
        conn._ws.closed = False
        assert conn.is_connected

        # Simulate closed ws
        conn._ws.closed = True
        assert not conn.is_connected


# ============================================================================
# SubscriptionManager Tests
# ============================================================================


class TestSubscriptionManager:
    """Tests for SubscriptionManager."""

    def test_init(self) -> None:
        """Test manager initialization."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        assert manager.core_connection is None
        assert manager.halov_connection is None

    def test_set_callback(self) -> None:
        """Test setting callback propagates to existing connections."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        manager.set_callback(callback)
        assert manager._callback is callback

    def test_set_callback_with_existing_connections(self) -> None:
        """Test that set_callback updates existing connections."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        # Manually set up connections
        core = MagicMock(spec=AppSyncConnection)
        halov = MagicMock(spec=AppSyncConnection)
        manager._core_conn = core
        manager._halov_conn = halov

        manager.set_callback(callback)

        assert core._callback == callback
        assert halov._callback == callback

    async def test_ensure_core_creates_connection(self) -> None:
        """Test that _ensure_core lazily creates the Core API connection."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock):
            conn = await manager._ensure_core()

        assert conn is not None
        assert manager.core_connection is conn

    async def test_ensure_halov_creates_connection(self) -> None:
        """Test that _ensure_halov lazily creates the Halo V API connection."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock):
            conn = await manager._ensure_halov()

        assert conn is not None
        assert manager.halov_connection is conn

    async def test_subscribe_device(self) -> None:
        """Test subscribing to device events uses Core API."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value="sub-id-1",
            ),
        ):
            sub_id = await manager.async_subscribe_device("test@example.com")

        assert sub_id == "sub-id-1"
        assert manager.core_connection is not None

    async def test_subscribe_user(self) -> None:
        """Test subscribing to user events uses Core API."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value="sub-id-2",
            ),
        ):
            sub_id = await manager.async_subscribe_user("test@example.com")

        assert sub_id == "sub-id-2"

    async def test_subscribe_home(self) -> None:
        """Test subscribing to home events uses Core API."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value="sub-id-3",
            ),
        ):
            sub_id = await manager.async_subscribe_home("test@example.com")

        assert sub_id == "sub-id-3"

    async def test_subscribe_hav_device_status(self) -> None:
        """Test subscribing to Halo V device status uses Halo V API."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value="sub-hav-1",
            ),
        ):
            sub_id = await manager.async_subscribe_hav_device_status("test@example.com")

        assert sub_id == "sub-hav-1"
        assert manager.halov_connection is not None

    async def test_subscribe_hav_device(self) -> None:
        """Test subscribing to Halo V device updates."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value="sub-hav-2",
            ),
        ):
            sub_id = await manager.async_subscribe_hav_device("test@example.com")

        assert sub_id == "sub-hav-2"

    async def test_subscribe_hav_led_status(self) -> None:
        """Test subscribing to Halo V LED status updates."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value="sub-hav-3",
            ),
        ):
            sub_id = await manager.async_subscribe_hav_led_status("test@example.com")

        assert sub_id == "sub-hav-3"

    async def test_subscribe_hav_event(self) -> None:
        """Test subscribing to Halo V event updates."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value="sub-hav-event",
            ),
        ):
            sub_id = await manager.async_subscribe_hav_event("test@example.com")

        assert sub_id == "sub-hav-event"

    async def test_subscribe_hav_battery_voltage(self) -> None:
        """Test subscribing to Halo V battery voltage updates."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value="sub-hav-batt",
            ),
        ):
            sub_id = await manager.async_subscribe_hav_battery_voltage("test@example.com")

        assert sub_id == "sub-hav-batt"

    async def test_unsubscribe_core(self) -> None:
        """Test unsubscribing from a Core API subscription."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        core_conn = AsyncMock(spec=AppSyncConnection)
        core_conn._subscriptions = {
            "sub-1": {"name": "onManageDevice", "query": "q", "variables": {}}
        }
        manager._core_conn = core_conn

        await manager.async_unsubscribe("sub-1")

        core_conn.async_unsubscribe.assert_called_once_with("sub-1")

    async def test_unsubscribe_halov(self) -> None:
        """Test unsubscribing from a Halo V API subscription."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        halov_conn = AsyncMock(spec=AppSyncConnection)
        halov_conn._subscriptions = {
            "sub-hav-1": {"name": "onUpdateHavDeviceStatus", "query": "q", "variables": {}}
        }
        manager._halov_conn = halov_conn
        # Core conn is None, so it shouldn't look there
        manager._core_conn = None

        await manager.async_unsubscribe("sub-hav-1")

        halov_conn.async_unsubscribe.assert_called_once_with("sub-hav-1")

    async def test_async_close(self) -> None:
        """Test that async_close disconnects all connections."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        core_conn = AsyncMock(spec=AppSyncConnection)
        halov_conn = AsyncMock(spec=AppSyncConnection)
        manager._core_conn = core_conn
        manager._halov_conn = halov_conn

        await manager.async_close()

        core_conn.async_disconnect.assert_called_once()
        halov_conn.async_disconnect.assert_called_once()
        assert manager._core_conn is None
        assert manager._halov_conn is None

    async def test_async_close_no_connections(self) -> None:
        """Test that async_close is safe when no connections exist."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        # Should not raise
        await manager.async_close()

    async def test_ensure_core_reuses_connection(self) -> None:
        """Test that _ensure_core reuses an existing connected connection."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock):
            conn1 = await manager._ensure_core()

        # Simulate connected state
        conn1._state = "connected"
        conn1._ws = MagicMock()
        conn1._ws.closed = False

        conn2 = await manager._ensure_core()
        assert conn1 is conn2


# ============================================================================
# Error Class Tests
# ============================================================================


class TestSubscriptionErrors:
    """Tests for subscription error classes."""

    def test_subscription_error_hierarchy(self) -> None:
        """Test that SubscriptionError inherits from KwiksetError."""
        from aiokwikset.errors import KwiksetError

        err = SubscriptionError("test")
        assert isinstance(err, KwiksetError)

    def test_subscription_timeout_hierarchy(self) -> None:
        """Test that SubscriptionTimeout inherits from SubscriptionError."""
        err = SubscriptionTimeout("timeout")
        assert isinstance(err, SubscriptionError)

    def test_subscription_timeout_default_message(self) -> None:
        """Test SubscriptionTimeout default message."""
        err = SubscriptionTimeout()
        assert "timed out" in str(err).lower()

    def test_subscription_error_message(self) -> None:
        """Test custom error message."""
        err = SubscriptionError("custom message")
        assert str(err) == "custom message"


# ============================================================================
# API Integration Tests
# ============================================================================


class TestAPISubscriptionIntegration:
    """Tests for subscription integration in the API class."""

    def test_subscriptions_property_unauthenticated(self) -> None:
        """Test that accessing subscriptions when unauthenticated raises."""
        from aiokwikset import API
        from aiokwikset.errors import Unauthenticated

        api = API()
        with pytest.raises(Unauthenticated, match="Cannot access subscriptions"):
            _ = api.subscriptions

    def test_subscriptions_property_authenticated(self) -> None:
        """Test that subscriptions are available when authenticated."""
        from aiokwikset import API

        session = MagicMock(spec=aiohttp.ClientSession)
        api = API(websession=session)
        api.id_token = "tok_id"
        api.access_token = "tok_access"
        api.refresh_token = "tok_refresh"

        subs = api.subscriptions
        assert isinstance(subs, SubscriptionManager)

    def test_subscriptions_property_cached(self) -> None:
        """Test that the subscriptions property returns the same instance."""
        from aiokwikset import API

        session = MagicMock(spec=aiohttp.ClientSession)
        api = API(websession=session)
        api.id_token = "tok_id"
        api.access_token = "tok_access"
        api.refresh_token = "tok_refresh"

        subs1 = api.subscriptions
        subs2 = api.subscriptions
        assert subs1 is subs2

    async def test_async_close_cleans_subscriptions(self) -> None:
        """Test that async_close cleans up the subscription manager."""
        from aiokwikset import API

        session = MagicMock(spec=aiohttp.ClientSession)
        api = API(websession=session)
        api.id_token = "tok_id"
        api.access_token = "tok_access"
        api.refresh_token = "tok_refresh"

        # Access subscriptions to create the manager
        _ = api.subscriptions

        # Patch async_close on the class so __slots__ is not an issue
        with patch.object(SubscriptionManager, "async_close", new_callable=AsyncMock) as mock_close:
            await api.async_close()

        mock_close.assert_called_once()
        assert api._subscription_manager is None

    async def test_async_close_cleans_subscription_session(self) -> None:
        """Test that async_close closes a subscription session created internally."""
        from aiokwikset import API

        api = API()
        api.id_token = "tok_id"
        api.access_token = "tok_access"
        api.refresh_token = "tok_refresh"

        # Access subscriptions — this creates an internal session since no websession
        with patch("aiohttp.ClientSession") as mock_cs_cls:
            mock_session = MagicMock()
            mock_session.close = AsyncMock()
            mock_cs_cls.return_value = mock_session
            _ = api.subscriptions

        assert api._subscription_session is mock_session

        with patch.object(SubscriptionManager, "async_close", new_callable=AsyncMock):
            await api.async_close()

        mock_session.close.assert_called_once()
        assert api._subscription_session is None

    def test_subscriptions_no_websession_tracks_session(self) -> None:
        """Test that accessing subscriptions without websession tracks the created session."""
        from aiokwikset import API

        api = API()
        api.id_token = "tok_id"
        api.access_token = "tok_access"
        api.refresh_token = "tok_refresh"

        with patch("aiohttp.ClientSession") as mock_cs_cls:
            mock_session = MagicMock()
            mock_cs_cls.return_value = mock_session
            _ = api.subscriptions

        assert api._subscription_session is mock_session

    def test_subscriptions_with_websession_no_tracked_session(self) -> None:
        """Test that accessing subscriptions with websession does not create tracked session."""
        from aiokwikset import API

        session = MagicMock(spec=aiohttp.ClientSession)
        api = API(websession=session)
        api.id_token = "tok_id"
        api.access_token = "tok_access"
        api.refresh_token = "tok_refresh"

        _ = api.subscriptions

        assert api._subscription_session is None


# ============================================================================
# Background Task Tests (R2)
# ============================================================================


class TestReceiveLoop:
    """Tests for the _receive_loop background task."""

    async def test_receive_loop_dispatches_data(self) -> None:
        """Test that _receive_loop dispatches data messages to the callback."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            callback=callback,
        )

        conn._subscriptions["sub-1"] = {
            "name": "onManageDevice",
            "query": "subscription { ... }",
            "variables": {"email": "test@example.com"},
        }

        # Build mock WS message sequence
        msg1 = MagicMock()
        msg1.type = aiohttp.WSMsgType.TEXT
        msg1.data = json.dumps(
            {
                "type": "data",
                "id": "sub-1",
                "payload": {"data": {"deviceid": "dev-001"}},
            }
        )

        msg2 = MagicMock()
        msg2.type = aiohttp.WSMsgType.CLOSED

        mock_ws = _AsyncWSMessages([msg1, msg2])
        conn._ws = mock_ws  # type: ignore[assignment]
        conn._state = "connected"

        await conn._receive_loop()

        callback.assert_called_once_with("onManageDevice", {"deviceid": "dev-001"})

    async def test_receive_loop_handles_start_ack(self) -> None:
        """Test that _receive_loop resolves start_ack events."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        event = asyncio.Event()
        conn._pending_acks["sub-42"] = event

        msg1 = MagicMock()
        msg1.type = aiohttp.WSMsgType.TEXT
        msg1.data = json.dumps({"type": "start_ack", "id": "sub-42"})

        msg2 = MagicMock()
        msg2.type = aiohttp.WSMsgType.CLOSED

        mock_ws = _AsyncWSMessages([msg1, msg2])
        conn._ws = mock_ws  # type: ignore[assignment]
        conn._state = "connected"

        await conn._receive_loop()

        assert event.is_set()

    async def test_receive_loop_updates_ka_time(self) -> None:
        """Test that _receive_loop updates _last_ka_time on ka messages."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        conn._last_ka_time = 0.0

        msg1 = MagicMock()
        msg1.type = aiohttp.WSMsgType.TEXT
        msg1.data = json.dumps({"type": "ka"})

        msg2 = MagicMock()
        msg2.type = aiohttp.WSMsgType.CLOSED

        mock_ws = _AsyncWSMessages([msg1, msg2])
        conn._ws = mock_ws  # type: ignore[assignment]
        conn._state = "connected"

        await conn._receive_loop()

        assert conn._last_ka_time > 0.0

    async def test_receive_loop_handles_error_message(self) -> None:
        """Test that _receive_loop logs error messages without crashing."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        msg1 = MagicMock()
        msg1.type = aiohttp.WSMsgType.TEXT
        msg1.data = json.dumps({"type": "error", "payload": {"message": "bad"}})

        msg2 = MagicMock()
        msg2.type = aiohttp.WSMsgType.CLOSED

        mock_ws = _AsyncWSMessages([msg1, msg2])
        conn._ws = mock_ws  # type: ignore[assignment]
        conn._state = "connected"

        # Should not raise
        await conn._receive_loop()

    async def test_receive_loop_removes_completed_subscription(self) -> None:
        """Test that _receive_loop removes subs on complete messages."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        conn._subscriptions["sub-77"] = {
            "name": "onManageUser",
            "query": "subscription { ... }",
            "variables": {},
        }

        msg1 = MagicMock()
        msg1.type = aiohttp.WSMsgType.TEXT
        msg1.data = json.dumps({"type": "complete", "id": "sub-77"})

        msg2 = MagicMock()
        msg2.type = aiohttp.WSMsgType.CLOSED

        mock_ws = _AsyncWSMessages([msg1, msg2])
        conn._ws = mock_ws  # type: ignore[assignment]
        conn._state = "connected"

        await conn._receive_loop()

        assert "sub-77" not in conn._subscriptions

    async def test_receive_loop_handles_non_json(self) -> None:
        """Test that _receive_loop handles non-JSON text messages gracefully."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        msg1 = MagicMock()
        msg1.type = aiohttp.WSMsgType.TEXT
        msg1.data = "not valid json"

        msg2 = MagicMock()
        msg2.type = aiohttp.WSMsgType.CLOSED

        mock_ws = _AsyncWSMessages([msg1, msg2])
        conn._ws = mock_ws  # type: ignore[assignment]
        conn._state = "connected"

        # Should not raise
        await conn._receive_loop()

    async def test_receive_loop_triggers_reconnect_on_unexpected_exit(self) -> None:
        """Test that _receive_loop triggers reconnect when exiting while connected."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        # Empty message sequence — loop exits immediately
        mock_ws = _AsyncWSMessages([])
        conn._ws = mock_ws  # type: ignore[assignment]
        conn._state = "connected"

        with patch.object(AppSyncConnection, "_reconnect", new_callable=AsyncMock) as mock_recon:
            await conn._receive_loop()
            # Give the created task a chance to run
            await asyncio.sleep(0.01)

        # _reconnect should have been scheduled
        assert mock_recon.called or conn._state == "connected"


# ============================================================================
# Keepalive Monitor Tests (R3)
# ============================================================================


class TestKaMonitor:
    """Tests for the _ka_monitor background task."""

    async def test_ka_monitor_detects_timeout(self) -> None:
        """Test that _ka_monitor detects when no ka is received within timeout."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        # Set up connected state with old ka time
        conn._state = "connected"
        conn._connection_timeout_ms = 100  # 100ms timeout for fast test
        conn._last_ka_time = asyncio.get_event_loop().time() - 10  # 10s ago
        mock_ws = MagicMock()
        mock_ws.closed = False
        conn._ws = mock_ws

        with patch.object(AppSyncConnection, "_reconnect", new_callable=AsyncMock):
            # Run ka_monitor — it should detect timeout quickly
            task = asyncio.create_task(conn._ka_monitor())
            await asyncio.sleep(0.2)
            # After detecting timeout, state should trigger reconnect
            if not task.done():
                task.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await task

    async def test_ka_monitor_cancelled(self) -> None:
        """Test that _ka_monitor handles cancellation gracefully."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        conn._state = "connected"
        conn._connection_timeout_ms = 300_000
        conn._last_ka_time = asyncio.get_event_loop().time()
        mock_ws = MagicMock()
        mock_ws.closed = False
        conn._ws = mock_ws

        task = asyncio.create_task(conn._ka_monitor())
        await asyncio.sleep(0.05)
        task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await task

    async def test_ka_monitor_detects_closed_ws(self) -> None:
        """Test that _ka_monitor detects a closed WebSocket."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        conn._state = "connected"
        conn._connection_timeout_ms = 10_000  # long timeout
        conn._last_ka_time = asyncio.get_event_loop().time()
        mock_ws = MagicMock()
        mock_ws.closed = True  # Already closed
        conn._ws = mock_ws

        with patch.object(AppSyncConnection, "_reconnect", new_callable=AsyncMock):
            task = asyncio.create_task(conn._ka_monitor())
            await asyncio.sleep(0.2)
            if not task.done():
                task.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await task


# ============================================================================
# Reconnect Tests (R4)
# ============================================================================


class TestReconnect:
    """Tests for the _reconnect method."""

    async def test_reconnect_success(self) -> None:
        """Test successful reconnection replays subscriptions."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        # Set up subscriptions
        conn._subscriptions["sub-1"] = {
            "name": "onManageDevice",
            "query": "subscription { onManageDevice }",
            "variables": {"email": "test@example.com"},
        }

        with (
            patch.object(
                AppSyncConnection, "async_disconnect", new_callable=AsyncMock
            ) as mock_disc,
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value="new-sub-1",
            ) as mock_sub,
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            await conn._reconnect()

        mock_disc.assert_called_once()
        # Verify re-subscription was attempted with the saved query/variables
        mock_sub.assert_called_once_with(
            "onManageDevice",
            "subscription { onManageDevice }",
            {"email": "test@example.com"},
        )

    async def test_reconnect_retries_on_failure(self) -> None:
        """Test that reconnect retries with backoff on failure."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        attempt_count = 0

        async def connect_with_failures() -> None:
            nonlocal attempt_count
            attempt_count += 1
            if attempt_count < 3:
                raise OSError("Connection failed")

        with (
            patch.object(AppSyncConnection, "async_disconnect", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "async_connect", side_effect=connect_with_failures),
            patch.object(AppSyncConnection, "_replay_subscriptions", new_callable=AsyncMock),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            await conn._reconnect()

        assert attempt_count == 3  # Failed twice, succeeded on third

    async def test_reconnect_preserves_subscription_details(self) -> None:
        """Test that reconnect saves all subscription details before disconnecting."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        conn._subscriptions["sub-a"] = {
            "name": "onManageDevice",
            "query": "subscription A { ... }",
            "variables": {"email": "a@test.com"},
        }
        conn._subscriptions["sub-b"] = {
            "name": "onManageUser",
            "query": "subscription B { ... }",
            "variables": {"email": "b@test.com"},
        }

        replayed_subs: dict[str, Any] = {}

        async def capture_replay(saved: dict[str, Any]) -> None:
            replayed_subs.update(saved)

        with (
            patch.object(AppSyncConnection, "async_disconnect", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "_replay_subscriptions", side_effect=capture_replay),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            await conn._reconnect()

        # Both subscriptions should have been passed to replay
        assert "sub-a" in replayed_subs
        assert "sub-b" in replayed_subs
        assert replayed_subs["sub-a"]["query"] == "subscription A { ... }"
        assert replayed_subs["sub-b"]["variables"] == {"email": "b@test.com"}


# ============================================================================
# Replay Subscriptions Tests
# ============================================================================


class TestReplaySubscriptions:
    """Tests for _replay_subscriptions."""

    async def test_replay_subscriptions_success(self) -> None:
        """Test that _replay_subscriptions re-subscribes all saved entries."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        saved = {
            "old-1": {
                "name": "onManageDevice",
                "query": "subscription { onManageDevice }",
                "variables": {"email": "test@example.com"},
            },
        }

        with patch.object(
            AppSyncConnection,
            "async_subscribe",
            new_callable=AsyncMock,
            return_value="new-1",
        ) as mock_sub:
            await conn._replay_subscriptions(saved)

        mock_sub.assert_called_once_with(
            "onManageDevice",
            "subscription { onManageDevice }",
            {"email": "test@example.com"},
        )

    async def test_replay_subscriptions_handles_failure(self) -> None:
        """Test that _replay_subscriptions continues even if one fails."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        saved = {
            "old-1": {
                "name": "onManageDevice",
                "query": "q1",
                "variables": {"email": "t@e.com"},
            },
            "old-2": {
                "name": "onManageUser",
                "query": "q2",
                "variables": {"email": "t@e.com"},
            },
        }

        call_count = 0

        async def sub_with_failure(
            name: str,  # noqa: ARG001
            query: str,  # noqa: ARG001
            variables: dict[str, Any],  # noqa: ARG001
        ) -> str:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise SubscriptionError("Failed")
            return "new-2"

        with patch.object(AppSyncConnection, "async_subscribe", side_effect=sub_with_failure):
            await conn._replay_subscriptions(saved)

        assert call_count == 2  # Both were attempted


# ============================================================================
# Halo V Subscription Methods — Parametrized (R5)
# ============================================================================


class TestHaloVSubscriptionMethods:
    """Parametrized tests for all 13 Halo V subscription methods."""

    @pytest.mark.parametrize(
        ("method_name", "expected_sub_name"),
        [
            ("async_subscribe_hav_device_status", "onUpdateHavDeviceStatus"),
            ("async_subscribe_hav_device", "onUpdateHavDevice"),
            ("async_subscribe_hav_led_status", "onUpdateHavLedStatus"),
            ("async_subscribe_hav_percentage", "onUpdateHavPercentage"),
            ("async_subscribe_hav_vacation_mode", "onUpdateHavVacationMode"),
            ("async_subscribe_hav_auto_close_schedule", "onUpdateHavAutoCloseSchedule"),
            ("async_subscribe_hav_event", "onUpdateHavEvent"),
            ("async_subscribe_hav_wifi_signal", "onUpdateHavWifiSignal"),
            ("async_subscribe_hav_battery_voltage", "onUpdateHavBatteryVoltage"),
            ("async_subscribe_hav_cycles", "onUpdateHavCycles"),
            ("async_subscribe_hav_temporary_key", "onUpdateHavTemporaryKey"),
            ("async_subscribe_hav_sensor", "onUpdateHavSensor"),
            ("async_subscribe_hav_brightness_level", "onUpdateHavBrightnessLevel"),
        ],
    )
    async def test_hav_subscription_method(
        self,
        method_name: str,
        expected_sub_name: str,
    ) -> None:
        """Test that each Halo V subscription method uses the Halo V connection."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value=f"sub-{expected_sub_name}",
            ) as mock_sub,
        ):
            method = getattr(manager, method_name)
            sub_id = await method("test@example.com")

        assert sub_id == f"sub-{expected_sub_name}"
        assert manager.halov_connection is not None
        mock_sub.assert_called_once()
        # Verify the subscription name passed to async_subscribe
        call_args = mock_sub.call_args
        assert call_args[0][0] == expected_sub_name


# ============================================================================
# Core Subscription Methods — Parametrized
# ============================================================================


class TestCoreSubscriptionMethods:
    """Parametrized tests for all Core API subscription methods."""

    @pytest.mark.parametrize(
        ("method_name", "expected_sub_name"),
        [
            ("async_subscribe_device", "onManageDevice"),
            ("async_subscribe_user", "onManageUser"),
            ("async_subscribe_home", "onManageHome"),
        ],
    )
    async def test_core_subscription_method(
        self,
        method_name: str,
        expected_sub_name: str,
    ) -> None:
        """Test that each Core subscription method uses the Core connection."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        with (
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(
                AppSyncConnection,
                "async_subscribe",
                new_callable=AsyncMock,
                return_value=f"sub-{expected_sub_name}",
            ) as mock_sub,
        ):
            method = getattr(manager, method_name)
            sub_id = await method("test@example.com")

        assert sub_id == f"sub-{expected_sub_name}"
        assert manager.core_connection is not None
        mock_sub.assert_called_once()
        call_args = mock_sub.call_args
        assert call_args[0][0] == expected_sub_name


# ============================================================================
# Subscription Names Property Tests
# ============================================================================


class TestSubscriptionNames:
    """Tests for the subscription_names property."""

    def test_empty_subscriptions(self) -> None:
        """Test subscription_names returns empty list when no subs."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        assert conn.subscription_names == []

    def test_with_subscriptions(self) -> None:
        """Test subscription_names returns names of active subs."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        conn._subscriptions["sub-1"] = {
            "name": "onManageDevice",
            "query": "q",
            "variables": {},
        }
        conn._subscriptions["sub-2"] = {
            "name": "onManageUser",
            "query": "q",
            "variables": {},
        }

        names = conn.subscription_names
        assert "onManageDevice" in names
        assert "onManageUser" in names
        assert len(names) == 2


# ============================================================================
# Disconnect / Reconnect Callback Tests — AppSyncConnection
# ============================================================================


class TestAppSyncConnectionNotifyCallbacks:
    """Tests for disconnect/reconnect notify callbacks on AppSyncConnection."""

    def test_init_with_notify_callbacks(self) -> None:
        """Test that AppSyncConnection stores notify callbacks from __init__."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_disconnect = MagicMock()
        on_reconnect = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            on_disconnect_notify=on_disconnect,
            on_reconnect_notify=on_reconnect,
        )

        assert conn._on_disconnect_notify is on_disconnect
        assert conn._on_reconnect_notify is on_reconnect

    def test_init_defaults_notify_to_none(self) -> None:
        """Test that notify callbacks default to None for backward compat."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        assert conn._on_disconnect_notify is None
        assert conn._on_reconnect_notify is None

    def test_fire_disconnect_notify(self) -> None:
        """Test that _fire_disconnect_notify calls the callback."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_disconnect = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            on_disconnect_notify=on_disconnect,
        )

        conn._fire_disconnect_notify()

        on_disconnect.assert_called_once()

    def test_fire_disconnect_notify_none(self) -> None:
        """Test that _fire_disconnect_notify is safe when callback is None."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        # Should not raise
        conn._fire_disconnect_notify()

    def test_fire_disconnect_notify_exception(self) -> None:
        """Test that _fire_disconnect_notify catches callback exceptions."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_disconnect = MagicMock(side_effect=RuntimeError("callback error"))

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            on_disconnect_notify=on_disconnect,
        )

        # Should not raise even when callback raises
        conn._fire_disconnect_notify()
        on_disconnect.assert_called_once()

    def test_fire_reconnect_notify(self) -> None:
        """Test that _fire_reconnect_notify calls the callback."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_reconnect = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            on_reconnect_notify=on_reconnect,
        )

        conn._fire_reconnect_notify()

        on_reconnect.assert_called_once()

    def test_fire_reconnect_notify_none(self) -> None:
        """Test that _fire_reconnect_notify is safe when callback is None."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )

        # Should not raise
        conn._fire_reconnect_notify()

    def test_fire_reconnect_notify_exception(self) -> None:
        """Test that _fire_reconnect_notify catches callback exceptions."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_reconnect = MagicMock(side_effect=RuntimeError("callback error"))

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            on_reconnect_notify=on_reconnect,
        )

        # Should not raise even when callback raises
        conn._fire_reconnect_notify()
        on_reconnect.assert_called_once()

    async def test_receive_loop_fires_disconnect_on_unexpected_exit(self) -> None:
        """Test that _receive_loop calls disconnect notify before reconnect."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_disconnect = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            on_disconnect_notify=on_disconnect,
        )

        # Empty message sequence — loop exits immediately
        mock_ws = _AsyncWSMessages([])
        conn._ws = mock_ws  # type: ignore[assignment]
        conn._state = "connected"

        with patch.object(AppSyncConnection, "_reconnect", new_callable=AsyncMock):
            await conn._receive_loop()
            await asyncio.sleep(0.01)

        on_disconnect.assert_called_once()

    async def test_ka_monitor_fires_disconnect_on_timeout(self) -> None:
        """Test that _ka_monitor calls disconnect notify on keep-alive timeout."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_disconnect = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            on_disconnect_notify=on_disconnect,
        )

        conn._state = "connected"
        conn._connection_timeout_ms = 100  # 100ms for fast test
        conn._last_ka_time = asyncio.get_event_loop().time() - 10  # 10s ago
        mock_ws = MagicMock()
        mock_ws.closed = False
        conn._ws = mock_ws

        with patch.object(AppSyncConnection, "_reconnect", new_callable=AsyncMock):
            task = asyncio.create_task(conn._ka_monitor())
            await asyncio.sleep(0.2)
            if not task.done():
                task.cancel()
                with contextlib.suppress(asyncio.CancelledError):
                    await task

        on_disconnect.assert_called_once()

    async def test_reconnect_fires_reconnect_notify_on_success(self) -> None:
        """Test that _reconnect calls reconnect notify after successful reconnection."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_reconnect = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            on_reconnect_notify=on_reconnect,
        )

        conn._subscriptions["sub-1"] = {
            "name": "onManageDevice",
            "query": "subscription { onManageDevice }",
            "variables": {"email": "test@example.com"},
        }

        with (
            patch.object(AppSyncConnection, "async_disconnect", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "_replay_subscriptions", new_callable=AsyncMock),
            patch("asyncio.sleep", new_callable=AsyncMock),
        ):
            await conn._reconnect()

        on_reconnect.assert_called_once()

    async def test_reconnect_does_not_fire_reconnect_on_failure(self) -> None:
        """Test that reconnect notify is NOT called if reconnection never succeeds."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_reconnect = MagicMock()

        conn = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
            on_reconnect_notify=on_reconnect,
        )

        max_attempts = 3
        attempt_count = 0

        async def connect_always_fails() -> None:
            nonlocal attempt_count
            attempt_count += 1
            if attempt_count >= max_attempts:
                raise asyncio.CancelledError
            raise OSError("Connection failed")

        with (
            patch.object(AppSyncConnection, "async_disconnect", new_callable=AsyncMock),
            patch.object(AppSyncConnection, "async_connect", side_effect=connect_always_fails),
            patch("asyncio.sleep", new_callable=AsyncMock),
            contextlib.suppress(asyncio.CancelledError),
        ):
            await conn._reconnect()

        assert attempt_count == max_attempts
        on_reconnect.assert_not_called()


# ============================================================================
# Disconnect / Reconnect Callback Tests — SubscriptionManager
# ============================================================================


class TestSubscriptionManagerCallbacks:
    """Tests for disconnect/reconnect callbacks on SubscriptionManager."""

    def test_init_with_callbacks(self) -> None:
        """Test that SubscriptionManager stores callbacks from __init__."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_disconnect = MagicMock()
        on_reconnect = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
            on_disconnect=on_disconnect,
            on_reconnect=on_reconnect,
        )

        assert manager._on_disconnect is on_disconnect
        assert manager._on_reconnect is on_reconnect

    def test_init_callbacks_default_to_none(self) -> None:
        """Test that callbacks default to None for backward compatibility."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        assert manager._on_disconnect is None
        assert manager._on_reconnect is None

    def test_set_on_disconnect(self) -> None:
        """Test setting on_disconnect callback."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        manager.set_on_disconnect(callback)
        assert manager._on_disconnect is callback

    def test_set_on_disconnect_propagates_to_existing_connections(self) -> None:
        """Test that set_on_disconnect updates existing connections."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        core = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )
        halov = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )
        manager._core_conn = core
        manager._halov_conn = halov

        manager.set_on_disconnect(callback)

        # Verify propagation by invoking the notify and checking user callback fires
        assert core._on_disconnect_notify is not None
        assert halov._on_disconnect_notify is not None
        core._on_disconnect_notify()
        assert callback.call_count == 1
        halov._on_disconnect_notify()
        assert callback.call_count == 2

    def test_set_on_reconnect(self) -> None:
        """Test setting on_reconnect callback."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        manager.set_on_reconnect(callback)
        assert manager._on_reconnect is callback

    def test_set_on_reconnect_propagates_to_existing_connections(self) -> None:
        """Test that set_on_reconnect updates existing connections."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        core = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )
        halov = AppSyncConnection(
            api_host=MOCK_API_HOST,
            realtime_host=MOCK_REALTIME_HOST,
            credentials_provider=creds,
            session=session,
        )
        manager._core_conn = core
        manager._halov_conn = halov

        manager.set_on_reconnect(callback)

        # Verify propagation by invoking the notify and checking user callback fires
        assert core._on_reconnect_notify is not None
        assert halov._on_reconnect_notify is not None
        core._on_reconnect_notify()
        assert callback.call_count == 1
        halov._on_reconnect_notify()
        assert callback.call_count == 2

    def test_fire_on_disconnect(self) -> None:
        """Test that _fire_on_disconnect calls the user callback."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
            on_disconnect=callback,
        )

        manager._fire_on_disconnect()

        callback.assert_called_once()

    def test_fire_on_disconnect_none(self) -> None:
        """Test that _fire_on_disconnect is safe when no callback set."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        # Should not raise
        manager._fire_on_disconnect()

    def test_fire_on_disconnect_exception(self) -> None:
        """Test that _fire_on_disconnect catches callback exceptions."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock(side_effect=RuntimeError("callback error"))

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
            on_disconnect=callback,
        )

        # Should not raise
        manager._fire_on_disconnect()
        callback.assert_called_once()

    def test_fire_on_reconnect(self) -> None:
        """Test that _fire_on_reconnect calls the user callback."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
            on_reconnect=callback,
        )

        manager._fire_on_reconnect()

        callback.assert_called_once()

    def test_fire_on_reconnect_none(self) -> None:
        """Test that _fire_on_reconnect is safe when no callback set."""
        creds = _make_credentials_provider()
        session = _make_mock_session()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
        )

        # Should not raise
        manager._fire_on_reconnect()

    def test_fire_on_reconnect_exception(self) -> None:
        """Test that _fire_on_reconnect catches callback exceptions."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback = MagicMock(side_effect=RuntimeError("callback error"))

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
            on_reconnect=callback,
        )

        # Should not raise
        manager._fire_on_reconnect()
        callback.assert_called_once()

    async def test_ensure_core_passes_notify_callbacks(self) -> None:
        """Test that _ensure_core passes notify callables to AppSyncConnection."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_disconnect = MagicMock()
        on_reconnect = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
            on_disconnect=on_disconnect,
            on_reconnect=on_reconnect,
        )

        with patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock):
            conn = await manager._ensure_core()

        # Verify by invoking the notify and checking user callbacks fire
        assert conn._on_disconnect_notify is not None
        assert conn._on_reconnect_notify is not None
        conn._on_disconnect_notify()
        on_disconnect.assert_called_once()
        conn._on_reconnect_notify()
        on_reconnect.assert_called_once()

    async def test_ensure_halov_passes_notify_callbacks(self) -> None:
        """Test that _ensure_halov passes notify callables to AppSyncConnection."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        on_disconnect = MagicMock()
        on_reconnect = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
            on_disconnect=on_disconnect,
            on_reconnect=on_reconnect,
        )

        with patch.object(AppSyncConnection, "async_connect", new_callable=AsyncMock):
            conn = await manager._ensure_halov()

        # Verify by invoking the notify and checking user callbacks fire
        assert conn._on_disconnect_notify is not None
        assert conn._on_reconnect_notify is not None
        conn._on_disconnect_notify()
        on_disconnect.assert_called_once()
        conn._on_reconnect_notify()
        on_reconnect.assert_called_once()

    def test_replace_callbacks(self) -> None:
        """Test that callbacks can be replaced after initial setting."""
        creds = _make_credentials_provider()
        session = _make_mock_session()
        callback1 = MagicMock()
        callback2 = MagicMock()

        manager = SubscriptionManager(
            credentials_provider=creds,
            session=session,
            on_disconnect=callback1,
        )

        manager.set_on_disconnect(callback2)
        manager._fire_on_disconnect()

        callback1.assert_not_called()
        callback2.assert_called_once()
