"""WLED/LED target processor — gets colors from a ColorStripStream, sends via DDP."""

from __future__ import annotations

import asyncio
import collections
import time
from datetime import datetime, timezone
from typing import Optional

import httpx
import numpy as np

from wled_controller.core.devices.led_client import (
    LEDClient,
    create_led_client,
    get_device_capabilities,
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.target_processor import (
    DeviceInfo,
    ProcessingMetrics,
    TargetContext,
    TargetProcessor,
)
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer

logger = get_logger(__name__)


class WledTargetProcessor(TargetProcessor):
    """Streams LED colors from a single ColorStripStream to a WLED/LED device."""

    def __init__(
        self,
        target_id: str,
        device_id: str,
        color_strip_source_id: str = "",
        fps: int = 30,
        keepalive_interval: float = 1.0,
        state_check_interval: int = 30,
        brightness=None,
        # legacy compat
        brightness_value_source_id: str = "",
        min_brightness_threshold: int = 0,
        adaptive_fps: bool = False,
        protocol: str = "ddp",
        ctx: TargetContext = None,
    ):
        from wled_controller.storage.bindable import BindableFloat, bfloat

        super().__init__(target_id, ctx)
        self._device_id = device_id
        _fps = bfloat(fps, 30.0)
        self._target_fps = int(_fps) if _fps > 0 else 30
        self._keepalive_interval = keepalive_interval
        self._state_check_interval = state_check_interval
        self._css_id = color_strip_source_id
        # Accept BindableFloat or legacy string
        if brightness is not None and isinstance(brightness, BindableFloat):
            self._brightness = brightness
        else:
            self._brightness = BindableFloat(1.0, source_id=brightness_value_source_id or "")
        self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
        self._adaptive_fps = adaptive_fps
        self._protocol = protocol

        # Adaptive FPS / liveness probe runtime state
        self._effective_fps: int = self._target_fps
        self._device_reachable: Optional[bool] = None  # None = not yet probed

        # Runtime state (populated on start)
        self._led_client: Optional[LEDClient] = None
        self._css_stream: Optional[object] = None  # active stream reference
        self._value_stream = None  # active brightness value stream
        self._device_state_before: Optional[dict] = None
        self._overlay_active = False
        self._needs_keepalive = True

        self._effective_led_count: int = 0
        self._resolved_display_index: Optional[int] = None

        # Fit-to-device linspace cache (per-instance to avoid cross-target thrash)
        self._fit_cache_key: tuple = (0, 0)
        self._fit_cache_src: Optional[np.ndarray] = None
        self._fit_cache_dst: Optional[np.ndarray] = None
        self._fit_result_buf: Optional[np.ndarray] = None

        # LED preview WebSocket clients
        self._preview_clients: list = []
        self._last_preview_colors: np.ndarray | None = None
        self._last_preview_brightness: int = 255
        self._last_preview_data: bytes | None = None  # cached full binary frame
        self._preview_force_send: bool = False  # flag to force immediate broadcast

    # ----- Properties -----

    @property
    def device_id(self) -> str:
        return self._device_id

    @property
    def led_client(self) -> Optional[LEDClient]:
        return self._led_client

    # ----- Lifecycle -----

    async def start(self) -> None:
        if self._is_running:
            logger.debug(f"Processing already running for target {self._target_id}")
            return

        device_info = self._ctx.get_device_info(self._device_id)
        if device_info is None:
            raise ValueError(f"Device {self._device_id} not registered")

        # Connect to LED device
        try:
            self._led_client = create_led_client(
                device_info.device_type,
                device_info.device_url,
                use_ddp=(self._protocol == "ddp"),
                led_count=device_info.led_count,
                baud_rate=device_info.baud_rate,
                send_latency_ms=device_info.send_latency_ms,
                rgbw=device_info.rgbw,
                zone_mode=device_info.zone_mode,
                dmx_protocol=device_info.dmx_protocol,
                dmx_start_universe=device_info.dmx_start_universe,
                dmx_start_channel=device_info.dmx_start_channel,
                espnow_peer_mac=device_info.espnow_peer_mac,
                espnow_channel=device_info.espnow_channel,
                hue_username=device_info.hue_username,
                hue_client_key=device_info.hue_client_key,
                hue_entertainment_group_id=device_info.hue_entertainment_group_id,
                spi_speed_hz=device_info.spi_speed_hz,
                spi_led_type=device_info.spi_led_type,
                chroma_device_type=device_info.chroma_device_type,
                gamesense_device_type=device_info.gamesense_device_type,
            )
            await self._led_client.connect()

            # Use client-reported LED count if available (more accurate than stored)
            client_led_count = self._led_client.device_led_count
            effective_led_count = (
                client_led_count
                if client_led_count and client_led_count > 0
                else device_info.led_count
            )
            self._effective_led_count = effective_led_count

            if effective_led_count != device_info.led_count:
                logger.info(
                    f"Target {self._target_id}: device reports {effective_led_count} LEDs "
                    f"(stored: {device_info.led_count}), using actual count"
                )

            logger.info(
                f"Target {self._target_id} connected to {device_info.device_type} "
                f"device ({effective_led_count} LEDs)"
            )
            self._device_state_before = await self._led_client.snapshot_device_state()
            self._needs_keepalive = "standby_required" in get_device_capabilities(
                device_info.device_type
            )
        except Exception as e:
            logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
            raise RuntimeError(f"Failed to connect to LED device: {e}")

        # Acquire color strip stream
        css_manager = self._ctx.color_strip_stream_manager
        if css_manager is None:
            await self._led_client.close()
            self._led_client = None
            raise RuntimeError("Color strip stream manager not available in context")

        if not self._css_id:
            await self._led_client.close()
            self._led_client = None
            raise RuntimeError(f"Target {self._target_id} has no color strip source configured")

        try:
            stream = await asyncio.to_thread(css_manager.acquire, self._css_id, self._target_id)
            if hasattr(stream, "configure") and self._effective_led_count > 0:
                stream.configure(self._effective_led_count)
            css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)

            self._resolved_display_index = getattr(stream, "display_index", None)
            self._css_stream = stream

            logger.info(f"Acquired CSS stream '{self._css_id}' for target {self._target_id}")
        except Exception as e:
            if self._led_client:
                await self._led_client.close()
                self._led_client = None
            raise RuntimeError(f"Failed to acquire CSS stream: {e}")

        # Acquire value stream for brightness modulation (if configured)
        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"Failed to acquire value stream {self._brightness.source_id}: {e}")
                self._value_stream = None

        # Reset metrics and start loop
        self._metrics = ProcessingMetrics(start_time=datetime.now(timezone.utc))
        self._is_running = True
        self._task = asyncio.create_task(self._processing_loop())

        logger.info(f"Started processing for target {self._target_id}")
        self._ctx.fire_event(
            {"type": "state_change", "target_id": self._target_id, "processing": True}
        )

    async def stop(self) -> None:
        if not self._is_running:
            logger.warning(f"Processing not running for target {self._target_id}")
            return

        self._is_running = False

        # Cancel task
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                logger.debug("WLED target processor task cancelled")
                pass
            self._task = None
            await asyncio.sleep(0.05)

        # Restore device state (only if auto_shutdown is enabled)
        if self._led_client and self._device_state_before:
            device_info = self._ctx.get_device_info(self._device_id)
            if device_info and device_info.auto_shutdown:
                await self._led_client.restore_device_state(self._device_state_before)
            self._device_state_before = None

        # Close LED connection
        if self._led_client:
            await self._led_client.close()
            self._led_client = None

        # Release CSS stream
        css_manager = self._ctx.color_strip_stream_manager
        if css_manager and self._css_stream is not None:
            try:
                css_manager.remove_target_fps(self._css_id, self._target_id)
                await asyncio.to_thread(css_manager.release, self._css_id, self._target_id)
            except Exception as e:
                logger.warning(
                    f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}"
                )
        self._css_stream = None

        # Release value stream
        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 as e:
                logger.warning(f"Error releasing value stream: {e}")
        self._value_stream = None

        logger.info(f"Stopped processing for target {self._target_id}")
        self._ctx.fire_event(
            {"type": "state_change", "target_id": self._target_id, "processing": False}
        )

    # ----- Settings -----

    def update_settings(self, settings: dict) -> None:
        """Update target-specific timing settings."""
        from wled_controller.storage.bindable import bfloat

        if isinstance(settings, dict):
            if "fps" in settings:
                _fps = bfloat(settings["fps"], 30.0)
                self._target_fps = int(_fps) if _fps > 0 else 30
                self._effective_fps = self._target_fps  # reset adaptive
                css_manager = self._ctx.color_strip_stream_manager
                if css_manager and self._is_running and self._css_id:
                    css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
            if "keepalive_interval" in settings:
                self._keepalive_interval = settings["keepalive_interval"]
            if "state_check_interval" in settings:
                self._state_check_interval = settings["state_check_interval"]
            if "min_brightness_threshold" in settings:
                self._min_brightness_threshold = int(
                    bfloat(settings["min_brightness_threshold"], 0.0)
                )
            if "adaptive_fps" in settings:
                self._adaptive_fps = settings["adaptive_fps"]
                if not self._adaptive_fps:
                    self._effective_fps = self._target_fps
        logger.info(f"Updated settings for target {self._target_id}")

    def update_device(self, device_id: str) -> None:
        """Update the device this target streams to."""
        self._device_id = device_id

    def update_css_source(self, new_css_id: str) -> None:
        """Hot-swap the color strip source for a running target."""
        old_css_id = self._css_id
        self._css_id = new_css_id

        if not self._is_running:
            return

        css_manager = self._ctx.color_strip_stream_manager
        if css_manager is None:
            return

        device_info = self._ctx.get_device_info(self._device_id)
        device_leds = getattr(self, "_effective_led_count", None) or (
            device_info.led_count if device_info else 0
        )

        # Release old stream
        if self._css_stream is not None and old_css_id:
            try:
                css_manager.remove_target_fps(old_css_id, self._target_id)
                css_manager.release(old_css_id, self._target_id)
            except Exception as e:
                logger.warning(f"Error releasing old CSS {old_css_id}: {e}")

        # Acquire new stream
        new_stream = None
        if new_css_id:
            try:
                new_stream = css_manager.acquire(new_css_id, self._target_id)
                if hasattr(new_stream, "configure") and device_leds > 0:
                    new_stream.configure(device_leds)
                css_manager.notify_target_fps(new_css_id, self._target_id, self._target_fps)
            except Exception as e:
                logger.error(f"Failed to acquire new CSS {new_css_id}: {e}")

        # Atomic swap — the processing loop detects via identity check
        self._css_stream = new_stream
        logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}")

    def update_brightness_value_source(self, vs_id: str) -> None:
        """Legacy: hot-swap brightness value source by ID string."""
        from wled_controller.storage.bindable import BindableFloat

        self.update_brightness(BindableFloat(value=self._brightness.value, source_id=vs_id))

    def update_brightness(self, brightness) -> None:
        """Hot-swap the brightness binding for a running target."""
        from wled_controller.storage.bindable import BindableFloat

        old_vs_id = self._brightness.source_id
        if isinstance(brightness, BindableFloat):
            self._brightness = brightness
        else:
            self._brightness = self._brightness.apply_update(brightness)
        new_vs_id = self._brightness.source_id

        vs_mgr = self._ctx.value_stream_manager
        if not self._is_running or vs_mgr is None:
            return

        # Only swap streams if source_id actually changed
        if old_vs_id == new_vs_id:
            return

        # Release old stream
        if self._value_stream is not None and old_vs_id:
            try:
                vs_mgr.release(old_vs_id)
            except Exception as e:
                logger.warning(f"Error releasing old value stream {old_vs_id}: {e}")
            self._value_stream = None

        # Acquire new stream
        if new_vs_id:
            try:
                self._value_stream = vs_mgr.acquire(new_vs_id)
            except Exception as e:
                logger.warning(f"Failed to acquire value stream {new_vs_id}: {e}")
                self._value_stream = None

        logger.info(f"Hot-swapped brightness VS for {self._target_id}: {old_vs_id} -> {new_vs_id}")

    async def _probe_device(self, device_url: str, client: httpx.AsyncClient) -> bool:
        """HTTP liveness probe — lightweight GET to check if device is reachable."""
        try:
            resp = await client.get(f"{device_url}/json/info")
            return resp.status_code == 200
        except Exception as e:
            logger.debug("Device probe failed for %s: %s", device_url, e)
            return False

    def get_display_index(self) -> Optional[int]:
        """Display index being captured, from the active stream."""
        if self._resolved_display_index is not None:
            return self._resolved_display_index
        if self._css_stream is not None:
            return getattr(self._css_stream, "display_index", None)
        return None

    # ----- State / Metrics -----

    def get_state(self) -> dict:
        metrics = self._metrics
        fps_target = self._target_fps

        css_timing: dict = {}
        if self._is_running and self._css_stream is not None:
            css_timing = self._css_stream.get_last_timing()

        send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
        # Picture source timing
        extract_ms = round(css_timing.get("extract_ms", 0), 1) if css_timing else None
        map_ms = round(css_timing.get("map_leds_ms", 0), 1) if css_timing else None
        smooth_ms = round(css_timing.get("smooth_ms", 0), 1) if css_timing else None
        # Audio source timing (keyed on audio_render_ms presence)
        is_audio_source = css_timing and "audio_render_ms" in css_timing
        audio_read_ms = round(css_timing.get("audio_read_ms", 0), 1) if is_audio_source else None
        audio_fft_ms = round(css_timing.get("audio_fft_ms", 0), 1) if is_audio_source else None
        audio_render_ms = (
            round(css_timing.get("audio_render_ms", 0), 1) if is_audio_source else None
        )
        # Suppress picture timing when audio source is active
        if is_audio_source:
            extract_ms = map_ms = smooth_ms = None

        if css_timing:
            total_ms = round(css_timing.get("total_ms", 0) + metrics.timing_send_ms, 1)
        elif self._is_running and send_ms is not None:
            total_ms = send_ms
        else:
            total_ms = None

        # Lazily convert monotonic timestamp to UTC datetime for API
        last_update = metrics.last_update
        if metrics.last_update_mono > 0:
            elapsed = time.monotonic() - metrics.last_update_mono
            last_update = (
                datetime.now(timezone.utc)
                if elapsed < 1.0
                else datetime.fromtimestamp(time.time() - elapsed, tz=timezone.utc)
            )

        return {
            "target_id": self._target_id,
            "device_id": self._device_id,
            "color_strip_source_id": self._css_id,
            "brightness": self._brightness.to_dict(),
            "processing": self._is_running,
            "fps_actual": metrics.fps_actual if self._is_running else None,
            "fps_potential": metrics.fps_potential if self._is_running else None,
            "fps_target": fps_target,
            "frames_skipped": metrics.frames_skipped if self._is_running else None,
            "frames_keepalive": metrics.frames_keepalive if self._is_running else None,
            "fps_current": metrics.fps_current if self._is_running else None,
            "timing_send_ms": send_ms,
            "timing_extract_ms": extract_ms,
            "timing_map_leds_ms": map_ms,
            "timing_smooth_ms": smooth_ms,
            "timing_audio_read_ms": audio_read_ms,
            "timing_audio_fft_ms": audio_fft_ms,
            "timing_audio_render_ms": audio_render_ms,
            "timing_total_ms": total_ms,
            "display_index": self._resolved_display_index,
            "overlay_active": self._overlay_active,
            "needs_keepalive": self._needs_keepalive,
            "last_update": last_update,
            "errors": [metrics.last_error] if metrics.last_error else [],
            "device_streaming_reachable": self._device_reachable if self._is_running else None,
            "fps_effective": self._effective_fps if self._is_running else None,
            "protocol": self._protocol,
        }

    def get_metrics(self) -> dict:
        metrics = self._metrics
        fps_target = self._target_fps
        uptime_seconds = 0.0
        if metrics.start_time and self._is_running:
            uptime_seconds = (datetime.now(timezone.utc) - metrics.start_time).total_seconds()

        # Lazily convert monotonic timestamp to UTC datetime for API
        last_update = metrics.last_update
        if metrics.last_update_mono > 0:
            elapsed = time.monotonic() - metrics.last_update_mono
            last_update = (
                datetime.now(timezone.utc)
                if elapsed < 1.0
                else datetime.fromtimestamp(time.time() - elapsed, tz=timezone.utc)
            )

        return {
            "target_id": self._target_id,
            "device_id": self._device_id,
            "processing": self._is_running,
            "fps_actual": metrics.fps_actual if self._is_running else None,
            "fps_target": fps_target,
            "uptime_seconds": uptime_seconds,
            "frames_processed": metrics.frames_processed,
            "errors_count": metrics.errors_count,
            "last_error": metrics.last_error,
            "last_update": last_update,
        }

    # ----- Overlay -----

    def supports_overlay(self) -> bool:
        return True

    async def start_overlay(
        self, target_name: Optional[str] = None, calibration=None, display_info=None
    ) -> None:
        if self._overlay_active:
            raise RuntimeError(f"Overlay already active for {self._target_id}")

        if calibration is None or display_info is None:
            stream = self._css_stream
            if stream is None or not (hasattr(stream, "calibration") and stream.calibration):
                raise ValueError(
                    f"Cannot start overlay for {self._target_id}: no stream with calibration"
                )

            if calibration is None:
                calibration = stream.calibration

            if display_info is None:
                display_index = self._resolved_display_index
                if display_index is None:
                    display_index = getattr(stream, "display_index", None)
                if display_index is None or display_index < 0:
                    raise ValueError(f"Invalid display index {display_index} for overlay")
                displays = get_available_displays()
                if display_index >= len(displays):
                    raise ValueError(f"Invalid display index {display_index}")
                display_info = displays[display_index]

        await asyncio.to_thread(
            self._ctx.overlay_manager.start_overlay,
            self._target_id,
            display_info,
            calibration,
            target_name,
        )

        self._overlay_active = True
        logger.info(f"Started overlay for target {self._target_id}")

    async def stop_overlay(self) -> None:
        if not self._overlay_active:
            logger.warning(f"Overlay not active for {self._target_id}")
            return

        await asyncio.to_thread(
            self._ctx.overlay_manager.stop_overlay,
            self._target_id,
        )

        self._overlay_active = False
        logger.info(f"Stopped overlay for target {self._target_id}")

    def is_overlay_active(self) -> bool:
        return self._overlay_active

    # ----- LED Preview WebSocket -----

    def supports_websocket(self) -> bool:
        return True

    def add_led_preview_client(self, ws) -> None:
        self._preview_clients.append(ws)
        # Send last known frame immediately so late joiners see current state
        if self._last_preview_data is not None:
            asyncio.ensure_future(self._send_preview_to(ws, self._last_preview_data))
        elif self._last_preview_colors is not None:
            data = bytes([self._last_preview_brightness]) + self._last_preview_colors.tobytes()
            self._last_preview_data = data
            asyncio.ensure_future(self._send_preview_to(ws, data))
        # Also force the processing loop to broadcast on next iteration
        # (handles edge cases where cached data is stale or None)
        self._preview_force_send = True

    @staticmethod
    async def _send_preview_to(ws, data: bytes) -> None:
        try:
            await ws.send_bytes(data)
        except Exception as e:
            logger.debug("LED preview WS send failed: %s", e)
            pass

    def remove_led_preview_client(self, ws) -> None:
        if ws in self._preview_clients:
            self._preview_clients.remove(ws)

    async def _broadcast_led_preview(self, colors: np.ndarray, brightness: int = 255) -> None:
        """Broadcast LED colors as binary RGB bytes to preview WebSocket clients.

        Standard wire format: [brightness_byte] [R G B R G B ...]
        Composite wire format: [brightness_byte] [0xFE] [layer_count] [led_count_hi] [led_count_lo]
                               [layer_0_rgb...] [layer_1_rgb...] ... [composite_rgb...]
        """
        if not self._preview_clients:
            return

        # Check if source is composite with multiple layers
        from wled_controller.core.processing.composite_stream import CompositeColorStripStream

        stream = self._css_stream
        layer_colors = None
        if isinstance(stream, CompositeColorStripStream):
            layer_colors = stream.get_layer_colors()

        if layer_colors and len(layer_colors) > 1:
            led_count = len(colors)
            header = bytes(
                [brightness, 0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF]
            )
            parts = [header]
            for lc in layer_colors:
                if len(lc) != led_count:
                    lc = self._fit_to_device(lc, led_count)
                parts.append(lc.tobytes())
            parts.append(colors.tobytes())
            data = b"".join(parts)
        else:
            data = bytes([brightness]) + colors.tobytes()

        self._last_preview_data = data

        async def _send_safe(ws):
            try:
                await ws.send_bytes(data)
                return True
            except Exception as e:
                logger.debug("LED preview broadcast WS send failed: %s", e)
                return False

        clients = list(self._preview_clients)
        results = await asyncio.gather(*[_send_safe(ws) for ws in clients])

        for ws, ok in zip(clients, results):
            if not ok and ws in self._preview_clients:
                self._preview_clients.remove(ws)

    # ----- Private: processing loop -----

    def _fit_to_device(self, colors: np.ndarray, device_led_count: int) -> np.ndarray:
        """Resample colors to match the target LED count."""
        n = len(colors)
        if n == device_led_count or device_led_count <= 0:
            return colors
        key = (n, device_led_count)
        if self._fit_cache_key != key:
            self._fit_cache_src = np.linspace(0, 1, n)
            self._fit_cache_dst = np.linspace(0, 1, device_led_count)
            self._fit_cache_key = key
            self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
        buf = self._fit_result_buf
        for ch in range(min(colors.shape[1], 3)):
            np.copyto(
                buf[:, ch],
                np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
                casting="unsafe",
            )
        return buf

    async def _send_to_device(self, send_colors: np.ndarray) -> float:
        """Send colors to LED device and return send time in ms."""
        t_start = time.perf_counter()
        if self._led_client.supports_fast_send:
            self._led_client.send_pixels_fast(send_colors)
        else:
            await self._led_client.send_pixels(send_colors)
        return (time.perf_counter() - t_start) * 1000

    @staticmethod
    def _emit_diagnostics(
        target_id: str,
        sleep_jitters: collections.deque,
        iter_times: collections.deque,
        slow_iters: collections.deque,
        frame_time: float,
        diag_interval: float,
    ) -> None:
        """Log periodic timing diagnostics and clear the deques."""
        if sleep_jitters:
            jitters = [a - r for r, a in sleep_jitters]
            avg_j = sum(jitters) / len(jitters)
            max_j = max(jitters)
            p95_j = sorted(jitters)[int(len(jitters) * 0.95)] if len(jitters) >= 20 else max_j
            logger.info(
                f"[DIAG] {target_id} sleep jitter: "
                f"avg={avg_j:.1f}ms max={max_j:.1f}ms p95={p95_j:.1f}ms "
                f"(n={len(sleep_jitters)})"
            )
        if iter_times:
            avg_iter = sum(iter_times) / len(iter_times)
            max_iter = max(iter_times)
            logger.info(
                f"[DIAG] {target_id} iter: "
                f"avg={avg_iter:.1f}ms max={max_iter:.1f}ms "
                f"target={frame_time*1000:.1f}ms iters={len(iter_times)}"
            )
        if slow_iters:
            logger.warning(
                f"[DIAG] {target_id} slow iterations: "
                f"{len(slow_iters)} in last {diag_interval}s — "
                f"{list(slow_iters)[:5]}"
            )
        sleep_jitters.clear()
        slow_iters.clear()
        iter_times.clear()

    async def _processing_loop(self) -> None:
        """Main processing loop — poll CSS stream -> brightness -> send."""
        keepalive_interval = self._keepalive_interval

        fps_samples: collections.deque = collections.deque(maxlen=10)
        _fps_sum = 0.0
        send_timestamps: collections.deque = collections.deque(maxlen=self._target_fps + 10)

        def _fps_current_from_timestamps():
            """Count timestamps within the last second."""
            cutoff = time.perf_counter() - 1.0
            while send_timestamps and send_timestamps[0] <= cutoff:
                send_timestamps.popleft()
            return len(send_timestamps)

        last_send_time = 0.0
        _last_preview_broadcast = 0.0
        prev_frame_time_stamp = time.perf_counter()
        asyncio.get_running_loop()
        _init_device_info = self._ctx.get_device_info(self._device_id)
        _total_leds = getattr(self, "_effective_led_count", None) or (
            _init_device_info.led_count if _init_device_info else 0
        )

        # Stream reference — re-read each tick to detect hot-swaps
        stream = self._css_stream
        prev_frame_ref = None
        has_any_frame = False
        _prev_brightness = -1  # force first send

        # Pre-allocate brightness scratch (uint16 intermediate + uint8 output)
        _bright_u16: Optional[np.ndarray] = None
        _bright_out: Optional[np.ndarray] = None
        _bright_n = 0

        def _cached_brightness(colors_in, brightness: int):
            nonlocal _bright_n, _bright_u16, _bright_out
            if brightness >= 255:
                return colors_in
            _dn = len(colors_in)
            if _dn != _bright_n:
                _bright_n = _dn
                _bright_u16 = np.empty((_dn, 3), dtype=np.uint16)
                _bright_out = np.empty((_dn, 3), dtype=np.uint8)
            np.copyto(_bright_u16, colors_in, casting="unsafe")
            _bright_u16 *= brightness
            _bright_u16 >>= 8
            np.copyto(_bright_out, _bright_u16, casting="unsafe")
            return _bright_out

        def _effective_brightness(dev_info):
            """Compute effective brightness = software_brightness * brightness binding."""
            base = dev_info.software_brightness if dev_info else 255
            vs = self._value_stream
            if vs is not None:
                vs_val = vs.get_value()
                return max(0, min(255, int(base * vs_val)))
            # No value stream — use static value from BindableFloat
            static_val = self._brightness.value
            if static_val < 1.0:
                return max(0, min(255, int(base * static_val)))
            return base

        SKIP_REPOLL = 0.005  # 5 ms

        # --- Timing diagnostics ---
        _diag_interval = 5.0
        _diag_next_report = time.perf_counter() + _diag_interval
        _diag_sleep_jitters: collections.deque = collections.deque(maxlen=300)
        _diag_slow_iters: collections.deque = collections.deque(maxlen=50)
        _diag_iter_times: collections.deque = collections.deque(maxlen=300)
        _diag_device_info: Optional[DeviceInfo] = None
        _diag_device_info_age = 0

        # --- Liveness probe + adaptive FPS ---
        _device_url = _init_device_info.device_url if _init_device_info else ""
        _probe_enabled = _device_url.startswith("http")
        _probe_interval = 10.0  # seconds between probes
        _last_probe_time = 0.0  # force first probe soon (after 10s)
        _probe_task: Optional[asyncio.Task] = None
        _probe_client: Optional[httpx.AsyncClient] = None
        if _probe_enabled:
            _probe_client = httpx.AsyncClient(timeout=httpx.Timeout(2.0))
        self._effective_fps = self._target_fps
        self._device_reachable = None

        # --- CSPT (Color Strip Processing Template) filter cache ---
        _cspt_cached_template_id: Optional[str] = None  # last resolved template ID
        _cspt_filters: list = []  # list of PostprocessingFilter instances
        _cspt_check_interval = 30  # re-check device template ID every N iterations
        _cspt_check_counter = 0

        logger.info(
            f"Processing loop started for target {self._target_id} "
            f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps}"
            f"{', adaptive' if self._adaptive_fps else ''})"
        )

        next_frame_time = time.perf_counter()

        try:
            with high_resolution_timer():
                while self._is_running:
                    loop_start = now = time.perf_counter()
                    target_fps = self._target_fps if self._target_fps > 0 else 30

                    # --- Liveness probe ---
                    # Collect result as soon as it's done (every iteration)
                    if _probe_task is not None and _probe_task.done():
                        try:
                            reachable = _probe_task.result()
                        except Exception:
                            reachable = False
                        prev_reachable = self._device_reachable
                        self._device_reachable = reachable
                        self._metrics.device_streaming_reachable = reachable
                        _probe_task = None

                        if self._adaptive_fps:
                            if not reachable:
                                # Backoff: halve effective FPS
                                old_eff = self._effective_fps
                                self._effective_fps = max(1, self._effective_fps // 2)
                                if old_eff != self._effective_fps:
                                    logger.warning(
                                        f"[ADAPTIVE] {self._target_id} device unreachable, "
                                        f"FPS {old_eff} → {self._effective_fps}"
                                    )
                                    next_frame_time = time.perf_counter()
                            else:
                                # Recovery: gradually increase
                                if self._effective_fps < target_fps:
                                    step = max(1, target_fps // 8)
                                    old_eff = self._effective_fps
                                    self._effective_fps = min(
                                        target_fps, self._effective_fps + step
                                    )
                                    if old_eff != self._effective_fps:
                                        logger.info(
                                            f"[ADAPTIVE] {self._target_id} device reachable, "
                                            f"FPS {old_eff} → {self._effective_fps}"
                                        )
                                        next_frame_time = time.perf_counter()

                        if prev_reachable != reachable:
                            logger.info(
                                f"[PROBE] {self._target_id} device "
                                f"{'reachable' if reachable else 'UNREACHABLE'}"
                            )

                    # Fire new probe every _probe_interval seconds
                    if (
                        _probe_enabled
                        and _probe_task is None
                        and (now - _last_probe_time) >= _probe_interval
                    ):
                        if _probe_client is not None:
                            _last_probe_time = now
                            _probe_task = asyncio.create_task(
                                self._probe_device(_device_url, _probe_client)
                            )

                    # Use effective FPS for frame timing
                    effective_fps = self._effective_fps if self._adaptive_fps else target_fps
                    self._metrics.fps_effective = effective_fps
                    frame_time = 1.0 / effective_fps
                    keepalive_interval = self._keepalive_interval

                    # Detect hot-swapped CSS stream
                    cur_stream = self._css_stream
                    if cur_stream is not stream:
                        stream = cur_stream
                        prev_frame_ref = None
                        has_any_frame = False

                    _diag_device_info_age += 1
                    if _diag_device_info is None or _diag_device_info_age >= 300:
                        _diag_device_info = self._ctx.get_device_info(self._device_id)
                        _diag_device_info_age = 0
                    device_info = _diag_device_info

                    # Skip send while in calibration test mode
                    if device_info and device_info.test_mode_active:
                        await asyncio.sleep(frame_time)
                        continue

                    if stream is None:
                        await asyncio.sleep(frame_time)
                        continue

                    try:
                        # Poll the CSS stream
                        frame = stream.get_latest_colors()

                        if frame is None:
                            if self._metrics.frames_processed == 0:
                                logger.info(f"No data from CSS stream for {self._target_id}")
                            await asyncio.sleep(frame_time)
                            continue

                        # --- Apply device default CSPT filters ---
                        _cspt_check_counter += 1
                        if _cspt_check_counter >= _cspt_check_interval:
                            _cspt_check_counter = 0
                            # Re-read the device's default template ID
                            _cur_cspt_id = ""
                            if self._ctx.device_store:
                                try:
                                    _dev = self._ctx.device_store.get(self._device_id)
                                    _cur_cspt_id = (
                                        getattr(_dev, "default_css_processing_template_id", "")
                                        or ""
                                    )
                                except Exception:
                                    _cur_cspt_id = ""
                            if _cur_cspt_id != _cspt_cached_template_id:
                                _cspt_cached_template_id = _cur_cspt_id
                                _cspt_filters = []
                                if _cur_cspt_id and self._ctx.cspt_store:
                                    try:
                                        from wled_controller.core.filters.registry import (
                                            FilterRegistry,
                                        )

                                        _resolved = self._ctx.cspt_store.resolve_filter_instances(
                                            self._ctx.cspt_store.get_template(_cur_cspt_id).filters
                                        )
                                        _cspt_filters = [
                                            FilterRegistry.create_instance(fi.filter_id, fi.options)
                                            for fi in _resolved
                                            if getattr(
                                                FilterRegistry.get(fi.filter_id),
                                                "supports_strip",
                                                True,
                                            )
                                        ]
                                        logger.info(
                                            f"CSPT resolved {len(_cspt_filters)} filters for "
                                            f"device {self._device_id} template {_cur_cspt_id}"
                                        )
                                    except Exception as e:
                                        logger.warning(
                                            f"Failed to resolve CSPT {_cur_cspt_id}: {e}"
                                        )
                                        _cspt_filters = []
                        if _cspt_filters:
                            for _flt in _cspt_filters:
                                _result = _flt.process_strip(frame)
                                if _result is not None:
                                    frame = _result

                        cur_brightness = _effective_brightness(device_info)

                        # Min brightness threshold: combine brightness source
                        # with max pixel value to get effective output brightness.
                        # If below cutoff → snap to 0 (LEDs off).
                        _thresh = self._min_brightness_threshold
                        if _thresh > 0 and cur_brightness > 0:
                            max_pixel = int(np.max(frame))
                            if max_pixel * cur_brightness // 255 < _thresh:
                                cur_brightness = 0

                        # Zero-brightness suppression: if output is black and
                        # the last sent frame was also black, skip sending
                        # (but still send periodic keepalive to hold DDP live mode).
                        if cur_brightness <= 1 and _prev_brightness <= 1 and has_any_frame:
                            if (
                                self._needs_keepalive
                                and (loop_start - last_send_time) >= keepalive_interval
                            ):
                                if not self._is_running or self._led_client is None:
                                    break
                                send_colors = _cached_brightness(
                                    self._fit_to_device(prev_frame_ref, _total_leds),
                                    cur_brightness,
                                )
                                await self._send_to_device(send_colors)
                                now = time.perf_counter()
                                last_send_time = now
                                send_timestamps.append(now)
                                self._metrics.frames_keepalive += 1
                                if (
                                    self._preview_clients
                                    and (now - _last_preview_broadcast) >= 0.066
                                ):
                                    await self._broadcast_led_preview(send_colors, cur_brightness)
                                    _last_preview_broadcast = now
                            self._metrics.frames_skipped += 1
                            self._metrics.fps_current = _fps_current_from_timestamps()
                            await asyncio.sleep(SKIP_REPOLL)
                            continue

                        # Force-send preview when a new client just connected
                        if (
                            self._preview_force_send
                            and self._preview_clients
                            and prev_frame_ref is not None
                        ):
                            self._preview_force_send = False
                            _force_colors = _cached_brightness(
                                self._fit_to_device(prev_frame_ref, _total_leds),
                                cur_brightness,
                            )
                            await self._broadcast_led_preview(_force_colors, cur_brightness)
                            _last_preview_broadcast = time.perf_counter()

                        if frame is prev_frame_ref and cur_brightness == _prev_brightness:
                            # Same frame + same brightness — keepalive or skip
                            if (
                                self._needs_keepalive
                                and has_any_frame
                                and (loop_start - last_send_time) >= keepalive_interval
                            ):
                                if not self._is_running or self._led_client is None:
                                    break
                                send_colors = _cached_brightness(
                                    self._fit_to_device(prev_frame_ref, _total_leds),
                                    cur_brightness,
                                )
                                await self._send_to_device(send_colors)
                                now = time.perf_counter()
                                last_send_time = now
                                send_timestamps.append(now)
                                self._metrics.frames_keepalive += 1
                                if (
                                    self._preview_clients
                                    and (now - _last_preview_broadcast) >= 0.066
                                ):
                                    await self._broadcast_led_preview(send_colors, cur_brightness)
                                    _last_preview_broadcast = now
                            self._metrics.frames_skipped += 1
                            self._metrics.fps_current = _fps_current_from_timestamps()
                            is_animated = stream.is_animated
                            repoll = SKIP_REPOLL if is_animated else frame_time
                            await asyncio.sleep(repoll)
                            continue

                        prev_frame_ref = frame
                        has_any_frame = True
                        _prev_brightness = cur_brightness

                        # Fit to device LED count and apply brightness
                        device_colors = self._fit_to_device(frame, _total_leds)
                        send_colors = _cached_brightness(device_colors, cur_brightness)
                        self._last_preview_colors = send_colors
                        self._last_preview_brightness = cur_brightness

                        # Send to LED device
                        if not self._is_running or self._led_client is None:
                            break
                        send_ms = await self._send_to_device(send_colors)

                        now = time.perf_counter()
                        last_send_time = now
                        send_timestamps.append(now)

                        # Broadcast to LED preview WebSocket clients (throttled to ~15 fps)
                        if self._preview_clients and (now - _last_preview_broadcast) >= 0.066:
                            await self._broadcast_led_preview(send_colors, cur_brightness)
                            _last_preview_broadcast = now

                        self._metrics.timing_send_ms = send_ms
                        self._metrics.frames_processed += 1
                        self._metrics.last_update_mono = time.monotonic()

                        if (
                            self._metrics.frames_processed <= 3
                            or self._metrics.frames_processed % 100 == 0
                        ):
                            logger.info(
                                f"Frame {self._metrics.frames_processed} for {self._target_id} "
                                f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
                            )

                        # FPS tracking
                        interval = now - prev_frame_time_stamp
                        prev_frame_time_stamp = now
                        if self._metrics.frames_processed > 1:
                            new_fps = 1.0 / interval if interval > 0 else 0
                            if len(fps_samples) == fps_samples.maxlen:
                                _fps_sum -= fps_samples[0]
                            fps_samples.append(new_fps)
                            _fps_sum += new_fps
                            self._metrics.fps_actual = _fps_sum / len(fps_samples)

                        processing_time = now - loop_start
                        self._metrics.fps_potential = (
                            1.0 / processing_time if processing_time > 0 else 0
                        )

                        self._metrics.fps_current = _fps_current_from_timestamps()

                    except Exception as e:
                        self._metrics.errors_count += 1
                        self._metrics.last_error = str(e)
                        logger.error(
                            f"Processing error for target {self._target_id}: {e}", exc_info=True
                        )

                    # Drift-compensating throttle
                    next_frame_time += frame_time
                    sleep_time = next_frame_time - time.perf_counter()
                    if sleep_time > 0:
                        t_sleep_start = time.perf_counter()
                        await asyncio.sleep(sleep_time)
                        t_sleep_end = time.perf_counter()
                        actual_sleep = (t_sleep_end - t_sleep_start) * 1000
                        requested_sleep = sleep_time * 1000
                        jitter = actual_sleep - requested_sleep
                        _diag_sleep_jitters.append((requested_sleep, actual_sleep))
                        if jitter > 10.0:
                            _diag_slow_iters.append(
                                ((t_sleep_end - loop_start) * 1000, "sleep_jitter")
                            )
                    elif sleep_time < -frame_time:
                        next_frame_time = time.perf_counter()

                    # Track total iteration time
                    iter_end = time.perf_counter()
                    iter_ms = (iter_end - loop_start) * 1000
                    _diag_iter_times.append(iter_ms)
                    if iter_ms > frame_time * 1500:
                        if not _diag_slow_iters or _diag_slow_iters[-1][1] != "sleep_jitter":
                            _diag_slow_iters.append((iter_ms, "slow_iter"))

                    # Periodic diagnostics report
                    if iter_end >= _diag_next_report:
                        _diag_next_report = iter_end + _diag_interval
                        self._emit_diagnostics(
                            self._target_id,
                            _diag_sleep_jitters,
                            _diag_iter_times,
                            _diag_slow_iters,
                            frame_time,
                            _diag_interval,
                        )

        except asyncio.CancelledError:
            logger.info(f"Processing loop cancelled for target {self._target_id}")
            raise
        except Exception as e:
            logger.error(
                f"Fatal error in processing loop for target {self._target_id}: {e}", exc_info=True
            )
            self._metrics.last_error = f"FATAL: {e}"
            self._metrics.errors_count += 1
            self._is_running = False
            self._ctx.fire_event(
                {
                    "type": "state_change",
                    "target_id": self._target_id,
                    "processing": False,
                    "crashed": True,
                }
            )
            raise
        finally:
            # Clean up probe client
            if _probe_client is not None:
                await _probe_client.aclose()
            if _probe_task is not None and not _probe_task.done():
                _probe_task.cancel()
                try:
                    await _probe_task
                except (asyncio.CancelledError, Exception):
                    pass
            self._device_reachable = None
            self._metrics.device_streaming_reachable = None
            logger.info(f"Processing loop ended for target {self._target_id}")
