"""``MockDevice`` — a headless mock M1X so plugins can be tested without hardware.

Stands in for the physical device on the other end of the serial link: it
loads a widget spec, tracks the live data table, and can simulate both
directions of traffic — ``feed()`` plays the PC→device role (so it can be
wired up as a plugin's ``_wdata_sender``), and ``press``/``hold``/``toggle``/
``slide``/``switch_page`` play the device→PC role, emitting the same
``WEVENT``/``WPAGE`` lines the firmware would and routing them to a bound
plugin exactly like ``custom_widget.ino`` does. Stdlib only — Pillow (for
rendering a preview) is pulled in lazily by :func:`render_to`.
"""
from __future__ import annotations

import json
import os
from typing import Any, Callable, Dict, List, Optional, Union

from .. import protocol
from ..registry import PluginRegistry

#: A widget spec source: a dict already parsed, or a path to a widget.json.
SpecSource = Union[str, Dict[str, Any]]

EventCallback = Callable[[protocol.Event], None]


class MockDevice:
    """A headless mock M1X device: widget state, data table, event replay."""

    def __init__(self) -> None:
        self.spec: Optional[Dict[str, Any]] = None
        self.spec_dir: Optional[str] = None
        self.widget_id: str = ""
        #: Live ``WDATA`` table, fully-qualified key -> value (both namespaced).
        self.data_table: Dict[str, str] = {}
        self.current_page: Optional[str] = None
        #: Chronological log of every line sent/received, e.g. ``"-> WDATA,..."``.
        self.log: List[str] = []
        #: Last image pushed via ``push_widget_image``: (key, rgb565, w, h).
        self.last_image: Optional[tuple] = None

        self._element_ids: set = set()   # interactive element (btn/toggle/slider) ids
        self._bind_keys: set = set()     # every key any element binds to
        self._event_callbacks: List[EventCallback] = []
        self._plugin: Optional[Any] = None
        self._attached = False

    # ── Wiring ────────────────────────────────────────────────────────────────
    def attach(self) -> None:
        """Install this device as the process-wide plugin registry's sender."""
        reg = PluginRegistry.instance()
        # Keep the exact bound-method objects we install: ``self.feed`` yields a
        # NEW bound method each access, so identity checks in detach() must use
        # the stored references, not a fresh ``self.feed`` (which is never `is`).
        self._installed_wdata = self.feed
        self._installed_wimg = self._receive_image
        reg._wdata_sender = self._installed_wdata
        reg._wimg_sender = self._installed_wimg
        self._attached = True

    def detach(self) -> None:
        """Restore the registry senders to ``None`` (only if still ours)."""
        reg = PluginRegistry.instance()
        if reg._wdata_sender is getattr(self, "_installed_wdata", None):
            reg._wdata_sender = None
        if reg._wimg_sender is getattr(self, "_installed_wimg", None):
            reg._wimg_sender = None
        self._installed_wdata = None
        self._installed_wimg = None
        self._attached = False

    def bind_plugin(self, plugin: Any) -> None:
        """Route simulated events and shown/hidden hooks to ``plugin``.

        Adopts ``plugin.widget_id`` as this device's active widget id if none
        is set yet, mirroring the real pairing flow (the app selects a
        plugin's bundled widget via ``CUSTOMWIDGET,<id>`` on connect).
        """
        self._plugin = plugin
        if not self.widget_id and getattr(plugin, "widget_id", ""):
            self.widget_id = plugin.widget_id

    def on_event(self, callback: EventCallback) -> None:
        """Register ``callback(Event)`` to be called on every simulated event."""
        self._event_callbacks.append(callback)

    # ── Loading a widget spec ────────────────────────────────────────────────
    def load_widget(self, widget_json_path_or_dict: SpecSource) -> None:
        """Parse a widget spec (path or dict) and record its bindable state."""
        prev_widget_id = self.widget_id if self.spec is not None else ""

        if isinstance(widget_json_path_or_dict, dict):
            spec = widget_json_path_or_dict
            self.spec_dir = None
        else:
            path = widget_json_path_or_dict
            with open(path, "r", encoding="utf-8") as f:
                spec = json.load(f)
            self.spec_dir = os.path.dirname(os.path.abspath(path))
            self.widget_id = self.widget_id or os.path.splitext(
                os.path.basename(path))[0]

        if self._plugin is not None and prev_widget_id and \
                prev_widget_id != self.widget_id:
            self._safe_call(self._plugin.on_widget_hidden, prev_widget_id)

        self.spec = spec
        self._element_ids = set()
        self._bind_keys = set()

        pages = spec.get("pages")
        if pages:
            self.current_page = next(
                (p.get("name") for p in pages if not p.get("hidden")), None)
            for page in pages:
                self._scan_elements(page.get("elements", []))
        else:
            self.current_page = None
            self._scan_elements(spec.get("elements", []))

        if self._plugin is not None and self.widget_id:
            self._safe_call(self._plugin.on_widget_shown, self.widget_id)

    def _scan_elements(self, elements: List[Dict[str, Any]]) -> None:
        for el in elements:
            bind = el.get("bind")
            if bind:
                self._bind_keys.add(bind)
            eid = el.get("id")
            if eid and el.get("t") in ("btn", "toggle", "slider"):
                self._element_ids.add(eid)

    # ── PC → device (this device acting as the ``_wdata_sender``) ───────────
    def feed(self, line: str) -> bool:
        """Parse one or more ``\\n``-joined protocol lines and apply them."""
        for raw in str(line).splitlines():
            raw = raw.strip()
            if not raw:
                continue
            self.log.append(f"-> {raw}")
            self._apply_line(raw)
        return True

    def _apply_line(self, line: str) -> None:
        parts = line.split(",", 2)
        verb = parts[0]
        if verb == protocol.WDATA and len(parts) >= 3:
            key, value = parts[1], parts[2]
            if len(self.data_table) >= protocol.MAX_DATA_KEYS and \
                    key not in self.data_table:
                self.log.append("<- WDATA_ERR")
                return
            self.data_table[key] = value
        elif verb == protocol.WPAGE and len(parts) >= 2:
            self.switch_page(parts[1], _from_device=False)
        elif verb == protocol.CUSTOMWIDGET and len(parts) >= 2:
            self.widget_id = parts[1]

    def _receive_image(self, key: str, rgb565: bytes, w: int, h: int) -> bool:
        """Stand-in ``_wimg_sender``: stores the last pushed image."""
        self.last_image = (key, rgb565, w, h)
        self.log.append(f"-> WIMG,{key},{w}x{h},{len(rgb565)}B")
        return True

    # ── Device → PC (simulated user interaction) ─────────────────────────────
    def press(self, element_id: str) -> None:
        """Simulate a tap: emits ``WEVENT,<widget>,<id>,press``."""
        self._emit(element_id, protocol.ACTION_PRESS)

    def hold(self, element_id: str) -> None:
        """Simulate a long-press: emits ``WEVENT,<widget>,<id>,hold``."""
        self._emit(element_id, protocol.ACTION_HOLD)

    def toggle(self, element_id: str, on: bool) -> None:
        """Simulate a switch flip: emits ``WEVENT,<widget>,<id>,on|off``."""
        self._emit(element_id, protocol.ACTION_ON if on else protocol.ACTION_OFF)

    def slide(self, element_id: str, value: int) -> None:
        """Simulate a drag: emits ``WEVENT,<widget>,<id>,value=<n>``."""
        self._emit(element_id, f"value={int(value)}")

    def switch_page(self, name: str, _from_device: bool = True) -> None:
        """Simulate (or apply) a page switch; fires the ``_page`` event."""
        prev = self.current_page
        self.current_page = name
        if _from_device:
            self._emit(protocol.ACTION_PAGE, name)
        if self._plugin is not None and prev != name:
            pass  # no dedicated page-change hook beyond the _page WEVENT above

    def _emit(self, element_id: str, action: str) -> None:
        line = f"{protocol.WEVENT},{self.widget_id},{element_id},{action}"
        self.log.append(f"<- {line}")
        event = protocol.parse_event(line)
        if event is None:
            return
        for cb in self._event_callbacks:
            self._safe_call(cb, event)
        if self._plugin is not None:
            self._safe_call(self._plugin.on_widget_event,
                            event.widget, event.element, event.action)

    @staticmethod
    def _safe_call(fn: Callable, *args: Any) -> None:
        """Crash-isolated dispatch — a broken plugin never breaks the device."""
        try:
            fn(*args)
        except Exception:
            pass

    # ── Inspection ────────────────────────────────────────────────────────────
    def data(self, key: str) -> Optional[str]:
        """Get one raw value from the live data table (namespaced key)."""
        return self.data_table.get(key)

    def snapshot(self) -> Dict[str, str]:
        """A copy of the full data table, for test assertions."""
        return dict(self.data_table)

    def render_to(self, path: str) -> str:
        """Render the loaded spec's current state to a PNG at ``path``."""
        if self.spec is None:
            raise ValueError("no widget loaded")
        from .render import save_preview
        save_preview(self.spec, path, data=self.data_table,
                    spec_dir=self.spec_dir, page=self.current_page)
        return path


__all__ = ["MockDevice"]
