"""
macOS menu bar icon for Terminull.

Runs on the main thread (macOS requirement for AppKit).
The asyncio bridge runs in a daemon thread; it pushes status updates
through a thread-safe queue that a rumps timer drains.

All labels are localized to the macOS language via `menu_i18n` (with a persisted override,
picked from the "Language" submenu).

Menu layout:
  ◉ (status icon, + circular device-count badge)
  Terminull vX          ▸    ← version header + View Logs · Restart Bridge · Check for Updates
  ● N connected devices
     N active sessions
  ─────────────────
  Relay: connected
  Agents                ▸    ← submenu of detected adapters
  ─────────────────
  ＋ Add device by QR (pairing)   ← pairing section …
  Paired Devices        ▸    ← … one entry per active pairing (→ last-used + "Drop This Device")
  ─────────────────
  Voice & AI Models     ▸    ← overall model download progress + per-model status/loaded names
  Start at Login  ✓
  Keep Mac Awake        ▸
  LAN-only (no relay)
  Terminal Access
  Language              ▸    ← System + supported macOS languages + English (fallback)
  ─────────────────
  Quit
"""
from __future__ import annotations
import logging
import os
import queue
import re
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, field
from typing import Callable

import rumps

from . import launchagent
from . import menu_i18n as i18n
from terminull_bridge import config as cfg
from terminull_bridge.session_store import SessionStore

log = logging.getLogger(__name__)

# ── Status update messages sent from the bridge thread ──────────────────────

@dataclass
class RelayStatus:
    connected: bool

@dataclass
class AdapterStatus:
    agent_id: str
    name: str
    state: str  # "connected" | "ready" | "error" | "not_found"
    detail: str = ""

@dataclass
class QRData:
    pair_url: str
    auto_show: bool = False  # first-run: proactively open the QR + welcome, don't wait for a menu click

@dataclass
class UpdateAvailable:
    version: str
    url: str           # pip wheel (venv / Linux builds)
    mac_url: str = ""  # zip of the notarized Beacon.app (self-contained macOS builds)

@dataclass
class UpdateProgress:
    state: str   # "checking" | "downloading" | "done" | "error"
    detail: str = ""

@dataclass
class SessionCount:
    active: int             # number of currently running agent sessions
    connections: int = 0    # number of distinct connected client devices (LAN/relay)

@dataclass
class AuthExpired:
    pass  # relay closed with 4003 — user must re-authenticate

@dataclass
class WhisperStatus:
    loaded: bool
    state: str = ""       # "" idle | "downloading" | "loading" (transient, from load_status)
    pct: float = 0.0      # download fraction 0..1 when known (state == "downloading")

@dataclass
class SummarizerStatus:
    enabled: bool         # on-device model available (model_name != "none")
    model_loaded: bool    # model currently in RAM
    model_name: str       # e.g. "mlx-community/Qwen2.5-3B-Instruct-4bit"
    state: str = ""       # "" idle | "downloading" | "loading" (transient, from load_status)
    pct: float = 0.0      # download fraction 0..1 when known (state == "downloading")

StatusMessage = RelayStatus | AdapterStatus | QRData | UpdateAvailable | UpdateProgress | SessionCount | AuthExpired | WhisperStatus | SummarizerStatus


def _load_hint(state: str, pct: float, *, loaded: bool, idle_label: str) -> str:
    """Menu-item suffix for a model's status. A transient `load_status` phase
    ("downloading"/"loading") wins over the resident loaded/idle readout."""
    if state == "downloading":
        return f" — downloading… {int(pct * 100)}%" if pct > 0 else " — downloading…"
    if state == "loading":
        return " — loading…"
    return f" — {'loaded' if loaded else idle_label}"


def _load_hint_l10n(state: str, pct: float, *, loaded: bool, idle_key: str) -> str:
    """Localized variant of `_load_hint` — status words go through menu_i18n. `idle_key`
    is the i18n key used when the model isn't loaded (e.g. 'st_not_loaded' / 'st_idle')."""
    if state == "downloading":
        if pct > 0:
            return f" — {i18n.t('st_downloading_pct', pct=int(pct * 100))}"
        return f" — {i18n.t('st_downloading')}"
    if state == "loading":
        return f" — {i18n.t('st_loading')}"
    return f" — {i18n.t('st_loaded' if loaded else idle_key)}"

# ── Icons ────────────────────────────────────────────────────────────────────

_ICON_OK    = "◉"
_ICON_WARN  = "◎"
_ICON_OFF   = "○"

# SF-Symbol icons for the top-level menu rows. Template images so they auto-adapt to the
# macOS menu appearance (light/dark, highlight). Cached per symbol name — _rebuild_menu
# runs on every change, so we must not re-decode an NSImage each pass.
_SF_ICON_CACHE: dict = {}


def _sf_icon(symbol: str):
    """Return a 16×16 template NSImage for an SF Symbol name, or None if unavailable
    (pre-Big-Sur, missing symbol, or no AppKit). Cached; never raises."""
    if symbol in _SF_ICON_CACHE:
        return _SF_ICON_CACHE[symbol]
    img = None
    try:
        from AppKit import NSImage
        from Foundation import NSMakeSize
        img = NSImage.imageWithSystemSymbolName_accessibilityDescription_(symbol, None)
        if img is not None:
            img.setTemplate_(True)
            img.setSize_(NSMakeSize(16, 16))
    except Exception:
        img = None
    _SF_ICON_CACHE[symbol] = img
    return img


def _ic(item, symbol: str):
    """Attach an SF-Symbol icon to a rumps.MenuItem and return it (for inline use in the
    menu list). No-op on rumps.separator or if the symbol can't be resolved."""
    try:
        img = _sf_icon(symbol)
        if img is not None and getattr(item, "_menuitem", None) is not None:
            item._menuitem.setImage_(img)
    except Exception:
        pass
    return item

# Multi-state brand mark for the menu bar: the Terminull "t_" mark with a
# state-tinted underscore — green = relay connected (online), amber = warning /
# connecting (auth expired / update pending / adapters-only), grey = offline.
# Coloured (non-template) so the state reads at a glance. The connected-device
# COUNT rides as a purple badge composited onto the mark at runtime (see
# `_status_icon_path`). Falls back to the text glyphs (_ICON_OK/WARN/OFF) if the
# base PNGs are missing. Regenerate the bases with platform/render_menubar_icons.swift.
_RES_DIR = os.path.join(os.path.dirname(__file__), "resources")
_MENUBAR_BASE = {
    "ok":   os.path.join(_RES_DIR, "menubar-online.png"),
    "warn": os.path.join(_RES_DIR, "menubar-connecting.png"),
    "off":  os.path.join(_RES_DIR, "menubar-offline.png"),
}
_DOT_GREEN  = "● "
_DOT_RED    = "○ "

# Runtime cache for count-badged icon variants (base mark + count pill),
# composited lazily per (state, count) into a temp dir. Content is deterministic,
# so a cached file is safely reused across runs. `_BADGE_VER` is baked into the
# cache filename so a badge-style change (colour/shape) never reuses a stale file.
_BADGE_CACHE_DIR = os.path.join(tempfile.gettempdir(), "terminull-menubar")
_BADGE_VER = 3                      # 1: purple pill/white digit · 2: white pill/black digit · 3: bigger digit + ring
_badge_cache: dict[tuple[str, int], str] = {}


def _status_icon_path(state: str, count: int) -> str:
    """Resolve the menu-bar icon for (state, connected-device count): the plain
    state mark for count < 1, else the mark with a purple count badge."""
    base = _MENUBAR_BASE[state]
    if count < 1:
        return base
    key = (state, count)
    hit = _badge_cache.get(key)
    if hit and os.path.exists(hit):
        return hit
    composed = _compose_status_icon(base, count, state)
    if composed:
        _badge_cache[key] = composed
        return composed
    return base   # AppKit compositing unavailable → plain state mark


def _compose_status_icon(base_path: str, count: int, state: str) -> str | None:
    """Draw the purple connected-count badge (top-right pill + white digits) onto
    the state mark. Returns the composited PNG path, or None if AppKit drawing
    isn't available. Geometry mirrors the design SVGs (36-unit space, ×2 → 72px)."""
    try:
        from AppKit import (NSImage, NSBitmapImageRep, NSGraphicsContext, NSColor,
                            NSBezierPath, NSFont, NSMutableParagraphStyle,
                            NSDeviceRGBColorSpace, NSCompositeSourceOver, NSPNGFileType,
                            NSForegroundColorAttributeName, NSFontAttributeName,
                            NSParagraphStyleAttributeName, NSCenterTextAlignment)
        from Foundation import NSMakeRect, NSString, NSZeroRect
    except Exception:
        return None
    try:
        PX = 72
        S = PX / 36.0                      # svg 36-unit space → px
        base = NSImage.alloc().initWithContentsOfFile_(base_path)
        if base is None:
            return None
        rep = NSBitmapImageRep.alloc().initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bytesPerRow_bitsPerPixel_(
            None, PX, PX, 8, 4, True, False, NSDeviceRGBColorSpace, 0, 0)
        ctx = NSGraphicsContext.graphicsContextWithBitmapImageRep_(rep)
        NSGraphicsContext.saveGraphicsState()
        NSGraphicsContext.setCurrentContext_(ctx)
        base.drawInRect_fromRect_operation_fraction_(
            NSMakeRect(0, 0, PX, PX), NSZeroRect, NSCompositeSourceOver, 1.0)
        label = str(count) if count < 100 else "99+"
        n = len(label)
        h = 18.4 * S                       # badge slightly larger (was 16.56) for a bigger digit
        r = 9.2 * S                        # r = h/2 → a true circle for a single digit
        w = (18.4 + (n - 1) * 7.6) * S     # 1 digit → circle; grows leftward per digit
        margin = 1.4 * S                   # top-right inset so the ring stroke never clips the edge
        x = PX - margin - w
        y = PX - margin - h
        pill = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
            NSMakeRect(x, y, w, h), r, r)
        NSColor.colorWithSRGBRed_green_blue_alpha_(245 / 255.0, 245 / 255.0, 247 / 255.0, 1.0).set()  # #F5F5F7 white
        pill.fill()
        # Ring around the badge so the circle reads clearly against any menu-bar background.
        NSColor.colorWithSRGBRed_green_blue_alpha_(29 / 255.0, 24 / 255.0, 41 / 255.0, 1.0).set()  # dark ink #1D1829
        pill.setLineWidth_(1.6 * S)
        pill.stroke()
        font = NSFont.systemFontOfSize_(13.4 * S)   # bigger, REGULAR weight (was semibold)
        para = NSMutableParagraphStyle.alloc().init()
        para.setAlignment_(NSCenterTextAlignment)
        attrs = {NSForegroundColorAttributeName: NSColor.blackColor(),
                 NSFontAttributeName: font,
                 NSParagraphStyleAttributeName: para}
        ns = NSString.stringWithString_(label)
        tsize = ns.sizeWithAttributes_(attrs)
        ns.drawInRect_withAttributes_(
            NSMakeRect(x + (w - tsize.width) / 2.0, y + (h - tsize.height) / 2.0,
                       tsize.width, tsize.height), attrs)
        NSGraphicsContext.restoreGraphicsState()
        png = rep.representationUsingType_properties_(NSPNGFileType, {})
        os.makedirs(_BADGE_CACHE_DIR, exist_ok=True)
        out = os.path.join(_BADGE_CACHE_DIR, f"{state}-{count}-v{_BADGE_VER}.png")
        png.writeToFile_atomically_(out, True)
        return out
    except Exception:
        logging.getLogger(__name__).warning("menubar badge compose failed", exc_info=True)
        return None

# Keep-Mac-Awake tail options (label → seconds held after the last turn ends).
_KEEPAWAKE_OPTIONS = [
    ("Off",         0),
    ("5 minutes",   300),
    ("15 minutes",  900),
    ("30 minutes",  1800),
    ("1 hour",      3600),
    ("2 hours",     7200),
    ("4 hours",     14400),
    ("8 hours",     28800),
]

# ── App ─────────────────────────────────────────────────────────────────────

class BeaconMenuBar(rumps.App):
    def __init__(self, status_queue: queue.Queue[StatusMessage], api_url: str = "",
                 shutdown_cb: Callable[[], None] | None = None) -> None:
        _have_icon = all(os.path.exists(p) for p in _MENUBAR_BASE.values())
        super().__init__(
            # rumps.App requires a string title — use an empty one when an icon is
            # shown (the icon stands in for the title), never None.
            "" if _have_icon else _ICON_WARN,
            icon=_MENUBAR_BASE["warn"] if _have_icon else None,
            template=False,            # coloured state icons — not system-tinted
            quit_button=None,
        )
        self._have_icon = _have_icon
        # Last icon file actually set (base or count-badged), so we only reload the
        # NSImage when the (state, count) pairing changes.
        self._icon_path: str | None = _MENUBAR_BASE["warn"] if _have_icon else None
        self._q = status_queue
        self._api_url = api_url
        # Runs on the main thread right before bootout/kickstart (Quit / Restart Bridge), so the
        # daemon bridge thread can stop adapter subprocesses (kilo/opencode serve, …) before it's
        # hard-killed — otherwise those detached children orphan. Wired in main.py.
        self._shutdown_cb = shutdown_cb
        self._relay_connected = False
        self._launched_at = time.monotonic()   # short boot grace: relay/agents async-populate
        self._checking_updates = False         # drives the "Checking…" label in the app submenu
        self._adapters: dict[str, AdapterStatus] = {}
        self._pair_url: str | None = None
        self._first_run_shown = False  # guard: only auto-open the pairing QR once per launch
        self._update_url: str | None = None
        self._update_mac_url: str | None = None
        self._update_version: str | None = None
        self._active_sessions: int = 0
        self._active_connections: int = 0
        self._auth_expired: bool = False
        self._stt_loaded: bool = False
        self._stt_state: str = ""            # "" | "downloading" | "loading" (transient)
        self._stt_pct: float = 0.0
        # Seed the on-device-model availability synchronously so the first menu render is
        # accurate — otherwise the status poll's initial 5 s sleep left the "Voice & AI Models"
        # item briefly showing "— cloud" on a Mac that actually runs the model on-device.
        try:
            from terminull_bridge import summarizer as _summarizer
            _seed_model = _summarizer.model_name()
        except Exception:
            _seed_model = "none"
        self._summarizer_enabled: bool = _seed_model != "none"
        self._summarizer_loaded: bool = False
        self._summarizer_model: str = _seed_model if _seed_model != "none" else "on-device"
        self._summarizer_state: str = ""     # "" | "downloading" | "loading" (transient)
        self._summarizer_pct: float = 0.0
        # Own read/write connection to the shared history DB (WAL reads are
        # lock-free; revoke_pairing is a single atomic UPDATE the bridge
        # re-checks live on every session request). No event-loop hop needed.
        self._store = SessionStore()

        # Static items
        # Persistent items — titles localized to the macOS language (menu_i18n). The QR item is
        # renamed to an "add device" action; the keepalive toggle is kept for its state logic but
        # is no longer shown in the menu (hidden per product decision).
        self._show_qr_item      = rumps.MenuItem(i18n.t("add_device_qr"),        callback=self._show_qr)
        self._logs_item         = rumps.MenuItem(i18n.t("view_logs"),            callback=self._open_logs)
        self._login_item        = rumps.MenuItem(i18n.t("start_at_login"),       callback=self._toggle_login)
        self._keepalive_item    = rumps.MenuItem("Auto-restart if it crashes",   callback=self._toggle_keepalive)
        self._lanonly_item      = rumps.MenuItem(i18n.t("lan_only"),             callback=self._toggle_lanonly)
        self._terminal_item     = rumps.MenuItem(i18n.t("terminal_access"),      callback=self._toggle_terminal)
        self._proxy_item        = rumps.MenuItem(i18n.t("web_proxy"),            callback=self._toggle_proxy)
        self._stt_item          = rumps.MenuItem(i18n.t("voice_transcription"),  callback=self._toggle_stt)
        self._summarizer_item   = rumps.MenuItem(i18n.t("ai_summaries"),         callback=self._toggle_summarizer)
        self._check_upd_item    = rumps.MenuItem(i18n.t("check_updates"),        callback=self._check_updates_manual)
        self._restart_item      = rumps.MenuItem(i18n.t("restart_bridge"),       callback=self._restart)
        self._reauth_item       = rumps.MenuItem(i18n.t("reauthenticate"),       callback=self._reauth)
        self._quit_item         = rumps.MenuItem(i18n.t("quit"),                 callback=self._quit)

        self._login_item.state       = int(launchagent.run_at_login_enabled())
        self._keepalive_item.state   = int(launchagent.keep_alive_enabled())
        self._lanonly_item.state     = int(not cfg.relay_enabled())
        self._terminal_item.state    = int(cfg.get_feature("terminal_enabled", default=True))
        # [proxy] enabled — its own section, not a [features] key (see config.proxy_enabled).
        self._proxy_item.state       = int(cfg.proxy_enabled())
        self._stt_item.state         = int(cfg.get_feature("stt_enabled"))
        self._summarizer_item.state  = int(cfg.get_feature("summarizer_enabled", default=True))
        self._rebuild_menu()

        # Poll the status queue every second
        rumps.timer(1)(self._poll)

    # ── Queue polling ────────────────────────────────────────────────────────

    def _poll(self, _sender) -> None:
        changed = False
        # One-shot rebuild the moment the boot grace lapses, so a machine that reports
        # nothing new (relay genuinely down / zero agents) still flips "connecting…/detecting…"
        # to the real state instead of getting stuck on the placeholder.
        if not self._booting() and not getattr(self, "_boot_settled", False):
            self._boot_settled = True
            changed = True
        while True:
            try:
                msg = self._q.get_nowait()
            except queue.Empty:
                break
            if isinstance(msg, RelayStatus):
                self._relay_connected = msg.connected
                changed = True
            elif isinstance(msg, AdapterStatus):
                self._adapters[msg.agent_id] = msg
                changed = True
            elif isinstance(msg, QRData):
                self._pair_url = msg.pair_url
                if msg.auto_show and not self._first_run_shown:
                    self._first_run_shown = True
                    self._show_first_run_pairing()
            elif isinstance(msg, UpdateAvailable):
                self._update_version = msg.version
                self._update_url = msg.url
                self._update_mac_url = msg.mac_url
                changed = True
            elif isinstance(msg, UpdateProgress):
                if msg.state == "done":
                    self._update_version = None
                    self._update_url = None
                    self._update_mac_url = None
                elif msg.state == "error":
                    rumps.alert("Update failed", msg.detail)
                changed = True
            elif isinstance(msg, SessionCount):
                self._active_sessions = msg.active
                self._active_connections = msg.connections
                changed = True
            elif isinstance(msg, AuthExpired):
                self._auth_expired = True
                self._relay_connected = False
                changed = True
            elif isinstance(msg, WhisperStatus):
                if (msg.loaded != self._stt_loaded or msg.state != self._stt_state
                        or msg.pct != self._stt_pct):
                    self._stt_loaded = msg.loaded
                    self._stt_state = msg.state
                    self._stt_pct = msg.pct
                    changed = True
            elif isinstance(msg, SummarizerStatus):
                if (msg.enabled != self._summarizer_enabled
                        or msg.model_loaded != self._summarizer_loaded
                        or msg.model_name != self._summarizer_model
                        or msg.state != self._summarizer_state
                        or msg.pct != self._summarizer_pct):
                    self._summarizer_enabled = msg.enabled
                    self._summarizer_loaded = msg.model_loaded
                    self._summarizer_model = msg.model_name
                    self._summarizer_state = msg.state
                    self._summarizer_pct = msg.pct
                    changed = True
        if changed:
            # Never let a rebuild exception kill the 1 s timer — that would silently freeze
            # ALL menu updates (relay/agents/counts) at their last-built state. Log + carry on.
            try:
                self._rebuild_menu()
            except Exception:
                log.exception("menu rebuild failed")

    # ── Menu construction ────────────────────────────────────────────────────

    def _rebuild_menu(self) -> None:
        from terminull_bridge.updater import current_version
        self._apply_localized_titles()   # keep persistent-item titles in the current language

        # Connection state → icon colour (ok/warn/off) + optional session count.
        if self._auth_expired or self._update_version:
            state = "warn"
        elif self._relay_connected:
            state = "ok"
        elif self._adapters:
            state = "warn"
        else:
            state = "off"

        if self._have_icon:
            # Colour conveys state; the connected-device count rides as a purple
            # badge composited onto the mark. Reload the NSImage only when the
            # (state, count) pairing changes; keep the title empty (the badge is
            # the count).
            icon_path = _status_icon_path(state, self._active_connections)
            if icon_path != self._icon_path:
                self.icon = icon_path
                self._icon_path = icon_path
            self.title = ""
        else:
            base_icon = {"ok": _ICON_OK, "warn": _ICON_WARN, "off": _ICON_OFF}[state]
            self.title = (
                f"{base_icon} {self._active_connections}"
                if self._active_connections > 0
                else base_icon
            )

        if not cfg.relay_enabled():
            relay_label = i18n.t("relay_off")
        elif self._relay_connected:
            relay_label = i18n.t("relay_connected")
        elif self._booting():
            relay_label = i18n.t("relay_connecting")   # boot grace — not yet reported, not "down"
        else:
            relay_label = i18n.t("relay_disconnected")

        agents_menu = self._build_agents_submenu()
        devices_menu = self._build_devices_submenu()
        keepawake_menu = self._build_keepawake_submenu()
        app_menu = self._build_app_submenu(current_version())
        voice_ai_menu = self._build_voice_ai_submenu()

        if self._active_connections > 1:
            conn_label = i18n.t("connected_devices", n=self._active_connections)
        elif self._active_connections == 1:
            conn_label = i18n.t("connected_device_one")
        else:
            conn_label = i18n.t("no_devices")
        conn_item = rumps.MenuItem(conn_label)
        conn_item.set_callback(None)

        if self._active_sessions > 1:
            sess_label = i18n.t("active_sessions", n=self._active_sessions)
        elif self._active_sessions == 1:
            sess_label = i18n.t("active_session_one")
        else:
            sess_label = i18n.t("no_sessions")
        session_item = rumps.MenuItem(sess_label)
        session_item.set_callback(None)

        update_items = []
        if self._update_version:
            upd = rumps.MenuItem(
                i18n.t("update_to", v=f"v{self._update_version}"),
                callback=self._do_update,
            )
            update_items = [upd, rumps.separator]

        auth_items = []
        if self._auth_expired:
            auth_items = [self._reauth_item, rumps.separator]

        self.menu.clear()
        self.menu = [
            _ic(app_menu, "info.circle"),  # Terminull vX ▸  (View Logs · Restart Bridge · Check for Updates)
            _ic(conn_item, "dot.radiowaves.left.and.right"),
            _ic(session_item, "bubble.left.and.bubble.right"),
            *update_items,
            *auth_items,
            rumps.separator,
            _ic(rumps.MenuItem(relay_label), "network"),
            _ic(agents_menu, "cpu"),                       # Agents ▸
            rumps.separator,
            # ── Device pairing section ──
            _ic(self._show_qr_item, "qrcode"),             # ＋ Add device by QR (pairing)
            _ic(devices_menu, "laptopcomputer.and.iphone"),  # Paired Devices ▸
            rumps.separator,
            # ── Features / settings section ──
            _ic(voice_ai_menu, "waveform"),                # Voice & AI Models ▸
            _ic(self._login_item, "person.crop.circle"),
            _ic(keepawake_menu, "cup.and.saucer"),         # Keep Mac Awake ▸
            _ic(self._lanonly_item, "wifi"),
            _ic(self._terminal_item, "terminal"),
            _ic(self._proxy_item, "globe.badge.chevron.backward"),
            _ic(self._build_language_submenu(), "globe"),  # Language ▸
            rumps.separator,
            # ── Lifecycle section ──
            _ic(self._restart_item, "arrow.clockwise"),    # Restart the bridge
            _ic(self._quit_item, "power"),
        ]

    def _apply_localized_titles(self) -> None:
        """Re-apply localized titles to the persistent items so a live language switch
        retranslates the whole menu with no restart. (STT/LLM item titles carry a status
        suffix set in _build_voice_ai_submenu; keepalive is hidden, so it's skipped.)"""
        self._show_qr_item.title   = i18n.t("add_device_qr")
        self._logs_item.title      = i18n.t("view_logs")
        self._login_item.title     = i18n.t("start_at_login")
        self._lanonly_item.title   = i18n.t("lan_only")
        self._terminal_item.title  = i18n.t("terminal_access")
        self._proxy_item.title     = i18n.t("web_proxy")
        self._check_upd_item.title = i18n.t("check_updates")
        self._restart_item.title   = i18n.t("restart_bridge")
        self._reauth_item.title    = i18n.t("reauthenticate")
        self._quit_item.title      = i18n.t("quit")

    def _build_language_submenu(self) -> rumps.MenuItem:
        """Menu-language picker: 'System' (follow macOS) plus every macOS language we support,
        with English always offered as the universal fallback. Persisted via NSUserDefaults."""
        parent = rumps.MenuItem(i18n.t("language"))
        override = i18n.get_override()
        self._lang_by_title = {}
        sys_label = i18n.t("language_system")
        self._lang_by_title[sys_label] = i18n.SYSTEM
        sys_item = rumps.MenuItem(sys_label, callback=self._set_language)
        sys_item.state = int(override == i18n.SYSTEM)
        parent.add(sys_item)
        parent.add(rumps.separator)
        for code in i18n.available_languages():
            name = i18n.language_display_name(code)
            self._lang_by_title[name] = code
            item = rumps.MenuItem(name, callback=self._set_language)
            item.state = int(override == code)
            parent.add(item)
        return parent

    def _set_language(self, sender) -> None:
        code = getattr(self, "_lang_by_title", {}).get(sender.title, i18n.SYSTEM)
        i18n.set_override(code)
        self._rebuild_menu()

    def _build_app_submenu(self, version: str) -> rumps.MenuItem:
        """Version header + maintenance actions folded under one submenu, titled by the
        running version: View Logs · Restart Bridge · Check for Updates.

        ⚠️ Build FRESH items each rebuild — a persistent `MenuItem` can't be re-parented into a
        submenu on a later rebuild (rumps refuses), which silently froze the whole menu at its
        first-built state (relay/agents/etc. stuck on their init defaults). Top-level reuse is
        fine (that's how the reused toggles below still work); only `submenu.add(persistent)` is."""
        parent = rumps.MenuItem(i18n.t("app_menu", v=f"v{version}"))
        parent.add(rumps.MenuItem(i18n.t("view_logs"), callback=self._open_logs))
        # Restart now lives as a top-level lifecycle item (next to Quit), so it's not
        # duplicated inside this submenu.
        parent.add(rumps.separator)
        if self._checking_updates:
            chk = rumps.MenuItem(i18n.t("checking"))
            chk.set_callback(None)
        else:
            chk = rumps.MenuItem(i18n.t("check_updates"), callback=self._check_updates_manual)
        parent.add(chk)
        return parent

    def _build_voice_ai_submenu(self) -> rumps.MenuItem:
        """Single 'Voice & AI Models' entry. The parent title carries the OVERALL
        download/load progress (only while something is downloading/loading); expanding it
        shows per-model status (STT + LLM toggles) and which models are loaded."""
        stt_on = cfg.get_feature("stt_enabled")
        sum_on = cfg.get_feature("summarizer_enabled", default=True)

        # STT toggle title + status suffix. FRESH item each rebuild (see _build_app_submenu —
        # re-parenting a persistent item into a submenu freezes the whole menu). State from cfg;
        # the callback flips cfg via `sender`, so a fresh item works identically.
        if stt_on:
            stt_suffix = _load_hint_l10n(self._stt_state, self._stt_pct,
                                         loaded=self._stt_loaded, idle_key="st_not_loaded")
        else:
            stt_suffix = f" — {i18n.t('st_off')}"
        stt_item = rumps.MenuItem(i18n.t("voice_transcription") + stt_suffix, callback=self._toggle_stt)
        stt_item.state = int(stt_on)

        # LLM toggle title + status suffix. "not loaded" (not "idle") when the model is evicted,
        # matching STT — the on-device model is unloaded when no client is connected.
        if sum_on:
            if self._summarizer_enabled:   # on-device model available on this machine
                sum_suffix = _load_hint_l10n(self._summarizer_state, self._summarizer_pct,
                                             loaded=self._summarizer_loaded, idle_key="st_not_loaded")
            else:
                sum_suffix = f" — {i18n.t('st_cloud')}"   # non-mac / disabled: cloud or Haiku tier
        else:
            sum_suffix = f" — {i18n.t('st_off')}"
        sum_item = rumps.MenuItem(i18n.t("ai_summaries") + sum_suffix, callback=self._toggle_summarizer)
        sum_item.state = int(sum_on)

        parent = rumps.MenuItem(i18n.t("voice_ai_models") + self._overall_models_suffix(stt_on, sum_on))
        parent.add(stt_item)
        # Which STT model (non-clickable detail).
        stt_model = self._stt_model_name()
        if stt_on and stt_model:
            m = rumps.MenuItem("      " + i18n.t("model_label", model=stt_model))
            m.set_callback(None)
            parent.add(m)
        parent.add(sum_item)
        # Which on-device LLM (non-clickable detail), when the local tier is active.
        if sum_on and self._summarizer_enabled and self._summarizer_model:
            m = rumps.MenuItem("      " + i18n.t("model_label", model=self._short_model(self._summarizer_model)))
            m.set_callback(None)
            parent.add(m)
        return parent

    def _overall_models_suffix(self, stt_on: bool, sum_on: bool) -> str:
        """Aggregate download/load state across STT + LLM → a parent-title suffix.
        Empty when nothing is downloading/loading (steady state shows a bare title)."""
        fractions: list[float] = []
        loading = False
        if stt_on:
            if self._stt_state == "downloading":
                fractions.append(self._stt_pct)
            elif self._stt_state == "loading":
                loading = True
        if sum_on and self._summarizer_enabled:
            if self._summarizer_state == "downloading":
                fractions.append(self._summarizer_pct)
            elif self._summarizer_state == "loading":
                loading = True
        if fractions:
            known = [f for f in fractions if f > 0]
            if known:
                return f" — {i18n.t('st_downloading_pct', pct=int(sum(known) / len(known) * 100))}"
            return f" — {i18n.t('st_downloading')}"
        if loading:
            return f" — {i18n.t('st_loading')}"
        return ""

    @staticmethod
    def _short_model(name: str) -> str:
        """HF repo id → bare model name (drop the org prefix)."""
        return name.rsplit("/", 1)[-1] if name else name

    def _stt_model_name(self) -> str:
        """Best-effort resident Whisper model name for the detail line (cached)."""
        if getattr(self, "_stt_model_cached", None) is None:
            try:
                from terminull_bridge.stt_session import _model_for_language
                self._stt_model_cached = self._short_model(_model_for_language("en"))
            except Exception:
                self._stt_model_cached = "whisper-small"
        return self._stt_model_cached

    # ── Submenus ───────────────────────────────────────────────────────────────

    def _build_agents_submenu(self) -> rumps.MenuItem:
        parent = rumps.MenuItem(i18n.t("agents"))
        if self._adapters:
            for a in self._adapters.values():
                dot = _DOT_GREEN if a.state in ("connected", "ready") else _DOT_RED
                suffix = f" — {a.detail}" if a.detail else f" — {a.state}"
                item = rumps.MenuItem(dot + a.name + suffix)
                item.set_callback(None)  # status line, not actionable
                parent.add(item)
        else:
            # During the boot grace, detection hasn't finished — show "Detecting…" rather
            # than the alarming "No agents detected" (the bridge probes each CLI, ~seconds).
            empty = rumps.MenuItem(i18n.t("detecting_agents") if self._booting() else i18n.t("no_agents"))
            empty.set_callback(None)
            parent.add(empty)
        return parent

    # Seconds after launch during which relay/agents are still async-populating; the menu
    # shows "connecting…/detecting…" instead of a negative state so a fast menu-open right
    # after a restart doesn't look broken.
    _BOOT_GRACE_SECS = 10

    def _booting(self) -> bool:
        return (time.monotonic() - self._launched_at) < self._BOOT_GRACE_SECS

    def _keepawake_label(self, secs: int) -> str:
        if secs <= 0:
            return i18n.t("keepawake_off")
        for text, s in _KEEPAWAKE_OPTIONS:
            if s == secs:
                return text
        return f"{secs // 60} min"

    def _build_keepawake_submenu(self) -> rumps.MenuItem:
        """Keep-Mac-Awake picker — how long the bridge holds a `caffeinate` assertion
        after the last turn finishes (so a closed-lid Mac keeps serving briefly, then
        sleeps). Backed by cfg.keepawake_after_turn_secs(); the bridge applies the new
        value at the next turn boundary."""
        current = cfg.keepawake_after_turn_secs()
        parent = rumps.MenuItem(f"{i18n.t('keep_awake')} — {self._keepawake_label(current)}")
        # Localized "Off" needs a title→secs reverse map so the callback still resolves
        # the choice (numeric durations stay as-is).
        self._keepawake_by_title = {}
        for text, secs in _KEEPAWAKE_OPTIONS:
            label = i18n.t("keepawake_off") if secs == 0 else text
            self._keepawake_by_title[label] = secs
            item = rumps.MenuItem(label, callback=self._set_keepawake)
            item.state = int(secs == current)
            parent.add(item)
        parent.add(rumps.separator)
        note = rumps.MenuItem("Held after last turn ends · AC power")
        note.set_callback(None)   # caption, not actionable
        parent.add(note)
        return parent

    def _set_keepawake(self, sender) -> None:
        secs = getattr(self, "_keepawake_by_title", {}).get(sender.title, 0)
        cfg.set_keepawake_after_turn_secs(secs)
        self._rebuild_menu()

    def _build_devices_submenu(self) -> rumps.MenuItem:
        parent = rumps.MenuItem(i18n.t("paired_devices"))
        try:
            pairings = self._store.list_active_pairings()
        except Exception as e:  # never let a DB hiccup break the whole menu
            pairings = []
            err = rumps.MenuItem(f"(unavailable: {e})")
            err.set_callback(None)
            parent.add(err)
        for p in pairings:
            label = p["device_name"] or p["device_id"][:12] or "Unknown device"
            sub = rumps.MenuItem(_DOT_GREEN + label)
            last = rumps.MenuItem(i18n.t("last_used", when=self._humanize_ts(p['last_used'])))
            last.set_callback(None)
            sub.add(last)
            if p["user_email"]:
                em = rumps.MenuItem(p["user_email"])
                em.set_callback(None)
                sub.add(em)
            sub.add(rumps.separator)
            drop = rumps.MenuItem(
                i18n.t("drop_device"),
                callback=self._make_drop_cb(p["device_id"], label),
            )
            sub.add(drop)
            parent.add(sub)
        if not pairings:
            empty = rumps.MenuItem(i18n.t("no_paired_devices"))
            empty.set_callback(None)
            parent.add(empty)
        return parent

    def _make_drop_cb(self, device_id: str, label: str) -> Callable:
        def _cb(_sender) -> None:
            clicked = rumps.alert(
                "Drop paired device?",
                f"“{label}” will lose access to this bridge. Active sessions keep "
                "running until they end; new connections are refused. You can re-pair "
                "later by scanning the QR code again.",
                ok="Drop", cancel="Cancel",
            )
            if clicked == 1:
                self._store.revoke_pairing(device_id)
                self._rebuild_menu()
        return _cb

    @staticmethod
    def _humanize_ts(iso: str | None) -> str:
        if not iso:
            return "never"
        from datetime import datetime, timezone
        try:
            dt = datetime.fromisoformat(iso)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            secs = int((datetime.now(timezone.utc) - dt).total_seconds())
        except Exception:
            return iso
        if secs < 60:
            return "just now"
        if secs < 3600:
            return f"{secs // 60} min ago"
        if secs < 86400:
            return f"{secs // 3600} h ago"
        days = secs // 86400
        if days < 30:
            return f"{days} d ago"
        return dt.strftime("%b %d, %Y")

    # ── Actions ──────────────────────────────────────────────────────────────

    def _show_first_run_pairing(self) -> None:
        """First launch (unpaired): proactively guide the user to pair instead of
        leaving the QR only in the log file. Shown once per launch.

        Uses a small multi-page wizard window (no minimize/zoom — only ✕ cancels) instead of
        a plain NSAlert the user could dismiss with one stray click. Explains up front that
        AI plugins / better STT models load in the background, so features may lag being
        enabled. Falls back to the old alert + QR-in-Preview if the window can't be built."""
        from . import wizard
        # on_finish resyncs the menu's feature-toggle states with whatever the wizard persisted
        # (its Voice-input / AI-assists switches write config on close) — without this the menu
        # keeps the states it built at startup and shows Voice "off" after the wizard enabled it.
        self._wizard = wizard.present(self._pair_url,
                                      on_finish=self._resync_feature_toggles)  # retain: non-modal
        if self._wizard is not None:
            return
        # Fallback (no AppKit window): the original alert + Preview path.
        rumps.alert(
            title="Welcome to Terminull",
            message="To connect your phone, open the Terminull app on your iPhone and scan "
                    "the QR code that opens next.",
            ok="Show QR Code",
        )
        self._show_qr(None)

    def _show_qr(self, _) -> None:
        if not self._pair_url:
            rumps.alert("No QR yet", "Bridge has not connected to the relay yet.")
            return
        # Prefer the in-app pairing window (renders the QR inline). Fall back to Preview.
        from . import wizard
        self._wizard = wizard.present(self._pair_url, pairing_only=True)
        if self._wizard is not None:
            return
        try:
            import qrcode
            import tempfile, os
            img = qrcode.make(self._pair_url)
            tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False, prefix="beacon_qr_")
            tmp.close()
            img.save(tmp.name)
            # Open in Preview — stays on top, user can scan with iPhone camera
            subprocess.run(["open", "-a", "Preview", tmp.name], check=False)
            # Also copy URL to clipboard as fallback
            subprocess.run(["pbcopy"], input=self._pair_url.encode(), check=False)
            # Temp file is cleaned up when Preview closes (macOS quarantine doesn't apply here)
            threading.Timer(30, lambda: os.unlink(tmp.name) if os.path.exists(tmp.name) else None).start()
        except Exception as e:
            # Fallback: clipboard + alert
            subprocess.run(["pbcopy"], input=self._pair_url.encode(), check=False)
            rumps.alert("QR Code", f"Couldn't render QR ({e}).\nURL copied to clipboard.")

    def _open_logs(self, _) -> None:
        log_path = launchagent.err_path()
        script = (
            f'tell application "Terminal"\n'
            f'    do script "tail -f {log_path} -n 100"\n'
            f'    activate\n'
            f'end tell'
        )
        subprocess.run(["osascript", "-e", script], check=False)

    def _toggle_login(self, sender) -> None:
        if launchagent.is_installed():
            launchagent.uninstall()
            sender.state = 0
        else:
            launchagent.install()
            sender.state = 1
        # Auto-restart lives in the same launchd job, so its availability tracks
        # whether the job is installed at all — keep the checkbox in sync.
        self._keepalive_item.state = int(launchagent.keep_alive_enabled())

    def _toggle_keepalive(self, sender) -> None:
        # Auto-restart needs a launchd job to live in; install it if missing.
        if not launchagent.is_installed():
            launchagent.install()
            self._login_item.state = 1
        new_state = not launchagent.keep_alive_enabled()
        launchagent.set_keep_alive(new_state)
        sender.state = int(new_state)

    def _toggle_lanonly(self, sender) -> None:
        # LAN-only = relay disabled. The relay connection is decided at startup, so
        # this only takes effect after the next bridge restart (like Auto-restart).
        lan_only = cfg.relay_enabled()  # turning ON lan-only means relay is currently enabled
        cfg.set_relay_enabled(not lan_only)
        sender.state = int(lan_only)
        rumps.alert(
            "LAN-only mode",
            ("The bridge will stay on the local network only and won't connect to the "
             "relay — you'll reach it from the same Wi-Fi. Pairing and cloud AI still "
             "use the internet."
             if lan_only else
             "The bridge will reconnect to the relay so it's reachable when you're away.")
            + "\n\nApplies after the next restart (use Restart Bridge).",
        )

    def _do_update(self, _) -> None:
        url = self._update_url
        mac_url = self._update_mac_url
        version = self._update_version
        if not (url or mac_url):
            return
        self._update_version = None
        self._update_url = None
        self._update_mac_url = None
        self._rebuild_menu()
        threading.Thread(target=self._run_update, args=(url, mac_url, version), daemon=True).start()

    def _run_update(self, url: str | None, mac_url: str | None, version: str) -> None:
        from terminull_bridge.updater import build_kind, install_update, install_app_update
        try:
            if build_kind() == "app":
                # Notarized self-contained .app → swap the bundle (never pip-install,
                # which would break the signature seal + stapled ticket).
                install_app_update(mac_url or "")
            else:
                install_update(url or "")
            launchagent.restart()
            # restart() sends SIGTERM via launchctl — process may not reach here
        except Exception as e:
            self._q.put(UpdateProgress(state="error", detail=str(e)))

    def _check_updates_manual(self, _) -> None:
        if not self._api_url:
            return
        self._checking_updates = True
        self._rebuild_menu()   # show "Checking…" immediately (the app submenu reads the flag)

        def _check() -> None:
            from terminull_bridge.updater import check_for_update_sync
            try:
                result = check_for_update_sync(self._api_url)
            finally:
                self._checking_updates = False   # cleared before the message → next rebuild is normal
            if result:
                version, url, mac_url = result
                self._q.put(UpdateAvailable(version=version, url=url, mac_url=mac_url))
            else:
                self._q.put(UpdateProgress(state="done"))  # triggers a rebuild → restores the label

        threading.Thread(target=_check, daemon=True).start()

    def _toggle_terminal(self, sender) -> None:
        new_state = not cfg.get_feature("terminal_enabled", default=True)
        cfg.set_feature("terminal_enabled", new_state)
        sender.state = int(new_state)

    def _toggle_proxy(self, sender) -> None:
        """[proxy] enabled — lets the phone's WebView fetch remote http/https URLs through
        this machine. Same shape as _toggle_terminal, but writes its own [proxy] section
        rather than a [features] key. The bridge re-reads the flag per request and re-syncs
        the `cap_http_proxy` cap on its next agent/status refresh — no restart needed."""
        new_state = not cfg.proxy_enabled()
        cfg.set_proxy_enabled(new_state)
        sender.state = int(new_state)

    def _toggle_stt(self, sender) -> None:
        new_state = not cfg.get_feature("stt_enabled")
        cfg.set_feature("stt_enabled", new_state)
        sender.state = int(new_state)

    def _toggle_summarizer(self, sender) -> None:
        new_state = not cfg.get_feature("summarizer_enabled", default=True)
        cfg.set_feature("summarizer_enabled", new_state)
        sender.state = int(new_state)
        self._rebuild_menu()

    def _resync_feature_toggles(self) -> None:
        """Re-read config into the feature-toggle menu items — called after the setup wizard
        closes (it persists Voice-input / AI-assists on close). Without this the menu keeps the
        states it captured at startup, so Voice shows "off" after the wizard turned it on and
        _toggle_stt reads inverted (not True → False). Cheap; safe to call anytime."""
        self._stt_item.state        = int(cfg.get_feature("stt_enabled"))
        self._summarizer_item.state = int(cfg.get_feature("summarizer_enabled", default=True))
        self._rebuild_menu()

    def _reauth(self, _) -> None:
        rumps.alert(
            "Authentication expired",
            "Your Terminull auth token has expired.\n\n"
            "Open the Terminull iOS app and scan the QR code again to re-pair.",
        )

    def _restart(self, _) -> None:
        # Stop adapter subprocesses first — kickstart -k SIGKILLs us with no SIGTERM grace,
        # so without this the daemon bridge thread never runs adapter.stop() and kilo/opencode
        # serve children orphan across the restart.
        self._graceful_bridge_shutdown("restart")
        # kickstart -k only restarts the *launchd job*. A dev app started with `open`
        # has the plist on disk (is_installed() → True) but is NOT that job, so kickstart
        # would no-op (or spawn a conflicting duplicate — see memory bridge_launch_mechanism)
        # while THIS process keeps running: "Restart" did nothing (2026-07-13). So kickstart
        # only when this process is genuinely the managed job; otherwise re-exec in place.
        if launchagent.is_installed() and _running_under_launchd():
            # kickstart -k is atomic: launchd kills the current instance and
            # immediately starts a fresh one — no deadlock from calling within
            # the running process (launchd handles the kill asynchronously).
            log.info("menubar: restart via launchctl kickstart (%s)", launchagent._LABEL)
            subprocess.Popen(
                ["launchctl", "kickstart", "-k",
                 f"gui/{os.getuid()}/{launchagent._LABEL}"],
                start_new_session=True,
            )
        else:
            # Dev/open-launched (or job not managing us) — re-exec this process in place.
            log.info("menubar: restart via os.execv (not launchd-managed)")
            os.execv(sys.executable, [sys.executable] + sys.argv)

    def _quit(self, _) -> None:
        # Stop adapter subprocesses BEFORE bootout — bootout SIGTERMs us and the daemon bridge
        # thread (which has no signal handler in the Mac process) is then hard-killed without
        # running adapter.stop(), so kilo/opencode serve children would orphan.
        self._graceful_bridge_shutdown("quit")
        # Boot the job out of launchd first so KeepAlive can't relaunch us —
        # otherwise a bare quit just triggers an immediate restart. The plist
        # stays on disk, so the bridge still comes back on the next login if
        # "Start at Login" is enabled.
        if launchagent.is_installed():
            launchagent.stop()
        rumps.quit_application()

    def _graceful_bridge_shutdown(self, reason: str) -> None:
        """Invoke the bridge-thread cleanup callback (bounded) before we kill the process."""
        if self._shutdown_cb is None:
            return
        log.info("menubar: graceful bridge shutdown (%s)", reason)
        try:
            self._shutdown_cb()
        except Exception as e:
            log.warning("menubar: graceful bridge shutdown failed: %s", e)


# ── Public helpers ───────────────────────────────────────────────────────────

def _running_under_launchd() -> bool:
    """True only if THIS process is the launchd-managed bridge job.

    A dev app started with `open ~/.terminull/Terminull.app` has the LaunchAgent
    plist on disk (so `launchagent.is_installed()` is True) yet is NOT the job that
    launchd runs — `launchctl kickstart -k` on the label would restart that other
    (possibly not-loaded) job while this process keeps running, so "Restart" appears
    to do nothing. We confirm management by asking launchd for the job's pid and
    checking it against our own process (or our .app-launcher parent)."""
    try:
        out = subprocess.run(
            ["launchctl", "print", f"gui/{os.getuid()}/{launchagent._LABEL}"],
            capture_output=True, text=True, timeout=3,
        ).stdout
    except Exception:
        return False
    m = re.search(r"^\s*pid\s*=\s*(\d+)", out, re.MULTILINE)
    if not m:
        return False   # job not loaded / not running → we're not it
    return int(m.group(1)) in (os.getpid(), os.getppid())


def run_menubar(status_queue: queue.Queue[StatusMessage], api_url: str = "",
                shutdown_cb: Callable[[], None] | None = None) -> None:
    """Call from main thread. Blocks until the app quits.

    `shutdown_cb` is invoked on the main thread just before Quit/Restart kills the process,
    giving the daemon bridge thread a chance to stop adapter subprocesses gracefully."""
    # Python.framework has no LSUIElement, so its NSApplication would show a
    # Dock icon. Explicitly set accessory policy before the run loop starts.
    try:
        from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
        NSApplication.sharedApplication().setActivationPolicy_(
            NSApplicationActivationPolicyAccessory
        )
    except Exception:
        pass
    BeaconMenuBar(status_queue, api_url=api_url, shutdown_cb=shutdown_cb).run()


def make_status_queue() -> queue.Queue[StatusMessage]:
    return queue.Queue()
