"""
Helper functions for Alexa Media Player.

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 functools
import hashlib
import logging
from typing import Any, Callable, Optional, TypeVar, overload

from alexapy import AlexapyLoginCloseRequested, AlexapyLoginError, hide_email
from alexapy.alexalogin import AlexaLogin
from dictor import dictor
from homeassistant.const import CONF_EMAIL, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConditionErrorMessage
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
import wrapt

from .const import DATA_ALEXAMEDIA, EXCEPTION_TEMPLATE

_LOGGER = logging.getLogger(__name__)
ArgType = TypeVar("ArgType")


def _norm_filter_token(value: Any) -> str | None:
    """Normalize a single filter token for reliable matching."""
    if value is None:
        return None
    s = str(value).strip()
    if not s:
        return None
    return s.casefold()


def _coerce_filter(value: Any) -> set[str]:
    """Coerce include/exclude filter input into a normalized set[str].

    Accepts:
    - None / empty -> empty set
    - comma-separated str -> split on commas
    - list/set/tuple -> per-item normalization
    - anything else -> single token (best effort)
    """
    if not value:
        return set()

    # Legacy/back-compat: allow comma-separated string
    if isinstance(value, str):
        out = set()
        for part in value.split(","):
            token = _norm_filter_token(part)
            if token:
                out.add(token)
        return out

    if isinstance(value, (list, set, tuple)):
        out = set()
        for v in value:
            token = _norm_filter_token(v)
            if token:
                out.add(token)
        return out

    token = _norm_filter_token(value)
    return {token} if token else set()


async def add_devices(
    account: str,
    devices: list[Entity],
    add_devices_callback: Callable[[list[Entity], bool], None],
    include_filter: str | list[str] | set[str] | tuple[str, ...] | None = None,
    exclude_filter: str | list[str] | set[str] | tuple[str, ...] | None = None,
) -> bool:
    """Add devices using add_devices_callback."""
    include_filter_set = _coerce_filter(include_filter)
    exclude_filter_set = _coerce_filter(exclude_filter)
    if include_filter_set:
        _LOGGER.debug(
            "%s: include_filter_set: %s",
            account,
            include_filter_set,
        )
    if exclude_filter_set:
        _LOGGER.debug(
            "%s: exclude_filter_set: %s",
            account,
            exclude_filter_set,
        )

    def _device_name(dev: Entity) -> str | None:
        """Best-effort name before entity_id is assigned.

        For AMP switches, reconstruct the legacy "<device> <suffix> switch"
        name only if those attributes were explicitly set.
        """

        # First prefer explicitly set name attributes (works for tests + most entities)
        name = (
            getattr(dev, "name", None)
            or getattr(dev, "_attr_name", None)
            or getattr(dev, "_name", None)
            or getattr(dev, "_device_name", None)
            or getattr(dev, "_friendly_name", None)
        )
        if name:
            return name

        # Only attempt switch reconstruction if attributes were explicitly defined
        # (avoids MagicMock auto-attribute trap in tests)
        dev_dict = getattr(dev, "__dict__", {})

        client = dev_dict.get("_client")
        suffix = dev_dict.get("_unique_id_suffix")

        if client and suffix:
            client_dict = getattr(client, "__dict__", {})
            base = (
                client_dict.get("name")
                or client_dict.get("_attr_name")
                or client_dict.get("_name")
                or client_dict.get("_device_name")
            )
            if base:
                return f"{base} {suffix} switch"

        return None

    def _device_label(dev: Entity) -> str:
        """Return a compact, stable identifier for logging."""
        name = _device_name(dev)
        entity_id = getattr(dev, "entity_id", None)  # often not set yet
        dev_type = type(dev).__name__

        if name and entity_id:
            return f"{name} ({dev_type}, {entity_id})"
        if name:
            return f"{name} ({dev_type})"
        return f"<unnamed> ({dev_type})"

    def _devices_preview(devs: list[Entity]) -> str:
        max_items = 8
        labels = [_device_label(d) for d in devs[:max_items]]
        suffix = f" …(+{len(devs) - max_items} more)" if len(devs) > max_items else ""
        return ", ".join(labels) + suffix

    def _filter_devices(
        devs: list[Entity],
        include_set: set[str],
        exclude_set: set[str],
    ) -> list[Entity]:
        selected: list[Entity] = []

        include_mode = bool(include_set)
        if include_mode and exclude_set:
            _LOGGER.debug(
                "%s: include_devices set; ignoring exclude_devices per documented precedence",
                account,
            )

        for dev in devs:
            dev_name = _norm_filter_token(_device_name(dev))

            # INCLUDE MODE: only include explicitly listed names
            if include_mode:
                if dev_name and dev_name in include_set:
                    selected.append(dev)
                else:
                    if not dev_name:
                        _LOGGER.debug(
                            "%s: Not including device (no name yet): %s",
                            account,
                            _device_label(dev),
                        )
                    else:
                        _LOGGER.debug(
                            "%s: Not including device: %s (match key=%r)",
                            account,
                            _device_label(dev),
                            dev_name,
                        )
                continue

            # EXCLUDE MODE: exclude listed names
            if exclude_set and dev_name and dev_name in exclude_set:
                _LOGGER.debug(
                    "%s: Excluding device: %s (match key=%r)",
                    account,
                    _device_label(dev),
                    dev_name,
                )
                continue

            selected.append(dev)

        return selected

    devices = _filter_devices(devices, include_filter_set, exclude_filter_set)
    if not devices:
        return True

    _LOGGER.debug(
        "%s: Adding %d device(s): %s",
        account,
        len(devices),
        _devices_preview(devices),
    )

    try:
        add_devices_callback(devices, False)
    except ConditionErrorMessage as exception_:
        message: str = exception_.message
        if message.startswith("Entity id already exists"):
            _LOGGER.debug("%s: Device already added: %s", account, message)
        else:
            _LOGGER.debug(
                "%s: Unable to add %d device(s): %s",
                account,
                len(devices),
                message,
            )
    except Exception as ex:  # pylint: disable=broad-except
        _LOGGER.debug(
            "%s: Unable to add %d device(s): %s",
            account,
            len(devices),
            EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
        )
    else:
        return True

    return False


def retry_async(
    limit: int = 5, delay: float = 1, catch_exceptions: bool = True
) -> Callable:
    """Wrap function with retry logic.

    The function will retry until true or the limit is reached. It will delay
    for the period of time specified exponentially increasing the delay.

    Parameters
    ----------
    limit : int
        The max number of retries.
    delay : float
        The delay in seconds between retries.
    catch_exceptions : bool
        Whether exceptions should be caught and treated as failures or thrown.

    Returns
    -------
    def
        Wrapped function.

    """

    def wrap(func) -> Callable:
        @functools.wraps(func)
        async def wrapper(*args, **kwargs) -> Any:
            _LOGGER.debug(
                "%s.%s: Trying with limit %s delay %s catch_exceptions %s",
                func.__module__[func.__module__.find(".") + 1 :],
                func.__name__,
                limit,
                delay,
                catch_exceptions,
            )
            retries: int = 0
            result: bool = False
            next_try: int = 0
            while not result and retries < limit:
                if retries != 0:
                    next_try = delay * 2**retries
                    await asyncio.sleep(next_try)
                retries += 1
                try:
                    result = await func(*args, **kwargs)
                except Exception as ex:  # pylint: disable=broad-except
                    if not catch_exceptions:
                        raise
                    _LOGGER.debug(
                        "%s.%s: failure caught due to exception: %s",
                        func.__module__[func.__module__.find(".") + 1 :],
                        func.__name__,
                        EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
                    )
                _LOGGER.debug(
                    "%s.%s: Try: %s/%s after waiting %s seconds result: %s",
                    func.__module__[func.__module__.find(".") + 1 :],
                    func.__name__,
                    retries,
                    limit,
                    next_try,
                    result,
                )
            return result

        return wrapper

    return wrap


@wrapt.decorator
async def _catch_login_errors(func, instance, args, kwargs) -> Any:
    """Detect AlexapyLoginError and attempt relogin."""

    result = None
    if instance is None and args:
        instance = args[0]
    if hasattr(instance, "check_login_changes"):
        # _LOGGER.debug(
        #     "%s checking for login changes", instance,
        # )
        instance.check_login_changes()
    try:
        result = await func(*args, **kwargs)
    except AlexapyLoginCloseRequested:
        _LOGGER.debug(
            "%s.%s: Ignoring attempt to access Alexa after HA shutdown",
            func.__module__[func.__module__.find(".") + 1 :],
            func.__name__,
        )
        return None
    except AlexapyLoginError as ex:
        login = None
        email = None
        all_args = list(args) + list(kwargs.values())
        # _LOGGER.debug("Func %s instance %s %s %s", func, instance, args, kwargs)
        if instance:
            if hasattr(instance, "_login"):
                login = instance._login  # pylint: disable=protected-access
                hass = instance.hass
        else:
            for arg in all_args:
                _LOGGER.debug("Checking %s", arg)

                if isinstance(arg, AlexaLogin):
                    login = arg
                    break
                if hasattr(arg, "_login"):
                    login = instance._login
                    hass = instance.hass
                    break

        if login:
            # Try to re-login
            email = login.email
            if await login.test_loggedin():
                _LOGGER.info(
                    "%s.%s: Successful re-login after a login error for %s",
                    func.__module__[func.__module__.find(".") + 1 :],
                    func.__name__,
                    hide_email(email),
                )
                return None
            _LOGGER.debug(
                "%s.%s: detected bad login for %s: %s",
                func.__module__[func.__module__.find(".") + 1 :],
                func.__name__,
                hide_email(email),
                EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
            )
        try:
            hass
        except NameError:
            hass = None
        report_relogin_required(hass, login, email)
        return None
    return result


def report_relogin_required(hass, login, email) -> bool:
    """Send message for relogin required."""
    if hass and login and email:
        if login.status:
            _LOGGER.debug(
                "Reporting need to relogin to %s with %s stats: %s",
                login.url,
                hide_email(email),
                login.stats,
            )
            hass.bus.async_fire(
                "alexa_media_relogin_required",
                event_data={
                    "email": hide_email(email),
                    "url": login.url,
                    "stats": login.stats,
                },
            )
            return True
    return False


def _existing_serials(hass, login_obj) -> list:
    """Retrieve existing serial numbers for a given login object."""
    email: str = login_obj.email
    if (
        DATA_ALEXAMEDIA in hass.data
        and "accounts" in hass.data[DATA_ALEXAMEDIA]
        and email in hass.data[DATA_ALEXAMEDIA]["accounts"]
    ):
        existing_serials = list(
            hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][
                "media_player"
            ].keys()
        )
        device_data = (
            hass.data[DATA_ALEXAMEDIA]["accounts"][email]
            .get("devices", {})
            .get("media_player", {})
        )
        for serial in existing_serials[:]:
            device = device_data.get(serial, {})
            if "appDeviceList" in device and device["appDeviceList"]:
                apps = [
                    x["serialNumber"]
                    for x in device["appDeviceList"]
                    if "serialNumber" in x
                ]
                existing_serials.extend(apps)
    else:
        _LOGGER.warning(
            "No accounts data found for %s. Skipping serials retrieval.", email
        )
        existing_serials = []
    return existing_serials


async def calculate_uuid(hass, email: str, url: str) -> dict:
    """Return uuid and index of email/url.

    Args
        hass (bool): Hass entity
        url (str): url for account
        email (str): email for account

    Returns
        dict: dictionary with uuid and index

    """
    result = {}
    return_index = 0
    if hass.config_entries.async_entries(DATA_ALEXAMEDIA):
        for index, entry in enumerate(
            hass.config_entries.async_entries(DATA_ALEXAMEDIA)
        ):
            if entry.data.get(CONF_EMAIL) == email and entry.data.get(CONF_URL) == url:
                return_index = index
                break
    uuid = await async_get_instance_id(hass)
    result["uuid"] = hex(
        int(uuid, 16)
        # increment uuid for second accounts
        + return_index
        # hash email/url in case HA uuid duplicated
        + int(
            hashlib.sha256((email.lower() + url.lower()).encode()).hexdigest(),
            16,  # nosec
        )
    )[-32:]
    result["index"] = return_index
    _LOGGER.debug("%s: Returning uuid %s", hide_email(email), result)
    return result


def alarm_just_dismissed(
    alarm: dict[str, Any],
    previous_status: Optional[str],
    previous_version: Optional[str],
) -> bool:
    """Given the previous state of an alarm, determine if it has just been dismissed."""

    if (
        previous_status not in ("SNOOZED", "ON")
        # The alarm had to be in a status that supported being dismissed
        or previous_version is None
        # The alarm was probably just created
        or not alarm
        # The alarm that was probably just deleted.
        or alarm.get("status") not in ("OFF", "ON")
        # A dismissed alarm is guaranteed to be turned off(one-off alarm) or left on(recurring alarm)
        or previous_version == alarm.get("version")
        # A dismissal always has a changed version.
        or int(alarm.get("version", "0")) > 1 + int(previous_version)
    ):
        # This is an absurd thing to check, but it solves many, many edge cases.
        # Experimentally, when an alarm is dismissed, the version always increases by 1
        # When an alarm is edited either via app or voice, its version always increases by 2+
        return False

    # It seems obvious that a check involving time should be necessary. It is not.
    # We know there was a change and that it wasn't an edit.
    # We also know the alarm's status rules out a snooze.
    # The only remaining possibility is that this alarm was just dismissed.
    return True


def is_http2_enabled(hass: HomeAssistant | None, login_email: str) -> bool:
    """Whether HTTP2 push is enabled for the current account session"""
    if hass:
        return bool(
            safe_get(
                hass.data,
                [DATA_ALEXAMEDIA, "accounts", login_email, "http2"],
            )
        )
    return False


@overload
def safe_get(
    data: Any,
    path_list: list[str | int] | None = None,
    checknone: bool = False,
    ignorecase: bool = False,
    pathsep: str = ".",
    search: Any = None,
    pretty: bool = False,
    rtype: str | None = None,
) -> Any | None: ...


@overload
def safe_get(
    data: Any, path_list: list[str | int] | None, default: ArgType, *args, **kwargs
) -> ArgType: ...


def safe_get(
    data: Any, path_list: list[str | int] | None = None, *args, **kwargs
) -> None | Any:
    """Safely get nested value using path segments with optional type checking.

    Args:
        data: Source data structure
        path_list: List of path segments (dots in segment names are auto-escaped)
        *args: Positional arguments passed to dictor (e.g., default value)
        **kwargs: Keyword arguments passed to dictor (checknone, ignorecase)

    Returns:
        The value at the specified path, or None if:
        - The path doesn't exist and no default is provided
        or default if:
        - A default is provided and the path doesn't exist
        - A default is provided and the retrieved value's type doesn't match the default's type

    Note:
        - Do not pass 'pathsep' in kwargs as the path is pre-built.
        - Type checking: When a default value is provided and a non-None value is retrieved,
          the result is validated against the default's type. If types don't match, default is returned.
          This prevents silent type errors from malformed data structures.

    Examples:
        >>> safe_get({"a": {"b": "value"}}, ["a", "b"])
        'value'

        >>> safe_get({"a": {"b": 123}}, ["a", "b"], "default")
        'default'  # Type mismatch: int vs str

        >>> safe_get({"a": {"b": "value"}}, ["a", "b"], "default")
        'value'  # Type matches
    """
    if not path_list:
        raise ValueError("path_list cannot be empty")

    if "pathsep" in kwargs:
        kwargs.pop("pathsep")  # Ignore pathsep since we build the path

    escaped_segments = (str(seg).replace(".", "\\.") for seg in path_list)
    path = ".".join(escaped_segments)
    default = args[0] if args else (kwargs.get("default") if kwargs else None)
    result = dictor(data, path, *args, **kwargs)
    if default is not None and result is not None:
        if not isinstance(result, type(default)):
            result = default
    return result
