"""AWS AppSync GraphQL WebSocket subscription support.

This module provides real-time event subscriptions for Kwikset smart lock
devices via AWS AppSync WebSocket connections. Two AppSync endpoints are
supported: the Core API (device/user/home events) and the Halo V API
(detailed device status updates).

The implementation follows the AppSync real-time WebSocket protocol:
``graphql-ws`` subprotocol with ``connection_init`` / ``start`` / ``ka``
message flow.
"""

from __future__ import annotations

import asyncio
import base64
import contextlib
import json
import uuid
from typing import TYPE_CHECKING, Any, Final
from urllib.parse import quote

import aiohttp

from .const import (
    CORE_APPSYNC_API_HOST,
    CORE_APPSYNC_REALTIME_HOST,
    GQL_ON_MANAGE_DEVICE,
    GQL_ON_MANAGE_HOME,
    GQL_ON_MANAGE_USER,
    GQL_ON_UPDATE_HAV_AUTO_CLOSE_SCHEDULE,
    GQL_ON_UPDATE_HAV_BATTERY_VOLTAGE,
    GQL_ON_UPDATE_HAV_BRIGHTNESS_LEVEL,
    GQL_ON_UPDATE_HAV_CYCLES,
    GQL_ON_UPDATE_HAV_DEVICE,
    GQL_ON_UPDATE_HAV_DEVICE_STATUS,
    GQL_ON_UPDATE_HAV_EVENT,
    GQL_ON_UPDATE_HAV_LED_STATUS,
    GQL_ON_UPDATE_HAV_PERCENTAGE,
    GQL_ON_UPDATE_HAV_SENSOR,
    GQL_ON_UPDATE_HAV_TEMPORARY_KEY,
    GQL_ON_UPDATE_HAV_VACATION_MODE,
    GQL_ON_UPDATE_HAV_WIFI_SIGNAL,
    HALOV_APPSYNC_API_HOST,
    HALOV_APPSYNC_REALTIME_HOST,
    LOGGER,
    WS_CONNECTION_TIMEOUT,
    WS_KA_TIMEOUT_MULTIPLIER,
    WS_MAX_RECONNECT_DELAY,
    WS_MIN_RECONNECT_DELAY,
    WS_START_ACK_TIMEOUT,
)
from .errors import (
    ConnectionError as KwiksetConnectionError,
)
from .errors import (
    SubscriptionError,
    SubscriptionTimeout,
)

if TYPE_CHECKING:
    from collections.abc import Callable

    from .types import (
        CredentialsProvider,
        DisconnectCallback,
        ReconnectCallback,
        SubscriptionCallback,
    )

# Connection states
_STATE_DISCONNECTED: Final[str] = "disconnected"
_STATE_CONNECTING: Final[str] = "connecting"
_STATE_CONNECTED: Final[str] = "connected"


def _build_ws_url(realtime_host: str, api_host: str, id_token: str) -> str:
    """Build the AppSync real-time WebSocket URL.

    The URL encodes the authorization header and an empty payload
    as base64url query parameters per the AppSync WebSocket protocol.

    :param realtime_host: The AppSync realtime API hostname
    :type realtime_host: ``str``
    :param api_host: The AppSync API hostname
    :type api_host: ``str``
    :param id_token: The Cognito ID token for authorization
    :type id_token: ``str``
    :rtype: ``str``
    """
    header_obj = {"host": api_host, "Authorization": id_token}
    header_b64 = base64.b64encode(json.dumps(header_obj).encode()).decode()
    payload_b64 = "e30="  # base64('{}')
    return (
        f"wss://{realtime_host}/%2Fgraphql"
        f"?header={quote(header_b64, safe='')}"
        f"&payload={quote(payload_b64, safe='')}"
    )


class AppSyncConnection:
    """Manage a single WebSocket connection to an AppSync endpoint.

    Handles the full connection lifecycle: connect, subscribe,
    receive messages, keep-alive tracking, and reconnection with
    exponential backoff.

    :param api_host: The AppSync API hostname
    :type api_host: ``str``
    :param realtime_host: The AppSync realtime API hostname
    :type realtime_host: ``str``
    :param credentials_provider: Async callable returning a valid id_token
    :type credentials_provider: ``CredentialsProvider``
    :param session: aiohttp ClientSession for WebSocket connections
    :type session: ``aiohttp.ClientSession``
    :param callback: Callable invoked with (subscription_name, data) on events
    :type callback: ``Optional[SubscriptionCallback]``
    """

    __slots__ = (
        "_api_host",
        "_callback",
        "_connection_timeout_ms",
        "_credentials_provider",
        "_ka_task",
        "_last_ka_time",
        "_lock",
        "_on_disconnect_notify",
        "_on_reconnect_notify",
        "_pending_acks",
        "_receive_task",
        "_realtime_host",
        "_reconnect_delay",
        "_session",
        "_state",
        "_subscriptions",
        "_ws",
    )

    def __init__(
        self,
        api_host: str,
        realtime_host: str,
        credentials_provider: CredentialsProvider,
        session: aiohttp.ClientSession,
        callback: SubscriptionCallback | None = None,
        on_disconnect_notify: Callable[[], None] | None = None,
        on_reconnect_notify: Callable[[], None] | None = None,
    ) -> None:
        """Initialize the AppSync connection."""
        self._api_host: str = api_host
        self._realtime_host: str = realtime_host
        self._credentials_provider: CredentialsProvider = credentials_provider
        self._session: aiohttp.ClientSession = session
        self._callback: SubscriptionCallback | None = callback
        self._on_disconnect_notify: Callable[[], None] | None = on_disconnect_notify
        self._on_reconnect_notify: Callable[[], None] | None = on_reconnect_notify

        self._ws: aiohttp.ClientWebSocketResponse | None = None
        self._state: str = _STATE_DISCONNECTED
        self._receive_task: asyncio.Task[None] | None = None
        self._ka_task: asyncio.Task[None] | None = None
        self._connection_timeout_ms: int = 300_000  # default, updated from ack
        self._reconnect_delay: float = WS_MIN_RECONNECT_DELAY
        self._last_ka_time: float = 0.0

        # subscription_id -> {name, query, variables} for reconnection replay
        self._subscriptions: dict[str, dict[str, Any]] = {}
        # subscription_id -> asyncio.Event for start_ack
        self._pending_acks: dict[str, asyncio.Event] = {}
        self._lock: asyncio.Lock = asyncio.Lock()

    @property
    def is_connected(self) -> bool:
        """Return whether the WebSocket is connected.

        :rtype: ``bool``
        """
        return self._state == _STATE_CONNECTED and self._ws is not None and not self._ws.closed

    @property
    def subscription_count(self) -> int:
        """Return the number of active subscriptions.

        :rtype: ``int``
        """
        return len(self._subscriptions)

    @property
    def subscription_names(self) -> list[str]:
        """Return the names of active subscriptions.

        :rtype: ``list[str]``
        """
        return [info["name"] for info in self._subscriptions.values()]

    async def async_connect(self) -> None:
        """Establish the WebSocket connection and perform the handshake.

        :raises SubscriptionTimeout: If the connection or ack times out
        :raises KwiksetConnectionError: If the WebSocket connection fails
        """
        if self.is_connected:
            return

        async with self._lock:
            if self.is_connected:
                return
            self._state = _STATE_CONNECTING

            try:
                id_token = await self._credentials_provider()
                url = _build_ws_url(self._realtime_host, self._api_host, id_token)

                self._ws = await self._session.ws_connect(
                    url,
                    protocols=["graphql-ws"],
                    heartbeat=None,
                )

                # Send connection_init
                await self._ws.send_json({"type": "connection_init"})

                # Wait for connection_ack
                ack = await asyncio.wait_for(
                    self._ws.receive_json(),
                    timeout=WS_CONNECTION_TIMEOUT,
                )
                if ack.get("type") != "connection_ack":
                    raise SubscriptionError(f"Expected connection_ack, got {ack.get('type')}")

                # Extract connectionTimeoutMs from the ack payload
                payload = ack.get("payload", {})
                self._connection_timeout_ms = payload.get("connectionTimeoutMs", 300_000)

                self._state = _STATE_CONNECTED
                self._reconnect_delay = WS_MIN_RECONNECT_DELAY
                self._last_ka_time = asyncio.get_event_loop().time()

                # Start receiver and keep-alive monitor tasks
                self._receive_task = asyncio.create_task(self._receive_loop())
                self._ka_task = asyncio.create_task(self._ka_monitor())

                LOGGER.debug(
                    "AppSync WS connected to %s (timeout=%dms)",
                    self._realtime_host,
                    self._connection_timeout_ms,
                )

            except asyncio.TimeoutError as err:
                self._state = _STATE_DISCONNECTED
                raise SubscriptionTimeout(f"Timed out connecting to {self._realtime_host}") from err
            except SubscriptionError:
                self._state = _STATE_DISCONNECTED
                raise
            except Exception as err:
                self._state = _STATE_DISCONNECTED
                raise KwiksetConnectionError(
                    f"Failed to connect to {self._realtime_host}: {err}"
                ) from err

    async def async_subscribe(
        self,
        name: str,
        query: str,
        variables: dict[str, Any],
    ) -> str:
        """Register a GraphQL subscription on the connection.

        :param name: Human-readable subscription name (e.g. ``"onManageDevice"``)
        :type name: ``str``
        :param query: The GraphQL subscription query string
        :type query: ``str``
        :param variables: Query variables (e.g. ``{"email": "user@example.com"}``)
        :type variables: ``Dict[str, Any]``
        :rtype: ``str``
        :returns: The subscription ID
        :raises SubscriptionTimeout: If start_ack is not received in time
        :raises KwiksetConnectionError: If not connected
        """
        if not self.is_connected or self._ws is None:
            raise KwiksetConnectionError("WebSocket is not connected")

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

        id_token = await self._credentials_provider()

        payload: dict[str, Any] = {
            "data": json.dumps({"query": query, "variables": variables}),
            "extensions": {
                "authorization": {
                    "host": self._api_host,
                    "Authorization": id_token,
                }
            },
        }

        msg: dict[str, Any] = {
            "id": sub_id,
            "type": "start",
            "payload": payload,
        }

        await self._ws.send_json(msg)

        try:
            await asyncio.wait_for(ack_event.wait(), timeout=WS_START_ACK_TIMEOUT)
        except asyncio.TimeoutError as err:
            self._subscriptions.pop(sub_id, None)
            self._pending_acks.pop(sub_id, None)
            raise SubscriptionTimeout(f"Timed out waiting for start_ack for {name}") from err
        finally:
            self._pending_acks.pop(sub_id, None)

        LOGGER.debug("Subscribed to %s (id=%s)", name, sub_id)
        return sub_id

    async def _replay_subscriptions(
        self,
        saved_subs: dict[str, dict[str, Any]],
    ) -> None:
        """Re-register saved subscriptions after reconnection.

        :param saved_subs: Mapping of sub_id -> {name, query, variables}
        :type saved_subs: ``dict[str, dict[str, Any]]``
        """
        for _old_id, info in saved_subs.items():
            try:
                new_id = await self.async_subscribe(
                    info["name"],
                    info["query"],
                    info["variables"],
                )
                LOGGER.debug(
                    "Re-subscribed %s as %s (was %s)",
                    info["name"],
                    new_id,
                    _old_id,
                )
            except Exception:  # noqa: BLE001
                LOGGER.error("Failed to re-subscribe %s after reconnect", info["name"])

    async def async_unsubscribe(self, sub_id: str) -> None:
        """Unsubscribe from a specific subscription.

        :param sub_id: The subscription ID returned by ``async_subscribe``
        :type sub_id: ``str``
        """
        self._subscriptions.pop(sub_id, None)
        self._pending_acks.pop(sub_id, None)

        if self.is_connected and self._ws is not None:
            try:
                await self._ws.send_json({"id": sub_id, "type": "stop"})
            except Exception:  # noqa: BLE001
                LOGGER.debug("Failed to send stop for %s", sub_id)

    async def async_disconnect(self) -> None:
        """Close the WebSocket connection and cancel background tasks."""
        self._state = _STATE_DISCONNECTED

        if self._ka_task is not None and not self._ka_task.done():
            self._ka_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._ka_task
            self._ka_task = None

        if self._receive_task is not None and not self._receive_task.done():
            self._receive_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._receive_task
            self._receive_task = None

        if self._ws is not None and not self._ws.closed:
            await self._ws.close()
        self._ws = None

        self._subscriptions.clear()
        self._pending_acks.clear()

        LOGGER.debug("AppSync WS disconnected from %s", self._realtime_host)

    async def _receive_loop(self) -> None:
        """Background task: read messages from the WebSocket.

        Dispatches ``data`` messages to the callback, resolves ``start_ack``
        futures, and tracks ``ka`` (keep-alive) messages.
        """
        if self._ws is None:
            return

        try:
            async for raw_msg in self._ws:
                if raw_msg.type == aiohttp.WSMsgType.TEXT:
                    try:
                        msg: dict[str, Any] = json.loads(raw_msg.data)
                    except (json.JSONDecodeError, TypeError):
                        LOGGER.warning("Non-JSON WS message: %s", raw_msg.data)
                        continue

                    msg_type = msg.get("type")
                    msg_id = msg.get("id")

                    if msg_type == "data":
                        self._handle_data(msg_id, msg.get("payload", {}))
                    elif msg_type == "start_ack":
                        self._handle_start_ack(msg_id)
                    elif msg_type == "ka":
                        self._last_ka_time = asyncio.get_event_loop().time()
                    elif msg_type == "error":
                        LOGGER.error("AppSync WS error: %s", msg)
                    elif msg_type == "complete":
                        LOGGER.debug("Subscription %s completed", msg_id)
                        self._subscriptions.pop(msg_id, None) if msg_id else None
                    else:
                        LOGGER.debug("Unknown WS message type: %s", msg_type)

                elif raw_msg.type in (
                    aiohttp.WSMsgType.CLOSED,
                    aiohttp.WSMsgType.CLOSING,
                    aiohttp.WSMsgType.ERROR,
                ):
                    LOGGER.debug("WS connection ended: %s", raw_msg.type)
                    break

        except asyncio.CancelledError:
            raise
        except Exception:  # noqa: BLE001
            LOGGER.exception("Error in WS receive loop")

        # If we exit the loop unexpectedly while we should be connected,
        # attempt reconnection
        if self._state == _STATE_CONNECTED:
            self._fire_disconnect_notify()
            asyncio.create_task(self._reconnect())

    def _handle_data(self, msg_id: str | None, payload: dict[str, Any]) -> None:
        """Process a ``data`` message and invoke the callback.

        :param msg_id: The subscription ID from the message
        :type msg_id: ``Optional[str]``
        :param payload: The message payload
        :type payload: ``Dict[str, Any]``
        """
        if msg_id is None or self._callback is None:
            return

        info = self._subscriptions.get(msg_id)
        if info is None:
            LOGGER.debug("Data for unknown subscription id %s", msg_id)
            return

        name = info["name"]
        data = payload.get("data", {})
        try:
            self._callback(name, data)
        except Exception:  # noqa: BLE001
            LOGGER.exception("Error in subscription callback for %s", name)

    def _handle_start_ack(self, msg_id: str | None) -> None:
        """Resolve the pending ack future for a subscription start.

        :param msg_id: The subscription ID from the ack
        :type msg_id: ``Optional[str]``
        """
        if msg_id is None:
            return
        event = self._pending_acks.get(msg_id)
        if event is not None:
            event.set()

    async def _ka_monitor(self) -> None:
        """Background task: detect keep-alive timeout and trigger reconnection.

        Tracks the time since the last keep-alive (``ka``) message was received.
        If no ``ka`` arrives within ``connectionTimeoutMs * multiplier``,
        the connection is presumed dead and a reconnection is attempted.
        """
        timeout_s = (self._connection_timeout_ms / 1000.0) * WS_KA_TIMEOUT_MULTIPLIER
        check_interval = min(timeout_s / 2.0, 30.0)  # check frequently
        try:
            while self._state == _STATE_CONNECTED:
                await asyncio.sleep(check_interval)
                if self._ws is not None and self._ws.closed:
                    LOGGER.debug("KA monitor detected closed WS")
                    break
                # Check elapsed time since last ka message
                elapsed = asyncio.get_event_loop().time() - self._last_ka_time
                if elapsed > timeout_s:
                    LOGGER.debug(
                        "KA timeout: %.1fs since last ka (limit %.1fs)",
                        elapsed,
                        timeout_s,
                    )
                    break
        except asyncio.CancelledError:
            return

        if self._state == _STATE_CONNECTED:
            self._fire_disconnect_notify()
            asyncio.create_task(self._reconnect())

    def _fire_disconnect_notify(self) -> None:
        """Invoke the disconnect notification callback if set."""
        if self._on_disconnect_notify is not None:
            try:
                self._on_disconnect_notify()
            except Exception:  # noqa: BLE001
                LOGGER.exception("Error in disconnect notify callback")

    def _fire_reconnect_notify(self) -> None:
        """Invoke the reconnect notification callback if set."""
        if self._on_reconnect_notify is not None:
            try:
                self._on_reconnect_notify()
            except Exception:  # noqa: BLE001
                LOGGER.exception("Error in reconnect notify callback")

    async def _reconnect(self) -> None:
        """Reconnect the WebSocket with exponential backoff.

        Saves active subscriptions (including query and variables),
        reconnects, and replays all subscriptions. Uses unlimited
        retries with exponential backoff capped at ``WS_MAX_RECONNECT_DELAY``.
        """
        saved_subs = dict(self._subscriptions)
        await self.async_disconnect()

        attempt = 0
        while True:
            delay = min(
                self._reconnect_delay * (2**attempt),
                WS_MAX_RECONNECT_DELAY,
            )
            LOGGER.debug(
                "Reconnecting to %s in %.1fs (attempt %d)",
                self._realtime_host,
                delay,
                attempt + 1,
            )
            await asyncio.sleep(delay)

            try:
                await self.async_connect()
                # Replay saved subscriptions with full query/variables
                await self._replay_subscriptions(saved_subs)
                LOGGER.info(
                    "Reconnected to %s and replayed %d subscriptions",
                    self._realtime_host,
                    len(saved_subs),
                )
                self._fire_reconnect_notify()
                return
            except Exception:  # noqa: BLE001
                LOGGER.debug("Reconnect attempt %d failed", attempt + 1)
                attempt += 1


class SubscriptionManager:
    """High-level subscription API managing both AppSync endpoints.

    Provides typed methods for each available subscription. Lazily
    creates ``AppSyncConnection`` instances on first use.

    :param credentials_provider: Async callable returning a valid id_token
    :type credentials_provider: ``CredentialsProvider``
    :param session: aiohttp ClientSession for WebSocket connections
    :type session: ``aiohttp.ClientSession``
    :param callback: Optional default event callback
    :type callback: ``Optional[SubscriptionCallback]``
    """

    __slots__ = (
        "_callback",
        "_core_conn",
        "_credentials_provider",
        "_halov_conn",
        "_lock",
        "_on_disconnect",
        "_on_reconnect",
        "_session",
    )

    def __init__(
        self,
        credentials_provider: CredentialsProvider,
        session: aiohttp.ClientSession,
        callback: SubscriptionCallback | None = None,
        on_disconnect: DisconnectCallback | None = None,
        on_reconnect: ReconnectCallback | None = None,
    ) -> None:
        """Initialize the subscription manager."""
        self._credentials_provider: CredentialsProvider = credentials_provider
        self._session: aiohttp.ClientSession = session
        self._callback: SubscriptionCallback | None = callback
        self._on_disconnect: DisconnectCallback | None = on_disconnect
        self._on_reconnect: ReconnectCallback | None = on_reconnect
        self._core_conn: AppSyncConnection | None = None
        self._halov_conn: AppSyncConnection | None = None
        self._lock: asyncio.Lock = asyncio.Lock()

    @property
    def core_connection(self) -> AppSyncConnection | None:
        """Return the Core API connection, if established.

        :rtype: ``Optional[AppSyncConnection]``
        """
        return self._core_conn

    @property
    def halov_connection(self) -> AppSyncConnection | None:
        """Return the Halo V API connection, if established.

        :rtype: ``Optional[AppSyncConnection]``
        """
        return self._halov_conn

    def set_callback(self, callback: SubscriptionCallback) -> None:
        """Set the event callback for all connections.

        :param callback: Callable invoked with ``(subscription_name, data)``
        :type callback: ``SubscriptionCallback``
        """
        self._callback = callback
        if self._core_conn is not None:
            self._core_conn._callback = callback
        if self._halov_conn is not None:
            self._halov_conn._callback = callback

    def set_on_disconnect(self, callback: DisconnectCallback) -> None:
        """Set the callback invoked when a WebSocket connection drops.

        The callback receives no arguments and is called synchronously
        when either the Core or Halo V connection is lost unexpectedly.

        :param callback: Callable invoked on disconnect
        :type callback: ``DisconnectCallback``
        """
        self._on_disconnect = callback
        if self._core_conn is not None:
            self._core_conn._on_disconnect_notify = self._fire_on_disconnect
        if self._halov_conn is not None:
            self._halov_conn._on_disconnect_notify = self._fire_on_disconnect

    def set_on_reconnect(self, callback: ReconnectCallback) -> None:
        """Set the callback invoked when a WebSocket connection is restored.

        The callback receives no arguments and is called synchronously
        after a successful reconnection and subscription replay.

        :param callback: Callable invoked on reconnect
        :type callback: ``ReconnectCallback``
        """
        self._on_reconnect = callback
        if self._core_conn is not None:
            self._core_conn._on_reconnect_notify = self._fire_on_reconnect
        if self._halov_conn is not None:
            self._halov_conn._on_reconnect_notify = self._fire_on_reconnect

    def _fire_on_disconnect(self) -> None:
        """Invoke the user's on_disconnect callback if set."""
        if self._on_disconnect is not None:
            try:
                self._on_disconnect()
            except Exception:  # noqa: BLE001
                LOGGER.exception("Error in on_disconnect callback")

    def _fire_on_reconnect(self) -> None:
        """Invoke the user's on_reconnect callback if set."""
        if self._on_reconnect is not None:
            try:
                self._on_reconnect()
            except Exception:  # noqa: BLE001
                LOGGER.exception("Error in on_reconnect callback")

    async def _ensure_core(self) -> AppSyncConnection:
        """Lazily create and connect the Core API WebSocket.

        :rtype: ``AppSyncConnection``
        """
        async with self._lock:
            if self._core_conn is None:
                self._core_conn = AppSyncConnection(
                    api_host=CORE_APPSYNC_API_HOST,
                    realtime_host=CORE_APPSYNC_REALTIME_HOST,
                    credentials_provider=self._credentials_provider,
                    session=self._session,
                    callback=self._callback,
                    on_disconnect_notify=self._fire_on_disconnect,
                    on_reconnect_notify=self._fire_on_reconnect,
                )
            if not self._core_conn.is_connected:
                await self._core_conn.async_connect()
            return self._core_conn

    async def _ensure_halov(self) -> AppSyncConnection:
        """Lazily create and connect the Halo V API WebSocket.

        :rtype: ``AppSyncConnection``
        """
        async with self._lock:
            if self._halov_conn is None:
                self._halov_conn = AppSyncConnection(
                    api_host=HALOV_APPSYNC_API_HOST,
                    realtime_host=HALOV_APPSYNC_REALTIME_HOST,
                    credentials_provider=self._credentials_provider,
                    session=self._session,
                    callback=self._callback,
                    on_disconnect_notify=self._fire_on_disconnect,
                    on_reconnect_notify=self._fire_on_reconnect,
                )
            if not self._halov_conn.is_connected:
                await self._halov_conn.async_connect()
            return self._halov_conn

    # ========================================================================
    # Core API subscriptions
    # ========================================================================

    async def async_subscribe_device(self, email: str) -> str:
        """Subscribe to device management events (Core API).

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_core()
        return await conn.async_subscribe(
            "onManageDevice",
            GQL_ON_MANAGE_DEVICE,
            {"email": email},
        )

    async def async_subscribe_user(self, email: str) -> str:
        """Subscribe to user management events (Core API).

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_core()
        return await conn.async_subscribe(
            "onManageUser",
            GQL_ON_MANAGE_USER,
            {"email": email},
        )

    async def async_subscribe_home(self, email: str) -> str:
        """Subscribe to home management events (Core API).

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_core()
        return await conn.async_subscribe(
            "onManageHome",
            GQL_ON_MANAGE_HOME,
            {"email": email},
        )

    # ========================================================================
    # Halo V API subscriptions
    # ========================================================================

    async def async_subscribe_hav_device_status(self, email: str) -> str:
        """Subscribe to Halo V device status updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavDeviceStatus",
            GQL_ON_UPDATE_HAV_DEVICE_STATUS,
            {"email": email},
        )

    async def async_subscribe_hav_device(self, email: str) -> str:
        """Subscribe to Halo V device updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavDevice",
            GQL_ON_UPDATE_HAV_DEVICE,
            {"email": email},
        )

    async def async_subscribe_hav_led_status(self, email: str) -> str:
        """Subscribe to Halo V LED status updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavLedStatus",
            GQL_ON_UPDATE_HAV_LED_STATUS,
            {"email": email},
        )

    async def async_subscribe_hav_percentage(self, email: str) -> str:
        """Subscribe to Halo V percentage updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavPercentage",
            GQL_ON_UPDATE_HAV_PERCENTAGE,
            {"email": email},
        )

    async def async_subscribe_hav_vacation_mode(self, email: str) -> str:
        """Subscribe to Halo V vacation mode updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavVacationMode",
            GQL_ON_UPDATE_HAV_VACATION_MODE,
            {"email": email},
        )

    async def async_subscribe_hav_auto_close_schedule(self, email: str) -> str:
        """Subscribe to Halo V auto-close schedule updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavAutoCloseSchedule",
            GQL_ON_UPDATE_HAV_AUTO_CLOSE_SCHEDULE,
            {"email": email},
        )

    async def async_subscribe_hav_event(self, email: str) -> str:
        """Subscribe to Halo V event updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavEvent",
            GQL_ON_UPDATE_HAV_EVENT,
            {"email": email},
        )

    async def async_subscribe_hav_wifi_signal(self, email: str) -> str:
        """Subscribe to Halo V WiFi signal updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavWifiSignal",
            GQL_ON_UPDATE_HAV_WIFI_SIGNAL,
            {"email": email},
        )

    async def async_subscribe_hav_battery_voltage(self, email: str) -> str:
        """Subscribe to Halo V battery voltage updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavBatteryVoltage",
            GQL_ON_UPDATE_HAV_BATTERY_VOLTAGE,
            {"email": email},
        )

    async def async_subscribe_hav_cycles(self, email: str) -> str:
        """Subscribe to Halo V cycles updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavCycles",
            GQL_ON_UPDATE_HAV_CYCLES,
            {"email": email},
        )

    async def async_subscribe_hav_temporary_key(self, email: str) -> str:
        """Subscribe to Halo V temporary key updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavTemporaryKey",
            GQL_ON_UPDATE_HAV_TEMPORARY_KEY,
            {"email": email},
        )

    async def async_subscribe_hav_sensor(self, email: str) -> str:
        """Subscribe to Halo V sensor updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavSensor",
            GQL_ON_UPDATE_HAV_SENSOR,
            {"email": email},
        )

    async def async_subscribe_hav_brightness_level(self, email: str) -> str:
        """Subscribe to Halo V brightness level updates.

        :param email: The user's email address
        :type email: ``str``
        :rtype: ``str``
        :returns: The subscription ID
        """
        conn = await self._ensure_halov()
        return await conn.async_subscribe(
            "onUpdateHavBrightnessLevel",
            GQL_ON_UPDATE_HAV_BRIGHTNESS_LEVEL,
            {"email": email},
        )

    # ========================================================================
    # Lifecycle
    # ========================================================================

    async def async_unsubscribe(self, sub_id: str) -> None:
        """Unsubscribe from a specific subscription on either connection.

        :param sub_id: The subscription ID
        :type sub_id: ``str``
        """
        if self._core_conn is not None and sub_id in self._core_conn._subscriptions:
            await self._core_conn.async_unsubscribe(sub_id)
        elif self._halov_conn is not None and sub_id in self._halov_conn._subscriptions:
            await self._halov_conn.async_unsubscribe(sub_id)

    async def async_close(self) -> None:
        """Close all WebSocket connections and clean up resources."""
        if self._core_conn is not None:
            await self._core_conn.async_disconnect()
            self._core_conn = None
        if self._halov_conn is not None:
            await self._halov_conn.async_disconnect()
            self._halov_conn = None
