"""A programmatic, always-spec-correct builder for ``widget.json`` (mixw:1).

Hand-writing the JSON described in ``WIDGETS.md`` is error-prone (typo a field
name, forget a required ``id``, ship a bad hex color) and those mistakes only
surface as a silently-wrong render on a real device. :class:`Widget` builds the
same spec through a fluent, typed API — every element method appends and
returns ``self`` — and only emits fields that were actually set, keeping the
JSON minimal exactly like a hand-tuned spec.
"""
from __future__ import annotations

import json
import os
import re
from typing import Any, Dict, List, Optional, Tuple

from .protocol import MAX_ELEMENTS, MAX_PAGES

MANIFEST_NAME = "widget.json"

_HEX_RE = re.compile(r"^#?([0-9A-Fa-f]{6})$")

# Shared fields every element accepts (WIDGETS.md "Elements — shared fields").
_SHARED_KEYS = ("bind", "fmt", "c", "show", "cbind", "obind", "o", "pulse", "sym")


def _color(value: Optional[str]) -> Optional[str]:
    """Normalize ``"#RRGGBB"`` or ``"RRGGBB"`` to bare 6-hex (firmware wants no #)."""
    if value is None:
        return None
    m = _HEX_RE.match(str(value))
    if not m:
        raise ValueError(f"invalid color {value!r} (expected #RRGGBB or RRGGBB)")
    return m.group(1).upper()


# Field names that carry a color and therefore go through _color().
_COLOR_FIELDS = {"c", "bg", "bgc", "kc", "tc", "offc", "lc"}


def _elem(t: str, x: int, y: int, **fields: Any) -> Dict[str, Any]:
    """Build one element dict: type/position always present, everything else
    only when non-None. Colors normalized; other values passed through."""
    el: Dict[str, Any] = {"t": t, "x": x, "y": y}
    for k, v in fields.items():
        if v is None:
            continue
        el[k] = _color(v) if k in _COLOR_FIELDS else v
    return el


class Widget:
    """Fluent builder for a single ``widget.json`` (mixw:1) spec.

    Without any :meth:`page` call, elements accumulate into a flat top-level
    ``"elements"`` list. Calling :meth:`page` switches to multi-page mode
    (top-level ``"pages"``); every element call afterwards targets the most
    recently started page.
    """

    def __init__(self, name: str, bg: Optional[str] = None,
                version: Optional[str] = None) -> None:
        self.name = name
        self.bg = _color(bg)
        self.version = version
        self._menu: Optional[Dict[str, Any]] = None
        self._elements: List[Dict[str, Any]] = []          # flat mode
        self._pages: List[Dict[str, Any]] = []              # multi-page mode

    # ── pages ────────────────────────────────────────────────────────────
    def page(self, name: str, hidden: bool = False) -> "Widget":
        """Start a new page; subsequent element calls append to it."""
        if len(self._pages) >= MAX_PAGES:
            raise ValueError(f"widget already has the max {MAX_PAGES} pages")
        pg: Dict[str, Any] = {"name": name, "elements": []}
        if hidden:
            pg["hidden"] = True
        self._pages.append(pg)
        return self

    def _target(self) -> List[Dict[str, Any]]:
        """The element list new elements append to (current page, or flat)."""
        if self._pages:
            return self._pages[-1]["elements"]
        return self._elements

    def _total_elements(self) -> int:
        if self._pages:
            return sum(len(pg["elements"]) for pg in self._pages)
        return len(self._elements)

    def _add(self, el: Dict[str, Any]) -> "Widget":
        if self._total_elements() >= MAX_ELEMENTS:
            raise ValueError(f"widget already has the max {MAX_ELEMENTS} elements "
                             "(shared across all pages)")
        self._target().append(el)
        return self

    # ── menu theming ─────────────────────────────────────────────────────
    def menu(self, **fields: Any) -> "Widget":
        """Set the optional top-level ``"menu"`` theming block."""
        m: Dict[str, Any] = {}
        for k, v in fields.items():
            if v is None:
                continue
            m[k] = _color(v) if k in _COLOR_FIELDS else v
        self._menu = m
        return self

    # ── elements ─────────────────────────────────────────────────────────
    def label(self, x: int, y: int, text: Optional[str] = None,
              bind: Optional[str] = None, fmt: Optional[str] = None,
              s: Optional[int] = None, w: Optional[int] = None,
              a: Optional[str] = None, c: Optional[str] = None,
              sym: Optional[str] = None, scroll: Optional[int] = None,
              show: Optional[str] = None, cbind: Optional[str] = None,
              obind: Optional[str] = None, o: Optional[int] = None,
              pulse: Optional[int] = None) -> "Widget":
        return self._add(_elem("label", x, y, text=text, bind=bind, fmt=fmt,
                               s=s, w=w, a=a, c=c, sym=sym, scroll=scroll,
                               show=show, cbind=cbind, obind=obind, o=o,
                               pulse=pulse))

    def bar(self, x: int, y: int, bind: Optional[str] = None,
           w: Optional[int] = None, h: Optional[int] = None,
           c: Optional[str] = None, bgc: Optional[str] = None,
           r: Optional[int] = None, min: Optional[int] = None,
           max: Optional[int] = None, **shared: Any) -> "Widget":
        fields = dict(bind=bind, w=w, h=h, c=c, bgc=bgc, r=r, min=min, max=max)
        fields.update(_pick_shared(shared))
        return self._add(_elem("bar", x, y, **fields))

    def arc(self, x: int, y: int, bind: Optional[str] = None,
           d: Optional[int] = None, w: Optional[int] = None,
           c: Optional[str] = None, bgc: Optional[str] = None,
           min: Optional[int] = None, max: Optional[int] = None,
           fmt: Optional[str] = None, ls: Optional[int] = None,
           lc: Optional[str] = None, a0: Optional[int] = None,
           a1: Optional[int] = None, rot: Optional[int] = None,
           **shared: Any) -> "Widget":
        fields = dict(bind=bind, d=d, w=w, c=c, bgc=bgc, min=min, max=max,
                     fmt=fmt, ls=ls, lc=lc, a0=a0, a1=a1, rot=rot)
        fields.update(_pick_shared(shared))
        return self._add(_elem("arc", x, y, **fields))

    def img(self, x: int, y: int, src: Optional[str] = None,
           bind: Optional[str] = None, **shared: Any) -> "Widget":
        fields = dict(src=src, bind=bind)
        fields.update(_pick_shared(shared))
        return self._add(_elem("img", x, y, **fields))

    def panel(self, x: int, y: int, w: int, h: int, c: Optional[str] = None,
            r: Optional[int] = None, o: Optional[int] = None,
            **shared: Any) -> "Widget":
        fields = dict(w=w, h=h, c=c, r=r, o=o)
        fields.update(_pick_shared(shared))
        return self._add(_elem("panel", x, y, **fields))

    def led(self, x: int, y: int, d: Optional[int] = None,
           c: Optional[str] = None, offc: Optional[str] = None,
           bind: Optional[str] = None, **shared: Any) -> "Widget":
        fields = dict(d=d, c=c, offc=offc, bind=bind)
        fields.update(_pick_shared(shared))
        return self._add(_elem("led", x, y, **fields))

    def line(self, x: int, y: int, w: Optional[int] = None,
            h: Optional[int] = None, c: Optional[str] = None,
            o: Optional[int] = None, r: Optional[int] = None,
            **shared: Any) -> "Widget":
        fields = dict(w=w, h=h, c=c, o=o, r=r)
        fields.update(_pick_shared(shared))
        return self._add(_elem("line", x, y, **fields))

    def qr(self, x: int, y: int, text: Optional[str] = None,
          bind: Optional[str] = None, d: Optional[int] = None,
          c: Optional[str] = None, bgc: Optional[str] = None,
          **shared: Any) -> "Widget":
        fields = dict(text=text, bind=bind, d=d, c=c, bgc=bgc)
        fields.update(_pick_shared(shared))
        return self._add(_elem("qr", x, y, **fields))

    def button(self, id: str, x: int, y: int, text: Optional[str] = None,
              bind: Optional[str] = None, fmt: Optional[str] = None,
              w: Optional[int] = None, h: Optional[int] = None,
              r: Optional[int] = None, c: Optional[str] = None,
              tc: Optional[str] = None, s: Optional[int] = None,
              hold: Optional[int] = None, macro: Optional[str] = None,
              page: Optional[str] = None, **shared: Any) -> "Widget":
        fields = dict(id=id, text=text, bind=bind, fmt=fmt, w=w, h=h, r=r,
                     c=c, tc=tc, s=s, hold=hold, macro=macro, page=page)
        fields.update(_pick_shared(shared))
        return self._add(_elem("btn", x, y, **fields))

    def slider(self, id: str, x: int, y: int, w: Optional[int] = None,
              h: Optional[int] = None, c: Optional[str] = None,
              bgc: Optional[str] = None, kc: Optional[str] = None,
              min: Optional[int] = None, max: Optional[int] = None,
              val: Optional[int] = None, bind: Optional[str] = None,
              macro: Optional[str] = None, **shared: Any) -> "Widget":
        fields = dict(id=id, w=w, h=h, c=c, bgc=bgc, kc=kc, min=min, max=max,
                     val=val, bind=bind, macro=macro)
        fields.update(_pick_shared(shared))
        return self._add(_elem("slider", x, y, **fields))

    def toggle(self, id: str, x: int, y: int, w: Optional[int] = None,
              h: Optional[int] = None, c: Optional[str] = None,
              bgc: Optional[str] = None, bind: Optional[str] = None,
              macro: Optional[str] = None, **shared: Any) -> "Widget":
        fields = dict(id=id, w=w, h=h, c=c, bgc=bgc, bind=bind, macro=macro)
        fields.update(_pick_shared(shared))
        return self._add(_elem("toggle", x, y, **fields))

    # ── output ───────────────────────────────────────────────────────────
    def to_dict(self) -> Dict[str, Any]:
        """Assemble the mixw:1 spec dict (pages or flat elements, never both)."""
        doc: Dict[str, Any] = {"mixw": 1, "name": self.name}
        if self.version is not None:
            doc["version"] = self.version
        if self.bg is not None:
            doc["bg"] = self.bg
        if self._pages:
            doc["pages"] = self._pages
        else:
            doc["elements"] = self._elements
        if self._menu is not None:
            doc["menu"] = self._menu
        return doc

    def to_json(self, indent: int = 2) -> str:
        return json.dumps(self.to_dict(), indent=indent)

    def save(self, path: str) -> str:
        """Write ``widget.json``. ``path`` may be a directory (file appended)
        or a full file path. Returns the path written."""
        if os.path.isdir(path) or not path.lower().endswith(".json"):
            path = os.path.join(path, MANIFEST_NAME)
        os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            f.write(self.to_json())
        return path

    def lint(self, types: Optional[set] = None) -> Tuple[List[str], List[str]]:
        """Post-build lint via :func:`mixlar.validator.lint_widget`."""
        from . import validator
        return validator.lint_widget(self.to_dict(), types)


def _pick_shared(shared: Dict[str, Any]) -> Dict[str, Any]:
    """Filter kwargs down to the recognized shared fields (WIDGETS.md)."""
    return {k: v for k, v in shared.items() if k in _SHARED_KEYS}


def load(path: str) -> Dict[str, Any]:
    """Load a ``widget.json`` file (or a directory containing one) as a dict."""
    if os.path.isdir(path):
        path = os.path.join(path, MANIFEST_NAME)
    with open(path, encoding="utf-8") as f:
        return json.load(f)


__all__ = ["Widget", "load", "MANIFEST_NAME"]
