"""Device storage using SQLite."""

import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional

from ledgrab.storage.base_sqlite_store import BaseSqliteStore
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger, secret_box

if TYPE_CHECKING:
    from ledgrab.core.devices.device_config import DeviceConfig

logger = get_logger(__name__)


def _enc(value: str) -> str:
    """Encrypt a non-empty secret for at-rest storage; pass-through empty."""
    return secret_box.encrypt(value) if value else ""


def _dec(value: str) -> str:
    """Decrypt an envelope; legacy plaintext passes through unchanged.

    A corrupt envelope (key rotated, partial write, AEAD mismatch) returns
    an empty string and logs an error rather than re-raising. Without this
    fallback a single bad row would propagate up to ``BaseSqliteStore._load``
    and silently drop the entire device from ``self._items`` -- so a
    regenerated ``data/.secret_key`` would make every Hue / Nanoleaf /
    BLE-Govee device disappear with only a log line for a trail. Caught
    by pre-merge review HIGH #5.
    """
    if not value:
        return ""
    try:
        return secret_box.decrypt(value)
    except Exception as exc:
        logger.error(
            "Could not decrypt stored secret (%s); clearing the field. "
            "Re-pair the affected device to recover.",
            type(exc).__name__,
        )
        return ""


class Device:
    """Represents a WLED device configuration.

    A device holds connection state and output settings.
    Calibration, processing settings, and picture source assignments
    now live on ColorStripSource and WledOutputTarget respectively.
    """

    def __init__(
        self,
        device_id: str,
        name: str,
        url: str,
        led_count: int,
        enabled: bool = True,
        device_type: str = "wled",
        baud_rate: Optional[int] = None,
        software_brightness: int = 255,
        auto_shutdown: bool = False,
        send_latency_ms: int = 0,
        rgbw: bool = False,
        zone_mode: str = "combined",
        tags: Optional[List[str]] = None,
        # DMX (Art-Net / sACN) fields
        dmx_protocol: str = "artnet",
        dmx_start_universe: int = 0,
        dmx_start_channel: int = 1,
        # DDP (Distributed Display Protocol) fields
        ddp_port: int = 0,
        ddp_destination_id: int = 1,
        ddp_color_order: int = 1,
        # ESP-NOW fields
        espnow_peer_mac: str = "",
        espnow_channel: int = 1,
        # Philips Hue fields
        hue_username: str = "",
        hue_client_key: str = "",
        hue_entertainment_group_id: str = "",
        # Yeelight fields
        yeelight_min_interval_ms: int = 500,
        # WiZ fields
        wiz_min_interval_ms: int = 50,
        # LIFX fields
        lifx_min_interval_ms: int = 50,
        # Govee fields
        govee_min_interval_ms: int = 50,
        # OPC fields
        opc_channel: int = 0,
        # Nanoleaf fields
        nanoleaf_token: str = "",
        nanoleaf_min_interval_ms: int = 100,
        # SPI Direct fields
        spi_speed_hz: int = 800000,
        spi_led_type: str = "WS2812B",
        # Razer Chroma fields
        chroma_device_type: str = "chromalink",
        # SteelSeries GameSense fields
        gamesense_device_type: str = "keyboard",
        # BLE controller fields (SP110E / Triones / Zengge / Govee)
        ble_family: str = "",
        ble_govee_key: str = "",
        # MQTT device fields (references an MQTTSource for the broker connection)
        mqtt_source_id: str = "",
        # Default color strip processing template
        default_css_processing_template_id: str = "",
        # Group device fields
        group_device_ids: Optional[List[str]] = None,
        group_mode: str = "sequence",
        # Custom card icon (frontend display only)
        icon: str = "",
        icon_color: str = "",
        created_at: Optional[datetime] = None,
        updated_at: Optional[datetime] = None,
    ):
        self.id = device_id
        self.name = name
        self.url = url
        self.led_count = led_count
        self.enabled = enabled
        self.device_type = device_type
        self.baud_rate = baud_rate
        self.software_brightness = software_brightness
        self.auto_shutdown = auto_shutdown
        self.send_latency_ms = send_latency_ms
        self.rgbw = rgbw
        self.zone_mode = zone_mode
        self.tags = list(tags) if tags else []
        self.dmx_protocol = dmx_protocol
        self.dmx_start_universe = dmx_start_universe
        self.dmx_start_channel = dmx_start_channel
        self.ddp_port = ddp_port
        self.ddp_destination_id = ddp_destination_id
        self.ddp_color_order = ddp_color_order
        self.espnow_peer_mac = espnow_peer_mac
        self.espnow_channel = espnow_channel
        self.hue_username = hue_username
        self.hue_client_key = hue_client_key
        self.hue_entertainment_group_id = hue_entertainment_group_id
        self.yeelight_min_interval_ms = yeelight_min_interval_ms
        self.wiz_min_interval_ms = wiz_min_interval_ms
        self.lifx_min_interval_ms = lifx_min_interval_ms
        self.govee_min_interval_ms = govee_min_interval_ms
        self.opc_channel = opc_channel
        self.nanoleaf_token = nanoleaf_token
        self.nanoleaf_min_interval_ms = nanoleaf_min_interval_ms
        self.spi_speed_hz = spi_speed_hz
        self.spi_led_type = spi_led_type
        self.chroma_device_type = chroma_device_type
        self.gamesense_device_type = gamesense_device_type
        self.ble_family = ble_family
        self.ble_govee_key = ble_govee_key
        self.mqtt_source_id = mqtt_source_id
        self.default_css_processing_template_id = default_css_processing_template_id
        self.group_device_ids = group_device_ids or []
        self.group_mode = group_mode
        self.icon = icon or ""
        self.icon_color = icon_color or ""
        self.created_at = created_at or datetime.now(timezone.utc)
        self.updated_at = updated_at or datetime.now(timezone.utc)

    def to_config(self) -> "DeviceConfig":
        """Return a typed, immutable config snapshot for this device.

        This is the single place that maps the flat Device storage model to the
        correct per-provider config subclass. test_mode_active is runtime state
        (not stored) and always defaults to False here.
        """
        from ledgrab.core.devices.device_config import (
            AdalightConfig,
            AmbiLEDConfig,
            BLEConfig,
            ChromaConfig,
            DDPConfig,
            DemoConfig,
            DMXConfig,
            ESPNowConfig,
            GameSenseConfig,
            GroupConfig,
            HueConfig,
            MockConfig,
            MQTTConfig,
            OpenRGBConfig,
            SPIConfig,
            GoveeConfig,
            LIFXConfig,
            NanoleafConfig,
            OPCConfig,
            USBHIDConfig,
            WiZConfig,
            WLEDConfig,
            WSConfig,
            YeelightConfig,
        )

        base = dict(
            device_id=self.id,
            device_url=self.url,
            led_count=self.led_count,
            software_brightness=self.software_brightness,
            auto_shutdown=self.auto_shutdown,
            rgbw=self.rgbw,
        )
        dt = self.device_type
        if dt == "wled":
            return WLEDConfig(**base)
        if dt == "adalight":
            return AdalightConfig(**base, baud_rate=self.baud_rate)
        if dt == "ambiled":
            return AmbiLEDConfig(**base, baud_rate=self.baud_rate)
        if dt == "dmx":
            return DMXConfig(
                **base,
                dmx_protocol=self.dmx_protocol,
                dmx_start_universe=self.dmx_start_universe,
                dmx_start_channel=self.dmx_start_channel,
            )
        if dt == "ddp":
            return DDPConfig(
                **base,
                ddp_port=self.ddp_port,
                ddp_destination_id=self.ddp_destination_id,
                ddp_color_order=self.ddp_color_order,
            )
        if dt == "espnow":
            return ESPNowConfig(
                **base,
                baud_rate=self.baud_rate,
                espnow_peer_mac=self.espnow_peer_mac,
                espnow_channel=self.espnow_channel,
            )
        if dt == "hue":
            return HueConfig(
                **base,
                hue_username=self.hue_username,
                hue_client_key=self.hue_client_key,
                hue_entertainment_group_id=self.hue_entertainment_group_id,
            )
        if dt == "yeelight":
            return YeelightConfig(
                **base,
                yeelight_min_interval_ms=self.yeelight_min_interval_ms,
            )
        if dt == "wiz":
            return WiZConfig(
                **base,
                wiz_min_interval_ms=self.wiz_min_interval_ms,
            )
        if dt == "lifx":
            return LIFXConfig(
                **base,
                lifx_min_interval_ms=self.lifx_min_interval_ms,
            )
        if dt == "govee":
            return GoveeConfig(
                **base,
                govee_min_interval_ms=self.govee_min_interval_ms,
            )
        if dt == "opc":
            return OPCConfig(
                **base,
                opc_channel=self.opc_channel,
            )
        if dt == "nanoleaf":
            return NanoleafConfig(
                **base,
                nanoleaf_token=self.nanoleaf_token,
                nanoleaf_min_interval_ms=self.nanoleaf_min_interval_ms,
            )
        if dt == "spi":
            return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
        if dt == "chroma":
            return ChromaConfig(**base, chroma_device_type=self.chroma_device_type)
        if dt == "gamesense":
            return GameSenseConfig(**base, gamesense_device_type=self.gamesense_device_type)
        if dt == "ble":
            return BLEConfig(**base, ble_family=self.ble_family, ble_govee_key=self.ble_govee_key)
        if dt == "group":
            return GroupConfig(
                **base,
                group_mode=self.group_mode,
                group_device_ids=list(self.group_device_ids),
            )
        if dt == "openrgb":
            return OpenRGBConfig(**base, zone_mode=self.zone_mode)
        if dt == "mock":
            return MockConfig(**base, send_latency_ms=self.send_latency_ms)
        if dt == "demo":
            return DemoConfig(**base, send_latency_ms=self.send_latency_ms)
        if dt == "mqtt":
            return MQTTConfig(**base, mqtt_source_id=self.mqtt_source_id)
        if dt == "ws":
            return WSConfig(**base)
        if dt == "usbhid":
            return USBHIDConfig(**base)
        raise ValueError(f"Unknown device type: {dt!r}")

    def to_dict(self) -> dict:
        """Convert device to dictionary."""
        d = {
            "id": self.id,
            "name": self.name,
            "url": self.url,
            "led_count": self.led_count,
            "enabled": self.enabled,
            "device_type": self.device_type,
            "created_at": self.created_at.isoformat(),
            "updated_at": self.updated_at.isoformat(),
        }
        if self.baud_rate is not None:
            d["baud_rate"] = self.baud_rate
        if self.software_brightness != 255:
            d["software_brightness"] = self.software_brightness
        if self.auto_shutdown:
            d["auto_shutdown"] = True
        if self.send_latency_ms:
            d["send_latency_ms"] = self.send_latency_ms
        if self.rgbw:
            d["rgbw"] = True
        if self.zone_mode != "combined":
            d["zone_mode"] = self.zone_mode
        if self.tags:
            d["tags"] = self.tags
        if self.dmx_protocol != "artnet":
            d["dmx_protocol"] = self.dmx_protocol
        if self.dmx_start_universe != 0:
            d["dmx_start_universe"] = self.dmx_start_universe
        if self.dmx_start_channel != 1:
            d["dmx_start_channel"] = self.dmx_start_channel
        if self.ddp_port:
            d["ddp_port"] = self.ddp_port
        if self.ddp_destination_id != 1:
            d["ddp_destination_id"] = self.ddp_destination_id
        if self.ddp_color_order != 1:
            d["ddp_color_order"] = self.ddp_color_order
        if self.espnow_peer_mac:
            d["espnow_peer_mac"] = self.espnow_peer_mac
        if self.espnow_channel != 1:
            d["espnow_channel"] = self.espnow_channel
        if self.hue_username:
            # Hue bridge credentials act as long-lived API keys for the entire
            # bridge — encrypt at rest like HA tokens / MQTT passwords.
            d["hue_username"] = _enc(self.hue_username)
        if self.hue_client_key:
            d["hue_client_key"] = _enc(self.hue_client_key)
        if self.hue_entertainment_group_id:
            d["hue_entertainment_group_id"] = self.hue_entertainment_group_id
        if self.yeelight_min_interval_ms != 500:
            d["yeelight_min_interval_ms"] = self.yeelight_min_interval_ms
        if self.wiz_min_interval_ms != 50:
            d["wiz_min_interval_ms"] = self.wiz_min_interval_ms
        if self.lifx_min_interval_ms != 50:
            d["lifx_min_interval_ms"] = self.lifx_min_interval_ms
        if self.govee_min_interval_ms != 50:
            d["govee_min_interval_ms"] = self.govee_min_interval_ms
        if self.opc_channel:
            d["opc_channel"] = self.opc_channel
        if self.nanoleaf_token:
            # Long-lived auth token — encrypt at rest like Hue / MQTT creds.
            d["nanoleaf_token"] = _enc(self.nanoleaf_token)
        if self.nanoleaf_min_interval_ms != 100:
            d["nanoleaf_min_interval_ms"] = self.nanoleaf_min_interval_ms
        if self.spi_speed_hz != 800000:
            d["spi_speed_hz"] = self.spi_speed_hz
        if self.spi_led_type != "WS2812B":
            d["spi_led_type"] = self.spi_led_type
        if self.chroma_device_type != "chromalink":
            d["chroma_device_type"] = self.chroma_device_type
        if self.gamesense_device_type != "keyboard":
            d["gamesense_device_type"] = self.gamesense_device_type
        if self.ble_family:
            d["ble_family"] = self.ble_family
        if self.ble_govee_key:
            # Govee BLE AES key — encrypt at rest.
            d["ble_govee_key"] = _enc(self.ble_govee_key)
        if self.mqtt_source_id:
            d["mqtt_source_id"] = self.mqtt_source_id
        if self.default_css_processing_template_id:
            d["default_css_processing_template_id"] = self.default_css_processing_template_id
        if self.group_device_ids:
            d["group_device_ids"] = self.group_device_ids
        if self.group_mode != "sequence":
            d["group_mode"] = self.group_mode
        if self.icon:
            d["icon"] = self.icon
        if self.icon_color:
            d["icon_color"] = self.icon_color
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "Device":
        """Create device from dictionary."""
        return cls(
            device_id=data["id"],
            name=data["name"],
            url=data["url"],
            led_count=data["led_count"],
            enabled=data.get("enabled", True),
            device_type=data.get("device_type", "wled"),
            baud_rate=data.get("baud_rate"),
            software_brightness=data.get("software_brightness", 255),
            auto_shutdown=data.get("auto_shutdown", False),
            send_latency_ms=data.get("send_latency_ms", 0),
            rgbw=data.get("rgbw", False),
            zone_mode=data.get("zone_mode", "combined"),
            tags=data.get("tags", []),
            dmx_protocol=data.get("dmx_protocol", "artnet"),
            dmx_start_universe=data.get("dmx_start_universe", 0),
            dmx_start_channel=data.get("dmx_start_channel", 1),
            ddp_port=data.get("ddp_port", 0),
            ddp_destination_id=data.get("ddp_destination_id", 1),
            ddp_color_order=data.get("ddp_color_order", 1),
            espnow_peer_mac=data.get("espnow_peer_mac", ""),
            espnow_channel=data.get("espnow_channel", 1),
            hue_username=_dec(data.get("hue_username", "")),
            hue_client_key=_dec(data.get("hue_client_key", "")),
            hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""),
            yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500),
            wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50),
            lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50),
            govee_min_interval_ms=data.get("govee_min_interval_ms", 50),
            opc_channel=data.get("opc_channel", 0),
            nanoleaf_token=_dec(data.get("nanoleaf_token", "")),
            nanoleaf_min_interval_ms=data.get("nanoleaf_min_interval_ms", 100),
            spi_speed_hz=data.get("spi_speed_hz", 800000),
            spi_led_type=data.get("spi_led_type", "WS2812B"),
            chroma_device_type=data.get("chroma_device_type", "chromalink"),
            gamesense_device_type=data.get("gamesense_device_type", "keyboard"),
            ble_family=data.get("ble_family", ""),
            ble_govee_key=_dec(data.get("ble_govee_key", "")),
            mqtt_source_id=data.get("mqtt_source_id", ""),
            default_css_processing_template_id=data.get("default_css_processing_template_id", ""),
            group_device_ids=data.get("group_device_ids", []),
            group_mode=data.get("group_mode", "sequence"),
            icon=data.get("icon", ""),
            icon_color=data.get("icon_color", ""),
            created_at=datetime.fromisoformat(
                data.get("created_at", datetime.now(timezone.utc).isoformat())
            ),
            updated_at=datetime.fromisoformat(
                data.get("updated_at", datetime.now(timezone.utc).isoformat())
            ),
        )


# Fields that can be updated (all Device.__init__ params except identity/timestamps)
_UPDATABLE_FIELDS: frozenset[str] = frozenset(
    {
        "name",
        "url",
        "led_count",
        "enabled",
        "device_type",
        "baud_rate",
        "software_brightness",
        "auto_shutdown",
        "send_latency_ms",
        "rgbw",
        "zone_mode",
        "tags",
        "dmx_protocol",
        "dmx_start_universe",
        "dmx_start_channel",
        "ddp_port",
        "ddp_destination_id",
        "ddp_color_order",
        "espnow_peer_mac",
        "espnow_channel",
        "hue_username",
        "hue_client_key",
        "hue_entertainment_group_id",
        "yeelight_min_interval_ms",
        "wiz_min_interval_ms",
        "lifx_min_interval_ms",
        "govee_min_interval_ms",
        "opc_channel",
        "nanoleaf_token",
        "nanoleaf_min_interval_ms",
        "spi_speed_hz",
        "spi_led_type",
        "chroma_device_type",
        "gamesense_device_type",
        "ble_family",
        "ble_govee_key",
        "mqtt_source_id",
        "default_css_processing_template_id",
        "group_device_ids",
        "group_mode",
        "icon",
        "icon_color",
    }
)


class DeviceStore(BaseSqliteStore[Device]):
    """Persistent storage for WLED devices."""

    _table_name = "devices"
    _entity_name = "Device"

    def __init__(self, db: Database):
        super().__init__(db, Device.from_dict)
        logger.info(f"Device store initialized with {len(self._items)} devices")
        self._migrate_plaintext_credentials()

    def _migrate_plaintext_credentials(self) -> None:
        """Encrypt Hue and Govee credentials still in plaintext at rest.

        Mirrors the HA-token and MQTT-password migrations. ``hue_username``
        and ``hue_client_key`` together let an attacker control every light
        on a Hue bridge; ``ble_govee_key`` is the per-device AES key for
        Govee BLE devices. All three were stored plaintext before this
        change.
        """
        migrated = 0
        try:
            rows = self._db.load_all(self._table_name)
        except Exception as exc:
            logger.error("Could not inspect rows for device credential migration: %s", exc)
            return
        for row in rows:
            did = row.get("id")
            if not did:
                continue
            fields = (
                row.get("hue_username", "") or "",
                row.get("hue_client_key", "") or "",
                row.get("ble_govee_key", "") or "",
            )
            # Need migration if at least one secret field is non-empty AND
            # not already in envelope form.
            needs_migrate = any(v and not secret_box.is_encrypted(v) for v in fields)
            if not needs_migrate:
                continue
            device = self._items.get(did)
            if device is None:
                continue
            try:
                self._save_item(did, device)
                migrated += 1
            except Exception as exc:
                logger.error("Failed to migrate device credentials for %s: %s", did, exc)
        if migrated:
            logger.warning(
                "MIGRATION: encrypted plaintext credentials at rest for %d device(s).",
                migrated,
            )

    # ── Backward-compat aliases (thin re-export of base methods) ─
    get_device = BaseSqliteStore.get
    get_all_devices = BaseSqliteStore.get_all
    delete_device = BaseSqliteStore.delete

    # ── Create / Update ──────────────────────────────────────────

    def create_device(
        self,
        name: str,
        url: str,
        led_count: int,
        device_type: str = "wled",
        baud_rate: Optional[int] = None,
        auto_shutdown: bool = False,
        send_latency_ms: int = 0,
        rgbw: bool = False,
        zone_mode: str = "combined",
        tags: Optional[List[str]] = None,
        dmx_protocol: str = "artnet",
        dmx_start_universe: int = 0,
        dmx_start_channel: int = 1,
        ddp_port: int = 0,
        ddp_destination_id: int = 1,
        ddp_color_order: int = 1,
        espnow_peer_mac: str = "",
        espnow_channel: int = 1,
        hue_username: str = "",
        hue_client_key: str = "",
        hue_entertainment_group_id: str = "",
        yeelight_min_interval_ms: int = 500,
        wiz_min_interval_ms: int = 50,
        lifx_min_interval_ms: int = 50,
        govee_min_interval_ms: int = 50,
        opc_channel: int = 0,
        nanoleaf_token: str = "",
        nanoleaf_min_interval_ms: int = 100,
        spi_speed_hz: int = 800000,
        spi_led_type: str = "WS2812B",
        chroma_device_type: str = "chromalink",
        gamesense_device_type: str = "keyboard",
        ble_family: str = "",
        ble_govee_key: str = "",
        mqtt_source_id: str = "",
        group_device_ids: Optional[List[str]] = None,
        group_mode: str = "sequence",
    ) -> Device:
        """Create a new device."""
        with self._lock:
            self._check_name_unique(name)

            device_id = f"device_{uuid.uuid4().hex[:8]}"

            # Mock devices use their device ID as the URL authority
            if device_type == "mock":
                url = f"mock://{device_id}"

            device = Device(
                device_id=device_id,
                name=name,
                url=url,
                led_count=led_count,
                device_type=device_type,
                baud_rate=baud_rate,
                auto_shutdown=auto_shutdown,
                send_latency_ms=send_latency_ms,
                rgbw=rgbw,
                zone_mode=zone_mode,
                tags=tags or [],
                dmx_protocol=dmx_protocol,
                dmx_start_universe=dmx_start_universe,
                dmx_start_channel=dmx_start_channel,
                ddp_port=ddp_port,
                ddp_destination_id=ddp_destination_id,
                ddp_color_order=ddp_color_order,
                espnow_peer_mac=espnow_peer_mac,
                espnow_channel=espnow_channel,
                hue_username=hue_username,
                hue_client_key=hue_client_key,
                hue_entertainment_group_id=hue_entertainment_group_id,
                yeelight_min_interval_ms=yeelight_min_interval_ms,
                wiz_min_interval_ms=wiz_min_interval_ms,
                lifx_min_interval_ms=lifx_min_interval_ms,
                govee_min_interval_ms=govee_min_interval_ms,
                opc_channel=opc_channel,
                nanoleaf_token=nanoleaf_token,
                nanoleaf_min_interval_ms=nanoleaf_min_interval_ms,
                spi_speed_hz=spi_speed_hz,
                spi_led_type=spi_led_type,
                chroma_device_type=chroma_device_type,
                gamesense_device_type=gamesense_device_type,
                ble_family=ble_family,
                ble_govee_key=ble_govee_key,
                mqtt_source_id=mqtt_source_id,
                group_device_ids=group_device_ids or [],
                group_mode=group_mode,
            )

            self._items[device_id] = device
            self._save_item(device_id, device)

            logger.info(f"Created device {device_id}: {name}")
            return device

    def update_device(self, device_id: str, **kwargs) -> Device:
        """Update device fields.

        Pass any updatable Device field as a keyword argument.
        ``None`` values are ignored (no change).
        """
        with self._lock:
            device = self.get(device_id)  # raises ValueError if not found

            # Collect updates (ignore None values and unknown fields)
            updates = {
                key: value
                for key, value in kwargs.items()
                if value is not None and key in _UPDATABLE_FIELDS
            }

            # Check name uniqueness if name is being changed
            new_name = updates.get("name")
            if new_name is not None and new_name != device.name:
                self._check_name_unique(new_name, exclude_id=device_id)

            # Build new Device from existing fields + updates (immutable pattern).
            # Source kwargs directly from instance attributes via vars(), NOT
            # via to_dict() -- to_dict() encrypts hue_username/hue_client_key/
            # ble_govee_key/nanoleaf_token via _enc(), and Device.__init__
            # doesn't decrypt, so a round-trip through to_dict() would leave
            # the new in-memory Device holding ciphertext where plaintext
            # belongs. The cached _items entry would then return ciphertext
            # to provider.create_client(), and the device's runtime auth
            # would silently break until server restart (caught by review).
            device_fields = vars(device).copy()
            # Constructor takes device_id, but attribute is id.
            device_fields["device_id"] = device_fields.pop("id")
            device_fields["updated_at"] = datetime.now(timezone.utc)
            # Apply updates
            device_fields.update(updates)

            new_device = Device(**device_fields)
            self._items[device_id] = new_device
            self._save_item(device_id, new_device)

            logger.info(f"Updated device {device_id}")
            return new_device

    # ── Unique helpers ───────────────────────────────────────────

    def device_exists(self, device_id: str) -> bool:
        """Check if device exists."""
        return device_id in self._items

    # ── Group helpers ───────────────────────────────────────────

    def validate_group_no_cycles(
        self,
        device_id: Optional[str],
        group_device_ids: List[str],
    ) -> None:
        """Raise ValueError if adding these children would create a cycle.

        Uses DFS with backtracking — diamond DAGs (A→B, A→C, B→D, C→D) are
        allowed; only true cycles are rejected.
        """
        ancestors = set()
        if device_id:
            ancestors.add(device_id)

        def _dfs(child_ids: List[str]) -> None:
            for cid in child_ids:
                if cid in ancestors:
                    raise ValueError(f"Circular group reference detected: {cid}")
                device = self._items.get(cid)
                if device is None:
                    raise ValueError(f"Referenced device not found: {cid}")
                if device.group_device_ids:
                    ancestors.add(cid)
                    _dfs(device.group_device_ids)
                    ancestors.discard(cid)

        _dfs(group_device_ids)

    def resolve_group_led_count(
        self,
        device_ids: List[str],
        _seen: Optional[set] = None,
    ) -> int:
        """Sum led_counts of devices, recursively resolving nested sequence groups."""
        if _seen is None:
            _seen = set()
        total = 0
        for did in device_ids:
            if did in _seen:
                continue
            _seen.add(did)
            device = self._items.get(did)
            if device is None:
                continue
            if (
                device.device_type == "group"
                and device.group_mode == "sequence"
                and device.group_device_ids
            ):
                total += self.resolve_group_led_count(device.group_device_ids, _seen)
            else:
                total += device.led_count
        return total

    def resolve_group_max_led_count(
        self,
        device_ids: List[str],
    ) -> int:
        """Return max led_count among children (for independent mode default)."""
        max_count = 0
        for did in device_ids:
            device = self._items.get(did)
            if device is None:
                continue
            max_count = max(max_count, device.led_count)
        return max_count or 1

    def get_groups_referencing(self, device_id: str) -> List["Device"]:
        """Return all group devices whose group_device_ids contains device_id."""
        return [
            d
            for d in self._items.values()
            if d.device_type == "group" and device_id in d.group_device_ids
        ]

    def clear(self):
        """Clear all devices (for testing)."""
        self._items.clear()
        self._db.delete_all(self._table_name)
        logger.warning("Cleared all devices from storage")
