"""
Home Assistant MQTT bridge: LAN or direct BLE control + optional Toy Events -> MQTT Discovery.

Requires optional dependency: ``pip install 'lovensepy[mqtt]'`` (``paho-mqtt``).
BLE transport additionally needs ``pip install 'lovensepy[ble]'`` (``bleak``).
"""

from __future__ import annotations

import asyncio
import logging
from typing import TYPE_CHECKING, Any, Literal

from ..._constants import FUNCTION_RANGES, Presets
from ...exceptions import LovenseBLEError
from ...standard.async_base import LovenseAsyncControlClient
from ...standard.async_lan import AsyncLANClient
from ...toy_events.client import ToyEventsClient
from ...toy_utils import features_for_toy
from .discovery import (
    build_discovery_payloads,
    default_availability_topic,
    discovery_object_id,
    re_safe_prefix,
)
from .state import StateDeduper
from .topics import (
    bridge_status_topic,
    discovery_topic,
    feature_topic_segment,
    mqtt_safe_toy_id,
    state_topic,
    subscribe_wildcard,
    topic_segment_to_action_name,
    toy_availability_topic,
)

if TYPE_CHECKING:
    from ...ble_direct.hub import BleDirectHub

__all__ = ["HAMqttBridge"]

_logger = logging.getLogger(__name__)

try:
    import paho.mqtt.client as mqtt
except ImportError as e:  # pragma: no cover - import guard
    mqtt = None  # type: ignore[assignment, misc]
    _IMPORT_ERROR = e
else:
    _IMPORT_ERROR = None


def _require_mqtt() -> None:
    if mqtt is None or _IMPORT_ERROR is not None:
        raise ImportError(
            "MQTT support requires paho-mqtt. Install with: pip install 'lovensepy[mqtt]'"
        ) from _IMPORT_ERROR


def _normalize_toy_dict_from_event(toy: dict[str, Any]) -> dict[str, Any]:
    """Map Toy Events field names to shapes expected by ``features_for_toy`` / display."""
    d = dict(toy)
    if "toyType" not in d and "type" in d:
        d["toyType"] = d.get("type")
    if "nickName" not in d and "nickname" in d:
        d["nickName"] = d.get("nickname")
    return d


def _parse_toy_list_data(data: Any) -> list[dict[str, Any]]:  # pylint: disable=too-many-return-statements
    """Normalize toy-list / GetToys-like payloads into toy dicts."""
    if data is None:
        return []
    if isinstance(data, list):
        return [t for t in data if isinstance(t, dict)]
    if not isinstance(data, dict):
        return []
    if "toyList" in data:
        raw = data["toyList"]
        if isinstance(raw, list):
            return [_normalize_toy_dict_from_event(dict(t)) for t in raw if isinstance(t, dict)]
    if "toys" in data:
        raw = data["toys"]
        if isinstance(raw, list):
            return [t for t in raw if isinstance(t, dict)]
        if isinstance(raw, dict):
            out: list[dict[str, Any]] = []
            for tid, t in raw.items():
                if isinstance(t, dict):
                    d = dict(t)
                    d.setdefault("id", str(tid))
                    out.append(d)
            return out
    # Flat mapping id -> toy dict
    if data and all(isinstance(v, dict) for v in data.values()):
        out = []
        for tid, t in data.items():
            d = dict(t)
            d.setdefault("id", str(tid))
            out.append(d)
        return out
    return []


def _clamp_feature(action: str, value: float | int) -> int | float:
    if action in FUNCTION_RANGES:
        lo, hi = FUNCTION_RANGES[action]
        v = float(value)
        return int(max(lo, min(hi, round(v)))) if isinstance(lo, int) else max(lo, min(hi, v))
    return value


def _coerce_battery_percent(value: Any) -> int | None:
    """Parse 0–100 % from API values (int, float, numeric string)."""
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return max(0, min(100, value))
    if isinstance(value, float):
        return max(0, min(100, int(value)))
    if isinstance(value, str) and value.strip():
        try:
            return max(0, min(100, int(float(value.strip()))))
        except ValueError:
            return None
    return None


# Lovense Toy Events: battery-changed uses data.value; toy-list uses battery on each toy.
_BATTERY_KEYS: tuple[str, ...] = (
    "battery",
    "batteryLevel",
    "batteryPercent",
    "battery_level",
    "level",
    "value",
    "percent",
)


def _battery_from_payload(payload: Any) -> int | None:
    """Best-effort battery % from a flat or nested mapping (toy event / toy dict)."""
    if isinstance(payload, dict):
        for key in _BATTERY_KEYS:
            if key in payload:
                v = _coerce_battery_percent(payload.get(key))
                if v is not None:
                    return v
        nested = payload.get("data")
        if isinstance(nested, dict):
            return _battery_from_payload(nested)
    return None


def _strength_from_payload(payload: Any, toy_id: str) -> dict[str, int | float] | None:
    """Parse function-strength-changed into {ActionName: level}."""
    if not isinstance(payload, dict):
        return None
    tid = str(payload.get("id") or payload.get("toyId") or "")
    if tid and tid != str(toy_id):
        return None
    out: dict[str, int | float] = {}
    if "strength" in payload:
        s = payload["strength"]
        if isinstance(s, dict):
            for k, v in s.items():
                if isinstance(v, (int, float)):
                    mapped = topic_segment_to_action_name(str(k).lower())
                    an = mapped if mapped is not None else str(k)
                    out[an] = v
        elif isinstance(s, (int, float)):
            out["Vibrate"] = s
    for key in ("feature", "function"):
        if key in payload and "value" in payload:
            seg = str(payload[key]).lower()
            an = topic_segment_to_action_name(seg) or seg.capitalize()
            v = payload["value"]
            if isinstance(v, (int, float)):
                out[an] = v
    return out or None


def _normalize_toy_row_from_get_toys(info: Any) -> dict[str, Any]:
    """Normalize :class:`~lovensepy._models.ToyInfo` dump for ``features_for_toy``."""
    d = info.model_dump()
    tt = d.get("toyType")
    ty = d.get("type")
    if (not tt or not str(tt).strip()) and isinstance(ty, str) and ty.strip():
        d["toyType"] = ty
    return d


def _toy_connected_from_dict(toy: dict[str, Any]) -> bool:
    """Infer reachability from GetToys / toy-list ``status`` (BLE hub uses ``1``/``0``)."""
    st = toy.get("status")
    if st is None:
        return True
    s = str(st).strip().lower()
    if s in ("0", "false", "off", "disconnected", "no", "inactive", "unavailable"):
        return False
    if s in ("1", "true", "on", "connected", "yes", "active", "available"):
        return True
    try:
        return int(float(s)) != 0
    except ValueError:
        return True


class HAMqttBridge:
    """
    Publish Home Assistant MQTT Discovery and bridge commands to a
    :class:`~lovensepy.standard.async_base.LovenseAsyncControlClient`
    (:class:`~lovensepy.standard.async_lan.AsyncLANClient` or
    :class:`~lovensepy.ble_direct.hub.BleDirectHub`).

    Runs ``paho-mqtt`` networking in a background thread; Lovense I/O uses asyncio on the
    caller's loop (the loop active when :meth:`start` is called).
    """

    def __init__(
        self,
        mqtt_host: str,
        mqtt_port: int = 1883,
        *,
        lan_ip: str | None = None,
        transport: Literal["lan", "ble"] = "lan",
        lan_port: int = 20011,
        toy_events_port: int | None = None,
        app_name: str = "lovensepy_ha",
        topic_prefix: str = "lovensepy",
        mqtt_username: str | None = None,
        mqtt_password: str | None = None,
        mqtt_client_id: str | None = None,
        refresh_interval: float = 45.0,
        use_https: bool = False,
        use_toy_events: bool = True,
        ble_discover_timeout: float = 15.0,
        ble_name_prefix: str | None = "LVS-",
        ble_enrich_uart: bool = True,
        ble_client_kwargs: dict[str, Any] | None = None,
        ble_hub: BleDirectHub | None = None,
    ) -> None:
        _require_mqtt()
        if transport not in ("lan", "ble"):
            raise ValueError("transport must be 'lan' or 'ble'")
        if transport == "lan" and not (lan_ip and str(lan_ip).strip()):
            raise ValueError("lan_ip is required when transport='lan'")
        if ble_hub is not None and transport != "ble":
            raise ValueError("ble_hub is only valid when transport='ble'")

        self._mqtt_host = mqtt_host
        self._mqtt_port = int(mqtt_port)
        self._transport = transport
        self._lan_ip = str(lan_ip).strip() if lan_ip else ""
        self._lan_port = int(lan_port)
        self._toy_events_port = (
            int(toy_events_port) if toy_events_port is not None else self._lan_port
        )
        self._app_name = app_name
        self._topic_prefix = topic_prefix.strip("/") or "lovensepy"
        self._mqtt_username = mqtt_username
        self._mqtt_password = mqtt_password
        self._mqtt_client_id = mqtt_client_id or f"{app_name}_bridge"
        self._refresh_interval = max(5.0, float(refresh_interval))
        self._use_https = use_https
        # Toy Events reach the Lovense app over LAN only; BLE mode uses polling via get_toys.
        self._use_toy_events = bool(use_toy_events and transport == "lan")
        self._ble_discover_timeout = float(ble_discover_timeout)
        self._ble_name_prefix = ble_name_prefix
        self._ble_enrich_uart = bool(ble_enrich_uart)
        self._ble_client_kwargs = dict(ble_client_kwargs or {})
        self._ble_hub_injected = ble_hub

        self._loop: asyncio.AbstractEventLoop | None = None
        self._running = False
        self._lock = asyncio.Lock()

        self._control: LovenseAsyncControlClient | None = None
        self._mqtt: Any = None
        self._deduper = StateDeduper()

        # safe_toy_id -> toy dict (includes real "id")
        self._toys: dict[str, dict[str, Any]] = {}
        self._published_discovery: set[str] = set()
        self._initialized_toy_states: set[str] = set()
        self._feature_levels: dict[str, dict[str, int]] = {}
        self._inventory_ready = False
        self._cleared_stale_safe_ids: set[str] = set()

        self._refresh_task: asyncio.Task[None] | None = None
        self._toy_events_task: asyncio.Task[None] | None = None
        self._toy_client: ToyEventsClient | None = None
        self._mqtt_ready: asyncio.Event | None = None

    @property
    def availability_topic(self) -> str:
        """MQTT availability topic used by all published entities."""
        return default_availability_topic(self._topic_prefix)

    def _mqtt_signal_connected(self) -> None:
        loop = self._loop
        ev = self._mqtt_ready
        if loop is not None and ev is not None:
            loop.call_soon_threadsafe(ev.set)

    def _mqtt_signal_disconnected(self) -> None:
        loop = self._loop
        ev = self._mqtt_ready
        if loop is not None and ev is not None:

            def _clear() -> None:
                ev.clear()

            loop.call_soon_threadsafe(_clear)

    def _publish_toy_availability(self, safe: str, connected: bool) -> None:
        """Retained per-toy online/offline for MQTT Discovery (paired with bridge status)."""
        if self._mqtt is None:
            return
        topic = toy_availability_topic(self._topic_prefix, safe)
        payload = "online" if connected else "offline"
        self._mqtt.publish(topic, payload, qos=0, retain=True)

    def _mark_toy_disconnected(self, safe: str) -> None:
        """Mark toy as offline in local cache + retained MQTT availability."""
        toy = self._toys.get(safe)
        if isinstance(toy, dict):
            toy["status"] = "0"
        self._publish_toy_availability(safe, False)

    def _clear_discovery_for_stale_safe_id(self, safe_id: str) -> None:
        """Delete retained HA discovery rows for a stale unknown safe_id."""
        if self._mqtt is None or not safe_id or safe_id in self._cleared_stale_safe_ids:
            return
        self._cleared_stale_safe_ids.add(safe_id)
        pfx = self._topic_prefix.strip("/")
        oid_prefix = re_safe_prefix(pfx)
        kinds: list[str] = ["stop", "preset", "battery"]
        kinds.extend(
            f"num_{feature_topic_segment(action)}" for action in sorted(FUNCTION_RANGES.keys())
        )
        for kind in kinds:
            oid = discovery_object_id(oid_prefix, safe_id, kind)
            component = (
                "number"
                if kind.startswith("num_")
                else ("button" if kind == "stop" else "select" if kind == "preset" else "sensor")
            )
            topic = discovery_topic(component, oid)
            self._mqtt.publish(topic, payload="", qos=0, retain=True)
            self._published_discovery.discard(topic)

    def _schedule(self, coro: Any) -> None:
        loop = self._loop
        if loop is None or not self._running:
            return

        def _log_err(fut: asyncio.Future[Any]) -> None:
            try:
                fut.result()
            except asyncio.CancelledError:
                pass
            except Exception:  # pylint: disable=broad-exception-caught
                _logger.exception("HAMqttBridge async task failed")

        fut = asyncio.run_coroutine_threadsafe(coro, loop)
        fut.add_done_callback(_log_err)

    async def start(self) -> None:
        """Connect MQTT, open control transport (LAN or BLE), publish discovery, start tasks."""
        if self._running:
            return
        _require_mqtt()
        self._running = True
        self._loop = asyncio.get_running_loop()

        if self._transport == "lan":
            self._control = AsyncLANClient(
                self._app_name,
                self._lan_ip,
                port=self._lan_port,
                use_https=self._use_https,
            )
        elif self._ble_hub_injected is not None:
            self._control = self._ble_hub_injected
        # BLE auto-discover: connect MQTT first so bridge availability publishes quickly, then scan.

        self._mqtt = mqtt.Client(  # type: ignore[union-attr]
            mqtt.CallbackAPIVersion.VERSION2,
            client_id=self._mqtt_client_id,
        )
        if self._mqtt_username is not None:
            self._mqtt.username_pw_set(self._mqtt_username, self._mqtt_password)

        self._mqtt.on_connect = self._on_connect
        self._mqtt.on_message = self._on_message
        self._mqtt.on_disconnect = self._on_disconnect

        self._mqtt_ready = asyncio.Event()
        self._mqtt.connect_async(self._mqtt_host, self._mqtt_port, keepalive=60)
        self._mqtt.loop_start()
        try:
            await asyncio.wait_for(self._mqtt_ready.wait(), timeout=30.0)
        except TimeoutError as e:
            raise TimeoutError("MQTT broker did not connect within 30s") from e

        if self._transport == "ble" and self._ble_hub_injected is None:
            from ...ble_direct.client import ble_direct_client_preset_kwargs_from_env
            from ...ble_direct.hub import BleDirectHub as _BleDirectHub

            hub = _BleDirectHub()
            ble_kwargs = ble_direct_client_preset_kwargs_from_env()
            ble_kwargs.update(self._ble_client_kwargs)
            try:
                await hub.discover_and_connect(
                    timeout=self._ble_discover_timeout,
                    name_prefix=self._ble_name_prefix,
                    enrich_uart=self._ble_enrich_uart,
                    **ble_kwargs,
                )
            except ImportError as e:
                raise ImportError(
                    "BLE transport requires bleak. Install with: pip install 'lovensepy[ble]'"
                ) from e
            self._control = hub
            await self._refresh_toys_and_discovery()

        self._refresh_task = asyncio.create_task(self._refresh_loop())

        if self._use_toy_events:
            self._toy_events_task = asyncio.create_task(self._toy_events_loop())

    async def stop(self) -> None:
        """Stop tasks, disconnect MQTT, release control client (HTTP session / BLE)."""
        self._running = False
        if self._refresh_task:
            self._refresh_task.cancel()
            try:
                await self._refresh_task
            except asyncio.CancelledError:
                pass
            self._refresh_task = None

        if self._toy_events_task:
            self._toy_events_task.cancel()
            try:
                await self._toy_events_task
            except asyncio.CancelledError:
                pass
            self._toy_events_task = None

        if self._toy_client:
            self._toy_client.disconnect()
            self._toy_client = None

        if self._mqtt is not None:
            try:
                for safe in list(self._toys.keys()):
                    self._publish_toy_availability(safe, False)
                self._mqtt.publish(
                    bridge_status_topic(self._topic_prefix),
                    "offline",
                    qos=0,
                    retain=True,
                )
            except Exception:  # pylint: disable=broad-exception-caught
                _logger.exception("Failed to publish MQTT offline")
            self._mqtt.loop_stop()
            self._mqtt.disconnect()
            self._mqtt = None

        if self._control is not None:
            await self._control.aclose()
            self._control = None

        self._deduper.clear()
        self._feature_levels.clear()
        self._inventory_ready = False
        self._cleared_stale_safe_ids.clear()
        self._mqtt_ready = None
        self._loop = None

    def _on_connect(
        self,
        client: Any,
        _userdata: Any,
        _flags: Any,
        reason_code: Any,
        _properties: Any,
    ) -> None:
        try:
            failed = reason_code.is_failure  # type: ignore[union-attr]
        except AttributeError:
            failed = int(reason_code) != 0  # type: ignore[arg-type]
        if failed:
            _logger.error("MQTT connect failed: %s", reason_code)
            return
        sub = subscribe_wildcard(self._topic_prefix)
        client.subscribe(sub, qos=0)
        _logger.info("MQTT connected, subscribed %s", sub)
        try:
            client.publish(
                bridge_status_topic(self._topic_prefix),
                "online",
                qos=0,
                retain=True,
            )
        except Exception:  # pylint: disable=broad-exception-caught
            _logger.exception("Failed to publish bridge online")
        self._mqtt_signal_connected()
        self._schedule(self._refresh_toys_and_discovery())

    def _on_disconnect(
        self,
        _client: Any,
        _userdata: Any,
        _disconnect_flags: Any,
        reason_code: Any,
        _properties: Any,
    ) -> None:
        _logger.warning("MQTT disconnected: %s", reason_code)
        self._mqtt_signal_disconnected()

    def _on_message(self, _client: Any, _userdata: Any, msg: Any) -> None:
        topic = getattr(msg, "topic", "")
        payload = getattr(msg, "payload", b"") or b""
        self._schedule(self._handle_command_topic(topic, payload))

    async def _refresh_loop(self) -> None:
        while self._running:
            await asyncio.sleep(self._refresh_interval)
            try:
                await self._refresh_toys_and_discovery()
            except asyncio.CancelledError:
                break
            except Exception:  # pylint: disable=broad-exception-caught
                _logger.exception("Periodic toy refresh failed")

    async def _toy_events_loop(self) -> None:
        while self._running:

            async def on_event(event_type: str, data: Any) -> None:
                await self._handle_toy_event(event_type, data)

            self._toy_client = ToyEventsClient(
                self._lan_ip,
                port=self._toy_events_port,
                use_https=self._use_https,
                app_name=self._app_name,
                on_event=on_event,
            )
            try:
                await self._toy_client.connect()
            except asyncio.CancelledError:
                break
            except Exception:  # pylint: disable=broad-exception-caught
                _logger.exception("Toy Events connection ended with error")
            finally:
                if self._toy_client:
                    self._toy_client.disconnect()
                self._toy_client = None
            if self._running:
                await asyncio.sleep(3.0)

    async def _handle_toy_event(self, event_type: str, data: Any) -> None:
        if event_type == "toy-list":
            toys = _parse_toy_list_data(data)
            async with self._lock:
                for t in toys:
                    tid = str(t.get("id") or "")
                    if not tid:
                        continue
                    safe = mqtt_safe_toy_id(tid)
                    merged = _normalize_toy_dict_from_event(dict(t))
                    self._toys[safe] = merged
                    self._publish_toy_availability(safe, _toy_connected_from_dict(merged))
                await self._publish_discovery_unlocked()
            for t in toys:
                tid = str(t.get("id") or "")
                if not tid:
                    continue
                level = _battery_from_payload(t)
                if level is not None:
                    safe = mqtt_safe_toy_id(tid)
                    await self._publish_state_str(
                        safe, "battery", str(level), force=True, mqtt_retain=True
                    )
        elif event_type == "battery-changed":
            if not isinstance(data, dict):
                return
            tid = str(data.get("id") or data.get("toyId") or "")
            if not tid:
                return
            safe = mqtt_safe_toy_id(tid)
            level = _battery_from_payload(data)
            if level is not None:
                await self._publish_state_str(
                    safe, "battery", str(level), force=True, mqtt_retain=True
                )
        elif event_type == "function-strength-changed":
            if not isinstance(data, dict):
                return
            tid = str(data.get("id") or data.get("toyId") or "")
            if not tid:
                return
            safe = mqtt_safe_toy_id(tid)
            strengths = _strength_from_payload(data, tid)
            if not strengths:
                return
            toy = self._toys.get(safe) or {"id": tid}
            supported = set(features_for_toy(toy))
            for action, raw in strengths.items():
                if action in supported:
                    seg = feature_topic_segment(action)
                    clamped = _clamp_feature(action, float(raw))
                    self._feature_levels.setdefault(safe, {})[action] = int(clamped)
                    await self._publish_state_str(safe, seg, str(int(clamped)))

    async def _refresh_toys_and_discovery(self) -> None:
        if self._control is None or self._mqtt is None:
            return
        async with self._lock:
            try:
                resp = await self._control.get_toys()
            except Exception:  # pylint: disable=broad-exception-caught
                _logger.exception("GetToys failed")
                return
            data = resp.data
            if data is None:
                return
            toys = data.toys
            seen: set[str] = set()
            for info in toys:
                d = _normalize_toy_row_from_get_toys(info)
                tid = str(d.get("id") or "")
                if not tid:
                    continue
                safe = mqtt_safe_toy_id(tid)
                seen.add(safe)
                self._toys[safe] = d
                self._publish_toy_availability(safe, _toy_connected_from_dict(d))
                b_level = info.battery
                if b_level is None:
                    b_level = _battery_from_payload(d)
                if b_level is not None:
                    await self._publish_state_str(
                        safe,
                        "battery",
                        str(int(b_level)),
                        mqtt_retain=True,
                    )
            # Keep last-known toy rows when they disappear from this GetToys snapshot (empty
            # response, transport hiccup, id churn). HA retained discovery still targets the old
            # safe_id — deleting here causes "Unknown toy" spam while entities look available.
            for safe in list(self._toys.keys()):
                if safe not in seen:
                    self._publish_toy_availability(safe, False)
                    stale = self._toys.get(safe)
                    if isinstance(stale, dict):
                        stale["status"] = "0"
            self._inventory_ready = True
            await self._publish_discovery_unlocked()

    async def _publish_discovery_unlocked(self) -> None:
        if self._mqtt is None:
            return
        av = bridge_status_topic(self._topic_prefix)
        for toy in self._toys.values():
            for disc_topic, payload in build_discovery_payloads(
                topic_prefix=self._topic_prefix,
                toy_dict=toy,
                bridge_availability_topic=av,
            ):
                key = disc_topic
                if key in self._published_discovery:
                    continue
                self._published_discovery.add(key)
                self._mqtt.publish(disc_topic, payload, qos=0, retain=True)
            safe = mqtt_safe_toy_id(str(toy.get("id")))
            if safe not in self._initialized_toy_states:
                self._initialized_toy_states.add(safe)
                levels = self._feature_levels.setdefault(safe, {})
                for action in features_for_toy(toy):
                    levels[action] = 0
                    seg = feature_topic_segment(action)
                    await self._publish_state_str(safe, seg, "0", force=True)

    async def _publish_state_str(
        self,
        safe_id: str,
        feature_segment: str,
        value: str,
        *,
        force: bool = False,
        mqtt_retain: bool = False,
    ) -> None:
        if self._mqtt is None:
            return
        key = f"{self._topic_prefix}/{safe_id}/{feature_segment}/state"
        if not force and not self._deduper.should_publish(key, value):
            return
        topic = state_topic(self._topic_prefix, safe_id, feature_segment)
        self._mqtt.publish(topic, value, qos=0, retain=mqtt_retain)

    async def _handle_command_topic(self, topic: str, payload: bytes) -> None:
        # pylint: disable=too-many-return-statements
        pfx = self._topic_prefix
        if not topic.startswith(pfx + "/"):
            return
        rest = topic[len(pfx) + 1 :]
        parts = rest.split("/")
        if len(parts) != 3 or parts[2] != "set":
            return
        safe_id, feature_segment, _ = parts
        if self._control is None:
            return

        toy = self._toys.get(safe_id)
        if toy is None:
            self._publish_toy_availability(safe_id, False)
            if not self._inventory_ready:
                _logger.debug(
                    "Ignoring command before first inventory refresh safe_id=%s topic=%s",
                    safe_id,
                    topic,
                )
                return
            self._clear_discovery_for_stale_safe_id(safe_id)
            _logger.warning("Unknown toy safe_id=%s (topic=%s)", safe_id, topic)
            return
        toy_id = str(toy.get("id"))

        # MQTT delivers raw bytes; we decode explicitly with errors="replace" so a
        # mis-encoded or garbage payload does not raise UnicodeDecodeError and
        # tear down handling—invalid values are caught by validation below.
        text = payload.decode("utf-8", errors="replace").strip()

        try:
            if feature_segment == "stop":
                await self._control.stop(toy_id=toy_id)
                levels = self._feature_levels.setdefault(safe_id, {})
                for action in features_for_toy(toy):
                    levels[action] = 0
                    seg = feature_topic_segment(action)
                    await self._publish_state_str(safe_id, seg, "0", force=True)
                return

            if feature_segment == "preset":
                if not text:
                    return
                raw = text.strip()
                if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"":
                    raw = raw[1:-1].strip()
                preset = raw.lower()
                valid = {p.value for p in Presets}
                if preset not in valid:
                    _logger.warning("Invalid preset %r", text)
                    return
                await self._publish_state_str(safe_id, "preset", preset, force=True)
                await self._control.preset_request(
                    preset,
                    toy_id=toy_id,
                    wait_for_completion=False,
                )
                return

            action = topic_segment_to_action_name(feature_segment)
            if action is None:
                _logger.warning("Unknown feature segment %r", feature_segment)
                return
            if action not in features_for_toy(toy):
                _logger.warning("Toy %s does not support action %s", toy_id, action)
                return
            try:
                level = int(float(text))
            except ValueError:
                _logger.warning("Invalid number payload %r", text)
                return
            level_i = int(_clamp_feature(action, level))
            levels = self._feature_levels.setdefault(safe_id, {})
            merged_actions: dict[str, int] = {
                supported_action: int(levels.get(supported_action, 0))
                for supported_action in features_for_toy(toy)
            }
            merged_actions[action] = level_i
            await self._control.function_request(merged_actions, toy_id=toy_id)
            levels.update(merged_actions)
            await self._publish_state_str(safe_id, feature_segment, str(level_i), force=True)
        except LovenseBLEError as e:
            if "not connected" in str(e).lower():
                self._mark_toy_disconnected(safe_id)
                _logger.warning(
                    "Command ignored for disconnected toy id=%s topic=%s: %s",
                    toy_id,
                    topic,
                    e,
                )
                return
            _logger.exception("BLE command failed topic=%s", topic)
        except Exception:  # pylint: disable=broad-exception-caught
            _logger.exception("Command failed topic=%s", topic)
