"""Processing manager — thin orchestrator for devices and target processors."""

import asyncio
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple

import httpx

from wled_controller.core.capture.calibration import CalibrationConfig
from wled_controller.core.devices.led_client import DeviceHealth
from wled_controller.core.audio.audio_capture import AudioCaptureManager
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
from wled_controller.core.processing.metrics_history import MetricsHistory
from wled_controller.core.processing.value_stream import ValueStreamManager
from wled_controller.core.capture.screen_overlay import OverlayManager
from wled_controller.core.processing.target_processor import (
    DeviceInfo,
    TargetContext,
    TargetProcessor,
)
from wled_controller.core.processing.wled_target_processor import WledTargetProcessor
from wled_controller.core.processing.auto_restart import (
    AutoRestartMixin,
    RestartState as _RestartState,
    RESTART_MAX_ATTEMPTS as _RESTART_MAX_ATTEMPTS,
    RESTART_WINDOW_SEC as _RESTART_WINDOW_SEC,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.color_strip_processing_template_store import (
    ColorStripProcessingTemplateStore,
)
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.asset_store import AssetStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.processing.device_health import DeviceHealthMixin
from wled_controller.core.processing.device_test_mode import DeviceTestModeMixin
from wled_controller.utils import get_logger

logger = get_logger(__name__)

DEFAULT_STATE_CHECK_INTERVAL = 30  # seconds between health checks


@dataclass
class ProcessorDependencies:
    """Bundles all store and manager references needed by ProcessorManager.

    Keeps the constructor signature stable when new stores are added.
    """

    picture_source_store: Optional[PictureSourceStore] = None
    capture_template_store: Optional[TemplateStore] = None
    pp_template_store: Optional[PostprocessingTemplateStore] = None
    device_store: Optional[DeviceStore] = None
    color_strip_store: Optional[ColorStripStore] = None
    audio_source_store: Optional[AudioSourceStore] = None
    audio_template_store: Optional[AudioTemplateStore] = None
    value_source_store: Optional[ValueSourceStore] = None
    sync_clock_manager: Optional[SyncClockManager] = None
    cspt_store: Optional[ColorStripProcessingTemplateStore] = None
    gradient_store: Optional[GradientStore] = None
    weather_manager: Optional[WeatherManager] = None
    asset_store: Optional[AssetStore] = None
    ha_manager: Optional[Any] = None  # HomeAssistantManager
    game_event_bus: Optional[Any] = None  # GameEventBus
    audio_processing_template_store: Optional[Any] = None  # AudioProcessingTemplateStore


@dataclass
class DeviceState:
    """State for a registered LED device (health monitoring)."""

    device_id: str
    device_url: str
    led_count: int
    device_type: str = "wled"
    baud_rate: Optional[int] = None
    health: DeviceHealth = field(default_factory=DeviceHealth)
    health_task: Optional[asyncio.Task] = None
    # Software brightness for devices without hardware brightness (e.g. Adalight)
    software_brightness: int = 255
    # Cached hardware brightness (fetched once, updated via SET; avoids polling device)
    hardware_brightness: Optional[int] = None
    # Auto-restore: restore device to idle state when targets stop
    auto_shutdown: bool = False
    # Calibration test mode (works independently of target processing)
    test_mode_active: bool = False
    test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
    # Calibration used for the current test (from the CSS being tested)
    test_calibration: Optional[CalibrationConfig] = None
    # Tracked power state for serial devices (no hardware query)
    power_on: bool = True
    # OpenRGB zone mode: "combined" or "separate"
    zone_mode: str = "combined"


class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin):
    """Manages devices and delegates target processing to TargetProcessor instances.

    Devices are registered for health monitoring.
    Targets are registered for processing via polymorphic TargetProcessor subclasses.

    Health monitoring is provided by DeviceHealthMixin.
    Test mode and idle client management is provided by DeviceTestModeMixin.
    """

    def __init__(self, deps: ProcessorDependencies):
        """Initialize processor manager.

        Args:
            deps: Bundled store and manager references.
        """
        self._devices: Dict[str, DeviceState] = {}
        self._processors: Dict[str, TargetProcessor] = {}
        self._idle_clients: Dict[str, object] = {}  # device_id -> cached LEDClient
        self._health_monitoring_active = False
        self._http_client: Optional[httpx.AsyncClient] = None
        self._picture_source_store = deps.picture_source_store
        self._capture_template_store = deps.capture_template_store
        self._pp_template_store = deps.pp_template_store
        self._device_store = deps.device_store
        self._color_strip_store = deps.color_strip_store
        self._audio_source_store = deps.audio_source_store
        self._audio_template_store = deps.audio_template_store
        self._value_source_store = deps.value_source_store
        self._cspt_store = deps.cspt_store
        self._live_stream_manager = LiveStreamManager(
            deps.picture_source_store,
            deps.capture_template_store,
            deps.pp_template_store,
            asset_store=deps.asset_store,
        )
        self._audio_capture_manager = AudioCaptureManager()
        self._sync_clock_manager = deps.sync_clock_manager
        self._color_strip_stream_manager = ColorStripStreamManager(
            color_strip_store=deps.color_strip_store,
            live_stream_manager=self._live_stream_manager,
            audio_capture_manager=self._audio_capture_manager,
            audio_source_store=deps.audio_source_store,
            audio_template_store=deps.audio_template_store,
            sync_clock_manager=deps.sync_clock_manager,
            cspt_store=deps.cspt_store,
            gradient_store=deps.gradient_store,
            weather_manager=deps.weather_manager,
            asset_store=deps.asset_store,
            game_event_bus=deps.game_event_bus,
            audio_processing_template_store=deps.audio_processing_template_store,
        )
        self._value_stream_manager = (
            ValueStreamManager(
                value_source_store=deps.value_source_store,
                audio_capture_manager=self._audio_capture_manager,
                audio_source_store=deps.audio_source_store,
                live_stream_manager=self._live_stream_manager,
                audio_template_store=deps.audio_template_store,
                ha_manager=deps.ha_manager,
                css_stream_manager=self._color_strip_stream_manager,
                gradient_store=deps.gradient_store,
                event_bus=deps.game_event_bus,
                audio_processing_template_store=deps.audio_processing_template_store,
            )
            if deps.value_source_store
            else None
        )
        # Wire value stream manager into CSS stream manager for composite layer brightness
        self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
        self._ha_manager = deps.ha_manager
        self._overlay_manager = OverlayManager()
        self._event_queues: List[asyncio.Queue] = []
        self._metrics_history = MetricsHistory(self)
        self._restart_states: Dict[str, _RestartState] = {}
        logger.info("Processor manager initialized")

    @property
    def audio_capture_manager(self) -> AudioCaptureManager:
        return self._audio_capture_manager

    @property
    def value_stream_manager(self) -> Optional[ValueStreamManager]:
        return self._value_stream_manager

    @property
    def metrics_history(self) -> MetricsHistory:
        return self._metrics_history

    @property
    def color_strip_stream_manager(self) -> ColorStripStreamManager:
        return self._color_strip_stream_manager

    def refresh_audio_filter_pipelines(self, template_id: str) -> None:
        """Rebuild audio filter pipelines across all running streams when a
        template is updated or deleted. Dispatches to both CSS and value stream managers."""
        self._color_strip_stream_manager.refresh_audio_filter_pipelines(template_id)
        if self._value_stream_manager:
            self._value_stream_manager.refresh_audio_filter_pipelines(template_id)

    # ===== SHARED CONTEXT (passed to target processors) =====

    def _build_context(self) -> TargetContext:
        """Build a TargetContext for target processors."""
        return TargetContext(
            live_stream_manager=self._live_stream_manager,
            overlay_manager=self._overlay_manager,
            picture_source_store=self._picture_source_store,
            capture_template_store=self._capture_template_store,
            pp_template_store=self._pp_template_store,
            device_store=self._device_store,
            color_strip_stream_manager=self._color_strip_stream_manager,
            value_stream_manager=self._value_stream_manager,
            cspt_store=self._cspt_store,
            fire_event=self.fire_event,
            get_device_info=self._get_device_info,
            ha_manager=self._ha_manager,
        )

    # Default values for device-specific fields read from persistent storage
    _DEVICE_FIELD_DEFAULTS = {
        "send_latency_ms": 0,
        "rgbw": False,
        "dmx_protocol": "artnet",
        "dmx_start_universe": 0,
        "dmx_start_channel": 1,
        "espnow_peer_mac": "",
        "espnow_channel": 1,
        "hue_username": "",
        "hue_client_key": "",
        "hue_entertainment_group_id": "",
        "spi_speed_hz": 800000,
        "spi_led_type": "WS2812B",
        "chroma_device_type": "chromalink",
        "gamesense_device_type": "keyboard",
    }

    def _get_device_info(self, device_id: str) -> Optional[DeviceInfo]:
        """Create a DeviceInfo snapshot from the current device state."""
        ds = self._devices.get(device_id)
        if ds is None:
            return None
        # Read device-specific fields from persistent storage
        extras = dict(self._DEVICE_FIELD_DEFAULTS)
        if self._device_store:
            try:
                dev = self._device_store.get_device(ds.device_id)
                for key, default in self._DEVICE_FIELD_DEFAULTS.items():
                    extras[key] = getattr(dev, key, default)
            except ValueError as e:
                logger.debug("Device %s not found in store, using defaults: %s", ds.device_id, e)
                pass

        return DeviceInfo(
            device_id=ds.device_id,
            device_url=ds.device_url,
            led_count=ds.led_count,
            device_type=ds.device_type,
            baud_rate=ds.baud_rate,
            software_brightness=ds.software_brightness,
            test_mode_active=ds.test_mode_active,
            zone_mode=ds.zone_mode,
            auto_shutdown=ds.auto_shutdown,
            **extras,
        )

    # ===== EVENT SYSTEM (state change notifications) =====

    def subscribe_events(self) -> asyncio.Queue:
        """Subscribe to state change events. Returns queue to read from."""
        queue: asyncio.Queue = asyncio.Queue(maxsize=64)
        self._event_queues.append(queue)
        return queue

    def unsubscribe_events(self, queue: asyncio.Queue) -> None:
        """Unsubscribe from events."""
        if queue in self._event_queues:
            self._event_queues.remove(queue)

    def fire_event(self, event: dict) -> None:
        """Push event to all subscribers (non-blocking). Public API for route handlers."""
        for q in self._event_queues:
            try:
                q.put_nowait(event)
            except asyncio.QueueFull:
                logger.warning(f"Event queue full, dropping: {event.get('type', '?')}")

    async def _get_http_client(self) -> httpx.AsyncClient:
        """Get or create a shared HTTP client for health checks."""
        if self._http_client is None or self._http_client.is_closed:
            self._http_client = httpx.AsyncClient(timeout=5)
        return self._http_client

    # ===== DEVICE MANAGEMENT =====

    def add_device(
        self,
        device_id: str,
        device_url: str,
        led_count: int,
        device_type: str = "wled",
        baud_rate: Optional[int] = None,
        software_brightness: int = 255,
        auto_shutdown: bool = False,
        zone_mode: str = "combined",
    ):
        """Register a device for health monitoring."""
        if device_id in self._devices:
            raise ValueError(f"Device {device_id} already registered")

        state = DeviceState(
            device_id=device_id,
            device_url=device_url,
            led_count=led_count,
            device_type=device_type,
            baud_rate=baud_rate,
            software_brightness=software_brightness,
            auto_shutdown=auto_shutdown,
            zone_mode=zone_mode,
        )

        self._devices[device_id] = state

        if self._health_monitoring_active:
            self._start_device_health_check(device_id)

        logger.info(f"Registered device {device_id} with {led_count} LEDs")

    async def remove_device(self, device_id: str):
        """Unregister a device."""
        if device_id not in self._devices:
            raise ValueError(f"Device {device_id} not found")

        # Check if any processor is using this device
        for proc in self._processors.values():
            if proc.device_id == device_id:
                raise RuntimeError(
                    f"Cannot remove device {device_id}: target {proc.target_id} is using it"
                )

        await self._close_idle_client(device_id)
        self._stop_device_health_check(device_id)
        del self._devices[device_id]
        logger.info(f"Unregistered device {device_id}")

    def update_device_info(
        self,
        device_id: str,
        device_url: Optional[str] = None,
        led_count: Optional[int] = None,
        baud_rate: Optional[int] = None,
    ):
        """Update device connection info."""
        if device_id not in self._devices:
            raise ValueError(f"Device {device_id} not found")

        ds = self._devices[device_id]
        if device_url is not None:
            ds.device_url = device_url
        if led_count is not None:
            ds.led_count = led_count
        if baud_rate is not None:
            ds.baud_rate = baud_rate

    def get_device_state(self, device_id: str) -> DeviceState:
        """Get device state (for health info)."""
        if device_id not in self._devices:
            raise ValueError(f"Device {device_id} not found")
        return self._devices[device_id]

    def get_device_health(self, device_id: str) -> dict:
        """Get health status for a device."""
        if device_id not in self._devices:
            raise ValueError(f"Device {device_id} not found")

        h = self._devices[device_id].health
        return {
            "online": h.online,
            "latency_ms": h.latency_ms,
            "last_checked": h.last_checked,
            "device_name": h.device_name,
            "device_version": h.device_version,
            "device_led_count": h.device_led_count,
            "device_rgbw": h.device_rgbw,
            "device_led_type": h.device_led_type,
            "device_fps": h.device_fps,
            "error": h.error,
        }

    def get_device_health_dict(self, device_id: str) -> dict:
        """Get device connection/health state as a state response dict."""
        if device_id not in self._devices:
            raise ValueError(f"Device {device_id} not found")

        ds = self._devices[device_id]
        h = ds.health
        # Fall back to stored device rgbw when health check doesn't report it
        # (e.g. mock devices have no real hardware to query)
        rgbw = h.device_rgbw
        if rgbw is None and self._device_store:
            try:
                dev = self._device_store.get_device(device_id)
                rgbw = getattr(dev, "rgbw", False)
            except ValueError as e:
                logger.debug("Device %s not found for RGBW lookup: %s", device_id, e)
                pass
        return {
            "device_id": device_id,
            "device_online": h.online,
            "device_latency_ms": h.latency_ms,
            "device_name": h.device_name,
            "device_version": h.device_version,
            "device_led_count": h.device_led_count,
            "device_rgbw": rgbw,
            "device_led_type": h.device_led_type,
            "device_fps": h.device_fps,
            "device_last_checked": h.last_checked,
            "device_error": h.error,
            "test_mode": ds.test_mode_active,
            "test_mode_edges": list(ds.test_mode_edges.keys()),
        }

    def get_all_devices(self) -> List[str]:
        """Get list of all registered device IDs."""
        return list(self._devices.keys())

    # ===== TARGET REGISTRATION =====

    def add_target(
        self,
        target_id: str,
        device_id: str,
        color_strip_source_id: str = "",
        fps: int = 30,
        keepalive_interval: float = 1.0,
        state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
        brightness=None,
        # legacy compat
        brightness_value_source_id: str = "",
        min_brightness_threshold: int = 0,
        adaptive_fps: bool = False,
        protocol: str = "ddp",
    ):
        """Register a WLED target processor."""
        if target_id in self._processors:
            raise ValueError(f"Target {target_id} already registered")
        if device_id not in self._devices:
            raise ValueError(f"Device {device_id} not registered")

        proc = WledTargetProcessor(
            target_id=target_id,
            device_id=device_id,
            color_strip_source_id=color_strip_source_id,
            fps=fps,
            keepalive_interval=keepalive_interval,
            state_check_interval=state_check_interval,
            brightness=brightness,
            min_brightness_threshold=min_brightness_threshold,
            adaptive_fps=adaptive_fps,
            protocol=protocol,
            ctx=self._build_context(),
        )
        self._processors[target_id] = proc
        logger.info(f"Registered target {target_id} for device {device_id}")

    def add_ha_light_target(
        self,
        target_id: str,
        ha_source_id: str,
        color_strip_source_id: str = "",
        brightness=None,
        # legacy compat
        brightness_value_source_id: str = "",
        light_mappings=None,
        update_rate: float = 2.0,
        transition=None,
        min_brightness_threshold: int = 0,
        color_tolerance: int = 5,
    ) -> None:
        """Register a Home Assistant light target processor."""
        if target_id in self._processors:
            raise ValueError(f"HA light target {target_id} already registered")

        from wled_controller.core.processing.ha_light_target_processor import HALightTargetProcessor

        proc = HALightTargetProcessor(
            target_id=target_id,
            ha_source_id=ha_source_id,
            color_strip_source_id=color_strip_source_id,
            brightness=brightness,
            light_mappings=light_mappings or [],
            update_rate=update_rate,
            transition=transition,
            min_brightness_threshold=min_brightness_threshold,
            color_tolerance=color_tolerance,
            ctx=self._build_context(),
        )
        self._processors[target_id] = proc
        logger.info(f"Registered HA light target: {target_id}")

    def remove_target(self, target_id: str):
        """Unregister a target (any type)."""
        if target_id not in self._processors:
            raise ValueError(f"Target {target_id} not found")
        proc = self._processors[target_id]
        if proc.is_running:
            raise RuntimeError(f"Cannot remove target {target_id} while processing")
        # Clean up restart state
        rs = self._restart_states.pop(target_id, None)
        if rs and rs.restart_task and not rs.restart_task.done():
            rs.restart_task.cancel()
        del self._processors[target_id]
        logger.info(f"Unregistered target {target_id}")

    # ===== UNIFIED TARGET OPERATIONS =====

    def update_target_settings(self, target_id: str, settings):
        """Update processing settings for a target (any type)."""
        proc = self._get_processor(target_id)
        proc.update_settings(settings)

    def update_target_source(self, target_id: str, picture_source_id: str):
        """Update the picture source for a target (any type)."""
        proc = self._get_processor(target_id)
        proc.update_source(picture_source_id)

    def update_target_css(self, target_id: str, color_strip_source_id: str):
        """Update the color strip source for a WLED target."""
        proc = self._get_processor(target_id)
        proc.update_css_source(color_strip_source_id)

    async def update_target_device(self, target_id: str, device_id: str):
        """Update the device for a target.

        If the target is currently running, performs a stop -> swap -> start
        cycle so the new device connection is established properly.
        """
        proc = self._get_processor(target_id)
        if device_id not in self._devices:
            raise ValueError(f"Device {device_id} not registered")

        was_running = proc.is_running
        if was_running:
            logger.info(
                "Hot-switching device for running target %s: stopping first",
                target_id,
            )
            await self.stop_processing(target_id)

        proc.update_device(device_id)

        if was_running:
            await self.start_processing(target_id)
            logger.info(
                "Hot-switch complete for target %s -> device %s",
                target_id,
                device_id,
            )

    def update_target_brightness_vs(self, target_id: str, vs_id: str):
        """Legacy: update brightness value source by ID string."""
        from wled_controller.storage.bindable import BindableFloat

        self.update_target_brightness(target_id, BindableFloat(source_id=vs_id))

    def update_target_brightness(self, target_id: str, brightness):
        """Update the brightness binding for a target."""
        proc = self._get_processor(target_id)
        if hasattr(proc, "update_brightness"):
            proc.update_brightness(brightness)

    def update_value_source(self, vs_id: str):
        """Hot-update all running value streams for a given source."""
        if self._value_stream_manager:
            self._value_stream_manager.update_source(vs_id)

    async def start_processing(self, target_id: str):
        """Start processing for a target (any type)."""
        proc = self._get_processor(target_id)

        # Enforce one-target-per-device for device-aware targets
        if proc.device_id is not None:
            for other_id, other in self._processors.items():
                if other_id != target_id and other.device_id == proc.device_id and other.is_running:
                    # Stale state guard: if the task is actually finished,
                    # clean up and allow starting instead of blocking.
                    task = getattr(other, "_task", None)
                    if task is not None and task.done():
                        logger.warning(
                            "Processor %s had stale is_running=True (task done) — clearing",
                            other_id,
                        )
                        other._is_running = False
                        continue

                    dev_name = proc.device_id
                    tgt_name = other_id
                    if self._device_store:
                        try:
                            dev = self._device_store.get_device(proc.device_id)
                            dev_name = dev.name
                        except ValueError as e:
                            logger.debug(
                                "Device %s not found for name lookup: %s", proc.device_id, e
                            )
                            pass
                    raise RuntimeError(
                        f"Device '{dev_name}' is already being processed by target {tgt_name}"
                    )

            # Close cached idle client — processor creates its own connection
            await self._close_idle_client(proc.device_id)

        await proc.start()

        # Enable auto-restart and attach crash callback
        rs = self._restart_states.get(target_id)
        if rs:
            # Cancel any pending restart task (e.g. if manually restarted before backoff expired)
            if rs.restart_task and not rs.restart_task.done():
                rs.restart_task.cancel()
            rs.enabled = True
        else:
            rs = _RestartState()
            self._restart_states[target_id] = rs

        # Reset restart counter if previous crashes were long ago (stable period)
        now = time.monotonic()
        if rs.first_crash_time and (now - rs.first_crash_time) > _RESTART_WINDOW_SEC:
            rs.attempts = 0
            rs.first_crash_time = 0.0

        # Attach done callback to detect crashes
        if proc._task is not None:
            proc._task.add_done_callback(lambda task, tid=target_id: self._on_task_done(tid, task))

    async def stop_processing(self, target_id: str):
        """Stop processing for a target (any type).

        For WLED targets, if the associated device has auto_shutdown enabled
        and no other targets are still actively processing on it, the device
        is restored to its idle state (static color or pre-streaming snapshot).
        """
        # Disable auto-restart before stopping (manual stop = intentional)
        rs = self._restart_states.get(target_id)
        if rs:
            rs.enabled = False
            if rs.restart_task and not rs.restart_task.done():
                rs.restart_task.cancel()
                rs.restart_task = None

        proc = self._get_processor(target_id)
        await proc.stop()

        # Auto-shutdown device if applicable
        if proc.device_id is not None:
            await self._restore_device_idle_state(proc.device_id)

    def get_target_state(self, target_id: str) -> dict:
        """Get current processing state for a target (any type).

        For WLED targets, device health info is merged in.
        Auto-restart state is always included.
        """
        proc = self._get_processor(target_id)
        state = proc.get_state()

        # Include auto-restart info
        rs = self._restart_states.get(target_id)
        if rs and rs.attempts > 0:
            state["auto_restart_attempts"] = rs.attempts
            state["auto_restart_exhausted"] = rs.attempts > _RESTART_MAX_ATTEMPTS

        # Merge device health for device-aware targets
        if proc.device_id is not None and proc.device_id in self._devices:
            h = self._devices[proc.device_id].health
            state.update(
                {
                    "device_online": h.online,
                    "device_latency_ms": h.latency_ms,
                    "device_name": h.device_name,
                    "device_version": h.device_version,
                    "device_led_count": h.device_led_count,
                    "device_rgbw": h.device_rgbw,
                    "device_led_type": h.device_led_type,
                    "device_fps": h.device_fps,
                    "device_last_checked": h.last_checked,
                    "device_error": h.error,
                }
            )

        return state

    def get_target_metrics(self, target_id: str) -> dict:
        """Get detailed metrics for a target (any type)."""
        return self._get_processor(target_id).get_metrics()

    def get_all_target_states(self) -> Dict[str, dict]:
        """Get processing state for all targets (with device health merged)."""
        return {tid: self.get_target_state(tid) for tid in self._processors}

    def get_all_target_metrics(self) -> Dict[str, dict]:
        """Get metrics for all targets."""
        return {tid: proc.get_metrics() for tid, proc in self._processors.items()}

    def get_all_device_health_dicts(self) -> Dict[str, dict]:
        """Get health/connection state for all devices."""
        return {did: self.get_device_health_dict(did) for did in self._devices}

    def is_target_processing(self, target_id: str) -> bool:
        """Check if target is currently processing."""
        return self._get_processor(target_id).is_running

    def is_device_processing(self, device_id: str) -> bool:
        """Check if any target is processing for a device."""
        for proc in self._processors.values():
            if proc.device_id == device_id and proc.is_running:
                return True
        return False

    def get_processing_target_for_device(self, device_id: str) -> Optional[str]:
        """Get the target_id that is currently processing for a device."""
        for proc in self._processors.values():
            if proc.device_id == device_id and proc.is_running:
                return proc.target_id
        return None

    # ===== OVERLAY VISUALIZATION (delegates to processor) =====

    # Default edge colors for overlay LED test (same as frontend EDGE_TEST_COLORS)
    _OVERLAY_EDGE_COLORS = {
        "top": [255, 0, 0],
        "right": [0, 255, 0],
        "bottom": [0, 100, 255],
        "left": [255, 255, 0],
    }

    async def start_overlay(
        self, target_id: str, target_name: str = None, calibration=None, display_info=None
    ) -> None:
        proc = self._get_processor(target_id)
        if not proc.supports_overlay():
            raise ValueError(f"Target {target_id} does not support overlays")
        await proc.start_overlay(target_name, calibration=calibration, display_info=display_info)

        # Light up device LEDs with edge test colors while overlay is visible.
        # Always do this regardless of whether processing is running — the processing
        # loop pauses itself when test_mode_active is set.
        if proc.device_id is not None:
            try:
                await self.set_test_mode(proc.device_id, self._OVERLAY_EDGE_COLORS, calibration)
            except Exception as e:
                logger.warning(f"Failed to set edge test for overlay on {proc.device_id}: {e}")

    async def stop_overlay(self, target_id: str) -> None:
        proc = self._get_processor(target_id)
        await proc.stop_overlay()

        # Clear device LEDs when overlay is dismissed.
        if proc.device_id is not None:
            try:
                await self.set_test_mode(proc.device_id, {})
            except Exception as e:
                logger.warning(f"Failed to clear edge test after overlay on {proc.device_id}: {e}")

    def is_overlay_active(self, target_id: str) -> bool:
        return self._get_processor(target_id).is_overlay_active()

    # ===== CSS OVERLAY (direct, no target processor required) =====

    async def start_css_overlay(
        self, css_id: str, display_info, calibration, css_name: str = None
    ) -> None:
        await asyncio.to_thread(
            self._overlay_manager.start_overlay,
            css_id,
            display_info,
            calibration,
            css_name,
        )

    async def stop_css_overlay(self, css_id: str) -> None:
        await asyncio.to_thread(
            self._overlay_manager.stop_overlay,
            css_id,
        )

    def is_css_overlay_active(self, css_id: str) -> bool:
        return self._overlay_manager.is_running(css_id)

    # ===== WEBSOCKET (delegates to processor) =====

    def add_ha_light_ws_client(self, target_id: str, ws) -> None:
        proc = self._get_processor(target_id)
        proc.add_ws_client(ws)

    def remove_ha_light_ws_client(self, target_id: str, ws) -> None:
        proc = self._processors.get(target_id)
        if proc:
            proc.remove_ws_client(ws)

    def add_led_preview_client(self, target_id: str, ws) -> None:
        proc = self._get_processor(target_id)
        proc.add_led_preview_client(ws)

    def remove_led_preview_client(self, target_id: str, ws) -> None:
        proc = self._processors.get(target_id)
        if proc:
            proc.remove_led_preview_client(ws)

    # ===== LIFECYCLE =====

    async def stop_all(self):
        """Stop processing and health monitoring for all targets and devices."""
        await self._metrics_history.stop()
        await self.stop_health_monitoring()

        # Cancel all pending auto-restart tasks
        for rs in self._restart_states.values():
            rs.enabled = False
            if rs.restart_task and not rs.restart_task.done():
                rs.restart_task.cancel()

        # Stop all processors
        for target_id, proc in list(self._processors.items()):
            if proc.is_running:
                try:
                    await proc.stop()
                except Exception as e:
                    logger.error(f"Error stopping target {target_id}: {e}")

        # Restore idle state for devices that have auto-restore enabled
        # (serial devices already dark from processor close; WLED restored by snapshot)
        for device_id in self._devices:
            await self._restore_device_idle_state(device_id)

        # Close any cached idle LED clients (WLED only; serial has no cached clients)
        for did in list(self._idle_clients):
            await self._close_idle_client(did)

        # Safety net: release all color strip streams
        self._color_strip_stream_manager.release_all()

        # Safety net: release all value streams
        if self._value_stream_manager:
            self._value_stream_manager.release_all()

        # Safety net: release any remaining managed live streams
        self._live_stream_manager.release_all()

        # Close shared HTTP client
        if self._http_client and not self._http_client.is_closed:
            await self._http_client.aclose()
            self._http_client = None

        logger.info("Stopped all processors")

    # ===== HELPERS =====

    def has_device(self, device_id: str) -> bool:
        """Check if a device is registered."""
        return device_id in self._devices

    def find_device_state(self, device_id: str) -> Optional[DeviceState]:
        """Get device state, returning None if not registered."""
        return self._devices.get(device_id)

    def get_processor(self, target_id: str) -> Optional[TargetProcessor]:
        """Look up a processor by target_id, returning None if not found."""
        return self._processors.get(target_id)

    def _get_processor(self, target_id: str) -> TargetProcessor:
        """Look up a processor by target_id, raising ValueError if not found."""
        proc = self._processors.get(target_id)
        if proc is None:
            raise ValueError(f"Target {target_id} not found")
        return proc
