#!/usr/bin/env python3
"""
Windows tray status widget — TEMPLATE.

A tiny system-tray icon that polls a remote API and draws a live number onto
its own icon. Hardened against the two failure modes that each cost a debugging
round-trip on the original build:
  1. Windows tray tooltips are HARD-capped at 128 chars (Shell_NotifyIcon).
  2. Any exception escaping the background poll thread silently kills the icon.

HOW TO USE: copy this file, edit the CONFIG block + fetch_metric() + the
label/tooltip text for your metric, then run `python widget.py` on the Windows
machine. Deps: pip install pystray pillow requests

The reference deployment (Hermes spend counter) fetches from
GET {BASE_URL}/api/analytics/usage?days=N with bearer auth — the fetch functions
below already implement that shape; replace them for any other metric.
"""
import os
import sys
import threading
import datetime as dt
from pathlib import Path

# ---- CONFIG (edit these) ----------------------------------------------------
BASE_URL = os.environ.get("WIDGET_BASE_URL", "https://example.com")
TOKEN_FILE = Path(os.environ.get("WIDGET_TOKEN_FILE", Path.home() / ".widget_token"))
POLL_SECONDS = int(os.environ.get("WIDGET_POLL_SECONDS", "30"))

# Thresholds driving the icon tint (units of your metric).
GREEN_BELOW = 5.0
AMBER_BELOW = 20.0    # >= this -> red
# -----------------------------------------------------------------------------

try:
    import requests
except ImportError:
    sys.exit("Missing dependency: requests  ->  pip install requests")
try:
    import pystray
    from pystray import MenuItem as Item
except ImportError:
    sys.exit("Missing dependency: pystray  ->  pip install pystray")
try:
    from PIL import Image, ImageDraw, ImageFont
except ImportError:
    sys.exit("Missing dependency: pillow  ->  pip install pillow")


def load_token() -> str:
    try:
        tok = TOKEN_FILE.read_text(encoding="utf-8").strip()
        if not tok:
            raise ValueError("token file is empty")
        return tok
    except FileNotFoundError:
        sys.exit(f"Token file not found: {TOKEN_FILE}\nCreate it, paste ONLY the token inside.")
    except Exception as e:  # noqa: BLE001
        sys.exit(f"Could not read token file {TOKEN_FILE}: {e}")


# ---- METRIC-SPECIFIC CODE (replace for your metric) --------------------------
def fetch_metric(token: str) -> dict:
    """Return {'primary': float, 'secondary': float, 'detail': [(name, val), ...]}.

    Reference implementation: Hermes spend (today / this-week / by-model).
    """
    def get(days):
        r = requests.get(
            f"{BASE_URL}/api/analytics/usage",
            params={"days": days},
            headers={"Authorization": f"Bearer {token}"},
            timeout=15,
        )
        r.raise_for_status()
        return r.json()

    today = dt.date.today()
    week_days = today.weekday() + 1  # Monday-start week
    week = get(week_days)
    today_str = today.isoformat()
    today_val = next(
        (float(r.get("estimated_cost") or 0) for r in week.get("daily", []) if r.get("day") == today_str),
        0.0,
    )
    week_val = float(week.get("totals", {}).get("total_estimated_cost") or 0)
    detail = sorted(
        ((m.get("model", "?"), float(m.get("estimated_cost") or 0)) for m in week.get("by_model", [])),
        key=lambda t: -t[1],
    )
    return {
        "primary": today_val,      # the number drawn on the icon
        "secondary": week_val,     # shown in the tooltip
        "detail": detail,          # shown in the right-click menu
        "fetched_at": dt.datetime.now().strftime("%H:%M:%S"),
        "ok": True,
        "error": None,
    }


def label_for(primary: float) -> str:
    """The short text drawn on the icon. Keep it <= ~4 chars at icon size."""
    return f"${primary:.0f}" if primary >= 10 else f"${primary:.1f}"


def tooltip_for(s: dict) -> str:
    # 128-CHAR HARD CAP — keep it short, truncate defensively.
    return f"Today ${s['primary']:.2f} | Week ${s['secondary']:.2f}\nHermes spend, {s['fetched_at']}"[:127]


def detail_line_for(s: dict) -> str:
    # Menu text has no length cap — breakdown lives here, not in the tooltip.
    parts = [f"{n.split('/')[-1]} ${c:.2f}" for n, c in s["detail"][:3]]
    return " | ".join(parts) if parts else "no detail"
# ------------------------------------------------------------------------------


def tint_for(amount: float):
    if amount < GREEN_BELOW:
        return (34, 139, 34)      # green
    if amount < AMBER_BELOW:
        return (200, 150, 20)     # amber
    return (190, 40, 40)          # red


def make_icon(amount: float) -> Image.Image:
    """64x64 rounded-square with the metric drawn on it. Called inside a guarded
    repaint — a font hiccup here must never reach the poll thread."""
    size = 64
    img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    d = ImageDraw.Draw(img)
    d.rounded_rectangle([2, 2, size - 3, size - 3], radius=14, fill=tint_for(amount))

    label = label_for(amount)
    font = None
    for fname in ("arialbd.ttf", "arial.ttf", "segoeui.ttf", "DejaVuSans-Bold.ttf"):
        try:
            font = ImageFont.truetype(fname, 30 if len(label) <= 4 else 24)
            break
        except Exception:  # noqa: BLE001
            continue
    if font is None:
        font = ImageFont.load_default()

    try:
        bbox = d.textbbox((0, 0), label, font=font)
        w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
    except Exception:  # noqa: BLE001
        w, h = d.textsize(label, font=font)  # older Pillow
    d.text(((size - w) / 2, (size - h) / 2 - 2), label, fill=(255, 255, 255, 255), font=font)
    return img


class TrayWidget:
    def __init__(self):
        self.token = load_token()
        self.state = {"primary": 0.0, "secondary": 0.0, "detail": [],
                      "fetched_at": "—", "ok": False, "error": "starting…"}
        self.icon = pystray.Icon("tray_widget", make_icon(0.0), "status", menu=self._menu())
        self._stop = threading.Event()

    def _menu(self):
        return pystray.Menu(
            Item("Refresh now", self._on_refresh, default=True),
            Item("Open dashboard", self._on_open),
            pystray.Menu.SEPARATOR,
            Item(lambda icon, item: detail_line_for(self.state) if self.state["ok"]
                 else f"error: {str(self.state['error'])[:40]}", None, enabled=False),
            pystray.Menu.SEPARATOR,
            Item("Quit", self._on_quit),
        )

    def _on_refresh(self, icon=None, item=None):
        self.refresh()

    def _on_open(self, icon=None, item=None):
        import webbrowser
        webbrowser.open(BASE_URL)

    def _on_quit(self, icon=None, item=None):
        self._stop.set()
        self.icon.stop()

    def refresh(self):
        try:
            self.state = fetch_metric(self.token)
        except requests.HTTPError as e:
            code = getattr(e.response, "status_code", "?")
            self.state["ok"] = False
            self.state["error"] = f"HTTP {code} (token expired?)"
        except Exception as e:  # noqa: BLE001
            self.state["ok"] = False
            self.state["error"] = f"{type(e).__name__}: {e}"
        self._repaint()

    def _repaint(self):
        s = self.state
        try:
            self.icon.icon = make_icon(s["primary"] if s["ok"] else 0.0)
            self.icon.title = (tooltip_for(s) if s["ok"]
                               else f"ERR: {str(s['error'])[:90]}")[:127]
            # NO icon.update_menu() here — pystray-win32 can throw when the menu is
            # poked from a background thread; menu text refreshes when it's opened.
        except Exception:
            pass  # never let a paint error propagate into the poll thread

    def _poll_loop(self):
        # UNKILLABLE: any exception escaping this thread detaches the icon and
        # Windows sweeps it (the "was there, now gone" symptom) with no traceback.
        while not self._stop.is_set():
            try:
                self.refresh()
            except Exception as e:  # noqa: BLE001
                try:
                    self.state["ok"] = False
                    self.state["error"] = f"{type(e).__name__}: {e}"
                    self._repaint()
                except Exception:
                    pass
            self._stop.wait(POLL_SECONDS)

    def run(self):
        threading.Thread(target=self._poll_loop, daemon=True).start()
        self.icon.run()


if __name__ == "__main__":
    import traceback
    # Fatal errors land in a log file so a silent disappearance leaves evidence.
    LOG = Path.home() / ".tray_widget.log"
    try:
        TrayWidget().run()
    except Exception:
        try:
            LOG.write_text(traceback.format_exc(), encoding="utf-8")
        except Exception:
            pass
        raise
