"""The M1X serial wire protocol (PC ⇄ device) as constants and tiny helpers.

This mirrors the lines documented in ``WIDGETS.md`` §3 and implemented by the
firmware (``custom_widget.ino``) and the app. Plugins never touch the serial
port directly — ``MixlarPlugin.push_widget_data`` builds these for you — but the
emulator, the linter, and tests speak it explicitly, so it lives in one place.

Every builder returns a bytes/str line *without* the trailing newline unless
noted; senders add framing. Parsers accept a single decoded line and return a
structured tuple/dict or ``None`` when the line isn't that message.
"""
from __future__ import annotations

from typing import NamedTuple, Optional

# ── Device / canvas limits (enforced both ends) ──────────────────────────────
CANVAS_W = 240            #: widget panel width, px
CANVAS_H = 140            #: widget panel height, px
MAX_ELEMENTS = 48         #: elements per widget (shared across pages)
MAX_PAGES = 6             #: pages per widget
MAX_DATA_KEYS = 32        #: live WDATA key slots (RAM-only on device)
KEY_MAX_LEN = 23          #: device-side key length cap (first comma splits k/v)
VALUE_MAX_LEN = 47        #: device-side value length cap
SPEC_MAX_BYTES = 16384    #: widget.json size cap
WIDGET_ID_MIN, WIDGET_ID_MAX = 2, 24

# ── Command verbs ────────────────────────────────────────────────────────────
CUSTOMWIDGET = "CUSTOMWIDGET"        # PC→dev: load & switch to /fat/widgets/<id>
WDATA = "WDATA"                      # PC→dev: set a live data key
WPAGE = "WPAGE"                      # PC→dev: force active widget to a page
WIDGET_UPLOAD = "WIDGET_UPLOAD"      # PC→dev: install one file (+ raw bytes)
WLIST = "WLIST"                      # PC→dev: request installed-widget inventory
WEVENT = "WEVENT"                    # dev→PC: interactive element fired
WMACRO = "WMACRO"                    # dev→PC: run a named macro (btn/toggle macro)

# Interactive actions carried by WEVENT.
ACTION_PRESS = "press"
ACTION_HOLD = "hold"
ACTION_ON = "on"
ACTION_OFF = "off"
ACTION_PAGE = "_page"                # element id for page-switch announcements

# Reserved data keys the app auto-pushes while a custom widget is active.
RESERVED_KEYS = ("_time", "_time24", "_ampm", "_date")


# ── Builders (PC → device) ───────────────────────────────────────────────────
def wdata(key: str, value, widget_id: str = "") -> str:
    """Build a ``WDATA,<key>,<value>`` line (namespaced like the base class).

    Keys are prefixed ``<widget_id>.`` unless they start with ``_`` (global
    feeds). Commas/newlines are sanitised and lengths clamped, matching
    ``MixlarPlugin.push_widget_data`` exactly.
    """
    k = str(key).strip().replace(",", "_")
    if widget_id and not k.startswith("_"):
        k = f"{widget_id}.{k}"
    k = k[:VALUE_MAX_LEN]
    v = str(value).replace("\n", " ").replace(",", " ")[:VALUE_MAX_LEN]
    return f"{WDATA},{k},{v}"


def wpage(name: str) -> str:
    """Build a ``WPAGE,<name>`` line (force the active widget to a page)."""
    return f"{WPAGE},{name}"


def customwidget(widget_id: str) -> str:
    """Build a ``CUSTOMWIDGET,<id>`` line (load & switch the panel)."""
    return f"{CUSTOMWIDGET},{widget_id}"


def widget_upload(widget_id: str, filename: str, size: int) -> str:
    """Build the header line for a single-file widget upload."""
    return f"{WIDGET_UPLOAD},{widget_id}/{filename},{int(size)}"


# ── Parsers (device → PC) ────────────────────────────────────────────────────
class Event(NamedTuple):
    """A parsed ``WEVENT`` line."""
    widget: str
    element: str
    action: str        # "press" | "hold" | "on" | "off" | page name (when _page)
    value: Optional[int]  # set only for slider "value=<n>" actions


def parse_event(line: str) -> Optional[Event]:
    """Parse ``WEVENT,<widget>,<element>,<action>`` (incl. ``value=<n>``)."""
    parts = line.rstrip("\r\n").split(",", 3)
    if len(parts) < 4 or parts[0] != WEVENT:
        return None
    _, widget, element, action = parts
    value = None
    if action.startswith("value="):
        try:
            value = int(action.split("=", 1)[1])
        except ValueError:
            value = None
        action = "value"
    return Event(widget, element, action, value)


def parse_macro(line: str) -> Optional[str]:
    """Parse ``WMACRO,<name>`` → the macro name, or ``None``."""
    parts = line.rstrip("\r\n").split(",", 1)
    if len(parts) == 2 and parts[0] == WMACRO:
        return parts[1]
    return None


class WidgetInfo(NamedTuple):
    id: str
    version: str


def parse_wlist(line: str) -> Optional[WidgetInfo]:
    """Parse a ``WLIST,<id>,<version>`` inventory line."""
    parts = line.rstrip("\r\n").split(",", 2)
    if len(parts) == 3 and parts[0] == WLIST:
        return WidgetInfo(parts[1], parts[2])
    return None


def parse_customwidget_ok(line: str):
    """Parse ``CUSTOMWIDGET_OK,<id>,<n>`` → (id, element_count) or ``None``."""
    parts = line.rstrip("\r\n").split(",", 2)
    if len(parts) == 3 and parts[0] == "CUSTOMWIDGET_OK":
        try:
            return parts[1], int(parts[2])
        except ValueError:
            return parts[1], None
    return None


__all__ = [
    "CANVAS_W", "CANVAS_H", "MAX_ELEMENTS", "MAX_PAGES", "MAX_DATA_KEYS",
    "KEY_MAX_LEN", "VALUE_MAX_LEN", "SPEC_MAX_BYTES",
    "WIDGET_ID_MIN", "WIDGET_ID_MAX",
    "CUSTOMWIDGET", "WDATA", "WPAGE", "WIDGET_UPLOAD", "WLIST", "WEVENT", "WMACRO",
    "ACTION_PRESS", "ACTION_HOLD", "ACTION_ON", "ACTION_OFF", "ACTION_PAGE",
    "RESERVED_KEYS",
    "wdata", "wpage", "customwidget", "widget_upload",
    "Event", "parse_event", "parse_macro", "WidgetInfo", "parse_wlist",
    "parse_customwidget_ok",
]
