"""
Support to interface with Alexa Devices.

SPDX-License-Identifier: Apache-2.0

For more details about this platform, please refer to the documentation at
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
"""

import asyncio
import logging
import os
import re
import subprocess
from typing import Any, Optional
import urllib.request

from homeassistant import util
from homeassistant.components import media_source
from homeassistant.components.media_player import MediaPlayerEntity as MediaPlayerDevice
from homeassistant.components.media_player.browse_media import (
    async_process_play_media_url,
)
from homeassistant.components.media_player.const import (
    ATTR_MEDIA_ANNOUNCE,
    MediaPlayerEntityFeature,
    MediaPlayerState,
    MediaType,
    RepeatMode,
)
from homeassistant.const import CONF_EMAIL, CONF_NAME, CONF_PASSWORD, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import (
    async_dispatcher_connect,
    async_dispatcher_send,
)
from homeassistant.helpers.event import async_call_later
from homeassistant.util import slugify

from . import (
    CONF_PUBLIC_URL,
    CONF_QUEUE_DELAY,
    DATA_ALEXAMEDIA,
    DEFAULT_PUBLIC_URL,
    DEFAULT_QUEUE_DELAY,
    DOMAIN as ALEXA_DOMAIN,
    hide_email,
    hide_serial,
)
from .alexa_media import AlexaMedia
from .const import (
    DEPENDENT_ALEXA_COMPONENTS,
    MIN_TIME_BETWEEN_FORCED_SCANS,
    MIN_TIME_BETWEEN_SCANS,
    MODEL_IDS,
    PLAY_SCAN_INTERVAL,
    PUBLIC_URL_ERROR_MESSAGE,
    STREAMING_ERROR_MESSAGE,
    UPLOAD_PATH,
)
from .exceptions import TimeoutException
from .helpers import _catch_login_errors, add_devices, is_http2_enabled, safe_get

SUPPORT_ALEXA = (
    MediaPlayerEntityFeature.PAUSE
    | MediaPlayerEntityFeature.SEEK
    | MediaPlayerEntityFeature.PREVIOUS_TRACK
    | MediaPlayerEntityFeature.NEXT_TRACK
    | MediaPlayerEntityFeature.STOP
    | MediaPlayerEntityFeature.VOLUME_SET
    | MediaPlayerEntityFeature.PLAY
    | MediaPlayerEntityFeature.PLAY_MEDIA
    | MediaPlayerEntityFeature.TURN_OFF
    | MediaPlayerEntityFeature.TURN_ON
    | MediaPlayerEntityFeature.VOLUME_MUTE
    | MediaPlayerEntityFeature.SELECT_SOURCE
    | MediaPlayerEntityFeature.SHUFFLE_SET
    | MediaPlayerEntityFeature.REPEAT_SET
)

TRANSPORT_FEATURES: dict[str, MediaPlayerEntityFeature] = {
    "next": MediaPlayerEntityFeature.NEXT_TRACK,
    "previous": MediaPlayerEntityFeature.PREVIOUS_TRACK,
    "shuffle": MediaPlayerEntityFeature.SHUFFLE_SET,
    "repeat": MediaPlayerEntityFeature.REPEAT_SET,
    "seekForward": MediaPlayerEntityFeature.SEEK,
    "seekBackward": MediaPlayerEntityFeature.SEEK,
}

_LOGGER = logging.getLogger(__name__)

DEPENDENCIES = [ALEXA_DOMAIN]


async def create_www_directory(hass: HomeAssistant):
    """Create www directory."""
    paths = [
        hass.config.path("www"),  # http://homeassistant.local:8123/local
        hass.config.path(
            UPLOAD_PATH
        ),  # http://homeassistant.local:8123/local/alexa_tts
    ]

    def mkdir() -> None:
        """Create a directory."""
        for path in paths:
            if not os.path.exists(path):
                _LOGGER.debug("Creating directory: %s", path)
                os.makedirs(path, exist_ok=True)

    await hass.async_add_executor_job(mkdir)


# @retry_async(limit=5, delay=2, catch_exceptions=True)
async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None):
    """Set up the Alexa media player platform."""
    await create_www_directory(hass)

    devices = []  # type: List[AlexaClient]
    account = None
    if config:
        account = config.get(CONF_EMAIL)
    if account is None and discovery_info:
        account = safe_get(discovery_info, ["config", CONF_EMAIL])
    if account is None:
        raise ConfigEntryNotReady
    account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
    entry_setup = len(account_dict["entities"]["media_player"])
    media_players = account_dict["devices"]["media_player"]
    alexa_client = None
    # Make clusterMembers list from parentClusters
    for key, device in media_players.items():
        if parent_clusters := device.get("parentClusters"):
            for parent_id in parent_clusters:
                if media_players.get(parent_id):
                    if media_players[parent_id].get("clusterMembers") is None:
                        media_players[parent_id]["clusterMembers"] = []
                    if key not in media_players[parent_id]["clusterMembers"]:
                        media_players[parent_id]["clusterMembers"].append(key)
    for key, device in media_players.items():
        if key not in account_dict["entities"]["media_player"]:
            alexa_client = AlexaClient(
                device,
                account_dict["login_obj"],
                hass.data[DATA_ALEXAMEDIA]["accounts"][account]["second_account_index"],
            )
            await alexa_client.init(device)
            devices.append(alexa_client)
            (
                hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"][
                    "media_player"
                ][key]
            ) = alexa_client
        else:
            _LOGGER.debug(
                "%s: Skipping already added device: %s:%s",
                hide_email(account),
                hide_serial(key),
                alexa_client,
            )
    result = await add_devices(hide_email(account), devices, add_devices_callback)
    if result and entry_setup:
        _LOGGER.debug("Detected config entry already setup, using load platform")
        for component in DEPENDENT_ALEXA_COMPONENTS:
            hass.async_create_task(
                async_load_platform(
                    hass,
                    component,
                    ALEXA_DOMAIN,
                    {CONF_NAME: ALEXA_DOMAIN, "config": config},
                    config,
                )
            )
    return result


async def async_setup_entry(hass, config_entry, async_add_devices):
    """Set up the Alexa media player platform by config_entry."""
    if await async_setup_platform(
        hass, config_entry.data, async_add_devices, discovery_info=None
    ):
        account = config_entry.data[CONF_EMAIL]
        account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
        for component in DEPENDENT_ALEXA_COMPONENTS:
            try:
                entry_setup = len(account_dict["entities"][component])
            except (TypeError, KeyError):
                entry_setup = 1
            if entry_setup or component == "notify":
                _LOGGER.debug("%s: Loading %s", hide_email(account), component)
                cleaned_config = config_entry.data.copy()
                cleaned_config.pop(CONF_PASSWORD, None)
                # CONF_PASSWORD contains sensitive info which is no longer needed
                hass.async_create_task(
                    async_load_platform(
                        hass,
                        component,
                        ALEXA_DOMAIN,
                        {CONF_NAME: ALEXA_DOMAIN, "config": cleaned_config},
                        cleaned_config,
                    )
                )
            else:
                _LOGGER.debug(
                    "%s: Loading config entry for %s", hide_email(account), component
                )
                try:
                    await hass.config_entries.async_forward_entry_setups(
                        config_entry, [component]
                    )
                except (asyncio.TimeoutError, TimeoutException) as ex:
                    raise ConfigEntryNotReady(
                        f"Timeout while loading config entry for {component}"
                    ) from ex
        return True
    raise ConfigEntryNotReady


async def async_unload_entry(hass, entry) -> bool:
    """Unload a config entry."""
    account = entry.data[CONF_EMAIL]
    _LOGGER.debug("%s: Attempting to unload media players", hide_email(account))
    account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
    for device in account_dict["entities"]["media_player"].values():
        _LOGGER.debug("%s: Removing %s", hide_email(account), device)
        await device.async_remove()
    return True


class AlexaClient(MediaPlayerDevice, AlexaMedia):
    """Representation of a Alexa device."""

    def __init__(self, device, login, second_account_index=0):
        """Initialize the Alexa device."""
        super().__init__(self, login)

        # Logged in info
        self._authenticated = None
        self._can_access_prime_music = None
        self._customer_email = None
        self._customer_id = None
        self._customer_name = None

        # Device info
        self._device = device
        self._device_name = None
        self._device_serial_number = None
        self._device_type = None
        self._device_family = None
        self._device_owner_customer_id = None
        self._software_version = None
        self._available = None
        self._assumed_state = False
        self._capabilities = []
        self._cluster_members = []
        self._locale = None
        # Media
        self._session = None
        self._media_duration = None
        self._media_image_url = None
        self._media_title = None
        self._media_pos = None
        self._media_album_name = None
        self._media_artist = None
        self._media_player_state = None
        self._media_is_muted = False
        self._media_vol_level = None
        self._previous_volume = None
        self._saved_volume = None
        self._source = None
        self._source_list = []
        self._connected_bluetooth = None
        self._bluetooth_list = []
        self._history_records = []
        self._shuffle = None
        self._repeat = None
        self._playing_parent = None
        self._player_info = None
        self._waiting_media_id = None
        # Last Device
        self._last_called = None
        self._last_called_timestamp = None
        self._last_called_summary = None
        self._last_called_response = None
        # Do not Disturb state
        self._dnd = None
        # Polling state
        self._should_poll = True
        self._last_update = util.utcnow()
        self._listener = None
        self._bluetooth_state = None
        self._app_device_list = None
        self._parent_clusters = None
        self._timezone = None
        self._second_account_index = second_account_index

        self._prev_state = None
        self._state_call_later_cancel = None

        self._attr_supported_features = SUPPORT_ALEXA

    async def init(self, device):
        """Initialize."""
        await self.refresh(device, skip_api=True)

    async def async_added_to_hass(self):
        """Perform tasks after loading."""
        # Register event handler on bus
        await self.refresh(self._device)
        self._listener = async_dispatcher_connect(
            self.hass,
            f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32],
            self._handle_event,
        )
        # Register to coordinator:
        email = self._login.email
        coordinator = self.hass.data[DATA_ALEXAMEDIA]["accounts"][email].get(
            "coordinator"
        )
        if coordinator:
            coordinator.async_add_listener(self.update)

    async def async_will_remove_from_hass(self):
        """Prepare to remove entity."""
        # Register event handler on bus
        self._listener()
        email = self._login.email
        coordinator = self.hass.data[DATA_ALEXAMEDIA]["accounts"][email].get(
            "coordinator"
        )
        if coordinator:
            try:
                coordinator.async_remove_listener(self.update)
            except AttributeError:
                pass  # ignore missing listener

    async def _handle_event(self, event):
        # pylint: disable=too-many-branches,too-many-statements
        """Handle events.

        This will update last_called and player_state events.
        Each MediaClient reports if it's the last_called MediaClient and will
        listen for HA events to determine it is the last_called.
        When polling instead of websockets, all devices on same account will
        update to handle starting music with other devices. If websocket is on
        only the updated alexa will update.
        Last_called events are only sent if it's a new device or timestamp.
        Without polling, we must schedule the HA update manually.
        https://developers.home-assistant.io/docs/en/entity_index.html#subscribing-to-updates
        The difference between self.update and self.schedule_update_ha_state
        is self.update will pull data from Amazon, while schedule_update
        assumes the MediaClient state is already updated.
        """

        async def _refresh_if_no_audiopush(already_refreshed=False):
            email = self._login.email
            seen_commands = (
                self.hass.data[DATA_ALEXAMEDIA]["accounts"][email][
                    "websocket_commands"
                ].keys()
                if "websocket_commands"
                in (self.hass.data[DATA_ALEXAMEDIA]["accounts"][email])
                else None
            )
            if (
                not already_refreshed
                and seen_commands
                and not (
                    "PUSH_AUDIO_PLAYER_STATE" in seen_commands
                    or "PUSH_MEDIA_CHANGE" in seen_commands
                    or "PUSH_MEDIA_PROGRESS_CHANGE" in seen_commands
                )
            ):
                # force refresh if player_state update not found, see #397
                _LOGGER.debug(
                    "%s: No PUSH_AUDIO_PLAYER_STATE/"
                    "PUSH_MEDIA_CHANGE/PUSH_MEDIA_PROGRESS_CHANGE in %s;"
                    "forcing refresh",
                    hide_email(email),
                    seen_commands,
                )
                await self.async_update()

        async def _wait_player_info(media_id, timeout=3):
            self._player_info = None
            start = util.dt.as_timestamp(util.utcnow())
            while (
                self._player_info is None
                and media_id == self._waiting_media_id
                and (start + timeout >= util.dt.as_timestamp(util.utcnow()))
            ):
                await asyncio.sleep(0.1)

        try:
            if not self.enabled:
                return
        except AttributeError:
            pass
        already_refreshed = False
        event_serial = None
        info_changed = False
        if "last_called_change" in event:
            event_serial = (
                event["last_called_change"]["serialNumber"]
                if event["last_called_change"]
                else None
            )
        elif "bluetooth_change" in event:
            event_serial = (
                event["bluetooth_change"]["deviceSerialNumber"]
                if event["bluetooth_change"]
                else None
            )
        elif "player_state" in event:
            event_serial = (
                event["player_state"]["dopplerId"]["deviceSerialNumber"]
                if event["player_state"]
                else None
            )
            _LOGGER.debug("player_state event_serial: %s", hide_serial(event_serial))
        elif "queue_state" in event:
            event_serial = (
                event["queue_state"]["dopplerId"]["deviceSerialNumber"]
                if event["queue_state"]
                else None
            )
        elif "push_activity" in event:
            event_serial = safe_get(event, ["push_activity", "key", "serialNumber"])
        elif "now_playing" in event:
            player_info = safe_get(
                event, ["now_playing", "update", "update", "nowPlayingData"], {}
            )
            media_id = player_info.get("mediaId")
            if self._waiting_media_id and media_id in self._waiting_media_id:
                if player_info.get("playerState"):
                    player_info["state"] = player_info["playerState"]
                media_length = safe_get(player_info, ["progress", "mediaLength"])
                if media_length is not None:
                    player_info["progress"]["mediaLength"] = int(media_length / 1000)
                    # Get and set mediaProgress only when mediaLength is obtained.
                    # Fixed an issue where mediaLength was sometimes acquired as 0 on Spotify etc.,
                    # causing the progress bar to disappear.
                    media_progress = safe_get(
                        player_info, ["progress", "mediaProgress"]
                    )
                    if media_progress is not None:
                        player_info["progress"]["mediaProgress"] = int(
                            media_progress / 1000
                        )
                if safe_get(player_info, ["mainArt", "url"]) is None:
                    if not player_info.get("mainArt"):
                        player_info["mainArt"] = {}
                    player_info["mainArt"]["url"] = player_info["mainArt"].get(
                        "fullUrl"
                    )
                player_info["last_update"] = util.utcnow()
                event_serial = self.device_serial_number
                _LOGGER.debug(
                    f"Match media_id: {media_id} in waiting_media_id:{self._waiting_media_id} , player_info: {player_info}"
                )
                self._player_info = player_info
                info_changed = True
        elif "parent_state" in event:
            event_serial = safe_get(
                event, ["parent_state", "dopplerId", "deviceSerialNumber"]
            )
            if event_serial == self.device_serial_number:
                _LOGGER.debug(
                    "DeviceID(%s) receive event form parent: %s",
                    hide_serial(event_serial),
                    hide_serial(event),
                )
                parent_state = event.get("parent_state", {})
                if parent_state.get("volume") is None:
                    parent_state["volume"] = {
                        "muted": self._media_is_muted,
                        "volume": self._media_vol_level,
                    }
                self._set_attrs(parent_state)
                self._player_info = parent_state
                if parent_state.get("state") == "PLAYING" and (
                    parentSerial := (
                        safe_get(
                            event, ["parent_state", "dopplerId", "parentSerialNumber"]
                        )
                    )
                ):
                    self._playing_parent = self.hass.data[DATA_ALEXAMEDIA]["accounts"][
                        self._login.email
                    ]["entities"]["media_player"].get(parentSerial)
                else:
                    self._playing_parent = None
                info_changed = True
        if not event_serial:
            return
        if event_serial == self.device_serial_number:
            self._available = True
            self.schedule_update_ha_state()
        if "last_called_change" in event:
            if (
                event_serial == self.device_serial_number
                or any(
                    item["serialNumber"] == event_serial
                    for item in self._app_device_list
                )
                and self._last_called_timestamp
                != event["last_called_change"]["timestamp"]
            ):
                _LOGGER.debug(
                    "%s: last_called is %s (%s)",
                    hide_email(self._login.email),
                    self,
                    hide_serial(self.device_serial_number),
                )
                self._last_called = True
                self._last_called_timestamp = event["last_called_change"]["timestamp"]
                self._last_called_summary = event["last_called_change"].get("summary")
                self._last_called_response = event["last_called_change"].get("response")
                if self.hass and self.schedule_update_ha_state:
                    self.schedule_update_ha_state()
                _LOGGER.debug("[handle event] Updating notify targets")
                await self._update_notify_targets()
            else:
                self._last_called = False
            if self.hass and self.async_schedule_update_ha_state:
                force_refresh = not is_http2_enabled(self.hass, self._login.email)
                _LOGGER.debug(
                    "%s: scheduling ha_state update(force_refresh: %s)",
                    hide_email(self._login.email),
                    not is_http2_enabled(self.hass, self._login.email),
                )
                self.async_schedule_update_ha_state(force_refresh=force_refresh)
        elif "bluetooth_change" in event:
            if event_serial == self.device_serial_number:
                self._bluetooth_state = event["bluetooth_change"]
                self._source = self._get_source()
                self._source_list = self._get_source_list()
                self._connected_bluetooth = self._get_connected_bluetooth()
                self._bluetooth_list = self._get_bluetooth_list()
                streaming_state = self._bluetooth_state.get("streamingState")
                _LOGGER.debug(
                    "%s: Updating '%s' Bluetooth_state",
                    hide_email(self._login.email),
                    self.name,
                )
                if self._connected_bluetooth:
                    if (
                        not self._session
                        or self._session.get("mediaId") != "BluetoothMediaId"
                    ):
                        # Synthesize a Bluetooth media session when /api/np/player
                        # does not expose active Bluetooth playback state.
                        _LOGGER.debug("Creating synthesized Bluetooth media session")
                        self._session = {
                            "mediaId": "BluetoothMediaId",
                            "state": None,
                            "infoText": {},
                            "miniInfoText": {},
                            "mainArt": {},
                            "miniArt": {},
                            "progress": {},
                            "transport": {
                                "playPause": "ENABLED",
                                "next": "ENABLED",
                                "previous": "ENABLED",
                                "repeat": "HIDDEN",
                                "shuffle": "HIDDEN",
                            },
                            "volume": {
                                "muted": self._media_is_muted,
                                "volume": int((self._media_vol_level or 0) * 100),
                            },
                        }

                    # Defensive structure checks for nested dictionaries
                    for key in [
                        "infoText",
                        "miniInfoText",
                        "mainArt",
                        "miniArt",
                        "progress",
                    ]:
                        if key not in self._session or not isinstance(
                            self._session[key], dict
                        ):
                            self._session[key] = {}

                    # Handle playback states
                    if streaming_state == "MUSIC":
                        self._media_player_state = self._session["state"] = "PLAYING"
                    elif streaming_state in (None, "NONE"):
                        self._media_player_state = self._session["state"] = "PAUSED"

                    # Mutate track details
                    self._session["infoText"]["title"] = self._session["miniInfoText"][
                        "title"
                    ] = "Bluetooth"
                    self._session["infoText"]["subText1"] = self._session[
                        "miniInfoText"
                    ]["subText1"] = f"Streaming from {self._source}"
                    self._session["infoText"]["subText2"] = self._session[
                        "miniInfoText"
                    ]["subText2"] = ""

                    # Clear tracking progress lines
                    self._session["progress"]["mediaProgress"] = None
                    self._session["progress"]["mediaLength"] = None
                    self._media_pos = self._media_duration = None

                    # Prevent stale transport capabilities from previous media leaking into HA.
                    self._attr_supported_features = (
                        SUPPORT_ALEXA
                        & ~MediaPlayerEntityFeature.SEEK
                        & ~MediaPlayerEntityFeature.SHUFFLE_SET
                        & ~MediaPlayerEntityFeature.REPEAT_SET
                    )

                    # Use Bluetooth icon metadata for the synthesized Bluetooth session
                    self._session["mainArt"]["artType"] = "IconArtSource"
                    self._session["mainArt"]["iconId"] = "bluetooth-art"

                    self._set_attrs(self._session)

                else:
                    # This executes when self._connected_bluetooth evaluates to None/False
                    _LOGGER.debug(
                        "%s: Cleaning up Bluetooth state and session for %s",
                        hide_email(self._login.email),
                        self.name,
                    )

                    # Clear out media detail instance variables completely
                    self._clear_media_details()

                    # explicit resets to prevent stale data leakages
                    self._media_artist = None
                    self._media_album_name = None
                    self._media_title = None
                    self._media_pos = None
                    self._media_duration = None

                    # Teardown session tracking and push state back to IDLE
                    self._session = None
                    self._connected_bluetooth = None
                    self._media_player_state = "IDLE"

                if self.hass and self.schedule_update_ha_state:
                    self.schedule_update_ha_state()
        elif "player_state" in event:
            player_state = event["player_state"]
            _LOGGER.debug("player_state: %s", hide_serial(player_state))
            if event_serial == self.device_serial_number:
                if "audioPlayerState" in player_state:
                    _LOGGER.debug(
                        "%s: %s state update: %s",
                        hide_email(self._login.email),
                        self.name,
                        player_state["audioPlayerState"],
                    )
                    if player_state["audioPlayerState"] == "PLAYING":
                        self._media_player_state = "PLAYING"
                    elif player_state["audioPlayerState"] == "INTERRUPTED":
                        self._clear_media_details()
                    media_id = player_state.get("mediaReferenceId")
                    if media_id:
                        self._waiting_media_id = media_id
                        await _wait_player_info(media_id)
                        if self._waiting_media_id != media_id:
                            return
                    if not media_id and self._player_info is None:
                        # allow delay before trying to refresh to avoid http 400 errors
                        await asyncio.sleep(2)
                    await self.async_update()
                    already_refreshed = True
                elif "mediaReferenceId" in player_state:
                    _LOGGER.debug(
                        "%s: %s media update: %s",
                        hide_email(self._login.email),
                        self.name,
                        player_state["mediaReferenceId"],
                    )
                    await self.async_update()
                    already_refreshed = True
                elif "volumeSetting" in player_state:
                    _LOGGER.debug(
                        "%s: %s volume updated: %s",
                        hide_email(self._login.email),
                        self.name,
                        player_state["volumeSetting"],
                    )
                    if self._session:
                        if not self._session.get("volume"):
                            self._session["volume"] = {}
                        self._session["volume"]["volume"] = player_state[
                            "volumeSetting"
                        ]
                        self._session["volume"]["muted"] = player_state.get(
                            "isMuted", False
                        )
                        self._media_is_muted = self._session["volume"]["muted"]
                    self._media_vol_level = player_state["volumeSetting"] / 100
                    if self.hass and self.schedule_update_ha_state:
                        self.schedule_update_ha_state()
                elif "dopplerConnectionState" in player_state:
                    self.available = player_state["dopplerConnectionState"] == "ONLINE"
                    if self.hass and self.schedule_update_ha_state:
                        self.schedule_update_ha_state()
                await _refresh_if_no_audiopush(already_refreshed)
        elif "push_activity" in event:
            if self.state in {
                MediaPlayerState.IDLE,
                MediaPlayerState.PAUSED,
                MediaPlayerState.PLAYING,
            }:
                _LOGGER.debug(
                    "%s: %s checking for potential state update due to push activity on %s",
                    hide_email(self._login.email),
                    self.name,
                    hide_serial(event_serial),
                )
                # allow delay before trying to refresh to avoid http 400 errors
                await asyncio.sleep(2)
                await self.async_update()
                already_refreshed = True
        if info_changed and self._player_info and self._cluster_members:
            # This is Speaker Group or Speaker pair so throw event data
            if self.hass:
                for device_id in self._cluster_members:
                    json_payload = self._make_dispatcher_data(
                        self._player_info, device_id
                    )
                    _LOGGER.debug(
                        "Updating player info by parent (http2): %s",
                        hide_serial(json_payload),
                    )
                    async_dispatcher_send(
                        self.hass,
                        f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32],
                        {"parent_state": json_payload},
                    )

        if "queue_state" in event:
            queue_state = event["queue_state"]
            if event_serial == self.device_serial_number:
                if (
                    "trackOrderChanged" in queue_state
                    and not queue_state["trackOrderChanged"]
                    and "loopMode" in queue_state
                ):
                    self._attr_supported_features |= MediaPlayerEntityFeature.REPEAT_SET
                    self._repeat = queue_state["loopMode"] == "LOOP_QUEUE"
                    self._attr_repeat = (
                        RepeatMode.ALL if self._repeat else RepeatMode.OFF
                    )
                    _LOGGER.debug(
                        "%s: %s repeat updated to: %s %s",
                        hide_email(self._login.email),
                        self.name,
                        self._repeat,
                        queue_state["loopMode"],
                    )
                elif "playBackOrder" in queue_state:
                    self._attr_supported_features |= (
                        MediaPlayerEntityFeature.SHUFFLE_SET
                    )
                    self._shuffle = queue_state["playBackOrder"] == "SHUFFLE_ALL"
                    _LOGGER.debug(
                        "%s: %s shuffle updated to: %s %s",
                        hide_email(self._login.email),
                        self.name,
                        self._shuffle,
                        queue_state["playBackOrder"],
                    )
                await _refresh_if_no_audiopush(already_refreshed)

    def _make_dispatcher_data(
        self, player_info: dict[str, Any], device_id: str
    ) -> dict[str, Any]:
        """Rewrite data that propagates downstream"""
        json_payload = player_info.copy()
        json_payload["dopplerId"] = {
            "deviceSerialNumber": device_id,
            "parentSerialNumber": self._device_serial_number,
        }
        json_payload["isPlayingInLemur"] = False
        json_payload["lemurVolume"] = None
        json_payload["volume"] = None
        return json_payload

    def _clear_media_details(self):
        """Set all Media Items to None."""
        # General
        self._media_duration = None
        self._media_image_url = None
        self._media_title = None
        self._media_pos = None
        self._media_album_name = None
        self._media_artist = None
        self._media_player_state = "IDLE"
        self._media_is_muted = False
        # volume is also used for announce/tts so state should remain
        # self._media_vol_level = None
        self._attr_supported_features = SUPPORT_ALEXA
        self._player_info = None

    def _set_authentication_details(self, auth):
        """Set Authentication based off auth."""
        self._authenticated = auth["authenticated"]
        self._can_access_prime_music = auth["canAccessPrimeMusicContent"]
        self._customer_email = auth["customerEmail"]
        self._customer_id = auth["customerId"]
        self._customer_name = auth["customerName"]

    @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
    async def _api_get_state(self):
        return await self.alexa_api.get_state()

    @_catch_login_errors
    async def refresh(self, device=None, skip_api: bool = False, no_throttle=False):
        # pylint: disable=too-many-branches,too-many-statements
        """Refresh device data.

        This is a per device refresh and for many Alexa devices can result in
        many refreshes from each individual device. This will call the
        AlexaAPI directly.

        Args:
        device (json): A refreshed device json from Amazon. For efficiency,
                       an individual device does not refresh if it's reported
                       as offline.
        skip_api (bool): Whether to only due a device json update and not hit the API

        """
        if device is not None:
            self._device_name = device["accountName"]
            self._device_family = device["deviceFamily"]
            self._device_type = device["deviceType"]
            self._device_serial_number = device["serialNumber"]
            self._app_device_list = device["appDeviceList"]
            self._device_owner_customer_id = device["deviceOwnerCustomerId"]
            self._software_version = device["softwareVersion"]
            self._available = device["online"]
            self._capabilities = device["capabilities"]
            self._cluster_members = device["clusterMembers"]
            self._parent_clusters = device["parentClusters"]
            self._bluetooth_state = device.get("bluetooth_state", {})
            self._locale = device["locale"] if "locale" in device else "en-US"
            self._timezone = device["timeZoneId"] if "timeZoneId" in device else "UTC"
            self._dnd = device["dnd"] if "dnd" in device else None
            self._set_authentication_details(device["auth_info"])
        session = None
        api_call = False
        if self.available:
            _LOGGER.debug(
                "%s: Refreshing %s",
                self.account,
                self if device is None else self._device_name,
            )
            self._assumed_state = False
            if "PAIR_BT_SOURCE" in self._capabilities:
                self._source = self._get_source()
                self._source_list = self._get_source_list()
                self._connected_bluetooth = self._get_connected_bluetooth()
                self._bluetooth_list = self._get_bluetooth_list()
            new_last_called = self._get_last_called()
            if new_last_called and self._last_called != new_last_called:
                self._last_called = new_last_called
                self._last_called_timestamp = self.hass.data[DATA_ALEXAMEDIA][
                    "accounts"
                ][self._login.email]["last_called"]["timestamp"]
                self._last_called_summary = self.hass.data[DATA_ALEXAMEDIA]["accounts"][
                    self._login.email
                ]["last_called"].get("summary")
                self._last_called_response = self.hass.data[DATA_ALEXAMEDIA][
                    "accounts"
                ][self._login.email]["last_called"].get("response")
                _LOGGER.debug("[refresh] Updating notify targets")
                await self._update_notify_targets()
            if skip_api and self.hass:
                self.schedule_update_ha_state()
                return
            if "MUSIC_SKILL" in self._capabilities:
                if self._parent_clusters and self.hass:
                    playing_parents = list(
                        filter(
                            lambda x: (
                                self.hass.data[DATA_ALEXAMEDIA]["accounts"][
                                    self._login.email
                                ]["entities"]["media_player"].get(x)
                                and self.hass.data[DATA_ALEXAMEDIA]["accounts"][
                                    self._login.email
                                ]["entities"]["media_player"][x].state
                                == MediaPlayerState.PLAYING
                            ),
                            self._parent_clusters,
                        )
                    )
                else:
                    playing_parents = []
                parent_session = {}
                if playing_parents:
                    if len(playing_parents) > 1:
                        _LOGGER.warning(
                            "Found multiple playing parents please file an issue"
                        )
                    parent = self.hass.data[DATA_ALEXAMEDIA]["accounts"][
                        self._login.email
                    ]["entities"]["media_player"][playing_parents[0]]
                    self._playing_parent = parent
                    parent_session = parent.session
                if parent_session:
                    session = parent_session.copy()
                    session["isPlayingInLemur"] = False
                    session["lemurVolume"] = None
                    if parent_session.get("lemurVolume") and self.device_serial_number:
                        member_volume = safe_get(
                            parent_session,
                            ["lemurVolume", "memberVolume", self.device_serial_number],
                        )
                        if member_volume is not None:
                            session["volume"] = member_volume
                    session = {"playerInfo": session}
                else:
                    self._playing_parent = None
                    if self._player_info:
                        _player_info = self._player_info.copy()
                        if self._session:
                            _player_info["volume"] = self._session.get("volume", {})
                        session = {"playerInfo": _player_info}
                    else:
                        session = await self._api_get_state(no_throttle=no_throttle)
                        _LOGGER.debug("Returned data of _api_get_state(): %s", session)
                        api_call = True
                        if safe_get(session, ["playerInfo", "state"]) is None:
                            # _LOGGER.warning(
                            #     "%s: Can't get session state by alexa_api.get_state() of %s. Probably a re-login occurred, so ignore it this time.",
                            #     self.account,
                            #     self if device is None else self._device_name,
                            # )
                            return
        self._clear_media_details()
        # update the session if it exists
        self._session = session.get("playerInfo") if session else None
        if self._session:
            if self._session.get("isPlayingInLemur"):
                if menbers_volume := safe_get(
                    self._session, ["lemurVolume", "memberVolume"], {}
                ):
                    if self.hass:
                        for device_id in self._cluster_members:
                            json_payload = self._make_dispatcher_data(
                                self._session, device_id
                            )
                            json_payload["volume"] = menbers_volume.get(device_id)
                            _LOGGER.debug(
                                "Updating player info by parent (API Call): %s",
                                hide_serial(json_payload),
                            )
                            async_dispatcher_send(
                                self.hass,
                                f"{ALEXA_DOMAIN}_{hide_email(self._login.email)}"[0:32],
                                {"parent_state": json_payload},
                            )

            if _transport := self._session.get("transport"):
                if not api_call:
                    # API calls do not return correct values for "shuffle" and "repeat"
                    self._shuffle = (
                        _transport["shuffle"] == "SELECTED"
                        if (
                            "shuffle" in _transport
                            and _transport["shuffle"] not in ("DISABLED", "HIDDEN")
                        )
                        else None
                    )
                    self._repeat = (
                        _transport["repeat"] == "SELECTED"
                        if (
                            "repeat" in _transport
                            and _transport["repeat"] not in ("DISABLED", "HIDDEN")
                        )
                        else None
                    )
                    self._attr_repeat = (
                        RepeatMode.ALL if self._repeat else RepeatMode.OFF
                    )
                self._attr_supported_features = SUPPORT_ALEXA
                for transport_key, feature in TRANSPORT_FEATURES.items():
                    if api_call and transport_key in ("shuffle", "repeat"):
                        # API calls do not return correct values for "shuffle" and "repeat"
                        continue
                    if _transport.get(transport_key) in (
                        "DISABLED",
                        "HIDDEN",
                        None,
                    ) and self._attr_supported_features == (
                        self._attr_supported_features | feature
                    ):
                        self._attr_supported_features ^= feature

            if self._session.get("state"):
                self._set_attrs(self._session)
                # Safely access 'http2' setting
                push_disabled = not is_http2_enabled(self.hass, self._login.email)
                if (
                    push_disabled
                    and self.hass
                    and self._session.get("isPlayingInLemur")
                ):
                    asyncio.gather(
                        *map(
                            lambda x: (
                                self.hass.data[DATA_ALEXAMEDIA]["accounts"][
                                    self._login.email
                                ]["entities"]["media_player"][x].async_update()
                            ),
                            filter(
                                lambda x: (
                                    self.hass.data[DATA_ALEXAMEDIA]["accounts"][
                                        self._login.email
                                    ]["entities"]["media_player"].get(x)
                                    and self.hass.data[DATA_ALEXAMEDIA]["accounts"][
                                        self._login.email
                                    ]["entities"]["media_player"][x].available
                                ),
                                self._cluster_members,
                            ),
                        )
                    )
        if self.hass:
            self.schedule_update_ha_state()

    @property
    def source(self):
        """Return the current input source."""
        return self._source

    @property
    def source_list(self):
        """List of available input sources."""
        return self._source_list

    @_catch_login_errors
    async def async_select_source(self, source):
        """Select input source."""
        if source == "Local Speaker":
            if self.hass:
                self.hass.async_create_task(self.alexa_api.disconnect_bluetooth())
            else:
                await self.alexa_api.disconnect_bluetooth()
            self._source = "Local Speaker"
        elif self._bluetooth_state.get("pairedDeviceList"):
            for devices in self._bluetooth_state["pairedDeviceList"]:
                if devices["friendlyName"] == source:
                    if self.hass:
                        self.hass.async_create_task(
                            self.alexa_api.set_bluetooth(devices["address"])
                        )
                    else:
                        await self.alexa_api.set_bluetooth(devices["address"])
                    self._source = source
        # Safely access 'http2' setting
        if not is_http2_enabled(self.hass, self._login.email):
            await self.async_update()

    def _get_source(self):
        source = "Local Speaker"
        if self._bluetooth_state.get("pairedDeviceList"):
            for device in self._bluetooth_state["pairedDeviceList"]:
                if (
                    device["connected"] is True
                    and device["friendlyName"] in self.source_list
                ):
                    return device["friendlyName"]
        return source

    def _get_source_list(self):
        sources = []
        if self._bluetooth_state.get("pairedDeviceList"):
            for devices in self._bluetooth_state["pairedDeviceList"]:
                if devices["profiles"] and "A2DP-SOURCE" in devices["profiles"]:
                    sources.append(devices["friendlyName"])
        return ["Local Speaker"] + sources

    def _get_connected_bluetooth(self):
        source = None
        if self._bluetooth_state.get("pairedDeviceList"):
            for device in self._bluetooth_state["pairedDeviceList"]:
                if device["connected"] is True:
                    return device["friendlyName"]
        return source

    def _get_bluetooth_list(self):
        sources = []
        if self._bluetooth_state.get("pairedDeviceList"):
            for devices in self._bluetooth_state["pairedDeviceList"]:
                sources.append(devices["friendlyName"])
        return sources

    def _get_last_called(self):
        try:
            last_called_serial = (
                None
                if self.hass is None
                else (
                    self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._login.email][
                        "last_called"
                    ]["serialNumber"]
                )
            )
        except (TypeError, KeyError):
            last_called_serial = None
        _LOGGER.debug(
            "%s: %s: Last_called check: self: %s; reported: %s",
            hide_email(self._login.email),
            self._device_name,
            hide_serial(self._device_serial_number),
            hide_serial(last_called_serial),
        )
        return last_called_serial is not None and (
            self._device_serial_number == last_called_serial
            or any(
                item["serialNumber"] == last_called_serial
                for item in self._app_device_list
            )
        )

    def _set_attrs(self, player_info):
        """Set player attributes by player info dict."""
        self._media_player_state = player_info.get("state")
        self._media_title = safe_get(player_info, ["infoText", "title"])
        self._media_artist = safe_get(player_info, ["infoText", "subText1"])
        self._media_album_name = safe_get(player_info, ["infoText", "subText2"])
        self._media_image_url = safe_get(player_info, ["mainArt", "url"])
        self._media_pos = safe_get(player_info, ["progress", "mediaProgress"])
        self._media_duration = safe_get(player_info, ["progress", "mediaLength"])
        muted = volume = None
        if not player_info.get("lemurVolume"):
            if player_info.get("volume") is not None:
                volume_info = player_info.get("volume", {})
                muted = volume_info.get("muted")
                volume = volume_info.get("volume")
        else:
            if composite := safe_get(
                player_info, ["lemurVolume", "compositeVolume"], {}
            ):
                muted = safe_get(composite, ["muted"])
                volume = safe_get(composite, ["volume"])
        if muted is not None:
            self._media_is_muted = muted
        if volume is not None and isinstance(volume, (int, float)):
            if isinstance(volume, int) or volume > 1:
                self._media_vol_level = volume / 100
            else:
                self._media_vol_level = float(volume)

    @property
    def available(self):
        """Return the availability of the client."""
        return self._available

    @available.setter
    def available(self, state):
        """Set the availability state."""
        self._available = state

    @property
    def assumed_state(self):
        """Return whether the state is an assumed_state."""
        return self._assumed_state

    @property
    def hidden(self):
        """Return whether the sensor should be hidden."""
        return "MUSIC_SKILL" not in self._capabilities

    @property
    def unique_id(self):
        """Return the id of this Alexa client."""
        email = self._login.email
        return (
            slugify(f"{self.device_serial_number}_{email}")
            if self._second_account_index
            else self.device_serial_number
        )

    @property
    def name(self):
        """Return the name of the device."""
        return self._device_name

    @property
    def device_serial_number(self):
        """Return the machine identifier of the device."""
        return self._device_serial_number

    @property
    def session(self):
        """Return the session, if any."""
        return self._session

    @property
    def state(self):
        """Return the state of the device."""
        if not self.available:
            return STATE_UNAVAILABLE

        # Fix: Intercept and explicitly return standard Home Assistant player states
        # based on Amazon's streaming state string profile
        if self._connected_bluetooth and self._bluetooth_state:
            streaming_state = self._bluetooth_state.get("streamingState")
            if streaming_state == "MUSIC":
                return MediaPlayerState.PLAYING
            if streaming_state in (None, "NONE", "PAUSED"):
                return MediaPlayerState.PAUSED

        if self._media_player_state == "PLAYING":
            return MediaPlayerState.PLAYING
        if self._media_player_state == "PAUSED":
            return MediaPlayerState.PAUSED
        if self._media_player_state == "IDLE":
            return MediaPlayerState.IDLE
        return MediaPlayerState.IDLE

    def update(self):
        """Get the latest details on a media player synchronously."""
        return
        # return self.hass.add_job(async_update)

    @_catch_login_errors
    async def async_update(self):
        """Get the latest details on a media player.

        Because media players spend the majority of time idle, an adaptive
        update should be used to avoid flooding Amazon focusing on known
        play states. An initial version included an update_devices call on
        every update. However, this quickly floods the network for every new
        device added. This should only call refresh() to call the AlexaAPI.
        """
        try:
            if not self.enabled:
                return
        except AttributeError:
            pass
        email = self._login.email

        # Check if DATA_ALEXAMEDIA and 'accounts' exist
        accounts_data = safe_get(self.hass.data, [DATA_ALEXAMEDIA, "accounts"], {})
        if (
            self.entity_id is None  # Device has not initialized yet
            or email not in accounts_data
            or self._login.session.closed
        ):
            self._assumed_state = True
            self.available = False
            return

        # Safely access the device
        device = None
        if self.device_serial_number:
            device = safe_get(
                accounts_data,
                [email, "devices", "media_player", self.device_serial_number],
            )
        if not device:
            _LOGGER.warning(
                "Device serial number %s not found for account %s. Skipping update.",
                self.device_serial_number,
                hide_email(email),
            )
            self.available = False
            return

        # Safely access websocket_commands
        seen_commands = (
            accounts_data[email]["websocket_commands"].keys()
            if "websocket_commands" in accounts_data[email]
            else None
        )

        await self.refresh(device, no_throttle=True)

        # Safely access 'http2' setting
        push_enabled = is_http2_enabled(self.hass, self._login.email)

        _LOGGER.debug("is push_enabled? %s", push_enabled)

        if not push_enabled:
            if (
                self.state in [MediaPlayerState.PLAYING]
                and
                # Only enable polling if websocket not connected
                (
                    not push_enabled
                    or not seen_commands
                    or not (
                        "PUSH_AUDIO_PLAYER_STATE" in seen_commands
                        or "PUSH_MEDIA_CHANGE" in seen_commands
                        or "PUSH_MEDIA_PROGRESS_CHANGE" in seen_commands
                    )
                )
            ):
                self._should_poll = False  # disable polling since manual update
                if (
                    self._last_update == 0
                    or util.dt.as_timestamp(util.utcnow())
                    - util.dt.as_timestamp(self._last_update)
                    > PLAY_SCAN_INTERVAL
                ):
                    _LOGGER.debug(
                        "%s: %s playing; scheduling update in %s seconds",
                        hide_email(email),
                        self.name,
                        PLAY_SCAN_INTERVAL,
                    )
                    async_call_later(
                        self.hass,
                        PLAY_SCAN_INTERVAL,
                        self.async_schedule_update_ha_state,
                    )
            elif self._should_poll:  # Not playing, one last poll
                self._should_poll = False
                if not push_enabled:
                    _LOGGER.debug(
                        "%s: Disabling polling and scheduling last update in 300 seconds for %s",
                        hide_email(email),
                        self.name,
                    )
                    async_call_later(
                        self.hass,
                        300,
                        self.async_schedule_update_ha_state,
                    )
                else:
                    _LOGGER.debug(
                        "%s: Disabling polling for %s",
                        hide_email(email),
                        self.name,
                    )
        else:
            self._should_poll = False
        self._last_update = util.utcnow()
        self.schedule_update_ha_state()

    @property
    def media_content_type(self):
        """Return the content type of current playing media."""
        if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
            return MediaType.MUSIC
        return MediaPlayerState.IDLE

    @property
    def media_artist(self):
        """Return the artist of current playing media, music track only."""
        if self._connected_bluetooth:
            source_name = self._source or self._connected_bluetooth
            # Intercept if it's empty, None, or explicitly stuck on Amazon's generic boot string
            if not self._media_artist or self._media_artist == "Streaming":
                return f"Streaming from {source_name}"
        return self._media_artist

    @property
    def media_album_name(self):
        """Return the album name of current playing media, music track only."""
        if self._connected_bluetooth and self._source:
            return None
        return self._media_album_name

    @property
    def media_duration(self):
        """Return the duration of current playing media in seconds."""
        # Fix: Force None to stop Home Assistant core from falling back to zero-bound tracking
        if self._connected_bluetooth:
            return None
        return self._media_duration

    @property
    def media_position(self):
        """Return the position of current playing media in seconds."""
        if self._connected_bluetooth:
            return None
        return self._media_pos

    @property
    def media_position_updated_at(self):
        """When was the position of the current playing media valid."""
        # Fix: Prevent timestamp leaking when a simulated session has no linear timeline
        if self._session and self._session.get("mediaId") == "BluetoothMediaId":
            return None

        return (
            self._player_info["last_update"]
            if self._player_info and self._player_info.get("last_update")
            else self._last_update
        )

    @property
    def media_image_url(self) -> Optional[str]:
        """Return the image URL of current playing media."""
        # Force None during Bluetooth so Home Assistant stops looking for artwork files
        if (
            self._connected_bluetooth
            and self._session
            and self._session.get("mediaId") == "BluetoothMediaId"
        ):
            return None

        if self._media_image_url:
            return re.sub("\\(", "%28", re.sub("\\)", "%29", self._media_image_url))
            # fix failure of HA media player ui to quote "(" or ")"
        return None

    @property
    def icon(self) -> str:
        """Return the icon to use in the frontend."""
        # Dynamically inject the exact MDI bluetooth music icon during active push streams
        if (
            self._connected_bluetooth
            and self._session
            and self._session.get("mediaId") == "BluetoothMediaId"
        ):
            return "mdi:music-note-bluetooth"

        # Fall back to the default Alexa/Echo device icons defined elsewhere in the integration
        return super().icon

    @property
    def media_image_remotely_accessible(self) -> bool:
        """Return whether image is accessible outside of the home network."""
        return bool(self._media_image_url)

    @property
    def media_title(self):
        """Return the title of current playing media."""
        return self._media_title

    @property
    def device_family(self):
        """Return the make of the device (ex. Echo, Other)."""
        return self._device_family

    @property
    def dnd_state(self):
        """Return the Do Not Disturb state."""
        return self._dnd

    @dnd_state.setter
    def dnd_state(self, state):
        """Set the Do Not Disturb state."""
        self._dnd = state

    @_catch_login_errors
    async def async_set_shuffle(self, shuffle):
        """Enable/disable shuffle mode."""
        if self.hass:
            self.hass.async_create_task(self.alexa_api.shuffle(shuffle))
        else:
            await self.alexa_api.shuffle(shuffle)
        self._shuffle = shuffle

    @property
    def shuffle(self):
        """Return the Shuffle state."""
        return self._shuffle

    @shuffle.setter
    def shuffle(self, state):
        """Set the Shuffle state."""
        self._shuffle = state
        self.schedule_update_ha_state()

    @_catch_login_errors
    async def async_set_repeat(self, repeat: RepeatMode) -> None:
        """Set repeat mode."""
        repeat_state = repeat == RepeatMode.ALL
        if self.hass:
            self.hass.async_create_task(self.alexa_api.repeat(repeat_state))
        else:
            await self.alexa_api.repeat(repeat_state)
        self._repeat = repeat_state
        self._attr_repeat = RepeatMode.ALL if self._repeat else RepeatMode.OFF

    @property
    def repeat_state(self):
        """Return the Repeat state."""
        return self._repeat

    @repeat_state.setter
    def repeat_state(self, state):
        """Set the Repeat state."""
        self._repeat = state
        self.schedule_update_ha_state()

    @_catch_login_errors
    async def async_set_volume_level(self, volume):
        """Set volume level, range 0..1."""
        if not self.available:
            return

        # Save the current volume level before we change it
        _LOGGER.debug("Saving previous volume level: %s", self.volume_level)
        self._previous_volume = self.volume_level

        # Change the volume level on the device
        if self.hass:
            self.hass.async_create_task(self.alexa_api.set_volume(volume))
        else:
            await self.alexa_api.set_volume(volume)
        self._media_vol_level = volume

        # Let http2push update the new volume level
        if not is_http2_enabled(self.hass, self._login.email):
            # Otherwise we do it ourselves
            await self.async_update()

    @property
    def volume_level(self):
        """Return the volume level of the client (0..1)."""
        return self._media_vol_level

    @property
    def is_volume_muted(self):
        """Return boolean if volume is currently muted."""
        return self._media_is_muted

    @_catch_login_errors
    async def async_mute_volume(self, mute):
        """Mute the volume.

        Since we can't actually mute, we'll:
        - On mute, store volume and set volume to 0
        - On unmute, set volume to previously stored volume
        """
        if not self.available:
            return

        self._media_is_muted = mute
        if mute:
            self._saved_volume = self.volume_level
            if self.hass:
                self.hass.async_create_task(self.alexa_api.set_volume(0))
            else:
                await self.alexa_api.set_volume(0)
        else:
            if self._saved_volume is not None:
                if self.hass:
                    self.hass.async_create_task(
                        self.alexa_api.set_volume(self._saved_volume)
                    )
                else:
                    await self.alexa_api.set_volume(self._saved_volume)
            else:
                if self.hass:
                    self.hass.async_create_task(self.alexa_api.set_volume(50))
                else:
                    await self.alexa_api.set_volume(50)
        if not is_http2_enabled(self.hass, self._login.email):
            await self.async_update()

    @_catch_login_errors
    async def async_media_play(self):
        """Send play command."""
        if not (
            self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]
            and self.available
        ):
            return
        _LOGGER.debug(
            "%s: %s sending PLAY command; state=%s media_id=%s",
            hide_email(self._login.email),
            self.name,
            self.state,
            self._session.get("mediaId") if self._session else None,
        )
        if self._playing_parent:
            await self._playing_parent.async_media_play()
        else:
            is_bt = self._session and self._session.get("mediaId") == "BluetoothMediaId"
            _LOGGER.debug(
                "%s: %s PLAY precheck: is_bt=%s state=%s media_id=%s transport=%s",
                hide_email(self._login.email),
                self.name,
                is_bt,
                self.state,
                self._session.get("mediaId") if self._session else None,
                self._session.get("transport") if self._session else None,
            )
            result = await self.alexa_api.play()
            _LOGGER.debug(
                "%s: %s PLAY result: %s",
                hide_email(self._login.email),
                self.name,
                result,
            )
        if not is_http2_enabled(self.hass, self._login.email):
            await self.async_update()

    @_catch_login_errors
    async def async_media_pause(self):
        """Send pause command."""
        if not (
            self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]
            and self.available
        ):
            return
        if self._playing_parent:
            await self._playing_parent.async_media_pause()
        else:
            if self.hass:
                self.hass.async_create_task(self.alexa_api.pause())
            else:
                await self.alexa_api.pause()
        if not is_http2_enabled(self.hass, self._login.email):
            await self.async_update()

    @_catch_login_errors
    async def async_media_stop(self):
        """Send stop command."""
        if not self.available:
            return
        if self._playing_parent:
            await self._playing_parent.async_media_stop()
        else:
            if self.hass:
                self.hass.async_create_task(
                    self.alexa_api.stop(
                        customer_id=self._customer_id,
                        queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][
                            self.email
                        ]["options"][CONF_QUEUE_DELAY],
                    )
                )
            else:
                await self.alexa_api.stop(
                    customer_id=self._customer_id,
                    queue_delay=self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email][
                        "options"
                    ][CONF_QUEUE_DELAY],
                )
        if not is_http2_enabled(self.hass, self._login.email):
            await self.async_update()

    @_catch_login_errors
    async def async_turn_off(self):
        """Turn the client off.

        While Alexa's do not have on/off capability, we can use this as another
        trigger to do updates. For turning off, we can clear media_details.
        """
        self._should_poll = False
        await self.async_media_pause()
        self._clear_media_details()

    @_catch_login_errors
    async def async_turn_on(self):
        """Turn the client on.

        While Alexa's do not have on/off capability, we can use this as another
        trigger to do updates.
        """
        self._should_poll = True
        await self.async_media_pause()

    @_catch_login_errors
    async def async_media_next_track(self):
        """Send next track command."""
        if not (
            self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]
            and self.available
        ):
            return
        if self._playing_parent:
            await self._playing_parent.async_media_next_track()
        else:
            if self.hass:
                self.hass.async_create_task(self.alexa_api.next())
            else:
                await self.alexa_api.next()
        if not is_http2_enabled(self.hass, self._login.email):
            await self.async_update()

    @_catch_login_errors
    async def async_media_previous_track(self):
        """Send previous track command."""
        if not (
            self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]
            and self.available
        ):
            return
        if self._playing_parent:
            await self._playing_parent.async_media_previous_track()
        else:
            if self.hass:
                self.hass.async_create_task(self.alexa_api.previous())
            else:
                await self.alexa_api.previous()
        if not is_http2_enabled(self.hass, self._login.email):
            await self.async_update()

    @_catch_login_errors
    async def async_send_tts(self, message, **kwargs):
        """Send TTS to Device.

        NOTE: Does not work on WHA Groups.
        """
        if self.hass:
            self.hass.async_create_task(
                self.alexa_api.send_tts(
                    message, customer_id=self._customer_id, **kwargs
                )
            )
        else:
            await self.alexa_api.send_tts(
                message, customer_id=self._customer_id, **kwargs
            )

    @_catch_login_errors
    async def async_send_announcement(self, message, **kwargs):
        """Send announcement to the media player."""
        if self.hass:
            self.hass.async_create_task(
                self.alexa_api.send_announcement(
                    message, customer_id=self._customer_id, **kwargs
                )
            )
        else:
            await self.alexa_api.send_announcement(
                message, customer_id=self._customer_id, **kwargs
            )

    @_catch_login_errors
    async def async_send_mobilepush(self, message, **kwargs):
        """Send push to the media player's associated mobile devices."""
        if self.hass:
            self.hass.async_create_task(
                self.alexa_api.send_mobilepush(
                    message, customer_id=self._customer_id, **kwargs
                )
            )
        else:
            await self.alexa_api.send_mobilepush(
                message, customer_id=self._customer_id, **kwargs
            )

    @_catch_login_errors
    async def async_send_dropin_notification(self, message, **kwargs):
        """Send notification dropin to the media player's associated mobile devices."""
        if self.hass:
            self.hass.async_create_task(
                self.alexa_api.send_dropin_notification(
                    message, customer_id=self._customer_id, **kwargs
                )
            )
        else:
            await self.alexa_api.send_dropin_notification(
                message, customer_id=self._customer_id, **kwargs
            )

    @_catch_login_errors
    async def async_play_tts_cloud_say(self, public_url, media_id, **kwargs):
        file_name = media_id
        if media_source.is_media_source_id(media_id):
            media = await media_source.async_resolve_media(
                self.hass, media_id, self.entity_id
            )
            file_name = media.url[media.url.rindex("/") : media.url.rindex(".")]
            media_id = async_process_play_media_url(self.hass, media.url)

        if kwargs.get(ATTR_MEDIA_ANNOUNCE):
            input_file_path = self.hass.config.path(
                f"{UPLOAD_PATH}{file_name}_input.mp3"
            )
            output_file_name = f"{file_name}_output.mp3"
            output_file_path = self.hass.config.path(f"{UPLOAD_PATH}{output_file_name}")

            # file might already exist -> the same tts is cached from previous calls
            if not os.path.exists(output_file_path):
                await self.hass.async_add_executor_job(
                    urllib.request.urlretrieve, media_id, input_file_path
                )
                command = [
                    "ffmpeg",
                    "-i",
                    input_file_path,
                    "-ac",
                    "2",
                    "-codec:a",
                    "libmp3lame",
                    "-b:a",
                    "48k",
                    "-ar",
                    "24000",
                    "-write_xing",
                    "0",
                    output_file_path,
                ]
                if subprocess.run(command, check=True).returncode != 0:
                    _LOGGER.error(
                        "%s: %s:ffmpeg command FAILED converting %s to %s",
                        hide_email(self._login.email),
                        self,
                        input_file_path,
                        output_file_path,
                    )

            _LOGGER.debug(
                "%s: %s:Playing %slocal/alexa_tts%s",
                hide_email(self._login.email),
                self,
                public_url,
                output_file_name,
            )
            await self.async_send_tts(
                f"<audio src='{public_url}local/alexa_tts{output_file_name}' />"
            )
        else:
            await self.async_send_tts(STREAMING_ERROR_MESSAGE)
            _LOGGER.warning(STREAMING_ERROR_MESSAGE)

    @_catch_login_errors
    async def async_play_media(self, media_type, media_id, enqueue=None, **kwargs):
        # pylint: disable=unused-argument,too-many-branches
        """Send the play_media command to the media player."""
        queue_delay = self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email][
            "options"
        ].get(CONF_QUEUE_DELAY, DEFAULT_QUEUE_DELAY)
        public_url = self.hass.data[DATA_ALEXAMEDIA]["accounts"][self.email][
            "options"
        ].get(CONF_PUBLIC_URL, DEFAULT_PUBLIC_URL)
        if media_type == "music":
            if public_url:
                # Handle TTS playback
                await self.async_play_tts_cloud_say(public_url, media_id, **kwargs)
            else:
                # Log and notify for missing public URL
                _LOGGER.warning(PUBLIC_URL_ERROR_MESSAGE)
                await self.async_send_tts(PUBLIC_URL_ERROR_MESSAGE)
        elif media_type == "sequence":
            _LOGGER.debug(
                "%s: %s:Running sequence %s with queue_delay %s",
                hide_email(self._login.email),
                self,
                media_id,
                queue_delay,
            )
            if self.hass:
                self.hass.async_create_task(
                    self.alexa_api.send_sequence(
                        media_id,
                        customer_id=self._customer_id,
                        queue_delay=queue_delay,
                        **kwargs,
                    )
                )
            else:
                await self.alexa_api.send_sequence(
                    media_id,
                    customer_id=self._customer_id,
                    queue_delay=queue_delay,
                    **kwargs,
                )
        elif media_type == "routine":
            _LOGGER.debug(
                "%s: %s:Running routine %s with queue_delay %s",
                hide_email(self._login.email),
                self,
                media_id,
                queue_delay,
            )
            if self.hass:
                self.hass.async_create_task(
                    self.alexa_api.run_routine(
                        media_id,
                        queue_delay=queue_delay,
                    )
                )
            else:
                await self.alexa_api.run_routine(
                    media_id,
                    queue_delay=queue_delay,
                )
        elif media_type == "sound":
            _LOGGER.debug(
                "%s: %s:Playing sound %s with queue_delay %s",
                hide_email(self._login.email),
                self,
                media_id,
                queue_delay,
            )
            if self.hass:
                self.hass.async_create_task(
                    self.alexa_api.play_sound(
                        media_id,
                        customer_id=self._customer_id,
                        queue_delay=queue_delay,
                        **kwargs,
                    )
                )
            else:
                await self.alexa_api.play_sound(
                    media_id,
                    customer_id=self._customer_id,
                    queue_delay=queue_delay,
                    **kwargs,
                )
        elif media_type == "skill":
            _LOGGER.debug(
                "%s: %s:Running skill %s with queue_delay %s",
                hide_email(self._login.email),
                self,
                media_id,
                queue_delay,
            )
            if self.hass:
                self.hass.async_create_task(
                    self.alexa_api.run_skill(
                        media_id,
                        queue_delay=queue_delay,
                    )
                )
            else:
                await self.alexa_api.run_skill(
                    media_id,
                    queue_delay=queue_delay,
                )
        elif media_type == "image":
            _LOGGER.debug(
                "%s: %s:Setting background to %s",
                hide_email(self._login.email),
                self,
                media_id,
            )
            if self.hass:
                self.hass.async_create_task(self.alexa_api.set_background(media_id))
            else:
                await self.alexa_api.set_background(media_id)
        elif media_type == "custom":
            _LOGGER.debug(
                '%s: %s:Running custom command: "%s" with queue_delay %s',
                hide_email(self._login.email),
                self,
                media_id,
                queue_delay,
            )
            if self.hass:
                self.hass.async_create_task(
                    self.alexa_api.run_custom(
                        media_id,
                        customer_id=self._customer_id,
                        queue_delay=queue_delay,
                        **kwargs,
                    )
                )
            else:
                await self.alexa_api.run_custom(
                    media_id,
                    customer_id=self._customer_id,
                    queue_delay=queue_delay,
                    **kwargs,
                )
        else:
            _LOGGER.debug(
                "%s: %s:Playing music %s on %s with queue_delay %s",
                hide_email(self._login.email),
                self,
                media_id,
                media_type,
                queue_delay,
            )
            timer = safe_get(kwargs, ["extra", "timer"])
            if isinstance(timer, str):
                try:
                    timer = int(timer)
                except ValueError:
                    timer = None
            elif not isinstance(timer, int):
                timer = None
            if self.hass:
                self.hass.async_create_task(
                    self.alexa_api.play_music(
                        media_type,
                        media_id,
                        customer_id=self._customer_id,
                        queue_delay=queue_delay,
                        timer=timer,
                        **kwargs,
                    )
                )
            else:
                await self.alexa_api.play_music(
                    media_type,
                    media_id,
                    customer_id=self._customer_id,
                    queue_delay=queue_delay,
                    timer=timer,
                    **kwargs,
                )
        if not is_http2_enabled(self.hass, self._login.email):
            await self.async_update()

    @property
    def extra_state_attributes(self):
        """Return the state attributes."""
        attr = {
            "available": self.available,
            "last_called": self._last_called,
            "last_called_timestamp": self._last_called_timestamp,
            "last_called_summary": self._last_called_summary,
            "last_called_response": self._last_called_response,
            "connected_bluetooth": self._connected_bluetooth,
            "bluetooth_list": self._bluetooth_list,
            "history_records": self._history_records,
            "previous_volume": self._previous_volume,
        }
        return attr

    @property
    def should_poll(self):
        """Return the polling state."""
        return self._should_poll

    @property
    def device_info(self):
        """
        Provide the device information mapping used by Home Assistant for this entity.

        Returns:
            dict: A mapping containing the device's identification and metadata with keys:
                - `identifiers`: set containing a (domain, unique_id) tuple
                - `name`: device display name
                - `manufacturer`: device manufacturer (always "Amazon")
                - `model`: device model string
                - `serial_number`: device serial number (unique_id)
                - `sw_version`: software version
        """
        return {
            "identifiers": {(ALEXA_DOMAIN, self.unique_id)},
            "name": self.name,
            "manufacturer": "Amazon",
            "model": MODEL_IDS.get(
                self._device_type, f"{self._device_family} {self._device_type}"
            ),
            "serial_number": self.device_serial_number,
            "sw_version": self._software_version,
        }

    async def _update_notify_targets(self) -> None:
        """Update notification service targets."""
        notify = self.hass.data[DATA_ALEXAMEDIA].get("notify_service")
        if not notify:
            return

        if not hasattr(notify, "registered_targets"):
            _LOGGER.debug(
                "%s: Unable to refresh notify targets; notify not ready",
                hide_email(self._login.email),
            )
            return

        email = hide_email(self._login.email)

        _LOGGER.debug("%s: Refreshing notify targets", email)

        # Evaluate once for logging (HA legacy registration may evaluate again internally)
        targets = notify.targets

        entity_name = (self.entity_id or "").split(".", 1)[-1]
        suffix = (
            f"_{self._login.email}" if entity_name and entity_name[-1].isdigit() else ""
        )
        last_called_key = f"last_called{suffix}"
        last_called_uid = targets.get(last_called_key)

        _LOGGER.debug(
            "%s: Computed %d notify targets; %s -> %s",
            email,
            len(targets),
            last_called_key,
            hide_serial(last_called_uid),
        )

        await notify.async_register_services()

        prefix = getattr(notify, "_target_service_name_prefix", ALEXA_DOMAIN)
        service_key = slugify(f"{prefix}_{last_called_key}")
        mapped = notify.registered_targets.get(service_key)

        if notify.last_called and mapped != self.unique_id:
            _LOGGER.debug(
                "%s: notify last_called mapping mismatch: %s=%s; %s -> %s (expected %s)",
                email,
                last_called_key,
                hide_serial(last_called_uid),
                service_key,
                mapped,
                self.unique_id,
            )
            # Reset stale last_called target mapping and re-register notify services.
            previous_last_called = notify.last_called
            try:
                notify.last_called = False
                await notify.async_register_services()
                notify.last_called = True
                await notify.async_register_services()
                _LOGGER.debug(
                    "notify.last_called toggled for %s; re-registered notify services",
                    last_called_key,
                )
            except Exception:  # noqa: BLE001
                _LOGGER.exception(
                    "Failed to reset notify.last_called for %s", last_called_key
                )
            finally:
                notify.last_called = previous_last_called

        def _fire_last_called_event(_now) -> None:
            """Fire after yielding once so entity/service state has settled."""
            event_data = {
                "last_called": self.device_serial_number,
                "name": self._device_name,
                "timestamp": self._last_called_timestamp,
                "summary": self._last_called_summary,
                "response": self._last_called_response,
            }

            _LOGGER.debug("Firing alexa_media_last_called_event")
            self.hass.bus.fire("alexa_media_last_called_event", event_data)

        _LOGGER.debug("Scheduling alexa_media_last_called_event")
        # Defer to the next loop iteration so downstream consumers see updated state.
        async_call_later(self.hass, 0, _fire_last_called_event)
