"""BindableFloat — a scalar property that can be static or bound to a value source.

Any numeric property (brightness, smoothing, intensity, speed, etc.) can use
BindableFloat instead of a plain float.  When source_id is empty the static
value is used; when set, the runtime resolves the current value from the
corresponding ValueStream.

Serialisation is backward-compatible:
  • unbound  → plain float  (``0.3``)
  • bound    → dict          (``{"value": 0.3, "source_id": "vs_abc12345"}``)

``from_raw`` accepts both shapes, so old JSON files "just work".
"""

from dataclasses import dataclass


@dataclass
class BindableFloat:
    """A scalar that is either a static value or driven by a ValueSource."""

    value: float = 0.0
    source_id: str = ""  # empty → use static value

    # -- serialisation --

    def to_dict(self):
        """Serialize: plain float when unbound, dict when bound."""
        if not self.source_id:
            return self.value
        return {"value": self.value, "source_id": self.source_id}

    @classmethod
    def from_raw(cls, data, *, default: float = 0.0) -> "BindableFloat":
        """Deserialize from either a plain number or a dict.

        Also handles the legacy ``brightness_value_source_id`` migration
        when called with the *legacy_source_id* helper below.
        """
        if data is None:
            return cls(value=default)
        if isinstance(data, (int, float)):
            return cls(value=float(data))
        if isinstance(data, dict):
            return cls(
                value=float(data.get("value", default)),
                source_id=data.get("source_id") or "",
            )
        return cls(value=default)

    @classmethod
    def from_legacy(
        cls,
        data,
        legacy_source_id: str = "",
        *,
        default: float = 0.0,
    ) -> "BindableFloat":
        """Migrate from the old separate-field pattern.

        Old format::

            {"brightness": 1.0, "brightness_value_source_id": "vs_abc"}

        New format::

            {"brightness": {"value": 1.0, "source_id": "vs_abc"}}

        If *data* is already a dict with ``source_id``, it takes precedence
        (new format).  Otherwise *legacy_source_id* is folded in.
        """
        if isinstance(data, dict) and "source_id" in data:
            return cls.from_raw(data, default=default)
        value = float(data) if isinstance(data, (int, float)) else default
        return cls(value=value, source_id=legacy_source_id or "")

    # -- update helpers --

    def apply_update(self, raw) -> "BindableFloat":
        """Return a new BindableFloat from an update payload.

        Accepts:
          • plain number  → sets value, clears source_id
          • dict           → sets both
          • None           → returns self unchanged
        """
        if raw is None:
            return self
        if isinstance(raw, (int, float)):
            return BindableFloat(value=float(raw), source_id=self.source_id)
        if isinstance(raw, dict):
            return BindableFloat(
                value=float(raw.get("value", self.value)),
                source_id=raw.get("source_id", self.source_id),
            )
        return self

    @property
    def is_bound(self) -> bool:
        return bool(self.source_id)


@dataclass
class BindableColor:
    """An RGB color that is either static or driven by a color ValueSource."""

    color: list  # [R, G, B] static fallback
    source_id: str = ""  # empty → use static color

    def __post_init__(self):
        if not isinstance(self.color, list) or len(self.color) != 3:
            self.color = [255, 255, 255]

    def to_dict(self):
        """Serialize: plain [R,G,B] when unbound, dict when bound."""
        if not self.source_id:
            return list(self.color)
        return {"color": list(self.color), "source_id": self.source_id}

    @classmethod
    def from_raw(cls, data, *, default: list | None = None) -> "BindableColor":
        """Deserialize from [R,G,B] array or dict."""
        if default is None:
            default = [255, 255, 255]
        if data is None:
            return cls(color=list(default))
        if isinstance(data, list) and len(data) == 3:
            return cls(color=list(data))
        if isinstance(data, dict):
            raw_color = data.get("color", default)
            color = (
                list(raw_color)
                if isinstance(raw_color, list) and len(raw_color) == 3
                else list(default)
            )
            return cls(color=color, source_id=data.get("source_id") or "")
        return cls(color=list(default))

    def apply_update(self, raw) -> "BindableColor":
        """Return a new BindableColor from an update payload."""
        if raw is None:
            return self
        if isinstance(raw, list) and len(raw) == 3:
            return BindableColor(color=list(raw), source_id=self.source_id)
        if isinstance(raw, dict):
            color = raw.get("color", self.color)
            if isinstance(color, list) and len(color) == 3:
                color = list(color)
            else:
                color = list(self.color)
            return BindableColor(
                color=color,
                source_id=raw.get("source_id", self.source_id),
            )
        return self

    @property
    def is_bound(self) -> bool:
        return bool(self.source_id)


def bcolor(v, default: list | None = None) -> list:
    """Extract the static [R,G,B] from a value that may be BindableColor or plain list."""
    if default is None:
        default = [255, 255, 255]
    if isinstance(v, BindableColor):
        return list(v.color)
    if isinstance(v, list) and len(v) == 3:
        return list(v)
    return list(default)


def bfloat(v, default: float = 0.0) -> float:
    """Extract the static float from a value that may be BindableFloat or plain number.

    Useful in processing code that reads source model properties which may
    be either a plain ``float`` (legacy / unbound) or a ``BindableFloat``.
    """
    if isinstance(v, BindableFloat):
        return v.value
    if isinstance(v, (int, float)):
        return float(v)
    if hasattr(v, "value"):
        return float(v.value)
    return default
