"""API for communicating with OpenWebIf."""

from __future__ import annotations

import logging
import unicodedata
from collections import OrderedDict
from dataclasses import dataclass
from re import sub
from time import time
from typing import TYPE_CHECKING, Any, cast

import aiohttp
from yarl import URL

from .constants import (
    PATH_ABOUT,
    PATH_BOUQUETS,
    PATH_EPGNOW,
    PATH_GETALLSERVICES,
    PATH_GRAB,
    PATH_MESSAGE,
    PATH_POWERSTATE,
    PATH_REMOTECONTROL,
    PATH_STATUSINFO,
    PATH_VOL,
    PATH_ZAP,
)
from .enums import (
    MessageType,
    PlaybackType,
    PowerState,
    RemoteControlCodes,
    ScreenGrabFormat,
    ScreenGrabMode,
    SetVolumeOption,
    SType,
)
from .error import InvalidAuthError

if TYPE_CHECKING:
    from collections.abc import Mapping

    from openwebif.types import Bouquets

_LOGGER = logging.getLogger(__name__)


def enable_logging() -> None:
    """Set up the logging for home assistant."""
    logging.basicConfig(level=logging.INFO)


# pylint: disable=too-many-instance-attributes
@dataclass
class OpenWebIfServiceEvent:
    """Represent a OpenWebIf service event."""

    filename: str | None = None
    id: int | None = None
    name: str | None = None
    serviceref: str | None = None
    begin: str | None = None
    begin_timestamp: int | None = None
    end: str | None = None
    end_timestamp: int | None = None
    description: str | None = None
    fulldescription: str | None = None
    station: str | None = None


# pylint: disable=too-many-instance-attributes
@dataclass
class OpenWebIfStatus:
    """Repesent a OpenWebIf status."""

    currservice: OpenWebIfServiceEvent
    volume: int | None = None
    muted: bool | None = None
    in_standby: bool | None = False
    is_recording: bool | None = False
    streaming_list: str | None = None
    is_streaming: bool | None = False
    status_info: dict | None = None
    is_recording_playback: bool | None = False


# pylint: disable=too-many-instance-attributes, disable=too-many-public-methods
class OpenWebIfDevice:
    """Represent a OpenWebIf client device."""

    _session: aiohttp.ClientSession | None
    base: URL
    status: OpenWebIfStatus
    is_offline: bool = False
    turn_off_to_deep: bool
    picon_url: str | None = None
    source_bouquet: str | None = None
    mac_address: str | None = None
    sources: dict[str, Any]
    source_list: list[str]
    bouquet_list: OrderedDict[str, str]  # {bouquetname: bouquetref}

    def __init__(
        self,
        host: str | aiohttp.ClientSession,
        port: int = 80,
        username: str | None = None,
        password: str | None = None,
        is_https: bool = False,
        turn_off_to_deep: bool = False,
        source_bouquet: str | None = None,
    ) -> None:
        """Define an enigma2 device.

        :param host: IP or hostname or a ClientSession
        :param port: OpenWebif port
        :param username: e2 user
        :param password: e2 user password
        :param is_https: use https or not
        :param turn_off_to_deep: If True, send to deep standby on turn off
        :param source_bouquet: Which bouquet ref you want to load
        """
        enable_logging()

        if isinstance(host, str):
            _LOGGER.debug("Initialising new openwebif client for host: %s", host)
            self.base = URL.build(
                scheme="http" if not is_https else "https",
                host=host,
                port=port,
                user=username,
                password=password,
            )
            self._session = aiohttp.ClientSession(self.base)
        else:
            self.base = cast(URL, host._base_url)  # noqa: SLF001
            self._session = host
        self.turn_off_to_deep = turn_off_to_deep
        self.source_bouquet = source_bouquet
        self.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent())
        self.sources = {}
        self.source_list = []
        self.bouquet_list = OrderedDict()

    async def close(self) -> None:
        """Close the connection."""
        if self._session is not None and not self._session.closed:
            await self._session.close()
            self._session = None

    def default_all(self) -> None:
        """Set all properties to default."""
        self.status = OpenWebIfStatus(currservice=OpenWebIfServiceEvent())

    async def get_about(self) -> dict[str, Any]:
        """Get general information."""
        return await self._call_api(PATH_ABOUT)

    async def get_status_info(self) -> dict[str, Any]:
        """Get status info."""
        return await self._call_api(PATH_STATUSINFO)

    async def update(self) -> None:
        """Refresh current state based from <host>/api/statusinfo."""
        self.status.status_info = await self._call_api(PATH_STATUSINFO)

        if self.is_offline or not self.status.status_info:
            self.default_all()
            return

        self.status.currservice.filename = self.status.status_info.get(
            "currservice_filename",
        )
        self.status.currservice.id = self.status.status_info.get("currservice_id")
        self.status.currservice.name = self.status.status_info.get("currservice_name")
        self.status.currservice.serviceref = self.status.status_info.get(
            "currservice_serviceref",
        )
        self.status.currservice.begin = self.status.status_info.get("currservice_begin")
        self.status.currservice.begin_timestamp = self.status.status_info.get(
            "currservice_begin_timestamp",
        )

        self.status.currservice.end = self.status.status_info.get("currservice_end")

        self.status.currservice.end_timestamp = self.status.status_info.get(
            "currservice_end_timestamp",
        )

        self.status.currservice.description = self.status.status_info.get(
            "currservice_description",
        )
        self.status.currservice.station = self.status.status_info.get(
            "currservice_station",
        )
        self.status.currservice.fulldescription = self.status.status_info.get(
            "currservice_fulldescription",
        )
        self.status.in_standby = self.status.status_info["inStandby"] == "true"
        self.status.is_recording = self.status.status_info["isRecording"] == "true"
        if "isStreaming" in self.status.status_info:
            self.status.is_streaming = self.status.status_info["isStreaming"] == "true"
        else:
            self.status.is_streaming = None
        self.status.muted = self.status.status_info.get("muted")
        self.status.volume = self.status.status_info.get("volume")

        if not self.sources:
            self.sources = await self.get_bouquet_sources(bouquet=self.source_bouquet)
            self.source_list = list(self.sources.keys())

        if self.get_current_playback_type() == PlaybackType.RECORDING:
            # try get correct channel name
            channel_name = self.get_channel_name_from_serviceref()
            self.status.status_info["currservice_station"] = channel_name
            self.status.currservice.station = channel_name
            self.status.currservice.name = f"🔴 {self.status.currservice.name}"

        if not self.status.in_standby:
            url = await self.get_current_playing_picon_url(
                channel_name=self.status.currservice.station,
                currservice_serviceref=self.status.currservice.serviceref,
            )
            self.picon_url = str(self.base.with_path(url)) if url is not None else None

    async def get_volume(self) -> int:
        """Get the current volume."""

        return int((await self._call_api(PATH_VOL))["current"])

    async def set_volume(self, new_volume: int | SetVolumeOption) -> bool:
        """Set the volume to the new value.

        :param new_volume: int from 0-100
        :return: True if successful, false if there was a problem
        """
        return self._check_response_result(
            await self._call_api(
                PATH_VOL,
                {
                    "set": ("set" + str(new_volume))
                    if isinstance(new_volume, int)
                    else str(new_volume),
                },
            ),
        )

    async def send_message(
        self,
        text: str,
        message_type: MessageType = MessageType.INFO,
        timeout: int = -1,
    ) -> bool:
        """Send a message to the TV screen.

        :param text: The message to display
        :param message_type: The type of message (0 = YES/NO, 1 = INFO, 2 = WARNING, 3 = ERROR)
        :return: True if successful, false if there was a problem
        """

        return self._check_response_result(
            await self._call_api(
                PATH_MESSAGE,
                {"timeout": timeout, "type": message_type.value, "text": text},
            ),
        )

    async def turn_on(self) -> bool:
        """Take the box out of standby."""

        if self.is_offline:
            _LOGGER.debug("Box is offline, going to try wake on lan")
            # self.wake_up()

        return self._check_response_result(
            await self._call_api(PATH_POWERSTATE, {"newstate": PowerState.WAKEUP}),
        )

    def get_screen_grab_url(
        self,
        mode: ScreenGrabMode = ScreenGrabMode.ALL,
        file_format: ScreenGrabFormat = ScreenGrabFormat.JPG,
        resolution: int = 0,
    ) -> URL:
        """Get the URL for a screen grab.

        :param mode: The screen grab mode
        :param file_format: The picture format
        :param resolution: The resolution to grab (0 = native resolution)
        :return: The URL for the screen grab
        """
        return self.base.with_path(PATH_GRAB).with_query(
            {
                "mode": mode.value,
                "format": file_format.value,
                "t": int(time()),
                "r": resolution,
            },
        )

    async def turn_off(self) -> bool:
        """Put the box out into standby."""
        if self.turn_off_to_deep:
            return await self.deep_standby()

        return self._check_response_result(
            await self._call_api(PATH_POWERSTATE, {"newstate": PowerState.STANDBY}),
        )

    async def deep_standby(self) -> bool:
        """Go into deep standby."""

        return self._check_response_result(
            await self._call_api(
                PATH_POWERSTATE,
                {"newstate": PowerState.DEEP_STANDBY},
            ),
        )

    async def set_powerstate(self, newstate: PowerState) -> bool:
        """Set a new power state."""

        return self._check_response_result(
            await self._call_api(PATH_POWERSTATE, {"newstate": newstate.value}),
        )

    async def send_remote_control_action(self, action: RemoteControlCodes) -> bool:
        """Send a remote control command."""

        return self._check_response_result(
            await self._call_api(PATH_REMOTECONTROL, {"command": action.value}),
        )

    async def toggle_mute(self) -> bool:
        """Send mute command."""
        response = await self._call_api(PATH_VOL, {"set": SetVolumeOption.MUTE})
        return False if response is None else bool(response["ismute"])

    @staticmethod
    def _check_response_result(response: dict[str, Any] | None) -> bool:
        """Check the result of the response.

        :param response:
        :return: Returns True if command success, else, False
        """
        return False if response is None else bool(response["result"])

    def is_currently_recording_playback(self) -> bool:
        """Return true if playing back recording."""
        return self.get_current_playback_type() == PlaybackType.RECORDING

    def get_current_playback_type(self) -> PlaybackType | None:
        """Get the currservice_serviceref playing media type.

        :return: PlaybackType.LIVE or PlaybackType.RECORDING
        """

        if self.status.currservice and self.status.currservice.serviceref:
            if self.status.currservice.serviceref.startswith("1:0:0"):
                # This is a recording, not a live channel
                return PlaybackType.RECORDING

            return PlaybackType.LIVE
        return None

    async def get_current_playing_picon_url(
        self,
        channel_name: str | None = None,
        currservice_serviceref: str | None = None,
    ) -> str | None:
        """Return the URL to the picon image for the currently playing channel.

        :param channel_name: If specified, it will base url on this channel
        name else, fetch latest from get_status_info()
        :param currservice_serviceref: The service_ref for the current service
        :return: The URL, or None if not available
        """

        if channel_name is None:
            channel_name = self.status.currservice.station

        currservice_serviceref = str(self.status.currservice.serviceref)

        if self.status.is_recording_playback:
            channel_name = self.get_channel_name_from_serviceref()

        url = f"/picon/{self.get_picon_name(str(channel_name))}.png"
        _LOGGER.debug("trying picon url (by channel name): %s", url)
        if await self.url_exists(url):
            return url

        # Last ditch attempt.
        # Now try old way, using service ref name.
        # See https://github.com/home-assistant/home-assistant/issues/22293
        #
        # e.g.
        # sref: "1:0:19:2887:40F:1:C00000:0:0:0:"
        # url: http://vusolo2/picon/1_0_19_2887_40F_1_C00000_0_0_0.png)
        url = f"/picon/{currservice_serviceref.strip(':').replace(':', '_')}.png"
        _LOGGER.debug("trying picon url (with sref): %s", url)
        if await self.url_exists(url):
            return url

        _LOGGER.debug("Could not find picon for: %s", channel_name)
        return None

    def get_channel_name_from_serviceref(self) -> str | None:
        """Try to get the channel name from the recording file name."""
        if self.status.currservice.serviceref is None:
            return None
        if len(splits := self.status.currservice.serviceref.split("-")) >= 2:
            # We guess the channel name based on the default recording name scheme
            # Example file name: "20201019 2027 - SBS6 HD - Chateau Meiland"
            return splits[1].strip()
        return self.status.currservice.serviceref

    async def url_exists(self, url: str) -> bool:
        """Check if a given URL responds to a HEAD request.

        :param url: url to test
        :return: True or False
        """

        if self._session is None:
            self._session = aiohttp.ClientSession(self.base)

        request = await self._session.head(url)
        if request.status == 200:
            return True

        _LOGGER.debug("url at %s does not exist.", url)
        return False

    @staticmethod
    def get_picon_name(channel_name: str) -> str:
        """Get the name as format is outlined here.

        https://github.com/openatv/enigma2/blob/master/lib/python/Components/Renderer/Picon.py

        :param channel_name: The name of the channel
        :return: the correctly formatted name
        """
        _LOGGER.debug("Getting Picon URL for %s", channel_name)

        return sub(
            "[^a-z0-9]",
            "",
            (
                unicodedata.normalize("NFKD", channel_name)
                .encode("ASCII", "ignore")
                .decode("utf-8")
            )
            .replace("&", "and")
            .replace("+", "plus")
            .replace("*", "star")
            .lower(),
        )

    async def get_version(self) -> str:
        """Return the Openwebif version."""

        about = await self.get_about()
        return str(about["info"]["webifver"]) if about is not None else None

    async def get_bouquet_sources(self, bouquet: str | None = None) -> dict[str, Any]:
        """Get a dict of source names and sources in the bouquet.

        If bouquet is None, the first bouquet will be read from.

        :param bouquet: The bouquet
        :return: a dict
        """
        sources: dict[str, Any] = {}
        bRef = ""

        if not self.bouquet_list:
            self.bouquet_list = OrderedDict(
                {b[1]: b[0] for b in (await self.get_all_bouquets())["bouquets"]},
            )

        if not bouquet:
            # load first bouquet
            if len(self.bouquet_list) == 0:
                _LOGGER.debug("%s: No bouquets were found.", self.base)
                return sources
            bRef = next(iter(self.bouquet_list.values()))

        elif not bouquet.startswith("1:7:1:0:0:0:0:0:0:0"):
            # Not a bouquet reference -> get bref from name

            bRef = self.bouquet_list.get(bouquet)
            if not bRef:
                _LOGGER.error('Specified bouquet "%s" could not be found', bouquet)
                return sources

        result = await self._call_api(PATH_EPGNOW, {"bRef": bRef})

        if result and "events" in result and len(result["events"]) > 0:
            sources = {
                src["sname"]: src["sref"]
                for src in result["events"]
                if "sname" in src and "sref" in src
            }
        else:
            _LOGGER.warning("No sources could be loaded from specified bouquet.")
        return sources

    async def get_all_services(self) -> dict[str, Any]:
        """Get list of all services."""
        return await self._call_api(PATH_GETALLSERVICES)

    async def get_bouquets(self, stype: SType = SType.TV) -> Bouquets:
        """Get list of bouquets."""
        return await self._call_api(PATH_BOUQUETS, {"stype": str(stype)})

    async def get_all_bouquets(self) -> Bouquets:
        """Get list of all tv and radio bouquets."""
        return {
            "bouquets": [
                *((await self.get_bouquets(SType.TV))["bouquets"]),
                *((await self.get_bouquets(SType.RADIO))["bouquets"]),
            ],
        }

    async def zap(self, source: str) -> bool:
        """Change channel to selected source.

        :param source: the sRef of the channel.
        """

        return self._check_response_result(
            await self._call_api(PATH_ZAP, {"sRef": source}),
        )

    async def _call_api(
        self,
        path: str,
        params: Mapping[str, str | int | bool] | None = None,
    ) -> dict[str, Any]:
        """Perform one api request operation."""
        if self._session is None:
            self._session = aiohttp.ClientSession(self.base)
        async with self._session.get(path, params=params) as response:
            _LOGGER.debug("Got %d from: %s", response.status, response.request_info.url)
            if response.status == 401:
                raise InvalidAuthError
            if response.status != 200:
                _LOGGER.error(
                    "Got %d from %s: %s",
                    response.status,
                    response.request_info.url,
                    await response.text(),
                )
                raise ConnectionError
            return dict(await response.json(content_type=None))
