"""Zigbee2MQTT light output target — casts LED colors directly to Zigbee bulbs via MQTT.

Bypasses Home Assistant: publishes RGB+brightness payloads to ``zigbee2mqtt/<friendly_name>/set``
on the shared MQTT broker. Bulbs must already be paired with the Z2M coordinator and have
friendly names assigned in Z2M.
"""

from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List

from ledgrab.storage.bindable import BindableFloat
from ledgrab.storage.output_target import OutputTarget


@dataclass
class Z2MLightMapping:
    """Maps an LED range to a single Z2M bulb (by friendly name)."""

    friendly_name: str = ""  # Z2M friendly_name, e.g. "living_room_bulb_1"
    led_start: int = 0  # start LED index (0-based)
    led_end: int = -1  # end LED index (-1 = last)
    brightness_scale: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))

    def to_dict(self) -> dict:
        return {
            "friendly_name": self.friendly_name,
            "led_start": self.led_start,
            "led_end": self.led_end,
            "brightness_scale": self.brightness_scale.to_dict(),
        }

    @classmethod
    def from_dict(cls, data: dict) -> "Z2MLightMapping":
        return cls(
            friendly_name=data.get("friendly_name", ""),
            led_start=data.get("led_start", 0),
            led_end=data.get("led_end", -1),
            brightness_scale=BindableFloat.from_raw(data.get("brightness_scale"), default=1.0),
        )


VALID_STOP_ACTIONS = ("none", "turn_off")
VALID_SOURCE_KINDS = ("css", "color_vs")

# Z2M base topic prefix. Standard installations use "zigbee2mqtt"; can be
# overridden per-target so users with a non-default Z2M ``mqtt.base_topic``
# don't need code changes.
DEFAULT_Z2M_BASE_TOPIC = "zigbee2mqtt"


@dataclass
class Z2MLightOutputTarget(OutputTarget, type_key="z2m_light"):
    """Output target that casts LED colors directly to Zigbee bulbs via MQTT (Z2M).

    Mirrors :class:`HALightOutputTarget` but skips Home Assistant entirely —
    payloads are published to ``<base_topic>/<friendly_name>/set`` on the
    shared MQTT broker configured under MQTT Sources.

    Two source modes are supported:

    * ``source_kind="css"`` — colours come from a ColorStripSource.  Each
      ``Z2MLightMapping`` averages a slice of the strip onto its bulb.
    * ``source_kind="color_vs"`` — a single colour comes from a colour-returning
      ``ValueSource``.  The same colour is pushed to every mapped bulb;
      ``led_start``/``led_end`` are ignored.
    """

    source_kind: str = "css"  # one of VALID_SOURCE_KINDS
    color_strip_source_id: str = ""
    color_value_source_id: str = ""
    brightness: BindableFloat = field(default_factory=lambda: BindableFloat(1.0))
    light_mappings: List[Z2MLightMapping] = field(default_factory=list)
    # References an MQTTSource. Empty = unconfigured (target won't register/run
    # until set). Multi-broker model: the runtime is acquired from MQTTManager
    # using this id, so each target can talk to a different broker.
    mqtt_source_id: str = ""
    base_topic: str = DEFAULT_Z2M_BASE_TOPIC
    # Z2M tolerates higher rates than HA — Zigbee mesh ceiling is the real
    # bottleneck. Default 5 Hz, allow up to 10 Hz.
    update_rate: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
    transition: BindableFloat = field(default_factory=lambda: BindableFloat(0.3))
    min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
    color_tolerance: BindableFloat = field(default_factory=lambda: BindableFloat(5.0))
    stop_action: str = "none"  # one of VALID_STOP_ACTIONS

    def _has_source(self) -> bool:
        if self.source_kind == "color_vs":
            return bool(self.color_value_source_id)
        return bool(self.color_strip_source_id)

    def register_with_manager(self, manager) -> None:
        # Skip registration if no MQTT broker is selected — the processor
        # cannot publish without a runtime to acquire. The target stays in
        # the store so the user can fix the wiring via the editor.
        if self.light_mappings and self._has_source() and self.mqtt_source_id:
            manager.add_z2m_light_target(
                target_id=self.id,
                mqtt_source_id=self.mqtt_source_id,
                source_kind=self.source_kind,
                color_strip_source_id=self.color_strip_source_id,
                color_value_source_id=self.color_value_source_id,
                brightness=self.brightness,
                light_mappings=self.light_mappings,
                base_topic=self.base_topic,
                update_rate=self.update_rate,
                transition=self.transition,
                min_brightness_threshold=self.min_brightness_threshold,
                color_tolerance=self.color_tolerance,
                stop_action=self.stop_action,
            )

    def sync_with_manager(
        self,
        manager,
        *,
        settings_changed: bool = False,
        source_changed: bool = False,
        device_changed: bool = False,
        css_changed: bool = False,
        **_kwargs,
    ) -> None:
        if settings_changed:
            manager.update_target_settings(
                self.id,
                {
                    "source_kind": self.source_kind,
                    "color_value_source_id": self.color_value_source_id,
                    "brightness": self.brightness,
                    "mqtt_source_id": self.mqtt_source_id,
                    "base_topic": self.base_topic,
                    "update_rate": self.update_rate,
                    "transition": self.transition,
                    "min_brightness_threshold": self.min_brightness_threshold,
                    "color_tolerance": self.color_tolerance,
                    "light_mappings": self.light_mappings,
                    "stop_action": self.stop_action,
                },
            )
        if css_changed:
            manager.update_target_css(self.id, self.color_strip_source_id)

    def update_fields(
        self,
        *,
        name=None,
        source_kind=None,
        color_strip_source_id=None,
        color_value_source_id=None,
        brightness=None,
        # legacy compat
        brightness_value_source_id=None,
        light_mappings=None,
        mqtt_source_id=None,
        base_topic=None,
        update_rate=None,
        transition=None,
        min_brightness_threshold=None,
        color_tolerance=None,
        stop_action=None,
        description=None,
        tags: List[str] | None = None,
        icon: str | None = None,
        icon_color: str | None = None,
        **_kwargs,
    ) -> None:
        super().update_fields(
            name=name,
            description=description,
            tags=tags,
            icon=icon,
            icon_color=icon_color,
        )
        if source_kind is not None and source_kind in VALID_SOURCE_KINDS:
            self.source_kind = source_kind
        if color_strip_source_id is not None:
            self.color_strip_source_id = color_strip_source_id
        if color_value_source_id is not None:
            self.color_value_source_id = color_value_source_id
        if mqtt_source_id is not None:
            self.mqtt_source_id = mqtt_source_id
        if brightness is not None:
            self.brightness = self.brightness.apply_update(brightness)
        elif brightness_value_source_id is not None:
            self.brightness = BindableFloat(
                value=self.brightness.value,
                source_id=brightness_value_source_id,
            )
        if light_mappings is not None:
            self.light_mappings = light_mappings
        if base_topic is not None:
            self.base_topic = (base_topic or "").strip() or DEFAULT_Z2M_BASE_TOPIC
        if update_rate is not None:
            self.update_rate = self.update_rate.apply_update(update_rate)
        if transition is not None:
            self.transition = self.transition.apply_update(transition)
        if min_brightness_threshold is not None:
            self.min_brightness_threshold = self.min_brightness_threshold.apply_update(
                min_brightness_threshold
            )
        if color_tolerance is not None:
            self.color_tolerance = self.color_tolerance.apply_update(color_tolerance)
        if stop_action is not None and stop_action in VALID_STOP_ACTIONS:
            self.stop_action = stop_action

    def to_dict(self) -> dict:
        d = super().to_dict()
        d["source_kind"] = self.source_kind
        d["color_strip_source_id"] = self.color_strip_source_id
        d["color_value_source_id"] = self.color_value_source_id
        d["brightness"] = self.brightness.to_dict()
        d["light_mappings"] = [m.to_dict() for m in self.light_mappings]
        d["mqtt_source_id"] = self.mqtt_source_id
        d["base_topic"] = self.base_topic
        d["update_rate"] = self.update_rate.to_dict()
        d["transition"] = self.transition.to_dict()
        d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
        d["color_tolerance"] = self.color_tolerance.to_dict()
        d["stop_action"] = self.stop_action
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "Z2MLightOutputTarget":
        mappings = [Z2MLightMapping.from_dict(m) for m in data.get("light_mappings", [])]
        brightness = BindableFloat.from_legacy(
            data.get("brightness"),
            legacy_source_id=data.get("brightness_value_source_id", ""),
            default=1.0,
        )
        raw_kind = data.get("source_kind", "css")
        source_kind = raw_kind if raw_kind in VALID_SOURCE_KINDS else "css"
        return cls(
            id=data["id"],
            name=data["name"],
            target_type="z2m_light",
            source_kind=source_kind,
            color_strip_source_id=data.get("color_strip_source_id") or "",
            color_value_source_id=data.get("color_value_source_id") or "",
            brightness=brightness,
            light_mappings=mappings,
            # Legacy rows (pre-multi-broker) won't have this field — fall back
            # to empty so the target is parked until the user picks a broker
            # via the editor. We deliberately do NOT pick a "first" source as
            # a silent default: that could publish to an unexpected broker.
            mqtt_source_id=data.get("mqtt_source_id") or "",
            base_topic=(data.get("base_topic") or DEFAULT_Z2M_BASE_TOPIC).strip()
            or DEFAULT_Z2M_BASE_TOPIC,
            update_rate=BindableFloat.from_raw(data.get("update_rate"), default=5.0),
            transition=BindableFloat.from_raw(data.get("transition"), default=0.3),
            min_brightness_threshold=BindableFloat.from_raw(
                data.get("min_brightness_threshold"), default=0.0
            ),
            color_tolerance=BindableFloat.from_raw(data.get("color_tolerance"), default=5.0),
            stop_action=(
                data["stop_action"] if data.get("stop_action") in VALID_STOP_ACTIONS else "none"
            ),
            description=data.get("description"),
            tags=data.get("tags", []),
            icon=data.get("icon", ""),
            icon_color=data.get("icon_color", ""),
            created_at=datetime.fromisoformat(
                data.get("created_at", datetime.now(timezone.utc).isoformat())
            ),
            updated_at=datetime.fromisoformat(
                data.get("updated_at", datetime.now(timezone.utc).isoformat())
            ),
        )
