"""UPnP client for WiiM/Linkplay devices.

Follows Samsung/DLNA pattern using async_upnp_client (DmrDevice pattern).
Framework-agnostic implementation for use in any Python application.
"""

from __future__ import annotations

import asyncio
import logging
import ssl
from datetime import timedelta
from typing import Any, cast

from aiohttp import ClientError, ClientSession, TCPConnector
from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester
from async_upnp_client.client import UpnpDevice
from async_upnp_client.client_factory import UpnpFactory
from async_upnp_client.exceptions import UpnpError, UpnpResponseError
from async_upnp_client.profiles.dlna import DmrDevice
from async_upnp_client.utils import async_get_local_ip

_LOGGER = logging.getLogger(__name__)


class UpnpClient:
    """UPnP client wrapper for WiiM devices using async-upnp-client.

    Provides SOAP action calls and service discovery for AVTransport
    and RenderingControl services. Framework-agnostic implementation.
    """

    def __init__(
        self,
        host: str,
        description_url: str,
        session: Any,
    ) -> None:
        """Initialize UPnP client.

        Args:
            host: Device hostname or IP
            description_url: URL to device description.xml
            session: aiohttp session for HTTP requests (reused when possible)
        """
        self.host = host
        self.description_url = description_url
        self.session = session  # External session to reuse when possible
        self._device: UpnpDevice | None = None
        self._dmr_device: DmrDevice | None = None  # DmrDevice wrapper for subscriptions (DLNA pattern)
        self._av_transport_service: Any | None = None
        self._rendering_control_service: Any | None = None
        self._content_directory_service: Any | None = None
        self._play_queue_service: Any | None = None
        self._notify_server: AiohttpNotifyServer | None = None
        self._internal_session: ClientSession | None = None  # Session we created internally (only if needed)

    @classmethod
    async def create(
        cls,
        host: str,
        description_url: str,
        session: ClientSession | None = None,
    ) -> UpnpClient:
        """Create and initialize UPnP client from description URL.

        Args:
            host: Device hostname or IP
            description_url: URL to device description.xml
            session: Optional aiohttp session (reused for HTTP operations,
                new session created only for HTTPS with special SSL config)

        Returns:
            Initialized UpnpClient instance
        """
        client = cls(host, description_url, session)
        await client._initialize()
        return client

    async def _initialize(self) -> None:
        """Initialize UPnP device and services."""
        try:
            # Use passed session for HTTP operations, create new session only for HTTPS with special SSL config
            if self.description_url.startswith("https://"):
                _LOGGER.info("Using HTTPS for UPnP description (self-signed cert support enabled)")
                # HTTPS with self-signed cert support - need special SSL config
                # Create new session with custom SSL context
                # Use executor to avoid blocking event loop (Python 3.13 detects blocking calls)
                ssl_context = await asyncio.to_thread(lambda: ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT))
                ssl_context.check_hostname = False
                ssl_context.verify_mode = ssl.CERT_NONE
                ssl_context.set_ciphers("ALL:@SECLEVEL=0")
                connector = TCPConnector(ssl=ssl_context)
                session = ClientSession(connector=connector)
                self._internal_session = session  # Track for cleanup
            else:
                # HTTP - can reuse passed session if available
                if self.session is not None and not self.session.closed:
                    _LOGGER.debug("Reusing passed aiohttp session for UPnP HTTP operations")
                    session = self.session
                    self._internal_session = None  # Not our session, don't close it
                else:
                    _LOGGER.info("Using HTTP for UPnP description (no SSL needed)")
                    # No session provided or session is closed - create our own
                    connector = TCPConnector(ssl=False)
                    session = ClientSession(connector=connector)
                    self._internal_session = session  # Track for cleanup

            # DLNA pattern: with_sleep=True adds retry logic, timeout ensures we don't hang
            requester = AiohttpSessionRequester(session, with_sleep=True, timeout=10)

            # Create UPnP device from description.xml using factory (DLNA/DMR pattern)
            _LOGGER.info("Fetching UPnP device description from: %s", self.description_url)
            factory = UpnpFactory(requester, non_strict=True)

            # Add explicit timeout wrapper (5 seconds for description.xml fetch)
            try:
                async with asyncio.timeout(5):
                    self._device = await factory.async_create_device(self.description_url)
            except TimeoutError as timeout_err:
                _LOGGER.error(
                    "❌ Timeout fetching UPnP description from %s after 5 seconds - "
                    "device may not support UPnP properly",
                    self.description_url,
                )
                raise UpnpError(f"Timeout fetching UPnP description: {timeout_err}") from timeout_err

            _LOGGER.info(
                "✅ Successfully fetched and parsed UPnP device description for %s",
                self.host,
            )

            # Get AVTransport service
            if self._device is None:
                raise UpnpError("Device not initialized")
            self._av_transport_service = self._device.service("urn:schemas-upnp-org:service:AVTransport:1")

            # Get RenderingControl service
            self._rendering_control_service = self._device.service("urn:schemas-upnp-org:service:RenderingControl:1")

            # Get ContentDirectory service (for queue browsing) - optional
            try:
                self._content_directory_service = self._device.service(
                    "urn:schemas-upnp-org:service:ContentDirectory:1"
                )
            except KeyError:
                # ContentDirectory is optional - not all devices support it
                self._content_directory_service = None
                _LOGGER.debug(
                    "Device %s does not advertise ContentDirectory service - queue retrieval will not be available",
                    self.host,
                )

            # Get PlayQueue service (for playlist clearing) - optional, LinkPlay-specific
            try:
                self._play_queue_service = self._device.service("urn:schemas-wiimu-com:service:PlayQueue:1")
            except KeyError:
                # PlayQueue is optional - not all devices support it
                self._play_queue_service = None
                _LOGGER.debug(
                    "Device %s does not advertise PlayQueue service - playlist clearing via UPnP will not be available",
                    self.host,
                )

            if not self._av_transport_service:
                _LOGGER.warning(
                    "⚠️  Device %s does not advertise AVTransport service - UPnP eventing may not work",
                    self.host,
                )
            if not self._rendering_control_service:
                _LOGGER.warning(
                    "⚠️  Device %s does not advertise RenderingControl service - UPnP volume events may not work",
                    self.host,
                )
            if not self._content_directory_service:
                _LOGGER.info(
                    "ℹ️  Device %s does not advertise ContentDirectory service - queue retrieval will not be available",
                    self.host,
                )

            _LOGGER.info(
                "✅ UPnP client initialized for %s: AVTransport=%s, RenderingControl=%s, "
                "ContentDirectory=%s, PlayQueue=%s",
                self.host,
                self._av_transport_service is not None,
                self._rendering_control_service is not None,
                self._content_directory_service is not None,
                self._play_queue_service is not None,
            )

        except TimeoutError as err:
            _LOGGER.error(
                "❌ Timeout initializing UPnP client for %s after 5 seconds",
                self.host,
            )
            raise UpnpError(f"Timeout creating UPnP device: {err}") from err
        except ClientError as err:
            _LOGGER.error(
                "❌ Network error initializing UPnP client for %s: %s",
                self.host,
                err,
            )
            raise UpnpError(f"Network error creating UPnP device: {err}") from err
        except Exception as err:
            _LOGGER.error(
                "❌ Failed to initialize UPnP client for %s: %s (type: %s)",
                self.host,
                err,
                type(err).__name__,
            )
            raise UpnpError(f"Failed to create UPnP device: {err}") from err

    async def start_notify_server(
        self,
        callback_host: str | None = None,
        callback_port: int = 0,
    ) -> AiohttpNotifyServer:
        """Start the NOTIFY server for receiving event notifications.

        Args:
            callback_host: Host IP for callback URL (auto-detect if None)
            callback_port: Port for callback (0 = ephemeral)

        Returns:
            Started AiohttpNotifyServer instance
        """
        # Try to reuse passed session for notify server (used for subscription requests)
        # Only create new session if we need special SSL config or no session provided
        if self.session is not None and not self.session.closed:
            _LOGGER.debug("Reusing passed aiohttp session for UPnP notify server")
            session = self.session
        else:
            # Create notify server (DLNA/DMR pattern) with SSL disabled for self-signed certs
            # Use executor to avoid blocking event loop (Python 3.13 detects blocking calls)
            ssl_context = await asyncio.to_thread(lambda: ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT))
            ssl_context.check_hostname = False
            ssl_context.verify_mode = ssl.CERT_NONE
            ssl_context.set_ciphers("ALL:@SECLEVEL=0")

            connector = TCPConnector(ssl=ssl_context)
            session = ClientSession(connector=connector)
        requester = AiohttpSessionRequester(session, with_sleep=True, timeout=10)

        # Get the correct local IP for callback URL
        if callback_host:
            # Use explicit host if provided
            event_ip = callback_host
            _LOGGER.info("Using explicit host IP for UPnP callback: %s", event_ip)
        else:
            # Try to auto-detect
            try:
                _, event_ip = await async_get_local_ip(f"http://{self.host}:49152", asyncio.get_event_loop())
                _LOGGER.info("Detected local IP for UPnP callback: %s", event_ip)
                # Warn if we're in a container network that might not be reachable
                if event_ip.startswith("172."):
                    _LOGGER.warning(
                        "⚠️  Detected container network IP %s - devices on your LAN may not be able to reach "
                        "this for UPnP events. Solutions: 1) Use 'network_mode: host' in Docker, "
                        "2) Add '--network=host' to devcontainer.json runArgs, "
                        "or 3) Pass the host's LAN IP via callback_host parameter.",
                        event_ip,
                    )
            except Exception as err:
                _LOGGER.warning("Could not detect local IP for UPnP callback: %s", err)
                event_ip = "0.0.0.0"

        source_ip = event_ip if callback_port == 0 else callback_host or event_ip

        self._notify_server = AiohttpNotifyServer(
            requester=requester,
            source=(source_ip, callback_port),
            loop=None,  # Use default event loop
        )

        await self._notify_server.async_start_server()

        # Get server info from the notify server instance
        server_host = getattr(self._notify_server, "host", "unknown")
        server_port = getattr(self._notify_server, "port", "unknown")
        callback_url = getattr(self._notify_server, "callback_url", None)

        # Enhanced callback URL logging with validation
        if callback_url:
            _LOGGER.info(
                "✅ Notify server started on %s:%s",
                server_host,
                server_port,
            )
            _LOGGER.info(
                "📡 UPnP callback URL: %s (this URL will be sent to devices in SUBSCRIBE requests)",
                callback_url,
            )

            # Detect and warn about unreachable container IPs
            is_unreachable = False
            unreachable_reason = None

            if server_host.startswith("172."):
                is_unreachable = True
                unreachable_reason = "container network IP (172.x.x.x)"
            elif server_host == "0.0.0.0":
                is_unreachable = True
                unreachable_reason = "wildcard binding (0.0.0.0) - devices cannot reach this"

            if is_unreachable:
                _LOGGER.error(
                    "⚠️  CRITICAL: Callback URL is using %s - devices on your LAN CANNOT reach this for UPnP events!",
                    unreachable_reason,
                )
                _LOGGER.error(
                    "   ➤ Solutions:",
                )
                _LOGGER.error(
                    "      1. Use 'network_mode: host' in docker-compose.yml",
                )
                _LOGGER.error(
                    "      2. Add '--network=host' to devcontainer.json runArgs",
                )
                _LOGGER.error(
                    "      3. Configure callback_host parameter with your host's LAN IP",
                )
                _LOGGER.error(
                    "      4. Use port forwarding to map callback port to host",
                )
                _LOGGER.error(
                    "   Application will fall back to HTTP polling, but real-time events will not work.",
                )
            else:
                _LOGGER.info(
                    "✓ Callback URL appears reachable",
                )
        else:
            _LOGGER.error("⚠️  CRITICAL: No callback_url from notify server - UPnP events will NOT work!")
            _LOGGER.error(
                "   This is likely a networking issue - devices cannot send NOTIFY events to the container",
            )
            _LOGGER.error(
                "   Check Docker networking configuration (use --network=host or configure port forwarding)",
            )

        # Create DmrDevice wrapper (DLNA pattern from Samsung/DLNA integrations)
        # This provides async_subscribe_services() method
        if self._device is None:
            raise UpnpError("Device not initialized")
        if self._notify_server is None:
            raise UpnpError("Notify server not started")
        self._dmr_device = DmrDevice(self._device, self._notify_server.event_handler)
        _LOGGER.info("DmrDevice wrapper created for %s", self.host)

        return self._notify_server

    async def unwind_notify_server(self) -> None:
        """Stop and clean up the NOTIFY server."""
        if self._notify_server:
            try:
                await self._notify_server.async_stop_server()
            except Exception as err:  # noqa: BLE001
                _LOGGER.debug("Error stopping notify server for %s: %s", self.host, err)
            finally:
                self._notify_server = None
                _LOGGER.debug("Notify server stopped for %s", self.host)

    async def close(self) -> None:
        """Close the UPnP client and clean up resources.

        Stops the notify server and closes the internal aiohttp session (only if we created it).
        Does not close externally-provided sessions.
        """
        # Stop notify server first
        await self.unwind_notify_server()

        # Close internal session (only if we created it, not if it was passed in)
        if self._internal_session and not self._internal_session.closed:
            try:
                await self._internal_session.close()
                _LOGGER.debug("Closed internal session for %s", self.host)
            except Exception as err:  # noqa: BLE001
                _LOGGER.debug("Error closing internal session for %s: %s", self.host, err)
            finally:
                self._internal_session = None

    @property
    def av_transport(self) -> Any:
        """Get AVTransport service."""
        return self._av_transport_service

    @property
    def rendering_control(self) -> Any:
        """Get RenderingControl service."""
        return self._rendering_control_service

    @property
    def content_directory(self) -> Any:
        """Get ContentDirectory service."""
        return self._content_directory_service

    @property
    def play_queue(self) -> Any:
        """Get PlayQueue service."""
        return self._play_queue_service

    @property
    def notify_server(self) -> AiohttpNotifyServer:
        """Get notify server instance."""
        if self._notify_server is None:
            raise RuntimeError("notify server not started")
        return self._notify_server

    async def async_subscribe(
        self,
        service_name: str,
        timeout: int = 1800,
        sub_callback: Any = None,
        renew_fail_callback: Any = None,
    ) -> Any:
        """Subscribe to UPnP service events (Samsung/DLNA pattern).

        Uses event_handler.async_subscribe() like DmrDevice does.
        See: async_upnp_client/profiles/dlna.py async_subscribe_services()

        Args:
            service_name: Name of service ("AVTransport" or "RenderingControl")
            timeout: Subscription timeout in seconds
            sub_callback: Callback function for events (signature: (service, state_variables))
            renew_fail_callback: Unused (renewal handled separately)

        Returns:
            Subscription object (for storing and managing callback)
        """
        # Map service name to attribute name
        service_attr_map = {
            "avtransport": "_av_transport_service",
            "renderingcontrol": "_rendering_control_service",
            "contentdirectory": "_content_directory_service",
            "play_queue": "_play_queue_service",
            "playqueue": "_play_queue_service",
        }
        service_attr = service_attr_map.get(service_name.lower())
        if not service_attr:
            raise UpnpError(f"Service {service_name} not available")

        service = getattr(self, service_attr)
        if not service:
            raise UpnpError(f"Service {service_name} not initialized")

        if not self._notify_server:
            raise UpnpError("Notify server not started")

        # Get notify server host/port using getattr to handle attribute access
        server_host = getattr(self._notify_server, "host", "localhost")
        server_port = getattr(self._notify_server, "port", 8000)

        callback_url = f"http://{server_host}:{server_port}/notify"

        # Log the subscription request with callback URL
        _LOGGER.info(
            "📨 Subscribing to %s service on %s",
            service_name,
            self.host,
        )
        _LOGGER.debug(
            "   → Using callback URL: %s (devices will send NOTIFY events to this URL)",
            callback_url,
        )
        _LOGGER.debug("   → Requested timeout: %d seconds", timeout)

        # Following DmrDevice pattern from dlna_dmr/samsungtv integrations:
        # 1. Set service.on_event callback BEFORE subscribing
        # 2. Call event_handler.async_subscribe(service, timeout=...) which returns (sid, timeout_timedelta)
        # See: /workspaces/core/homeassistant/components/dlna_dmr/media_player.py:391
        # and async_upnp_client/profiles/dlna.py async_subscribe_services()
        event_handler = self._notify_server.event_handler

        # Set callback on service (DmrDevice pattern: service.on_event is called by event_handler)
        if sub_callback:
            service.on_event = sub_callback

        # Subscribe - returns (sid, timeout_timedelta) tuple
        # event_handler.async_subscribe() expects timeout as timedelta, not int
        timeout_delta = timedelta(seconds=timeout)
        sid, timeout_timedelta = await event_handler.async_subscribe(
            service,
            timeout=timeout_delta,
        )

        # Convert timedelta to seconds
        granted_timeout = int(timeout_timedelta.total_seconds())

        # Create subscription wrapper for tracking SID and timeout
        class SubscriptionWrapper:
            """Wrapper for subscription info (Samsung/DLNA pattern compatibility)."""

            def __init__(self, sid: str, timeout: int, service_obj: Any):
                self.sid = sid
                self.timeout = timeout
                self.service = service_obj

            @property
            def callback(self):
                return getattr(self.service, "on_event", None)

            @callback.setter
            def callback(self, value):
                # Update service.on_event (DmrDevice pattern)
                self.service.on_event = value

        subscription = SubscriptionWrapper(sid, granted_timeout, service)

        _LOGGER.info(
            "✅ Successfully subscribed to %s on %s",
            service_name,
            self.host,
        )
        _LOGGER.info(
            "   → SID: %s",
            sid,
        )
        _LOGGER.info(
            "   → Timeout: %d seconds (requested %d, granted %d)",
            granted_timeout,
            timeout,
            granted_timeout,
        )
        _LOGGER.info(
            "   → Callback URL: %s",
            callback_url,
        )

        return subscription

    async def async_subscribe_services(
        self,
        event_callback: Any = None,
    ) -> None:
        """Subscribe to UPnP services (DLNA pattern).

        This follows the Samsung/DLNA pattern using DmrDevice.async_subscribe_services().

        Args:
            event_callback: Callback function for events (called with service and state_variables)
        """
        if not self._dmr_device:
            raise UpnpError("DmrDevice not initialized - call start_notify_server() first")

        try:
            # Set event callback
            if event_callback:
                self._dmr_device.on_event = event_callback

            # Subscribe to all services (DLNA pattern)
            _LOGGER.info("📨 Subscribing to all UPnP services using DmrDevice pattern for %s", self.host)
            callback_url = getattr(self._notify_server, "callback_url", "unknown")
            _LOGGER.debug("   → Using callback URL: %s", callback_url)

            await self._dmr_device.async_subscribe_services(auto_resubscribe=True)
            _LOGGER.info("✅ Successfully subscribed to UPnP services for %s (DLNA/DmrDevice pattern)", self.host)
            _LOGGER.debug(
                "   → Devices will send NOTIFY events to: %s",
                callback_url,
            )

        except UpnpResponseError as err:
            # Device rejected subscription - this is OK, we'll poll instead
            _LOGGER.debug("Device rejected subscription for %s: %r", self.host, err)
            raise
        except Exception as err:  # noqa: BLE001
            _LOGGER.warning("Error subscribing to services for %s: %s", self.host, err)
            raise UpnpError(f"Failed to subscribe to services: {err}") from err

    async def async_renew(
        self,
        service_name: str,
        sid: str,
        timeout: int = 1800,
    ) -> tuple[str, int] | None:
        """Renew UPnP service subscription (Samsung/DLNA pattern).

        Uses event_handler.async_resubscribe() like DmrDevice does.
        See: async_upnp_client/profiles/dlna.py _async_resubscribe_services()

        Args:
            service_name: Name of service ("AVTransport" or "RenderingControl")
            sid: Subscription SID to renew
            timeout: New subscription timeout in seconds

        Returns:
            Tuple of (new_sid, timeout_timedelta) if successful, None otherwise
        """
        if not self._notify_server:
            _LOGGER.warning("Notify server not available for renewal")
            return None

        event_handler = self._notify_server.event_handler

        try:
            _LOGGER.debug(
                "🔄 Renewing subscription to %s on %s: SID=%s, timeout=%d seconds",
                service_name,
                self.host,
                sid,
                timeout,
            )
            # DmrDevice pattern: event_handler.async_resubscribe() returns (new_sid, timeout_timedelta)
            # event_handler.async_resubscribe() expects timeout as timedelta, not int
            timeout_delta = timedelta(seconds=timeout)
            new_sid, timeout_timedelta = await event_handler.async_resubscribe(
                sid,
                timeout=timeout_delta,
            )
            granted_timeout = int(timeout_timedelta.total_seconds())
            _LOGGER.info(
                "✅ Successfully renewed subscription to %s on %s: SID=%s->%s, expires=%d seconds",
                service_name,
                self.host,
                sid,
                new_sid,
                granted_timeout,
            )
            return (new_sid, granted_timeout)
        except Exception as err:  # noqa: BLE001
            _LOGGER.warning(
                "❌ Failed to renew subscription to %s on %s (SID=%s): %s",
                service_name,
                self.host,
                sid,
                err,
            )
            return None

    async def async_unsubscribe(
        self,
        service_name: str,
        sid: str,
    ) -> None:
        """Unsubscribe from UPnP service (Samsung/DLNA pattern).

        Uses event_handler.async_unsubscribe() like DmrDevice does.
        See: async_upnp_client/profiles/dlna.py async_unsubscribe_services()

        Args:
            service_name: Name of service ("AVTransport" or "RenderingControl")
            sid: Subscription SID to unsubscribe
        """
        if not self._notify_server:
            _LOGGER.warning("Notify server not available for unsubscribe")
            return

        event_handler = self._notify_server.event_handler

        try:
            _LOGGER.debug("Unsubscribing from %s on %s: SID=%s", service_name, self.host, sid)
            # DmrDevice pattern: event_handler.async_unsubscribe()
            await event_handler.async_unsubscribe(sid)
            _LOGGER.info("✅ Unsubscribed from %s on %s: SID=%s", service_name, self.host, sid)
        except Exception as err:  # noqa: BLE001
            _LOGGER.warning("⚠️  Error unsubscribing from %s on %s (SID=%s): %s", service_name, self.host, sid, err)

    async def async_call_action(
        self,
        service_name: str,
        action: str,
        arguments: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Call UPnP service action.

        Args:
            service_name: Name of service ("AVTransport", "RenderingControl", or "ContentDirectory")
            action: Action name (e.g., "Play", "Pause", "SetVolume", "Browse")
            arguments: Action arguments

        Returns:
            Action response as dict
        """
        # Normalize service name for attribute lookup
        service_name_lower = service_name.lower().replace(" ", "_")
        service_attr = f"_{service_name_lower}_service"
        service = getattr(self, service_attr, None)
        if not service:
            raise UpnpError(f"Service {service_name} not available")

        action_obj = service.action(action)
        if not action_obj:
            raise UpnpError(f"Action {action} not found in {service_name}")

        result = await action_obj.async_call(**arguments or {})

        return cast(dict[str, Any], result)

    async def get_media_info(self) -> dict[str, Any]:
        """Fetch current media info via GetMediaInfo UPnP action.

        This is a "pull" method that fetches the current media state on demand.
        Useful for:
        - Initial state when starting up
        - Debugging/diagnostics
        - Fallback when UPnP events fail

        Returns:
            Dictionary with media info:
            - CurrentURI: URI of currently playing media
            - CurrentURIMetaData: DIDL-Lite XML with title/artist/album/artwork
            - TrackSource: Source of the track (e.g., "spotify", "tunein")
            - NrTracks: Number of tracks
            - MediaDuration: Duration of current media
            - PlayMedium: Current play medium
            - RecordMedium: Current record medium
            - WriteStatus: Write status

        Raises:
            UpnpError: If AVTransport service is not available or action fails
        """
        if not self._av_transport_service:
            raise UpnpError("AVTransport service not available")

        try:
            result = await self.async_call_action(
                "av_transport",
                "GetMediaInfo",
                {"InstanceID": 0},
            )
            _LOGGER.debug("GetMediaInfo result: %s", result)
            return result
        except Exception as err:
            _LOGGER.warning("GetMediaInfo failed for %s: %s", self.host, err)
            raise UpnpError(f"GetMediaInfo failed: {err}") from err

    async def get_transport_info(self) -> dict[str, Any]:
        """Fetch current transport state via GetTransportInfo UPnP action.

        Returns:
            Dictionary with transport info:
            - CurrentTransportState: PLAYING, PAUSED_PLAYBACK, STOPPED, etc.
            - CurrentTransportStatus: OK, ERROR_OCCURRED, etc.
            - CurrentSpeed: Playback speed (1 = normal)

        Raises:
            UpnpError: If AVTransport service is not available or action fails
        """
        if not self._av_transport_service:
            raise UpnpError("AVTransport service not available")

        try:
            result = await self.async_call_action(
                "av_transport",
                "GetTransportInfo",
                {"InstanceID": 0},
            )
            _LOGGER.debug("GetTransportInfo result: %s", result)
            return result
        except Exception as err:
            _LOGGER.warning("GetTransportInfo failed for %s: %s", self.host, err)
            raise UpnpError(f"GetTransportInfo failed: {err}") from err

    async def get_position_info(self) -> dict[str, Any]:
        """Fetch current position info via GetPositionInfo UPnP action.

        Returns:
            Dictionary with position info:
            - Track: Current track number
            - TrackDuration: Duration of current track (HH:MM:SS format)
            - TrackMetaData: DIDL-Lite XML with track metadata
            - TrackURI: URI of current track
            - RelTime: Relative time position (HH:MM:SS format)
            - AbsTime: Absolute time position
            - RelCount: Relative counter position
            - AbsCount: Absolute counter position

        Raises:
            UpnpError: If AVTransport service is not available or action fails
        """
        if not self._av_transport_service:
            raise UpnpError("AVTransport service not available")

        try:
            result = await self.async_call_action(
                "av_transport",
                "GetPositionInfo",
                {"InstanceID": 0},
            )
            _LOGGER.debug("GetPositionInfo result: %s", result)
            return result
        except Exception as err:
            _LOGGER.warning("GetPositionInfo failed for %s: %s", self.host, err)
            raise UpnpError(f"GetPositionInfo failed: {err}") from err

    async def get_volume(self, channel: str = "Master") -> int:
        """Fetch current volume via GetVolume UPnP action.

        Useful for initial state or when UPnP events are not working.

        Args:
            channel: Audio channel ("Master", "LF", "RF"). Default "Master".

        Returns:
            Volume level (0-100)

        Raises:
            UpnpError: If RenderingControl service is not available or action fails
        """
        if not self._rendering_control_service:
            raise UpnpError("RenderingControl service not available")

        try:
            result = await self.async_call_action(
                "rendering_control",
                "GetVolume",
                {"InstanceID": 0, "Channel": channel},
            )
            volume = result.get("CurrentVolume", 0)
            _LOGGER.debug("GetVolume result for %s: %s", self.host, volume)
            return int(volume)
        except Exception as err:
            _LOGGER.warning("GetVolume failed for %s: %s", self.host, err)
            raise UpnpError(f"GetVolume failed: {err}") from err

    async def get_mute(self, channel: str = "Master") -> bool:
        """Fetch current mute state via GetMute UPnP action.

        Useful for initial state or when UPnP events are not working.

        Args:
            channel: Audio channel ("Master", "LF", "RF"). Default "Master".

        Returns:
            True if muted, False otherwise

        Raises:
            UpnpError: If RenderingControl service is not available or action fails
        """
        if not self._rendering_control_service:
            raise UpnpError("RenderingControl service not available")

        try:
            result = await self.async_call_action(
                "rendering_control",
                "GetMute",
                {"InstanceID": 0, "Channel": channel},
            )
            muted = result.get("CurrentMute", False)
            _LOGGER.debug("GetMute result for %s: %s", self.host, muted)
            return bool(muted)
        except Exception as err:
            _LOGGER.warning("GetMute failed for %s: %s", self.host, err)
            raise UpnpError(f"GetMute failed: {err}") from err

    async def get_device_capabilities(self) -> dict[str, Any]:
        """Fetch device capabilities via GetDeviceCapabilities UPnP action.

        Returns what media types and protocols the device supports.

        Returns:
            Dictionary with:
            - PlayMedia: Supported play media types
            - RecMedia: Supported record media types
            - RecQualityModes: Supported recording quality modes

        Raises:
            UpnpError: If AVTransport service is not available or action fails
        """
        if not self._av_transport_service:
            raise UpnpError("AVTransport service not available")

        try:
            result = await self.async_call_action(
                "av_transport",
                "GetDeviceCapabilities",
                {"InstanceID": 0},
            )
            _LOGGER.debug("GetDeviceCapabilities result for %s: %s", self.host, result)
            return result
        except Exception as err:
            _LOGGER.warning("GetDeviceCapabilities failed for %s: %s", self.host, err)
            raise UpnpError(f"GetDeviceCapabilities failed: {err}") from err

    async def get_current_transport_actions(self) -> list[str]:
        """Fetch currently available transport actions via GetCurrentTransportActions.

        Returns what actions are currently valid (e.g., can't Pause if already paused).

        Returns:
            List of available actions like ["Play", "Stop", "Pause", "Seek", "Next", "Previous"]

        Raises:
            UpnpError: If AVTransport service is not available or action fails
        """
        if not self._av_transport_service:
            raise UpnpError("AVTransport service not available")

        try:
            result = await self.async_call_action(
                "av_transport",
                "GetCurrentTransportActions",
                {"InstanceID": 0},
            )
            actions_str = result.get("Actions", "")
            actions = [a.strip() for a in actions_str.split(",") if a.strip()]
            _LOGGER.debug("GetCurrentTransportActions result for %s: %s", self.host, actions)
            return actions
        except Exception as err:
            _LOGGER.warning("GetCurrentTransportActions failed for %s: %s", self.host, err)
            raise UpnpError(f"GetCurrentTransportActions failed: {err}") from err

    async def get_full_state_snapshot(self) -> dict[str, Any]:
        """Fetch complete device state via UPnP for diagnostics.

        This is a convenience method that fetches all available state in one call.
        Intended for diagnostics and debugging - NOT for regular state updates
        (use HTTP polling + UPnP events for that).

        Returns:
            Dictionary with all available UPnP state:
            - transport: GetTransportInfo result (play state)
            - media: GetMediaInfo result (current URI, metadata)
            - position: GetPositionInfo result (position, duration)
            - volume: Current volume level (0-100)
            - muted: Current mute state
            - available_actions: List of valid transport actions
            - errors: Any errors encountered during fetch

        Note:
            Individual fetch errors are caught and reported in 'errors' dict
            rather than raising exceptions.
        """
        result: dict[str, Any] = {"errors": {}}

        # Fetch transport info (play state)
        try:
            result["transport"] = await self.get_transport_info()
        except Exception as err:
            result["transport"] = None
            result["errors"]["transport"] = str(err)

        # Fetch media info (what's playing)
        try:
            result["media"] = await self.get_media_info()
        except Exception as err:
            result["media"] = None
            result["errors"]["media"] = str(err)

        # Fetch position info
        try:
            result["position"] = await self.get_position_info()
        except Exception as err:
            result["position"] = None
            result["errors"]["position"] = str(err)

        # Fetch volume
        try:
            result["volume"] = await self.get_volume()
        except Exception as err:
            result["volume"] = None
            result["errors"]["volume"] = str(err)

        # Fetch mute state
        try:
            result["muted"] = await self.get_mute()
        except Exception as err:
            result["muted"] = None
            result["errors"]["muted"] = str(err)

        # Fetch available actions
        try:
            result["available_actions"] = await self.get_current_transport_actions()
        except Exception as err:
            result["available_actions"] = None
            result["errors"]["available_actions"] = str(err)

        # Clean up errors dict if empty
        if not result["errors"]:
            del result["errors"]

        return result

    async def browse_queue(
        self,
        object_id: str = "Q:0",
        starting_index: int = 0,
        requested_count: int = 0,
    ) -> dict[str, Any]:
        """Browse queue contents via ContentDirectory Browse action.

        Follows SoCo pattern: Uses Browse action to retrieve queue items.
        ObjectID "Q:0" is the standard queue identifier (Sonos uses this).

        **Availability Note:**
        ContentDirectory service is only available on:
        - WiiM Amp (when USB drive is connected)
        - WiiM Ultra (when USB drive is connected)

        Other WiiM devices do not expose ContentDirectory service.

        Args:
            object_id: Queue object ID (default "Q:0" for standard queue)
            starting_index: Starting index for pagination (0 = first item)
            requested_count: Number of items to retrieve (0 = all available)

        Returns:
            Dictionary with:
            - Result: DIDL-Lite XML string containing queue items
            - NumberReturned: Number of items returned
            - TotalMatches: Total number of items in queue
            - UpdateID: Update ID for change detection

        Raises:
            UpnpError: If ContentDirectory service is not available or action fails
        """
        if not self._content_directory_service:
            raise UpnpError("ContentDirectory service not available")

        try:
            result = await self.async_call_action(
                "ContentDirectory",
                "Browse",
                {
                    "ObjectID": object_id,
                    "BrowseFlag": "BrowseDirectChildren",
                    "Filter": "*",  # Get all properties
                    "StartingIndex": starting_index,
                    "RequestedCount": requested_count,
                    "SortCriteria": "",  # Default sorting
                },
            )
            _LOGGER.debug("Browse queue result for %s: %d items", self.host, result.get("NumberReturned", 0))
            return result
        except Exception as err:
            _LOGGER.warning("Browse queue failed for %s: %s", self.host, err)
            raise UpnpError(f"Browse queue failed: {err}") from err
