#!/usr/bin/env python3
"""Render 400x300 dashboard cards for the RLCD-4.2 Muse Gadget.

Cards: weather (Sadao), btc, node (public chain state), camera (C200C
snapshot dithered). Output JPEGs land in ./out/. Data comes from keyless
public APIs; the last good data is cached in ./cache.json so a network
failure still renders a card (marked with its age).

Text is drawn with two fonts: this VM's NotoSansThai is a Thai-only
subset (no Latin/digits), so non-Thai runs use DejaVu Sans, chosen per
character by actual cmap coverage.
"""
import json
import os
import sys
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path

from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw, ImageFont

HERE = Path(__file__).parent
OUT = Path(os.environ.get("RLCD_OUT", HERE / "out"))
CACHE = HERE / "cache.json"
W, H = 400, 300

THAI_TTF = {False: "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
            True: "/usr/share/fonts/truetype/noto/NotoSansThai-Bold.ttf"}
LATIN_TTF = {False: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
             True: "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"}
_CMAPS = {p: TTFont(p).getBestCmap() for p in set(THAI_TTF.values()) | set(LATIN_TTF.values())}
_FONTS = {}


def _font(path, size):
    key = (path, size)
    if key not in _FONTS:
        _FONTS[key] = ImageFont.truetype(path, size)
    return _FONTS[key]


def _pick(ch, bold):
    """Font path for one character: Thai font if it covers it, else Latin."""
    thai = THAI_TTF[bold]
    if ord(ch) in _CMAPS[thai]:
        return thai
    latin = LATIN_TTF[bold]
    if ord(ch) in _CMAPS[latin]:
        return latin
    return thai


def T(d, x, y, text, size, bold=False, fill=0):
    """Draw mixed Thai/Latin text, switching fonts per run."""
    runs = []
    for ch in text:
        p = _pick(ch, bold)
        if runs and runs[-1][0] == p:
            runs[-1][1] += ch
        else:
            runs.append([p, ch])
    for path, run in runs:
        f = _font(path, size)
        d.text((x, y), run, font=f, fill=fill)
        x += d.textlength(run, font=f)
    return x


def fetch_json(url, timeout=15):
    req = urllib.request.Request(url, headers={"User-Agent": "sunny-cards/1.0"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))


def load_cache():
    try:
        return json.loads(CACHE.read_text())
    except Exception:
        return {}


def save_cache(c):
    CACHE.write_text(json.dumps(c, ensure_ascii=False))


def base_card(title, stale_s=None):
    img = Image.new("L", (W, H), 255)
    d = ImageDraw.Draw(img)
    d.rectangle([0, 0, W, 44], fill=0)
    T(d, 14, 8, title, 26, True, 255)
    now = datetime.now().strftime("%H:%M")
    label = f"อัปเดต {now}" + (f" (ข้อมูลเก่า {stale_s // 60} นาที)" if stale_s else "")
    T(d, 14, H - 30, label, 17)
    return img, d


def save(img, name):
    OUT.mkdir(exist_ok=True)
    # RGB, not grayscale: the gadget's JPEG decoder rejects 1-component
    # (grayscale) JPEGs as "unsupported".
    img.convert("RGB").save(OUT / f"{name}.jpg", quality=88)


def error_card(title):
    img, d = base_card(title)
    T(d, 14, 120, "ดึงข้อมูลไม่ได้", 30, True)
    return img


WMO_TH = {
    0: "ท้องฟ้าแจ่มใส", 1: "ส่วนใหญ่แจ่มใส", 2: "เมฆบางส่วน", 3: "เมฆมาก",
    45: "มีหมอก", 48: "หมอกน้ำค้างแข็ง", 51: "ฝนปรอยเบา", 53: "ฝนปรอย",
    55: "ฝนปรอยหนัก", 61: "ฝนเบา", 63: "ฝนปานกลาง", 65: "ฝนหนัก",
    71: "หิมะเบา", 73: "หิมะ", 75: "หิมะหนัก", 80: "ฝนซู่เบา",
    81: "ฝนซู่", 82: "ฝนซู่หนัก", 95: "ฝนฟ้าคะนอง", 96: "ฝนฟ้าคะนอง+ลูกเห็บ",
    99: "ฝนฟ้าคะนอง+ลูกเห็บ",
}


def card_weather(cache):
    url = ("https://api.open-meteo.com/v1/forecast?latitude=6.637"
           "&longitude=100.420&current=temperature_2m,relative_humidity_2m,"
           "weather_code&daily=temperature_2m_max,temperature_2m_min,"
           "precipitation_probability_max&timezone=Asia%2FBangkok")
    data, stale = None, None
    try:
        data = fetch_json(url)
        cache["weather"] = {"t": datetime.now().timestamp(), "data": data}
        save_cache(cache)
    except Exception:
        old = cache.get("weather")
        if old:
            data = old["data"]
            stale = int(datetime.now().timestamp() - old["t"])
    if not data:
        return error_card("อากาศ — สะเดา")
    cur = data["current"]
    day = data["daily"]
    img, d = base_card("อากาศ — สะเดา", stale)
    endx = T(d, 14, 66, f"{cur['temperature_2m']:.0f}°C", 56, True)
    T(d, endx + 14, 88, WMO_TH.get(cur["weather_code"], ""), 26)
    T(d, 14, 150, f"วันนี้ {day['temperature_2m_min'][0]:.0f}–{day['temperature_2m_max'][0]:.0f}°C", 26)
    prob = day["precipitation_probability_max"][0]
    T(d, 14, 192, f"โอกาสฝน {prob if prob is not None else '-'}%  ชื้น {cur['relative_humidity_2m']:.0f}%", 26)
    return img


def card_btc(cache):
    data, stale = None, None
    try:
        prices = fetch_json("https://mempool.space/api/v1/prices")
        height = fetch_json("https://mempool.space/api/blocks/tip/height")
        rate = None
        fx = cache.get("fx") or {}
        if fx.get("rate") and datetime.now().timestamp() - fx.get("t", 0) < 6 * 3600:
            rate = fx["rate"]
        else:
            try:
                rate = fetch_json("https://open.er-api.com/v6/latest/USD")["rates"]["THB"]
                cache["fx"] = {"t": datetime.now().timestamp(), "rate": rate}
            except Exception:
                rate = fx.get("rate")
        data = {"usd": prices["USD"], "height": height,
                "thb": round(prices["USD"] * rate) if rate else None}
        cache["btc"] = {"t": datetime.now().timestamp(), "data": data}
        save_cache(cache)
    except Exception:
        old = cache.get("btc")
        if old:
            data = old["data"]
            stale = int(datetime.now().timestamp() - old["t"])
    if not data:
        return error_card("Bitcoin")
    img, d = base_card("Bitcoin", stale)
    T(d, 14, 66, f"${data['usd']:,}", 50, True)
    if data.get("thb"):
        T(d, 14, 140, f"฿{data['thb']:,}", 36)
    T(d, 14, 198, f"บล็อก #{data['height']:,}", 26)
    return img


def card_node(cache):
    data, stale = None, None
    try:
        height = fetch_json("https://mempool.space/api/blocks/tip/height")
        mempool = fetch_json("https://mempool.space/api/mempool")
        fees = fetch_json("https://mempool.space/api/v1/fees/recommended")
        data = {"height": height, "count": mempool["count"],
                "vsize": mempool["vsize"], "fast": fees["fastestFee"],
                "half": fees["halfHourFee"]}
        cache["node"] = {"t": datetime.now().timestamp(), "data": data}
        save_cache(cache)
    except Exception:
        old = cache.get("node")
        if old:
            data = old["data"]
            stale = int(datetime.now().timestamp() - old["t"])
    if not data:
        return error_card("เครือข่าย Bitcoin")
    img, d = base_card("เครือข่าย Bitcoin", stale)
    T(d, 14, 62, f"บล็อกล่าสุด #{data['height']:,}", 32, True)
    T(d, 14, 118, f"รอใน mempool {data['count']:,} รายการ", 26)
    T(d, 14, 160, f"ค่าธรรมเนียมด่วน {data['fast']} sat/vB", 26)
    T(d, 14, 202, f"ธรรมดา ~30 นาที {data['half']} sat/vB", 26)
    return img


def pm25_band(v):
    """Thai PCD AQI band label for a PM2.5 value in µg/m³."""
    if v <= 25:
        return "ดีมาก"
    if v <= 37:
        return "ดี"
    if v <= 50:
        return "ปานกลาง"
    if v <= 90:
        return "เริ่มมีผลต่อสุขภาพ"
    return "มีผลต่อสุขภาพ"


def card_pm25(cache):
    data, stale = None, None
    url = os.environ.get("HA_URL")
    token = os.environ.get("HA_TOKEN")
    if url and token:
        try:
            req = urllib.request.Request(
                url + "/api/states",
                headers={"User-Agent": "sunny-cards/1.0",
                         "Authorization": f"Bearer {token}"})
            with urllib.request.urlopen(req, timeout=15) as r:
                states = {s["entity_id"]: s["state"]
                          for s in json.loads(r.read().decode("utf-8"))}
            data = {
                "pm25": float(states["sensor.o_1pst_pm2_5"]),
                "pm1": float(states["sensor.o_1pst_pm1"]),
                "pm10": float(states["sensor.o_1pst_pm10"]),
                "co2": float(states["sensor.o_1pst_carbon_dioxide"]),
                "temp": float(states["sensor.o_1pst_temperature"]),
            }
            cache["pm25"] = {"t": datetime.now().timestamp(), "data": data}
            save_cache(cache)
        except Exception:
            data = None
    if data is None:
        old = cache.get("pm25")
        if old:
            data = old["data"]
            stale = int(datetime.now().timestamp() - old["t"])
    if not data:
        return error_card("ฝุ่น PM2.5")
    img, d = base_card("ฝุ่น PM2.5 — AirGradient", stale)
    endx = T(d, 14, 62, f"{data['pm25']:.1f}", 60, True)
    T(d, endx + 10, 96, "µg/m³", 24)
    T(d, 14, 142, f"คุณภาพอากาศ: {pm25_band(data['pm25'])}", 28, True)
    T(d, 14, 186, f"PM1 {data['pm1']:.1f}  PM10 {data['pm10']:.1f} µg/m³", 24)
    T(d, 14, 222, f"CO2 {data['co2']:.0f} ppm  อุณหภูมิ {data['temp']:.1f}°C", 24)
    return img


_MEASURE = ImageDraw.Draw(Image.new("L", (8, 8)))


def _measure(text, size, bold=False):
    """Width of `text` in px, measured with the same per-char fonts as T()."""
    total = 0.0
    runs = []
    for ch in text:
        p = _pick(ch, bold)
        if runs and runs[-1][0] == p:
            runs[-1][1] += ch
        else:
            runs.append([p, ch])
    for path, run in runs:
        total += _MEASURE.textlength(run, font=_font(path, size))
    return total


def _wrap(text, size, max_w, max_lines, bold=False):
    """Greedy wrap by measured width; prefer breaking at spaces (Thai
    headlines often have none, so fall back to character breaks)."""
    words = text.split(" ")
    lines, cur = [], ""
    for word in words:
        cand = word if not cur else cur + " " + word
        if _measure(cand, size, bold) <= max_w:
            cur = cand
            continue
        if cur:
            lines.append(cur)
            cur = ""
        while _measure(word, size, bold) > max_w and word:
            cut = len(word)
            while cut > 1 and _measure(word[:cut], size, bold) > max_w:
                cut -= 1
            lines.append(word[:cut])
            word = word[cut:]
        cur = word
        if len(lines) >= max_lines:
            break
    if cur and len(lines) < max_lines:
        lines.append(cur)
    lines = lines[:max_lines]
    if lines and (len(lines) == max_lines and
                  _measure(text, size, bold) > sum(_measure(ln, size, bold) for ln in lines) + max_w * 0.3):
        last = lines[-1]
        while last and _measure(last + "…", size, bold) > max_w:
            last = last[:-1]
        lines[-1] = last + "…"
    return lines


def _headlines(query, n):
    url = ("https://news.google.com/rss/search?q=" + urllib.parse.quote(query)
           + "&hl=th&gl=TH&ceid=TH:th")
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 sunny-cards/1.0"})
    with urllib.request.urlopen(req, timeout=15) as r:
        root = ET.fromstring(r.read())
    out = []
    for item in root.iter("item"):
        t = (item.findtext("title") or "").strip()
        if " - " in t:
            t = t.rsplit(" - ", 1)[0].strip()
        if t and t not in out:
            out.append(t)
        if len(out) >= n:
            break
    return out


def card_news(cache):
    data, stale = None, None
    try:
        data = {"ai": _headlines("ปัญญาประดิษฐ์", 4),
                "sport": _headlines("กีฬา", 4)}
        if not data["ai"] and not data["sport"]:
            raise ValueError("empty feed")
        cache["news"] = {"t": datetime.now().timestamp(), "data": data}
        save_cache(cache)
    except Exception:
        old = cache.get("news")
        if old:
            data = old["data"]
            stale = int(datetime.now().timestamp() - old["t"])
    if not data:
        return error_card("ข่าววันนี้")
    img = Image.new("L", (W, H), 255)
    d = ImageDraw.Draw(img)
    d.rectangle([0, 0, W, 44], fill=0)
    T(d, 14, 8, "ข่าววันนี้", 26, True, 255)
    now = datetime.now().strftime("%H:%M") + (" (เก่า)" if stale else "")
    T(d, W - 14 - _measure(now, 18), 13, now, 18, False, 255)
    y = 54
    for label, key in (("AI", "ai"), ("กีฬา", "sport")):
        T(d, 14, y, label, 20, True)
        y += 28
        for headline in data[key][:2]:
            for line in _wrap(headline, 17, W - 28, 2):
                T(d, 14, y, line, 17)
                y += 21
            y += 5
    return img


def card_camera(snap_path):
    img = Image.new("L", (W, H), 255)
    d = ImageDraw.Draw(img)
    d.rectangle([0, 0, W, 44], fill=0)
    T(d, 14, 8, "กล้อง C200C — บ้าน", 26, True, 255)
    if snap_path and Path(snap_path).exists():
        snap = Image.open(snap_path).convert("L")
        # Cover-fit into the area below the header, then dither for the
        # 1-bit reflective panel.
        area_w, area_h = W, H - 44
        scale = max(area_w / snap.width, area_h / snap.height)
        snap = snap.resize((int(snap.width * scale) + 1, int(snap.height * scale) + 1))
        x = (snap.width - area_w) // 2
        y = (snap.height - area_h) // 2
        snap = snap.crop((x, y, x + area_w, y + area_h))
        snap = snap.convert("1", dither=Image.Dither.FLOYDSTEINBERG).convert("L")
        img.paste(snap, (0, 44))
    else:
        T(d, 14, 140, "ยังไม่มีภาพ", 30, True)
    return img


def main():
    which = sys.argv[1] if len(sys.argv) > 1 else "all"
    cache = load_cache()
    jobs = {"weather": lambda: card_weather(cache),
            "btc": lambda: card_btc(cache),
            "node": lambda: card_node(cache),
            "pm25": lambda: card_pm25(cache),
            "news": lambda: card_news(cache),
            "camera": lambda: card_camera(HERE / "snap.jpg")}
    for name, fn in jobs.items():
        if which in ("all", name):
            save(fn(), name)
            print(f"{name}: ok")


if __name__ == "__main__":
    main()
