"""Background discovery watcher — long-running mDNS browser + serial port poller.

Existing per-target health monitoring (``DeviceHealthMixin``) already fires
``device_health_changed`` events when *configured* devices flip online/offline.
This module is the complementary half: it watches for *new* devices appearing
on the LAN/USB (and old discovered-but-never-configured ones disappearing) and
emits ``device_discovered`` / ``device_lost`` events on the same event bus.

Design notes
------------
- The mDNS browser is kept alive for the process lifetime (one ``AsyncZeroconf``
  + ``AsyncServiceBrowser``), which is the entire point of "background discovery".
  The on-demand scan in ``WLEDDeviceProvider.discover`` is unchanged — that one
  spins up its own short-lived browser for the Add Device modal.
- Serial-port hotplug has no equivalent of mDNS push, so we poll
  :func:`list_serial_ports` every 10 s. Cheap on desktop (one Windows API call),
  no-op on Android (handled separately by Kotlin USB receivers).
- Already-configured devices (matched by URL or MAC against ``device_store``)
  are intentionally suppressed from the discovery stream — those are covered by
  the health-monitor's online/offline events and would otherwise notify twice
  per device on startup.

The watcher is purely an event source; pref-driven snack/OS-toast routing
happens client-side in ``features/notifications-watcher.ts``.
"""

from __future__ import annotations

import asyncio
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Dict, Optional

from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf

from ledgrab.core.devices.serial_transport import list_serial_ports
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android

if TYPE_CHECKING:
    from ledgrab.storage.device_store import DeviceStore

logger = get_logger(__name__)

# Poll interval for serial-port enumeration. Cheap on desktop; skipped on Android.
_SERIAL_POLL_INTERVAL_SEC = 10.0


@dataclass(frozen=True)
class _DiscoveredEntry:
    """A device the watcher has seen at least once.

    Two snapshots are compared each cycle (mDNS by service name, serial by
    device path) to detect appearances vs. disappearances; the URL is what
    we cross-reference against ``device_store`` to know if the device is
    already configured.
    """

    key: str
    url: str
    name: str
    device_type: str  # "wled" | "serial"


FireEvent = Callable[[dict], None]


class DiscoveryWatcher:
    """Continuously scan for new WLED/serial devices and emit events."""

    def __init__(self, device_store: "DeviceStore", fire_event: FireEvent) -> None:
        self._device_store = device_store
        self._fire_event = fire_event

        self._aiozc: Optional[AsyncZeroconf] = None
        self._browser: Optional[AsyncServiceBrowser] = None
        self._serial_task: Optional[asyncio.Task] = None
        self._running = False
        self._started_at: float = 0.0

        # service-name -> entry. mDNS state-change callback runs on the
        # asyncio thread so no lock is needed; Python attr writes are atomic.
        self._wled_seen: Dict[str, _DiscoveredEntry] = {}
        # device-path -> entry. Only the serial poller mutates this.
        self._serial_seen: Dict[str, _DiscoveredEntry] = {}

    # --- lifecycle --------------------------------------------------------

    async def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._started_at = time.monotonic()

        # mDNS browser — kept alive for the whole process. The handler is sync
        # (zeroconf calls it via call_soon on our loop), but resolves run in a
        # short-lived task to avoid blocking the dispatcher.
        try:
            self._aiozc = AsyncZeroconf()
            self._browser = AsyncServiceBrowser(
                self._aiozc.zeroconf,
                WLED_MDNS_TYPE,
                handlers=[self._on_wled_state_change],
            )
            logger.info("Discovery watcher: mDNS browser started for %s", WLED_MDNS_TYPE)
        except Exception as e:
            # Don't let a zeroconf failure (firewall, multiple-host, etc.)
            # prevent the rest of the server from coming up.
            logger.error("Discovery watcher: failed to start mDNS browser: %s", e)
            self._aiozc = None
            self._browser = None

        # Serial poller — only on desktop. On Android, USB hotplug is delivered
        # through Kotlin receivers, not by polling pyserial.
        if not is_android():
            self._serial_task = asyncio.create_task(self._serial_poll_loop())

    async def stop(self) -> None:
        self._running = False

        if self._serial_task is not None:
            self._serial_task.cancel()
            try:
                await self._serial_task
            except (asyncio.CancelledError, Exception):
                pass
            self._serial_task = None

        if self._browser is not None:
            try:
                await self._browser.async_cancel()
            except Exception as e:
                logger.debug("Discovery watcher: browser cancel error: %s", e)
            self._browser = None

        if self._aiozc is not None:
            try:
                await self._aiozc.async_close()
            except Exception as e:
                logger.debug("Discovery watcher: zeroconf close error: %s", e)
            self._aiozc = None

        logger.info("Discovery watcher stopped")

    # --- mDNS -------------------------------------------------------------

    def _on_wled_state_change(self, **kwargs) -> None:
        """zeroconf state-change handler. Runs on the asyncio thread."""
        state_change = kwargs.get("state_change")
        service_type = kwargs.get("service_type", "")
        name = kwargs.get("name", "")

        if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
            # Resolve in a task — async_request blocks the handler if awaited
            # synchronously and we don't want to stall mDNS dispatch.
            asyncio.create_task(self._resolve_wled(service_type, name))
        elif state_change == ServiceStateChange.Removed:
            entry = self._wled_seen.pop(name, None)
            if entry is not None and not self._is_configured(entry.url):
                self._emit("device_lost", entry)

    async def _resolve_wled(self, service_type: str, name: str) -> None:
        if self._aiozc is None:
            return
        info = AsyncServiceInfo(service_type, name)
        try:
            await info.async_request(self._aiozc.zeroconf, timeout=2000)
        except Exception as e:
            logger.debug("Discovery watcher: resolve failed for %s: %s", name, e)
            return

        addrs = info.parsed_addresses()
        if not addrs:
            return
        ip = addrs[0]
        port = info.port or 80
        url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}"
        service_name = name.replace(f".{service_type}", "")

        entry = _DiscoveredEntry(
            key=name,
            url=url,
            name=service_name,
            device_type="wled",
        )

        first_sight = name not in self._wled_seen
        self._wled_seen[name] = entry

        if first_sight and not self._is_configured(url):
            self._emit("device_discovered", entry)

    # --- serial -----------------------------------------------------------

    async def _serial_poll_loop(self) -> None:
        """Detect serial-port appearances/disappearances on a fixed interval."""
        try:
            # Seed without notifying — ports already plugged in when the server
            # starts shouldn't generate "new device" toasts on every boot.
            for port in list_serial_ports():
                url = port.device
                self._serial_seen[url] = _DiscoveredEntry(
                    key=url,
                    url=url,
                    name=port.description,
                    device_type="serial",
                )

            while self._running:
                await asyncio.sleep(_SERIAL_POLL_INTERVAL_SEC)
                if not self._running:
                    break
                self._poll_serial_once()
        except asyncio.CancelledError:
            pass
        except Exception as e:
            logger.error("Discovery watcher: serial loop crashed: %s", e)

    def _poll_serial_once(self) -> None:
        try:
            current = {p.device: p for p in list_serial_ports()}
        except Exception as e:
            logger.debug("Discovery watcher: serial enumeration failed: %s", e)
            return

        # Appeared
        for device, port in current.items():
            if device in self._serial_seen:
                continue
            entry = _DiscoveredEntry(
                key=device,
                url=device,
                name=port.description,
                device_type="serial",
            )
            self._serial_seen[device] = entry
            if not self._is_configured(device):
                self._emit("device_discovered", entry)

        # Disappeared
        for device in list(self._serial_seen.keys()):
            if device in current:
                continue
            entry = self._serial_seen.pop(device)
            if not self._is_configured(entry.url):
                self._emit("device_lost", entry)

    # --- helpers ----------------------------------------------------------

    def _is_configured(self, url: str) -> bool:
        """True when the URL matches a device already in the user's store."""
        try:
            for device in self._device_store.get_all_devices():
                if device.url and device.url.rstrip("/") == url.rstrip("/"):
                    return True
        except Exception as e:
            logger.debug("Discovery watcher: device store lookup failed: %s", e)
        return False

    def _emit(self, event_type: str, entry: _DiscoveredEntry) -> None:
        try:
            self._fire_event(
                {
                    "type": event_type,
                    "device_type": entry.device_type,
                    "url": entry.url,
                    "name": entry.name,
                }
            )
        except Exception as e:
            logger.debug("Discovery watcher: fire_event failed: %s", e)
