"""Render a widget.json spec to a PNG preview of the 240×140 panel.

Best-effort, not pixel-perfect: this approximates what ``custom_widget.ino``
would draw on-device, enough to eyeball layout/colors/bindings while
authoring a widget without hardware. Requires Pillow, an optional SDK
dependency (``pip install "mixlar-sdk[render]"``) — everything else in the
emulator works without it.
"""
from __future__ import annotations

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

from ..protocol import CANVAS_H, CANVAS_W

SpecSource = Union[str, Dict[str, Any]]


def _require_pillow():
    try:
        from PIL import Image, ImageDraw, ImageFont  # noqa: F401
    except ImportError as e:  # pragma: no cover - exercised only without Pillow
        raise ImportError(
            'Rendering widget previews needs Pillow. Install it with: '
            'pip install "mixlar-sdk[render]"'
        ) from e
    return Image, ImageDraw, ImageFont


def _hex(color: Optional[str], default: str = "FFFFFF") -> tuple:
    """Parse a bare hex color string (``"1DB954"``) to an ``(r, g, b)`` tuple."""
    s = (color or default).lstrip("#")
    if len(s) != 6:
        s = default
    return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))


def _resolve(spec: SpecSource) -> "tuple[Dict[str, Any], Optional[str]]":
    """Load a spec (dict or path) and its containing folder (for ``img`` src)."""
    if isinstance(spec, dict):
        return spec, None
    import json
    with open(spec, "r", encoding="utf-8") as f:
        data = json.load(f)
    return data, os.path.dirname(os.path.abspath(spec))


def _active_elements(spec: Dict[str, Any], page: Optional[str]) -> List[Dict[str, Any]]:
    """Elements for ``page`` (or the first non-hidden page), else the flat list."""
    pages = spec.get("pages")
    if not pages:
        return spec.get("elements", [])
    if page is not None:
        for p in pages:
            if p.get("name") == page:
                return p.get("elements", [])
    for p in pages:
        if not p.get("hidden"):
            return p.get("elements", [])
    return pages[0].get("elements", []) if pages else []


def _value_of(el: Dict[str, Any], data: Dict[str, Any]) -> Optional[str]:
    bind = el.get("bind")
    if not bind:
        return None
    v = data.get(bind)
    return None if v is None else str(v)


def _fmt_text(el: Dict[str, Any], data: Dict[str, Any]) -> str:
    v = _value_of(el, data)
    if v is None:
        return str(el.get("text", ""))
    fmt = el.get("fmt")
    return fmt.replace("{v}", v) if fmt else v


def _visible(el: Dict[str, Any], data: Dict[str, Any]) -> bool:
    show = el.get("show")
    if not show:
        return True
    v = data.get(show)
    return bool(v) and str(v).strip().lower() not in ("0", "off", "false", "")


def _font(ImageFont, size: int):
    try:
        return ImageFont.truetype("arial.ttf", size)
    except Exception:
        return ImageFont.load_default()


def render_widget(spec: SpecSource, data: Optional[Dict[str, Any]] = None,
                  page: Optional[str] = None, scale: int = 2,
                  spec_dir: Optional[str] = None) -> "Any":
    """Draw ``spec`` (dict or path) to a scaled PIL ``Image`` of the panel.

    ``data`` supplies live ``WDATA`` values for ``bind``/``fmt`` templating and
    ``show`` layer visibility. ``spec_dir`` resolves ``img`` element ``src``
    paths when ``spec`` is a dict (a path's own folder is used automatically).
    Element support is approximate: label, bar, arc, panel, led, line, img, qr
    (placeholder), btn/toggle/slider (box + label). Unknown element types are
    skipped silently, matching firmware behaviour.
    """
    Image, ImageDraw, ImageFont = _require_pillow()
    resolved, resolved_dir = _resolve(spec)
    spec_dir = spec_dir if spec_dir is not None else resolved_dir
    data = data or {}

    w, h = CANVAS_W * scale, CANVAS_H * scale
    bg = _hex(resolved.get("bg"), "16171B")
    img = Image.new("RGB", (w, h), bg)
    draw = ImageDraw.Draw(img)

    for el in _active_elements(resolved, page):
        if not _visible(el, data):
            continue
        try:
            _draw_element(img, draw, el, data, spec_dir, scale, Image, ImageFont)
        except Exception:
            continue  # best-effort: one bad element never kills the preview
    return img


def _draw_element(img, draw, el: Dict[str, Any], data: Dict[str, Any],
                  spec_dir: Optional[str], s: int, Image, ImageFont) -> None:
    t = el.get("t")
    x, y = int(el.get("x", 0)) * s, int(el.get("y", 0)) * s

    if t == "label":
        color = _hex(el.get("c"), "FFFFFF")
        text = _fmt_text(el, data)
        font = _font(ImageFont, int(el.get("s", 14)) * s)
        w = el.get("w")
        align = el.get("a")
        if w and align in ("c", "r"):
            tw = draw.textlength(text, font=font)
            if align == "c":
                x += max(0, (int(w) * s - tw) // 2)
            elif align == "r":
                x += max(0, int(w) * s - tw)
        draw.text((x, y), text, fill=color, font=font)

    elif t == "bar":
        w, h = int(el.get("w", 100)) * s, int(el.get("h", 8)) * s
        bgc, fgc = _hex(el.get("bgc"), "2C2C2E"), _hex(el.get("c"), "FFFFFF")
        r = int(el.get("r", h // 2))
        draw.rounded_rectangle([x, y, x + w, y + h], radius=r, fill=bgc)
        vmin, vmax = float(el.get("min", 0)), float(el.get("max", 100))
        val = _value_of(el, data)
        frac = 0.0
        if val is not None and vmax > vmin:
            try:
                frac = max(0.0, min(1.0, (float(val) - vmin) / (vmax - vmin)))
            except ValueError:
                frac = 0.0
        if w >= h:  # horizontal: fills left-to-right
            fill_w = int(w * frac)
            if fill_w > 0:
                draw.rounded_rectangle([x, y, x + fill_w, y + h], radius=r, fill=fgc)
        else:  # vertical: fills bottom-up
            fill_h = int(h * frac)
            if fill_h > 0:
                draw.rounded_rectangle([x, y + h - fill_h, x + w, y + h],
                                       radius=r, fill=fgc)

    elif t == "arc":
        d = int(el.get("d", 64)) * s
        thickness = max(1, int(el.get("w", 6)) * s)
        bgc, fgc = _hex(el.get("bgc"), "2C2C2E"), _hex(el.get("c"), "FFFFFF")
        a0, a1 = float(el.get("a0", 0)), float(el.get("a1", 360))
        rot = float(el.get("rot", -90))
        bbox = [x, y, x + d, y + d]
        draw.arc(bbox, start=rot + a0, end=rot + a1, fill=bgc, width=thickness)
        vmin, vmax = float(el.get("min", 0)), float(el.get("max", 100))
        val = _value_of(el, data)
        if val is not None and vmax > vmin:
            try:
                frac = max(0.0, min(1.0, (float(val) - vmin) / (vmax - vmin)))
                draw.arc(bbox, start=rot + a0, end=rot + a0 + frac * (a1 - a0),
                         fill=fgc, width=thickness)
            except ValueError:
                pass
        fmt = el.get("fmt")
        if fmt and val is not None:
            ls = int(el.get("ls", 14)) * s
            font = _font(ImageFont, ls)
            text = fmt.replace("{v}", val)
            lc = _hex(el.get("lc"), "FFFFFF")
            tw = draw.textlength(text, font=font)
            draw.text((x + (d - tw) / 2, y + d / 2 - ls / 2), text, fill=lc, font=font)

    elif t == "panel":
        w, h = int(el.get("w", 60)) * s, int(el.get("h", 40)) * s
        r = int(el.get("r", 4)) * s
        c = _hex(el.get("c"), "2C2C2E")
        draw.rounded_rectangle([x, y, x + w, y + h], radius=r, fill=c)

    elif t == "led":
        d = int(el.get("d", 14)) * s
        val = _value_of(el, data)
        on = bool(val) and str(val).strip().lower() not in ("0", "off", "false", "")
        color = _hex(el.get("c"), "30D158") if (on or not el.get("bind")) else \
            _hex(el.get("offc"), "3A3A3C")
        draw.ellipse([x, y, x + d, y + d], fill=color)

    elif t == "line":
        w, h = int(el.get("w", 100)) * s, int(el.get("h", 2)) * s
        c = _hex(el.get("c"), "3A3A3C")
        draw.rectangle([x, y, x + w, y + h], fill=c)

    elif t == "img":
        src = el.get("src")
        if src and spec_dir:
            path = os.path.join(spec_dir, src)
            if os.path.isfile(path):
                sub = Image.open(path).convert("RGBA")
                img.paste(sub, (x, y), sub)
                return
        w, h = 40 * s, 40 * s
        draw.rectangle([x, y, x + w, y + h], outline=_hex(None), width=1)

    elif t == "qr":
        d = int(el.get("d", 90)) * s
        bgc, fgc = _hex(el.get("bgc"), "FFFFFF"), _hex(el.get("c"), "000000")
        draw.rectangle([x, y, x + d, y + d], fill=bgc, outline=fgc, width=max(1, s))
        draw.line([x, y, x + d, y + d], fill=fgc, width=max(1, s))
        draw.line([x + d, y, x, y + d], fill=fgc, width=max(1, s))

    elif t in ("btn", "toggle", "slider"):
        _draw_control(img, draw, el, data, x, y, s, ImageFont)

    # Unknown types: silently skipped (forward-compatible, matches firmware).


def _draw_control(img, draw, el: Dict[str, Any], data: Dict[str, Any],
                  x: int, y: int, s: int, ImageFont) -> None:
    t = el.get("t")
    if t == "btn":
        w, h = int(el.get("w", 80)) * s, int(el.get("h", 30)) * s
        r = int(el.get("r", 8)) * s
        c = _hex(el.get("c"), "3A3A3C")
        tc = _hex(el.get("tc"), "FFFFFF")
        draw.rounded_rectangle([x, y, x + w, y + h], radius=r, fill=c)
        text = _fmt_text(el, data)
        font = _font(ImageFont, int(el.get("s", 14)) * s)
        tw = draw.textlength(text, font=font)
        draw.text((x + (w - tw) / 2, y + h / 2 - 7 * s), text, fill=tc, font=font)
    elif t == "toggle":
        w, h = int(el.get("w", 46)) * s, int(el.get("h", 26)) * s
        val = _value_of(el, data)
        on = bool(val) and str(val).strip().lower() not in ("0", "off", "false", "")
        c = _hex(el.get("c"), "30D158") if on else _hex(el.get("bgc"), "3A3A3C")
        draw.rounded_rectangle([x, y, x + w, y + h], radius=h // 2, fill=c)
        knob_x = x + w - h if on else x
        draw.ellipse([knob_x, y, knob_x + h, y + h], fill=_hex(None, "FFFFFF"))
    elif t == "slider":
        w, h = int(el.get("w", 140)) * s, int(el.get("h", 12)) * s
        bgc = _hex(el.get("bgc"), "2C2C2E")
        fgc = _hex(el.get("c"), "1DB954")
        draw.rounded_rectangle([x, y, x + w, y + h], radius=h // 2, fill=bgc)
        vmin, vmax = float(el.get("min", 0)), float(el.get("max", 100))
        val = _value_of(el, data)
        if val is None:
            val = el.get("val", vmin)
        try:
            frac = max(0.0, min(1.0, (float(val) - vmin) / (vmax - vmin))) \
                if vmax > vmin else 0.0
        except (TypeError, ValueError):
            frac = 0.0
        fill_w = int(w * frac)
        if fill_w > 0:
            draw.rounded_rectangle([x, y, x + fill_w, y + h], radius=h // 2, fill=fgc)
        kc = _hex(el.get("kc"), "FFFFFF")
        kx = x + fill_w
        draw.ellipse([kx - h // 2, y - h // 4, kx + h // 2, y + h + h // 4], fill=kc)


def save_preview(spec: SpecSource, out_path: str,
                 data: Optional[Dict[str, Any]] = None,
                 spec_dir: Optional[str] = None,
                 page: Optional[str] = None) -> str:
    """Render ``spec`` and save it as a PNG at ``out_path``. Returns ``out_path``.

    ``spec_dir`` overrides the folder used to resolve ``img`` element ``src``
    paths when ``spec`` is a dict rather than a file path.
    """
    img = render_widget(spec, data=data, page=page, spec_dir=spec_dir)
    img.save(out_path)
    return out_path


__all__ = ["render_widget", "save_preview"]
