"""Zigbee2MQTT light target processor — publishes RGB+brightness directly to Z2M topics.

Reads from a ColorStripStream or a colour-returning ValueStream, averages LED segments
to single RGB values, and publishes ``{"color":{"r","g","b"}, "brightness", "transition"}``
payloads to ``<base_topic>/<friendly_name>/set`` on the shared MQTT broker.

Bypasses Home Assistant — bulbs must already be paired with the Z2M coordinator.
"""

import asyncio
import json
import time
from typing import Any, Dict, List, Optional, Tuple

import numpy as np

from ledgrab.core.processing.target_processor import TargetContext, TargetProcessor
from ledgrab.storage.z2m_light_output_target import (
    DEFAULT_Z2M_BASE_TOPIC,
    Z2MLightMapping,
)
from ledgrab.utils import get_logger

logger = get_logger(__name__)


class Z2MLightTargetProcessor(TargetProcessor):
    """Streams averaged LED colors to Zigbee2MQTT bulbs via MQTT."""

    def __init__(
        self,
        target_id: str,
        mqtt_source_id: str,
        source_kind: str = "css",
        color_strip_source_id: str = "",
        color_value_source_id: str = "",
        brightness=None,
        light_mappings: Optional[List[Z2MLightMapping]] = None,
        base_topic: str = DEFAULT_Z2M_BASE_TOPIC,
        update_rate: float = 5.0,
        transition=None,
        min_brightness_threshold: int = 0,
        color_tolerance: int = 5,
        stop_action: str = "none",
        ctx: Optional[TargetContext] = None,
    ):
        from ledgrab.storage.bindable import BindableFloat, bfloat

        super().__init__(target_id, ctx)
        self._mqtt_source_id = mqtt_source_id
        self._source_kind = source_kind if source_kind in ("css", "color_vs") else "css"
        self._css_id = color_strip_source_id
        self._color_vs_id = color_value_source_id
        if brightness is not None and isinstance(brightness, BindableFloat):
            self._brightness = brightness
        else:
            self._brightness = BindableFloat(1.0)
        if transition is not None and isinstance(transition, BindableFloat):
            self._transition = transition
        else:
            self._transition = BindableFloat(float(transition) if transition is not None else 0.3)
        self._light_mappings = light_mappings or []
        self._base_topic = (base_topic or "").strip() or DEFAULT_Z2M_BASE_TOPIC
        # Z2M ceiling — 10 Hz is the realistic Zigbee-mesh upper bound per bulb.
        self._update_rate = max(0.5, min(10.0, bfloat(update_rate, 5.0)))
        self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
        self._color_tolerance = int(bfloat(color_tolerance, 5.0))
        self._stop_action = stop_action if stop_action in ("none", "turn_off") else "none"

        # Runtime state
        self._css_stream = None
        self._color_stream = None
        self._value_stream = None  # brightness VS stream
        self._previous_colors: Dict[str, Tuple[int, int, int]] = {}
        self._previous_on: Dict[str, bool] = {}
        self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {}
        self._ws_clients: List[Any] = []
        self._start_time: Optional[float] = None
        # MQTT runtime acquired from MQTTManager at start(); released at stop().
        self._mqtt_runtime = None
        # Track whether we hold an outstanding acquire() so stop() knows to release.
        self._mqtt_acquired_id: Optional[str] = None

    @property
    def device_id(self) -> Optional[str]:
        return None  # Z2M targets don't use device providers

    # ─────────── Lifecycle ───────────

    async def start(self) -> None:
        if self._is_running:
            return

        if self._source_kind == "color_vs":
            if self._color_vs_id and self._ctx.value_stream_manager:
                try:
                    self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
                except Exception as e:
                    logger.warning(
                        f"Z2M light {self._target_id}: failed to acquire color VS stream: {e}"
                    )
        else:
            if self._css_id and self._ctx.color_strip_stream_manager:
                try:
                    self._css_stream = self._ctx.color_strip_stream_manager.acquire(
                        self._css_id, self._target_id
                    )
                except Exception as e:
                    logger.warning(
                        f"Z2M light {self._target_id}: failed to acquire CSS stream: {e}"
                    )

        if self._brightness.source_id and self._ctx.value_stream_manager:
            try:
                self._value_stream = self._ctx.value_stream_manager.acquire(
                    self._brightness.source_id
                )
            except Exception as e:
                logger.warning(f"Z2M light {self._target_id}: failed to acquire brightness VS: {e}")
                self._value_stream = None

        # Acquire the MQTT runtime for our broker source. If the manager is
        # missing or the source can't be resolved, the runtime stays None and
        # publishes are dropped — the loop guards on it.
        mqtt_manager = getattr(self._ctx, "mqtt_manager", None)
        if mqtt_manager is None:
            logger.warning(
                f"Z2M light {self._target_id}: no MQTT manager in context — frames will be dropped"
            )
        elif not self._mqtt_source_id:
            logger.warning(
                f"Z2M light {self._target_id}: no mqtt_source_id configured — frames will be dropped"
            )
        else:
            try:
                self._mqtt_runtime = await mqtt_manager.acquire(self._mqtt_source_id)
                self._mqtt_acquired_id = self._mqtt_source_id
            except Exception as e:
                logger.warning(
                    f"Z2M light {self._target_id}: failed to acquire MQTT runtime "
                    f"for source {self._mqtt_source_id}: {e}"
                )

        self._is_running = True
        self._start_time = time.monotonic()
        self._task = asyncio.create_task(self._processing_loop())
        logger.info(f"Z2M light target started: {self._target_id} -> mqtt[{self._mqtt_source_id}]")

    async def stop(self) -> None:
        self._is_running = False
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass
            self._task = None

        try:
            await self._apply_stop_action()
        except Exception as e:
            logger.warning(
                f"Z2M light {self._target_id}: stop_action '{self._stop_action}' failed: {e}"
            )

        if self._css_stream and self._ctx.color_strip_stream_manager:
            try:
                self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
            except Exception:
                pass
            self._css_stream = None

        if self._color_stream is not None and self._ctx.value_stream_manager:
            try:
                self._ctx.value_stream_manager.release(self._color_vs_id)
            except Exception:
                pass
            self._color_stream = None

        if self._value_stream is not None and self._ctx.value_stream_manager:
            try:
                self._ctx.value_stream_manager.release(self._brightness.source_id)
            except Exception:
                pass
            self._value_stream = None

        # Release the MQTT runtime so the broker connection can be torn down
        # if no other consumer holds it.
        if self._mqtt_acquired_id is not None:
            mqtt_manager = getattr(self._ctx, "mqtt_manager", None)
            if mqtt_manager is not None:
                try:
                    await mqtt_manager.release(self._mqtt_acquired_id)
                except Exception as e:
                    logger.warning(
                        f"Z2M light {self._target_id}: failed to release MQTT runtime: {e}"
                    )
            self._mqtt_acquired_id = None
        self._mqtt_runtime = None

        self._previous_colors.clear()
        self._previous_on.clear()
        self._latest_entity_colors.clear()
        self._ws_clients.clear()
        logger.info(f"Z2M light target stopped: {self._target_id}")

    # ─────────── Settings ───────────

    def update_settings(self, settings) -> None:
        from ledgrab.storage.bindable import BindableFloat, bfloat

        if not isinstance(settings, dict):
            return
        if "update_rate" in settings:
            self._update_rate = max(0.5, min(10.0, bfloat(settings["update_rate"], 5.0)))
        if "transition" in settings:
            t = settings["transition"]
            self._transition = (
                t if isinstance(t, BindableFloat) else self._transition.apply_update(t)
            )
        if "brightness" in settings:
            b = settings["brightness"]
            self._brightness = (
                b if isinstance(b, BindableFloat) else self._brightness.apply_update(b)
            )
        if "base_topic" in settings:
            bt = (settings["base_topic"] or "").strip()
            self._base_topic = bt or DEFAULT_Z2M_BASE_TOPIC
        if "mqtt_source_id" in settings:
            # The broker swap itself is deferred to a stop/restart cycle
            # (acquire/release happens at start()/stop() boundaries). We
            # record the new id so the next start() uses the right runtime.
            new_id = settings["mqtt_source_id"] or ""
            if new_id != self._mqtt_source_id:
                self._mqtt_source_id = new_id
        if "min_brightness_threshold" in settings:
            self._min_brightness_threshold = int(bfloat(settings["min_brightness_threshold"], 0.0))
        if "color_tolerance" in settings:
            self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0))
        if "light_mappings" in settings:
            self._light_mappings = settings["light_mappings"]
        if "stop_action" in settings:
            sa = settings["stop_action"]
            if sa in ("none", "turn_off"):
                self._stop_action = sa

        new_kind = settings.get("source_kind")
        new_color_vs = settings.get("color_value_source_id")
        kind_changed = new_kind in ("css", "color_vs") and new_kind != self._source_kind
        color_vs_changed = new_color_vs is not None and new_color_vs != self._color_vs_id
        if kind_changed or color_vs_changed:
            self._swap_color_source(
                new_kind if kind_changed else self._source_kind,
                new_color_vs if new_color_vs is not None else self._color_vs_id,
            )

    def update_css_source(self, color_strip_source_id: str) -> None:
        old_id = self._css_id
        self._css_id = color_strip_source_id
        if self._source_kind == "css" and self._is_running and self._ctx.color_strip_stream_manager:
            try:
                new_stream = self._ctx.color_strip_stream_manager.acquire(
                    color_strip_source_id, self._target_id
                )
                old_stream = self._css_stream
                self._css_stream = new_stream
                if old_stream:
                    self._ctx.color_strip_stream_manager.release(old_id, self._target_id)
            except Exception as e:
                logger.warning(f"Z2M light {self._target_id}: CSS swap failed: {e}")

    def _swap_color_source(self, new_kind: str, new_color_vs_id: str) -> None:
        if self._is_running:
            if self._css_stream and self._ctx.color_strip_stream_manager:
                try:
                    self._ctx.color_strip_stream_manager.release(self._css_id, self._target_id)
                except Exception:
                    pass
                self._css_stream = None
            if self._color_stream is not None and self._ctx.value_stream_manager:
                try:
                    self._ctx.value_stream_manager.release(self._color_vs_id)
                except Exception:
                    pass
                self._color_stream = None

        self._source_kind = new_kind
        self._color_vs_id = new_color_vs_id
        self._previous_colors.clear()
        self._previous_on.clear()

        if not self._is_running:
            return

        if self._source_kind == "color_vs":
            if self._color_vs_id and self._ctx.value_stream_manager:
                try:
                    self._color_stream = self._ctx.value_stream_manager.acquire(self._color_vs_id)
                except Exception as e:
                    logger.warning(f"Z2M light {self._target_id}: failed to acquire color VS: {e}")
        else:
            if self._css_id and self._ctx.color_strip_stream_manager:
                try:
                    self._css_stream = self._ctx.color_strip_stream_manager.acquire(
                        self._css_id, self._target_id
                    )
                except Exception as e:
                    logger.warning(
                        f"Z2M light {self._target_id}: failed to re-acquire CSS stream: {e}"
                    )

    # ─────────── WebSocket clients ───────────

    def add_ws_client(self, ws: Any) -> None:
        self._ws_clients.append(ws)

    def remove_ws_client(self, ws: Any) -> None:
        if ws in self._ws_clients:
            self._ws_clients.remove(ws)

    def supports_websocket(self) -> bool:
        return True

    # ─────────── State / metrics ───────────

    def get_state(self) -> dict:
        uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0
        entity_colors = {
            name: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"}
            for name, (r, g, b) in self._latest_entity_colors.items()
        }
        mqtt_connected = bool(self._mqtt_runtime and self._mqtt_runtime.is_connected)
        return {
            "target_id": self._target_id,
            "processing": self._is_running,
            "source_kind": self._source_kind,
            "css_id": self._css_id,
            "color_value_source_id": self._color_vs_id,
            "mqtt_source_id": self._mqtt_source_id,
            "is_running": self._is_running,
            "mqtt_connected": mqtt_connected,
            "base_topic": self._base_topic,
            "light_count": len(self._light_mappings),
            "update_rate": self._update_rate,
            "fps_actual": self._update_rate if self._is_running else None,
            "fps_target": self._update_rate,
            "fps_capture": self._update_rate if self._is_running else None,
            "uptime_seconds": uptime,
            "entity_colors": entity_colors,
        }

    def get_metrics(self) -> dict:
        uptime = time.monotonic() - self._start_time if self._start_time and self._is_running else 0
        return {
            "target_id": self._target_id,
            "processing": self._is_running,
            "fps_actual": self._update_rate if self._is_running else None,
            "fps_target": self._update_rate,
            "uptime_seconds": uptime,
            "frames_processed": 0,
            "errors_count": 0,
            "last_error": None,
            "last_update": None,
        }

    # ─────────── Processing loop ───────────

    async def _processing_loop(self) -> None:
        interval = 1.0 / self._update_rate
        while self._is_running:
            try:
                loop_start = time.monotonic()

                if self._mqtt_runtime is not None and self._mqtt_runtime.is_connected:
                    if self._source_kind == "color_vs" and self._color_stream is not None:
                        try:
                            color = self._color_stream.get_color()
                        except Exception:
                            color = None
                        if isinstance(color, (list, tuple)) and len(color) >= 3:
                            await self._update_lights_single_color(
                                int(color[0]), int(color[1]), int(color[2])
                            )
                    elif self._css_stream is not None:
                        colors = self._css_stream.get_latest_colors()
                        if colors is not None and len(colors) > 0:
                            await self._update_lights(colors)

                elapsed = time.monotonic() - loop_start
                # Refresh interval each loop so update_rate changes take effect immediately.
                interval = 1.0 / max(0.5, self._update_rate)
                sleep_time = max(0.05, interval - elapsed)
                await asyncio.sleep(sleep_time)

            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"Z2M light {self._target_id} loop error: {e}")
                await asyncio.sleep(1.0)

    def _read_brightness_multiplier(self) -> float:
        if self._value_stream is None:
            return 1.0
        try:
            return float(self._value_stream.get_value())
        except Exception:
            return 1.0

    def _topic_for(self, friendly_name: str) -> str:
        # Z2M topic convention: <base_topic>/<friendly_name>/set
        return f"{self._base_topic}/{friendly_name}/set"

    async def _publish_payload(self, friendly_name: str, payload: dict) -> None:
        if not friendly_name or self._mqtt_runtime is None:
            return
        try:
            await self._mqtt_runtime.publish(
                self._topic_for(friendly_name),
                json.dumps(payload),
                retain=False,
                qos=0,
            )
        except Exception as e:
            logger.debug(f"Z2M light {self._target_id}: publish failed for {friendly_name}: {e}")

    async def _send_entity_color(
        self,
        mapping: Z2MLightMapping,
        r: int,
        g: int,
        b: int,
        vs_multiplier: float,
    ) -> None:
        """Apply tolerance/threshold gates and publish one Z2M command."""
        friendly = mapping.friendly_name
        if not friendly:
            return

        # Cache for WS preview (always, even if MQTT is skipped).
        self._latest_entity_colors[friendly] = (r, g, b)

        bs = (
            mapping.brightness_scale.value
            if hasattr(mapping.brightness_scale, "value")
            else mapping.brightness_scale
        )
        eff_scale = bs * vs_multiplier
        # Z2M uses 0..254 for `brightness` on Zigbee bulbs.
        brightness = max(0, min(254, int(max(r, g, b) * eff_scale)))

        should_be_on = (
            brightness >= self._min_brightness_threshold or self._min_brightness_threshold == 0
        )

        prev_color = self._previous_colors.get(friendly)
        was_on = self._previous_on.get(friendly, True)

        if should_be_on:
            if prev_color is not None and was_on:
                dr = abs(r - prev_color[0])
                dg = abs(g - prev_color[1])
                db = abs(b - prev_color[2])
                if max(dr, dg, db) < self._color_tolerance:
                    return  # colour hasn't changed enough

            payload: Dict[str, Any] = {
                "state": "ON",
                "color": {"r": r, "g": g, "b": b},
                "brightness": brightness,
            }
            transition_val = self._transition.value
            if transition_val > 0:
                payload["transition"] = transition_val

            await self._publish_payload(friendly, payload)
            self._previous_colors[friendly] = (r, g, b)
            self._previous_on[friendly] = True

        elif was_on:
            await self._publish_payload(friendly, {"state": "OFF"})
            self._previous_on[friendly] = False
            self._previous_colors.pop(friendly, None)

    async def _update_lights(self, colors: np.ndarray) -> None:
        led_count = len(colors)
        vs_multiplier = self._read_brightness_multiplier()

        for mapping in self._light_mappings:
            if not mapping.friendly_name:
                continue
            start = max(0, mapping.led_start)
            end = mapping.led_end if mapping.led_end >= 0 else led_count
            end = min(end, led_count)
            if start >= end:
                continue
            segment = colors[start:end]
            avg = segment.mean(axis=0).astype(int)
            await self._send_entity_color(
                mapping, int(avg[0]), int(avg[1]), int(avg[2]), vs_multiplier
            )

        if self._ws_clients and self._latest_entity_colors:
            await self._broadcast_entity_colors()

    async def _update_lights_single_color(self, r: int, g: int, b: int) -> None:
        vs_multiplier = self._read_brightness_multiplier()
        for mapping in self._light_mappings:
            if not mapping.friendly_name:
                continue
            await self._send_entity_color(mapping, r, g, b, vs_multiplier)

        if self._ws_clients and self._latest_entity_colors:
            await self._broadcast_entity_colors()

    async def _broadcast_entity_colors(self) -> None:
        colors_payload = {
            name: {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"}
            for name, (r, g, b) in self._latest_entity_colors.items()
        }
        message = json.dumps({"type": "colors_update", "colors": colors_payload})
        dead: List[Any] = []
        for ws in self._ws_clients:
            try:
                await ws.send_text(message)
            except Exception:
                dead.append(ws)
        for ws in dead:
            self._ws_clients.remove(ws)

    # ─────────── Stop-action finalization ───────────

    def _unique_mapped_friendly_names(self) -> List[str]:
        names: List[str] = []
        seen = set()
        for m in self._light_mappings:
            fn = m.friendly_name
            if fn and fn not in seen:
                seen.add(fn)
                names.append(fn)
        return names

    async def turn_off_lights(self) -> int:
        """Publish ``{"state":"OFF"}`` to every mapped bulb.

        Works whether or not the processor is running. When the processor
        isn't running, temporarily borrows the runtime from the MQTT manager
        and releases it afterwards. Returns the number of bulbs the
        turn-off was issued for.
        """
        names = self._unique_mapped_friendly_names()
        if not names:
            return 0

        mqtt_manager = getattr(self._ctx, "mqtt_manager", None)
        if mqtt_manager is None:
            raise RuntimeError("MQTT manager not available")
        if not self._mqtt_source_id:
            raise RuntimeError("Target has no MQTT source configured")

        # Use the already-acquired runtime when running; otherwise borrow one.
        runtime = self._mqtt_runtime
        borrowed = False
        if runtime is None:
            runtime = await mqtt_manager.acquire(self._mqtt_source_id)
            borrowed = True

        try:
            if not runtime.is_connected:
                raise RuntimeError("MQTT broker not connected")
            # Swap in the borrowed runtime so _publish_payload uses it.
            prev_runtime = self._mqtt_runtime
            self._mqtt_runtime = runtime
            try:
                for name in names:
                    await self._publish_payload(name, {"state": "OFF"})
            finally:
                self._mqtt_runtime = prev_runtime
        finally:
            if borrowed:
                try:
                    await mqtt_manager.release(self._mqtt_source_id)
                except Exception as e:
                    logger.warning(
                        f"Z2M light {self._target_id}: failed to release borrowed runtime: {e}"
                    )
        return len(names)

    async def _apply_stop_action(self) -> None:
        if self._stop_action == "none":
            return
        if self._mqtt_runtime is None or not self._mqtt_runtime.is_connected:
            logger.info(
                f"Z2M light {self._target_id}: skipping stop_action "
                f"'{self._stop_action}' — MQTT not connected"
            )
            return
        if self._stop_action == "turn_off":
            for name in self._unique_mapped_friendly_names():
                await self._publish_payload(name, {"state": "OFF"})
