"""Support for interface with an Samsung TV."""
from __future__ import annotations

import asyncio
from collections.abc import Coroutine, Sequence
from datetime import datetime, timedelta
from typing import Any

from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester
from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable
from async_upnp_client.client_factory import UpnpFactory
from async_upnp_client.exceptions import (
    UpnpActionResponseError,
    UpnpCommunicationError,
    UpnpConnectionError,
    UpnpError,
    UpnpResponseError,
    UpnpXmlContentError,
)
from async_upnp_client.profiles.dlna import DmrDevice
from async_upnp_client.utils import async_get_local_ip
import voluptuous as vol
from wakeonlan import send_magic_packet

from homeassistant.components.media_player import (
    MediaPlayerDeviceClass,
    MediaPlayerEntity,
    MediaPlayerEntityFeature,
    MediaPlayerState,
    MediaType,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_component
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.script import Script
from homeassistant.util import dt as dt_util

from .bridge import SamsungTVBridge, SamsungTVWSBridge
from .const import (
    CONF_MANUFACTURER,
    CONF_ON_ACTION,
    CONF_SSDP_RENDERING_CONTROL_LOCATION,
    DEFAULT_NAME,
    DOMAIN,
    LOGGER,
)

SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"}

SUPPORT_SAMSUNGTV = (
    MediaPlayerEntityFeature.PAUSE
    | MediaPlayerEntityFeature.VOLUME_STEP
    | MediaPlayerEntityFeature.VOLUME_MUTE
    | MediaPlayerEntityFeature.PREVIOUS_TRACK
    | MediaPlayerEntityFeature.SELECT_SOURCE
    | MediaPlayerEntityFeature.NEXT_TRACK
    | MediaPlayerEntityFeature.TURN_OFF
    | MediaPlayerEntityFeature.PLAY
    | MediaPlayerEntityFeature.PLAY_MEDIA
)

# Since the TV will take a few seconds to go to sleep
# and actually be seen as off, we need to wait just a bit
# more than the next scan interval
SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta(
    seconds=5
)

# Max delay waiting for app_list to return, as some TVs simply ignore the request
APP_LIST_DELAY = 3


async def async_setup_entry(
    hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
    """Set up the Samsung TV from a config entry."""
    bridge = hass.data[DOMAIN][entry.entry_id]

    host = entry.data[CONF_HOST]
    on_script = None
    data = hass.data[DOMAIN]
    if turn_on_action := data.get(host, {}).get(CONF_ON_ACTION):
        on_script = Script(
            hass, turn_on_action, entry.data.get(CONF_NAME, DEFAULT_NAME), DOMAIN
        )

    async_add_entities([SamsungTVDevice(bridge, entry, on_script)], True)


class SamsungTVDevice(MediaPlayerEntity):
    """Representation of a Samsung TV."""

    _attr_source_list: list[str]

    def __init__(
        self,
        bridge: SamsungTVBridge,
        config_entry: ConfigEntry,
        on_script: Script | None,
    ) -> None:
        """Initialize the Samsung device."""
        self._config_entry = config_entry
        self._host: str | None = config_entry.data[CONF_HOST]
        self._mac: str | None = config_entry.data.get(CONF_MAC)
        self._ssdp_rendering_control_location: str | None = config_entry.data.get(
            CONF_SSDP_RENDERING_CONTROL_LOCATION
        )
        self._on_script = on_script
        # Assume that the TV is in Play mode
        self._playing: bool = True

        self._attr_name: str | None = config_entry.data.get(CONF_NAME)
        self._attr_unique_id = config_entry.unique_id
        self._attr_is_volume_muted: bool = False
        self._attr_device_class = MediaPlayerDeviceClass.TV
        self._attr_source_list = list(SOURCES)
        self._app_list: dict[str, str] | None = None
        self._app_list_event: asyncio.Event = asyncio.Event()

        self._attr_supported_features = SUPPORT_SAMSUNGTV
        if self._on_script or self._mac:
            # Add turn-on if on_script or mac is available
            self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON
        if self._ssdp_rendering_control_location:
            self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET

        self._attr_device_info = DeviceInfo(
            name=self.name,
            manufacturer=config_entry.data.get(CONF_MANUFACTURER),
            model=config_entry.data.get(CONF_MODEL),
        )
        if self.unique_id:
            self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)}
        if self._mac:
            self._attr_device_info["connections"] = {
                (CONNECTION_NETWORK_MAC, self._mac)
            }

        # Mark the end of a shutdown command (need to wait 15 seconds before
        # sending the next command to avoid turning the TV back ON).
        self._end_of_power_off: datetime | None = None
        self._bridge = bridge
        self._auth_failed = False
        self._bridge.register_reauth_callback(self.access_denied)
        self._bridge.register_app_list_callback(self._app_list_callback)

        self._dmr_device: DmrDevice | None = None
        self._upnp_server: AiohttpNotifyServer | None = None

    def _update_sources(self) -> None:
        self._attr_source_list = list(SOURCES)
        if app_list := self._app_list:
            self._attr_source_list.extend(app_list)

    def _app_list_callback(self, app_list: dict[str, str]) -> None:
        """App list callback."""
        self._app_list = app_list
        self._update_sources()
        self._app_list_event.set()

    def access_denied(self) -> None:
        """Access denied callback."""
        LOGGER.debug("Access denied in getting remote object")
        self._auth_failed = True
        self.hass.create_task(
            self.hass.config_entries.flow.async_init(
                DOMAIN,
                context={
                    "source": SOURCE_REAUTH,
                    "entry_id": self._config_entry.entry_id,
                },
                data=self._config_entry.data,
            )
        )

    async def async_will_remove_from_hass(self) -> None:
        """Handle removal."""
        await self._async_shutdown_dmr()

    async def async_update(self) -> None:
        """Update state of device."""
        if self._auth_failed or self.hass.is_stopping:
            return
        old_state = self._attr_state
        if self._power_off_in_progress():
            self._attr_state = MediaPlayerState.OFF
        else:
            self._attr_state = (
                MediaPlayerState.ON
                if await self._bridge.async_is_on()
                else MediaPlayerState.OFF
            )
        if self._attr_state != old_state:
            LOGGER.debug("TV %s state updated to %s", self._host, self.state)

        if self._attr_state != MediaPlayerState.ON:
            if self._dmr_device and self._dmr_device.is_subscribed:
                await self._dmr_device.async_unsubscribe_services()
            return

        startup_tasks: list[Coroutine[Any, Any, Any]] = []

        if not self._app_list_event.is_set():
            startup_tasks.append(self._async_startup_app_list())

        if self._dmr_device and not self._dmr_device.is_subscribed:
            startup_tasks.append(self._async_resubscribe_dmr())
        if not self._dmr_device and self._ssdp_rendering_control_location:
            startup_tasks.append(self._async_startup_dmr())

        if startup_tasks:
            await asyncio.gather(*startup_tasks)

        self._update_from_upnp()

    @callback
    def _update_from_upnp(self) -> bool:
        # Upnp events can affect other attributes that we currently do not track
        # We want to avoid checking every attribute in 'async_write_ha_state' as we
        # currently only care about two attributes
        if (dmr_device := self._dmr_device) is None:
            return False

        has_updates = False

        if (
            volume_level := dmr_device.volume_level
        ) is not None and self._attr_volume_level != volume_level:
            self._attr_volume_level = volume_level
            has_updates = True

        if (
            is_muted := dmr_device.is_volume_muted
        ) is not None and self._attr_is_volume_muted != is_muted:
            self._attr_is_volume_muted = is_muted
            has_updates = True

        return has_updates

    async def _async_startup_app_list(self) -> None:
        await self._bridge.async_request_app_list()
        if self._app_list_event.is_set():
            # The try+wait_for is a bit expensive so we should try not to
            # enter it unless we have to (Python 3.11 will have zero cost try)
            return
        try:
            await asyncio.wait_for(self._app_list_event.wait(), APP_LIST_DELAY)
        except asyncio.TimeoutError as err:
            # No need to try again
            self._app_list_event.set()
            LOGGER.debug("Failed to load app list from %s: %r", self._host, err)

    async def _async_startup_dmr(self) -> None:
        assert self._ssdp_rendering_control_location is not None
        if self._dmr_device is None:
            session = async_get_clientsession(self.hass)
            upnp_requester = AiohttpSessionRequester(session)
            # Set non_strict to avoid invalid data sent by Samsung TV:
            # Got invalid value for <UpnpStateVariable(PlaybackStorageMedium, string)>:
            # NETWORK,NONE
            upnp_factory = UpnpFactory(upnp_requester, non_strict=True)
            upnp_device: UpnpDevice | None = None
            try:
                upnp_device = await upnp_factory.async_create_device(
                    self._ssdp_rendering_control_location
                )
            except (UpnpConnectionError, UpnpResponseError, UpnpXmlContentError) as err:
                LOGGER.debug("Unable to create Upnp DMR device: %r", err, exc_info=True)
                return
            _, event_ip = await async_get_local_ip(
                self._ssdp_rendering_control_location, self.hass.loop
            )
            source = (event_ip or "0.0.0.0", 0)
            self._upnp_server = AiohttpNotifyServer(
                requester=upnp_requester,
                source=source,
                callback_url=None,
                loop=self.hass.loop,
            )
            await self._upnp_server.async_start_server()
            self._dmr_device = DmrDevice(upnp_device, self._upnp_server.event_handler)

            try:
                self._dmr_device.on_event = self._on_upnp_event
                await self._dmr_device.async_subscribe_services(auto_resubscribe=True)
            except UpnpResponseError as err:
                # Device rejected subscription request. This is OK, variables
                # will be polled instead.
                LOGGER.debug("Device rejected subscription: %r", err)
            except UpnpError as err:
                # Don't leave the device half-constructed
                self._dmr_device.on_event = None
                self._dmr_device = None
                await self._upnp_server.async_stop_server()
                self._upnp_server = None
                LOGGER.debug("Error while subscribing during device connect: %r", err)
                raise

    async def _async_resubscribe_dmr(self) -> None:
        assert self._dmr_device
        try:
            await self._dmr_device.async_subscribe_services(auto_resubscribe=True)
        except UpnpCommunicationError as err:
            LOGGER.debug("Device rejected re-subscription: %r", err, exc_info=True)

    async def _async_shutdown_dmr(self) -> None:
        """Handle removal."""
        if (dmr_device := self._dmr_device) is not None:
            self._dmr_device = None
            dmr_device.on_event = None
            await dmr_device.async_unsubscribe_services()

        if (upnp_server := self._upnp_server) is not None:
            self._upnp_server = None
            await upnp_server.async_stop_server()

    def _on_upnp_event(
        self, service: UpnpService, state_variables: Sequence[UpnpStateVariable]
    ) -> None:
        """State variable(s) changed, let home-assistant know."""
        # Ensure the entity has been added to hass to avoid race condition
        if self._update_from_upnp() and self.entity_id:
            self.async_write_ha_state()

    async def _async_launch_app(self, app_id: str) -> None:
        """Send launch_app to the tv."""
        if self._power_off_in_progress():
            LOGGER.info("TV is powering off, not sending launch_app command")
            return
        assert isinstance(self._bridge, SamsungTVWSBridge)
        await self._bridge.async_launch_app(app_id)

    async def _async_send_keys(self, keys: list[str]) -> None:
        """Send a key to the tv and handles exceptions."""
        assert keys
        if self._power_off_in_progress() and keys[0] != "KEY_POWEROFF":
            LOGGER.info("TV is powering off, not sending keys: %s", keys)
            return
        await self._bridge.async_send_keys(keys)

    def _power_off_in_progress(self) -> bool:
        return (
            self._end_of_power_off is not None
            and self._end_of_power_off > dt_util.utcnow()
        )

    @property
    def available(self) -> bool:
        """Return the availability of the device."""
        if self._auth_failed:
            return False
        return (
            self.state == MediaPlayerState.ON
            or self._on_script is not None
            or self._mac is not None
            or self._power_off_in_progress()
        )

    async def async_turn_off(self) -> None:
        """Turn off media player."""
        self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME
        await self._bridge.async_power_off()

    async def async_set_volume_level(self, volume: float) -> None:
        """Set volume level on the media player."""
        if (dmr_device := self._dmr_device) is None:
            LOGGER.info("Upnp services are not available on %s", self._host)
            return
        try:
            await dmr_device.async_set_volume_level(volume)
        except UpnpActionResponseError as err:
            LOGGER.warning("Unable to set volume level on %s: %r", self._host, err)

    async def async_volume_up(self) -> None:
        """Volume up the media player."""
        await self._async_send_keys(["KEY_VOLUP"])

    async def async_volume_down(self) -> None:
        """Volume down media player."""
        await self._async_send_keys(["KEY_VOLDOWN"])

    async def async_mute_volume(self, mute: bool) -> None:
        """Send mute command."""
        await self._async_send_keys(["KEY_MUTE"])

    async def async_media_play_pause(self) -> None:
        """Simulate play pause media player."""
        if self._playing:
            await self.async_media_pause()
        else:
            await self.async_media_play()

    async def async_media_play(self) -> None:
        """Send play command."""
        self._playing = True
        await self._async_send_keys(["KEY_PLAY"])

    async def async_media_pause(self) -> None:
        """Send media pause command to media player."""
        self._playing = False
        await self._async_send_keys(["KEY_PAUSE"])

    async def async_media_next_track(self) -> None:
        """Send next track command."""
        await self._async_send_keys(["KEY_CHUP"])

    async def async_media_previous_track(self) -> None:
        """Send the previous track command."""
        await self._async_send_keys(["KEY_CHDOWN"])

    async def async_play_media(
        self, media_type: str, media_id: str, **kwargs: Any
    ) -> None:
        """Support changing a channel."""
        if media_type == MediaType.APP:
            await self._async_launch_app(media_id)
            return

        if media_type != MediaType.CHANNEL:
            LOGGER.error("Unsupported media type")
            return

        # media_id should only be a channel number
        try:
            cv.positive_int(media_id)
        except vol.Invalid:
            LOGGER.error("Media ID must be positive integer")
            return

        await self._async_send_keys(
            keys=[f"KEY_{digit}" for digit in media_id] + ["KEY_ENTER"]
        )

    def _wake_on_lan(self) -> None:
        """Wake the device via wake on lan."""
        send_magic_packet(self._mac, ip_address=self._host)
        # If the ip address changed since we last saw the device
        # broadcast a packet as well
        send_magic_packet(self._mac)

    async def async_turn_on(self) -> None:
        """Turn the media player on."""
        if self._on_script:
            await self._on_script.async_run(context=self._context)
        elif self._mac:
            await self.hass.async_add_executor_job(self._wake_on_lan)

    async def async_select_source(self, source: str) -> None:
        """Select input source."""
        if self._app_list and source in self._app_list:
            await self._async_launch_app(self._app_list[source])
            return

        if source in SOURCES:
            await self._async_send_keys([SOURCES[source]])
            return

        LOGGER.error("Unsupported source")
