"""Lirelle menu bar app — Phase C1.

Holds Right Option (default) → records → release → transcribes → pastes at cursor + stores.
Model is warmed once on startup so dictations are sub-second after the first second of audio.
"""

from __future__ import annotations

import json
import threading
import time
from datetime import datetime
from pathlib import Path

import numpy as np
import rumps

import objc  # type: ignore
from AppKit import (  # type: ignore
    NSApplication,
    NSApplicationActivationPolicyRegular,
    NSImage,
    NSMenu,
    NSMenuItem,
    NSWorkspace,
    NSWorkspaceDidActivateApplicationNotification,
)
from Foundation import NSObject  # type: ignore
from PyObjCTools import AppHelper  # type: ignore
from Quartz import (  # type: ignore
    kCGEventFlagMaskAlternate,
    kCGEventFlagMaskCommand,
)

from .bank import BankWindow
from .cleanup import clean_text, DEFAULT_CLEANUP_MODEL
from .hotkey import HotkeyTap, PasteHotkey
from .licensing import load_license, LicenseState
from .overlay import Overlay
from .paster import paste_text
from .recorder import Recorder
from .store import Store
from .transcriber import make_transcriber
from .usage import words_used_today  # noqa: F401  — re-exported for tests + other modules

CLEANUP_LEVELS = [
    ("raw",      "Raw  (verbatim)"),
    ("light",    "Light  (strip filler words)"),
    ("standard", "Standard  (Claude API: clean + keep voice)"),
    ("polished", "Polished  (Claude API: written message)"),
]

KEYCODE_V = 9    # physical V key
KEYCODE_L = 37   # physical L key

ROOT = Path(__file__).resolve().parent.parent

ICON_IDLE = str(ROOT / "assets" / "icon_idle.png")
ICON_LISTENING = str(ROOT / "assets" / "icon_listening.png")
ICON_TRANSCRIBING = str(ROOT / "assets" / "icon_transcribing.png")
DOCK_ICON = str(ROOT / "assets" / "dock_icon.png")


class _LastAppTracker(NSObject):
    """Listens for NSWorkspaceDidActivateApplication and remembers the most recent
    app that ISN'T Lirelle itself. Used by the bank's `paste` buttons to know which
    app to send Cmd+V to (since clicking the button activates Lirelle and loses focus
    on the user's actual target)."""
    OUR_BUNDLE_ID = "com.lumo.dictation"

    def initWithApp_(self, app):  # type: ignore
        self = objc.super(_LastAppTracker, self).init()
        if self is None:
            return None
        self._app = app
        return self

    def appActivated_(self, notification):  # noqa: N802
        try:
            # Stamp the activation time on EVERY workspace switch (including
            # back to Lirelle itself). The hotkey handlers use this to ignore
            # synthetic modifier-release events macOS emits on app-switch:
            # Cmd+Tab → Lirelle bank window → Cmd+Tab back to e.g. Antigravity
            # used to kill an in-flight hands-free recording because the OS
            # dispatched a synthetic Right-Option-up to the new active app's
            # event stream. Within ~300ms of an activation we treat
            # press/release as noise.
            import time as _t
            self._app._last_app_activation_at = _t.time()
            info = notification.userInfo() or {}
            running = info.objectForKey_("NSWorkspaceApplicationKey")
            if running is None:
                return
            bundle_id = str(running.bundleIdentifier() or "")
            if bundle_id == self.OUR_BUNDLE_ID:
                return
            self._app.last_target_bundle_id = bundle_id
            self._app.last_target_name = str(running.localizedName() or "")
        except Exception as e:
            print(f"[lumo] last-app tracker error: {e}")


class _AppDelegateProxy(NSObject):
    """Custom NSApp delegate that handles applicationShouldHandleReopen (the
    canonical dock-click hook when app is already running with no visible windows).
    Proxies every other selector to the original rumps delegate so menu bar stays
    functional."""
    def initWithBank_inner_app_(self, bank, inner, app):  # type: ignore
        self = objc.super(_AppDelegateProxy, self).init()
        if self is None:
            return None
        self._bank = bank
        self._inner = inner
        self._app = app   # LirelleApp — used to detect active onboarding window
        return self

    def applicationShouldHandleReopen_hasVisibleWindows_(self, app, has_visible):  # noqa: N802, ARG002
        # User explicitly clicked the dock icon. If onboarding is still
        # in progress, bring THAT window forward instead of opening the
        # bank window over it (which is what was breaking the code-entry
        # flow: user switches to Mail to copy the code, clicks back on
        # Lirelle, dock-reopen used to force-show bank window over the
        # onboarding window, killing the activation flow).
        onb = getattr(self._app, "_onboarding_window", None)
        onb_window = getattr(onb, "window", None) if onb is not None else None
        onb_closed = getattr(onb, "_closed", True) if onb is not None else True
        if onb_window is not None and not onb_closed:
            print(f"[lumo] dock reopen (hasVisibleWindows={has_visible}) → "
                  f"showing onboarding (in progress)")
            try:
                onb_window.makeKeyAndOrderFront_(None)
                NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
            except Exception as e:
                print(f"[lumo] onboarding reopen error: {e}")
            return True
        print(f"[lumo] dock reopen (hasVisibleWindows={has_visible}) → showing bank")
        try:
            self._bank.show_from_user_action()
        except Exception as e:
            print(f"[lumo] reopen error: {e}")
        return True

    def respondsToSelector_(self, selector):  # noqa: N802
        if objc.super(_AppDelegateProxy, self).respondsToSelector_(selector):
            return True
        return self._inner is not None and self._inner.respondsToSelector_(selector)

    def forwardingTargetForSelector_(self, selector):  # noqa: N802
        if self._inner is not None and self._inner.respondsToSelector_(selector):
            return self._inner
        return None


def user_config_path() -> Path:
    """User-modified settings live outside the .app bundle. CR #3 —
    writing inside Contents/Resources/ breaks the code-signing seal
    (Sparkle then refuses to install the next update, and TCC may revoke
    Microphone/Accessibility on macOS 14+), AND every Sparkle update
    overwrites the bundle so settings would be silently reset on each
    release. Application Support survives both."""
    return (Path.home() / "Library" / "Application Support" / "Lumo"
            / "config.json")


def _strip_config_comments(d):
    """Drop `_comment*` keys recursively. They're inline docs in the
    bundled defaults file; the runtime schema only knows the real ones,
    and leaving the comments in means update_setting() can't write the
    key back without the inline doc reappearing on re-parse."""
    if isinstance(d, dict):
        return {k: _strip_config_comments(v) for k, v in d.items()
                if not (isinstance(k, str) and k.startswith("_comment"))}
    return d


def _deep_merge_config(base: dict, overlay: dict) -> dict:
    """overlay wins; nested dicts merge key-by-key. Lists / scalars / None
    replace the base value at that key."""
    out = dict(base)
    for k, v in overlay.items():
        if isinstance(v, dict) and isinstance(out.get(k), dict):
            out[k] = _deep_merge_config(out[k], v)
        else:
            out[k] = v
    return out


def load_config() -> dict:
    """Read bundled defaults (immutable, ships in the .app), then overlay
    any user-edited settings from ~/Library/Application Support/Lumo/
    config.json. Missing keys fall back to defaults — load_config()
    therefore never raises KeyError on cfg["audio"]["sample_rate"] even
    if the user file is partial or absent. CR #3.
    """
    bundled: dict = {}
    try:
        bundled = json.loads((ROOT / "config.json").read_text(encoding="utf-8"))
    except Exception as e:
        print(f"[lumo] bundled config.json unreadable: {e}", flush=True)
    bundled = _strip_config_comments(bundled)
    user: dict = {}
    p = user_config_path()
    if p.exists():
        try:
            user = json.loads(p.read_text(encoding="utf-8"))
            user = _strip_config_comments(user) if isinstance(user, dict) else {}
        except Exception as e:
            # A truncated / corrupt user file used to brick the app at
            # launch (load_config crashed before any UI existed). Now we
            # log and fall back to bundled defaults — settings the user
            # had made will appear lost, but Lirelle still boots.
            print(f"[lumo] user config.json unreadable, ignoring: {e}",
                  flush=True)
    return _deep_merge_config(bundled, user)


class LirelleApp(rumps.App):
    def __init__(self, cfg: dict):
        super().__init__("Lirelle", title=None, icon=ICON_IDLE, template=None, quit_button=None)
        # Promote to a Dock app (LSUIElement off). rumps defaults to Accessory mode
        # (menu bar only) which gets eaten by the notch when the menu bar is busy.
        # Regular = full Dock app, click dock icon to show window.
        try:
            app = NSApplication.sharedApplication()  # creates NSApp if not yet present
            app.setActivationPolicy_(NSApplicationActivationPolicyRegular)
            dock_img = NSImage.alloc().initWithContentsOfFile_(DOCK_ICON)
            if dock_img is not None:
                app.setApplicationIconImage_(dock_img)
            print("[lumo] dock mode enabled")
        except Exception as e:
            print(f"[lumo] failed to enable dock mode: {e}")
        self.cfg = cfg
        self._state = "idle"
        self._state_lock = threading.Lock()
        self._processing = False
        # Serialises the transcribe → cleanup → store pipeline so concurrent
        # _process calls (e.g. user double-taps before previous transcript is
        # finished) don't hammer mlx_whisper's non-thread-safe context.
        self._process_lock = threading.Lock()
        # Monotonic recording-cycle counter. Each _start_recording bumps it.
        # _process_body captures the generation at spawn time and the finally
        # block only resets state if the generation still matches — otherwise
        # a stale first-tap pipeline (from a double-tap or rapid re-press)
        # would forcibly set state=idle + hide overlay while the NEW
        # recording is still listening. That's the "что-то отключает после
        # двойного нажатия" symptom: visually it looked like Lirelle turned
        # itself off, but actually a new recording was running silently.
        self._record_generation = 0
        # App-level intent flag — set the instant a press is accepted, before
        # the background recorder.start() call has actually flipped recorder._armed.
        # on_release reads this so a fast press-release within the ~100ms cold-
        # start window of CoreAudio is still recognised as a recording cycle
        # and goes through stop+transcribe (vs leaking _armed=True forever).
        self._record_intent = False
        self._sample_rate = cfg["audio"]["sample_rate"]

        self.recorder = Recorder(
            sample_rate=self._sample_rate,
            channels=cfg["audio"]["channels"],
            device=cfg["audio"]["device"],
        )
        # Backend (MLX or whisper.cpp) is auto-selected based on Mac architecture.
        # Override via `whisper.backend` in config.json if MLX misbehaves on a
        # specific macOS release and we need a manual fallback without rebuilding.
        self.transcriber = make_transcriber(cfg["whisper"])
        # Dictation has its own transcriber so Cloud Mode (default, fast) can
        # route through our /transcribe endpoint while meetings keep using
        # the local MLX/whisper.cpp pipeline. The dictation transcriber falls
        # back to `self.transcriber` on a CloudDictatorError so a network
        # blip doesn't kill dictation — see `_process_body`.
        from .transcriber import make_dictation_transcriber
        self.dictation_transcriber = make_dictation_transcriber(
            cfg, fallback_local=self.transcriber,
        )
        # Data lives in ~/Library/Application Support/Lumo/ — surviving every
        # Sparkle update and manual reinstall. legacy_root=ROOT triggers a
        # one-time migration of any transcripts that earlier builds wrote
        # inside the bundle (so anyone updating from a pre-2026-06-03 build
        # keeps their history).
        self.store = Store(
            root=None,
            transcripts_dirname=cfg["storage"]["transcripts_dir"],
            audio_dirname=cfg["storage"]["audio_cache_dir"],
            keep_audio=cfg["storage"]["keep_audio_cache_count"],
            legacy_root=ROOT,
        )

        self.bank = BankWindow(store=self.store, app=self)

        # License + usage state. Cached at startup; updated whenever the bank's
        # _reload_license_and_rerender() runs (after activation / sign-out /
        # manual refresh button). The gate in _on_press / _process reads this
        # cached value so we don't hit disk on every keystroke.
        # If the on-disk cert is past its refresh window (or the subscription
        # window slipped while the user was paying via Robokassa), we kick a
        # one-shot background refresh so the menubar reflects the new plan
        # without the user having to click 'Обновить тариф'.
        self._license: LicenseState = load_license()
        print(f"[lumo] plan={self._license.plan} email={self._license.email} "
              f"subscription_valid_until={self._license.subscription_valid_until} "
              f"error={self._license.error}")
        if self._license.needs_refresh or (
            self._license.subscription_valid_until is not None
            and not self._license.is_active
        ):
            threading.Thread(target=self._startup_refresh_cert, daemon=True).start()

        # cleanup level lives in cfg + a runtime override so menu changes take effect
        # immediately without rewriting config.json on every click
        self._cleanup_level = cfg.get("cleanup", {}).get("level", "raw")
        # Window open paths:
        # (1) applicationShouldHandleReopen on the delegate proxy — fires on
        #     dock-icon click whether the app is active or not. The canonical
        #     macOS dock-reopen contract.
        # (2) ⌘+⌥+L global hotkey from anywhere.
        # We intentionally do NOT subscribe to NSApplicationDidBecomeActive
        # here — that notification fires on every focus event (menubar icon
        # click, hotkey-triggered activation, returning from a dictation, …)
        # and showing the bank window on each fire produced a focus-trap
        # where the user couldn't click into another app.
        # Track most-recent non-Lirelle focused app so Bank's `paste` button knows where to send Cmd+V
        self.last_target_bundle_id: str | None = None
        self.last_target_name: str | None = None
        self._last_app_tracker = _LastAppTracker.alloc().initWithApp_(self)
        NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_(
            self._last_app_tracker,
            "appActivated:",
            NSWorkspaceDidActivateApplicationNotification,
            None,
        )

        # ⌘⌥L opens the window from anywhere — explicit user action,
        # so we use show_from_user_action() which activates the app.
        self.open_window_hotkey = PasteHotkey(
            keycode=KEYCODE_L,
            modifiers_mask=kCGEventFlagMaskCommand | kCGEventFlagMaskAlternate,
            on_fire=lambda: self.bank.show_from_user_action(),
        )
        # NOTE: hands-free toggle is implemented as a DOUBLE-TAP on the same
        # modifier-hold hotkey (Right Option by default). See HotkeyTap above.
        # Cmd+Shift+; turned out to be unreliable (macOS-reserved on some layouts
        # / Cyrillic input source remaps the physical key).

        cleanup_menu = rumps.MenuItem("Cleanup")
        self._cleanup_items: dict[str, rumps.MenuItem] = {}
        for key, label in CLEANUP_LEVELS:
            item = rumps.MenuItem(label, callback=self._make_cleanup_setter(key))
            item.state = 1 if key == self._cleanup_level else 0
            cleanup_menu.add(item)
            self._cleanup_items[key] = item

        # Account section — pulled fresh on each menu render via _refresh_account_menu_items
        from .licensing import current_account_email
        _email = current_account_email()
        plan_upper = self._license.plan.upper()
        if _email:
            account_label = f"◐ {plan_upper} · {_email}"
        else:
            account_label = "◐ Not signed in"
        self._account_status_item = rumps.MenuItem(account_label, callback=None)
        self._cabinet_item = rumps.MenuItem("Open account cabinet…",
                                            callback=self._menu_open_cabinet)
        # CR #6 — single source of truth for meeting recording. Both the
        # menubar item and the bank window's Start/Stop buttons use this
        # one instance, so they always agree on is_recording() and a
        # menubar-started meeting actually gets transcribed (the old bank-
        # local recorder didn't see it). Instantiate lazily inside the
        # property so importing sysaudio_proxy still happens on first use,
        # not at boot.
        self._meeting_recorder = None  # type: ignore[assignment]
        self._meeting_item = rumps.MenuItem("🔴 Start meeting recording",
                                            callback=self._menu_toggle_meeting)

        self.menu = [
            rumps.MenuItem("Status: idle", callback=None),
            rumps.MenuItem(f"Cleanup: {self._cleanup_level}", callback=None),
            None,
            self._account_status_item,
            self._cabinet_item,
            None,
            self._meeting_item,
            None,
            rumps.MenuItem("Open Lirelle", callback=self._open_bank_window),
            cleanup_menu,
            rumps.MenuItem("Open Today's Log", callback=self._open_today_log),
            rumps.MenuItem("Open Transcripts Folder", callback=self._open_bank_folder),
            None,
            rumps.MenuItem("Check for Updates…", callback=self._menu_check_updates),
            rumps.MenuItem("Quit Lirelle", callback=rumps.quit_application),
        ]

        self.overlay = Overlay(levels_provider=self.recorder.get_levels)

        self.hotkey = HotkeyTap(
            mode=cfg["hotkey"]["mode"],
            on_press=self._on_press,
            on_release=self._on_release,
            on_double_tap=self._on_toggle,   # double-tap same modifier = hands-free toggle
        )
        # Cmd+Opt+V — paste the most recent transcript into whatever's focused right now
        # (escape hatch when the auto-paste landed in the wrong field)
        self._last_text = ""
        self.paste_hotkey = PasteHotkey(
            keycode=KEYCODE_V,
            modifiers_mask=kCGEventFlagMaskCommand | kCGEventFlagMaskAlternate,
            on_fire=self._on_paste_last,
        )

    def set_state(self, state: str) -> None:
        with self._state_lock:
            self._state = state

    def get_state(self) -> str:
        with self._state_lock:
            return self._state

    def _on_press(self) -> None:
        # Block re-entry if a press is already in flight OR the recorder is
        # actually capturing. Intent flag covers the ~100ms cold-start window
        # where recorder._armed is still False but a bg start() is in flight.
        if self._record_intent or self.recorder.is_recording:
            return
        if not self._gate_can_dictate():
            return
        self._start_recording()

    def _gate_can_dictate(self) -> bool:
        """Hard cap check before starting a recording. Free plan blocks once
        words_used_today >= daily cap. Pro tiers are unlimited."""
        cap = self._license.daily_word_cap
        if cap is None:
            return True
        # CR #13b — hot-path: this runs on the CGEventTap callback thread,
        # which must return fast or macOS disables the tap. Use the cached
        # variant so we don't JSON-parse all-of-history on every press.
        used = self.store.words_used_today_cached()
        if used >= cap:
            print(f"[lumo] blocked: free cap reached ({used}/{cap} words today)")
            try:
                rumps.notification(
                    "Lirelle",
                    "Daily limit reached",
                    f"{used} / {cap} words used today. Upgrade to Pro for unlimited dictation.",
                )
            except Exception as e:
                print(f"[lumo] notification error: {e}")
            return False
        return True

    def _gate_effective_cleanup_level(self) -> str:
        """Return the cleanup level we'll actually use, downgraded if the current
        plan doesn't allow the user's selection. Notifies once per downgrade."""
        chosen = self._cleanup_level
        allowed = self._license.allows_cleanup_levels
        if chosen in allowed:
            return chosen
        # downgrade to the most powerful level the plan allows
        for fallback in ("polished", "standard", "light", "raw"):
            if fallback in allowed:
                print(f"[lumo] cleanup downgrade: {chosen} → {fallback} (plan={self._license.plan})")
                return fallback
        return "raw"

    def _on_release(self) -> None:
        # Synthetic key-up events fired by macOS on app-activation (the
        # Cmd+Tab-kills-hands-free bug) arrive milliseconds after an
        # NSWorkspaceDidActivateApplication. We only suppress in
        # hands-free mode (VAD active): hold-mode users physically hold
        # the modifier the whole time, so any release they fire IS real.
        # Hands-free users are NOT holding anything, so any "release"
        # arriving here is synthetic by definition.
        import time as _t
        last_act = getattr(self, "_last_app_activation_at", 0.0)
        vad_active = getattr(self.recorder, "_vad_active", False)
        if vad_active and last_act and (_t.time() - last_act) < 0.30:
            print("[hotkey] release suppressed (hands-free + synthetic from app-switch)",
                  flush=True)
            return
        # Use intent flag OR live armed flag — a press accepted moments ago
        # may not yet have made it to recorder._armed=True if CoreAudio is
        # mid-cold-start. We still want to stop+transcribe.
        if not (self._record_intent or self.recorder.is_recording):
            return
        self._record_intent = False
        self._stop_and_transcribe()

    def _on_toggle(self) -> None:
        """Double-tap = start a hands-free recording with voice-activity
        auto-stop.

        UX flow:
          - User double-taps the modifier hotkey.
          - Lirelle enters listening mode, NO need to hold the key down.
          - User speaks.
          - When the user is silent for ~800 ms, webrtcvad in the parent
            recognises end-of-utterance and we auto-DISARM, transcribe,
            and paste. Total UX: tap, tap, speak, done.
          - User can also force-stop early with a single tap (on_press
            fires, sees `_record_intent` is True, treats it as stop).

        Critical: this runs on the Quartz event-tap thread. recorder.stop()
        does pipe I/O and waits on a thread Event; calling it inline could
        stall the event-tap callback long enough for macOS to disable the
        tap. Any cleanup of a residual recording from the FIRST tap of the
        double-tap goes to a background thread.
        """
        if self.recorder.is_recording:
            # CR #1 — Previously the discard ran on a bg thread while we
            # synchronously called _start_recording_vad on the event-tap
            # thread; that spawned a SECOND bg thread which called
            # recorder.start_with_vad(). The two bg threads raced on
            # recorder._armed: if start_with_vad ran first it saw _armed
            # already True (left over from the first tap) and returned
            # silently, then the discard disarmed. Net effect: state
            # stayed "listening", overlay stayed visible, but nothing
            # was actually recording — the user's "press 2-3 times to
            # start" complaint.
            #
            # Fix: do both on one bg thread, in order. discard runs
            # first (synchronous), then we hop back to the main run loop
            # to start fresh. The event-tap thread still doesn't block
            # (we return immediately).
            if not self._gate_can_dictate():
                return
            def _discard_then_start():
                try:
                    self._discard_residual_recording()
                finally:
                    AppHelper.callAfter(self._start_recording_vad)
            threading.Thread(target=_discard_then_start, daemon=True).start()
            return
        if not self._gate_can_dictate():
            return
        self._start_recording_vad()

    def _start_recording_vad(self) -> None:
        """Variant of _start_recording that uses recorder.start_with_vad()
        so webrtcvad auto-stops the recording on sustained silence. The
        on_auto_stop callback fires on the recorder's reader thread —
        we dispatch back to the main run loop via AppHelper.callAfter
        because state changes / overlay updates need the main thread."""
        try:
            app = NSWorkspace.sharedWorkspace().frontmostApplication()
            self._target_bundle_id = str(app.bundleIdentifier()) if app else None
            self._target_name = str(app.localizedName()) if app else None
            # Capture the PID so the paster can target THIS specific
            # process via `tell process id N` instead of `tell application
            # "Google Chrome"` — which is ambiguous when the user has two
            # Chrome processes (e.g. their normal Chrome + a debug-port
            # Chrome). PID is the only unambiguous handle.
            self._target_pid = int(app.processIdentifier()) if app else None
        except Exception:
            self._target_bundle_id = None
            self._target_name = None
            self._target_pid = None
        self._record_generation += 1
        self._record_intent = True
        self.set_state("listening")
        try:
            self.overlay.show_listening()
        except Exception as e:
            print(f"[lumo] overlay show error: {e}")

        def _on_vad_silence():
            # Called from the recorder reader thread when webrtcvad declares
            # end-of-utterance. Bounce to main so the standard transcribe
            # path runs in the right context.
            AppHelper.callAfter(self._auto_stop_from_vad)

        # Read configurable hands-free pause LIVE from the on-disk config
        # so a Settings UI change applies on the very next double-tap (no
        # restart needed). load_config() merges bundled defaults + user
        # overrides; the disk hit is ~ms, negligible for an event that
        # fires once per dictation. Default 2000ms (raised from 800ms
        # 2026-06-10 — 800 was clipping users mid-monologue).
        try:
            live_cfg = load_config()
            pause_ms = int((live_cfg.get("dictation") or {}).get(
                "hands_free_pause_ms", 2000))
        except Exception:
            pause_ms = 2000

        def _bg_start():
            try:
                self.recorder.start_with_vad(
                    on_auto_stop=_on_vad_silence,
                    silence_threshold_ms=pause_ms,
                )
            except Exception as e:
                print(f"[lumo] recorder.start_with_vad() failed: {e}")
                try:
                    from .telemetry import capture
                    capture(e, where="_start_recording_vad")
                except Exception:
                    pass
                self._record_intent = False
                self.set_state("idle")
                try:
                    self.overlay.hide()
                except Exception:
                    pass

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

    def _auto_stop_from_vad(self) -> None:
        """Main-thread handler for VAD-detected silence. Same path as
        on_release would take: clears intent, kicks off stop+transcribe."""
        if not self._record_intent:
            return  # user already manually stopped; VAD beat us to the punch
        self._record_intent = False
        self._stop_and_transcribe()

    def _discard_residual_recording(self) -> None:
        """Background drain of a stale recording from a double-tap. Errors are
        logged, not raised — a failed stop here doesn't break the new
        recording that's already starting on the main path."""
        try:
            self.recorder.stop()
        except Exception as e:
            print(f"[lumo] _on_toggle background stop error: {e}")

    def _start_recording(self) -> None:
        # capture the bundle ID of the app focused at start so we paste back into it
        try:
            app = NSWorkspace.sharedWorkspace().frontmostApplication()
            self._target_bundle_id = str(app.bundleIdentifier()) if app else None
            self._target_name = str(app.localizedName()) if app else None
            # Capture the PID so the paster can target THIS specific
            # process via `tell process id N` instead of `tell application
            # "Google Chrome"` — which is ambiguous when the user has two
            # Chrome processes (e.g. their normal Chrome + a debug-port
            # Chrome). PID is the only unambiguous handle.
            self._target_pid = int(app.processIdentifier()) if app else None
        except Exception:
            self._target_bundle_id = None
            self._target_name = None
            self._target_pid = None
        # Bump generation BEFORE starting the recorder so an in-flight
        # _process_body from a previous cycle sees a mismatched gen in its
        # finally and skips the state=idle reset.
        self._record_generation += 1
        # Mark intent IMMEDIATELY (synchronous, on the event-tap thread) so
        # a follow-up on_release in the cold-start window correctly enters
        # stop+transcribe instead of returning early. Then dispatch
        # recorder.start() to a background thread — if CoreAudio is in a
        # degraded state (the AOP wedge symptom we keep seeing) and a
        # stream.start()/stop() blocks inside HALB_Mutex::Lock for minutes
        # or forever, the BG thread is the one stuck, NOT main thread.
        # Main thread is what runs the rumps timer, overlay updates,
        # watchdogs — keeping it free is the only way recovery has any
        # chance of working.
        self._record_intent = True
        self.set_state("listening")
        try:
            self.overlay.show_listening()
        except Exception as e:
            print(f"[lumo] overlay show error: {e}")

        def _bg_start():
            try:
                self.recorder.start()
            except Exception as e:
                print(f"[lumo] recorder.start() failed: {e}")
                try:
                    from .telemetry import capture
                    capture(e, where="_start_recording")
                except Exception:
                    pass
                # Reset state so user isn't stuck in listening overlay forever.
                self._record_intent = False
                self.set_state("idle")
                try:
                    self.overlay.hide()
                except Exception:
                    pass

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

    def _stop_and_transcribe(self) -> None:
        # IMPORTANT: don't call recorder.stop() here. It blocks the main thread for
        # several seconds on long recordings (CoreAudio stream teardown + numpy
        # concatenation of thousands of chunks). The whole stop+transcribe+paste
        # chain runs in a background thread so the UI stays responsive and the
        # next dictation can start immediately.
        self.set_state("transcribing")
        try:
            self.overlay.show_transcribing()
        except Exception as e:
            print(f"[lumo] overlay transcribing error: {e}")
        self._processing = True
        # Snapshot the current generation — _process_body's finally only
        # resets state if no NEW recording started in the meantime.
        gen = self._record_generation
        threading.Thread(target=self._stop_and_process, args=(gen,), daemon=True).start()

    def _stop_and_process(self, gen: int) -> None:
        try:
            audio = self.recorder.stop()
        except Exception as e:
            print(f"[lumo] recorder stop error: {e}")
            if gen == self._record_generation:
                self._processing = False
                self.set_state("idle")
            return
        self._process_body(audio, gen)

    def _process_body(self, audio: np.ndarray, gen: int) -> None:
        try:
            duration = len(audio) / self._sample_rate if audio.size else 0
            if duration < 0.3:
                print(f"[lumo] clip too short ({duration:.2f}s), skipping")
                return

            # Stage 0: rescue copy. Dump the raw WAV to audio_cache/ BEFORE
            # touching the transcriber. If MLX crashes, the watchdog fired
            # mid-recording, or the cleanup step throws, the user still has
            # a playable file in ~/Library/Application Support/Lumo/
            # audio_cache/ to replay. save_audio_cache() also self-prunes
            # the cache to keep_audio_cache_count entries so this can't
            # balloon. Failures here are non-fatal — we still try to
            # transcribe even if disk is full / locked.
            try:
                cached_path = self.store.save_audio_cache(
                    audio, sample_rate=self._sample_rate,
                )
                print(f"[lumo] audio cached → {cached_path.name} "
                      f"({duration:.2f}s)")
            except Exception as e:
                print(f"[lumo] audio_cache save failed (non-fatal): {e}")

            # === Stage 1: transcribe. MLX serialisation lives inside
            # transcriber.transcribe() (CR #5). Narrow MLX-stage watchdog
            # (CR #9) reads _transcribing_since; set it just before the
            # call, clear after — cleanup + paste below don't count toward
            # the 60 s budget.
            #
            # Cloud Mode (v8+): we route dictation through
            # `self.dictation_transcriber`, which is a CloudDictator by
            # default. On any CloudDictatorError (network down, server
            # down, daily-cap reached, no session token) we silently
            # retry on the local MLX/whisper.cpp path so the user is
            # never stranded on a bad WiFi.
            t0 = time.time()
            self._transcribing_since = time.time()
            try:
                try:
                    result = self.dictation_transcriber.transcribe(
                        audio, sample_rate=self._sample_rate)
                except Exception as cloud_err:
                    # Import locally so unbundled cloud_dictator doesn't
                    # break the except-clause type matching.
                    try:
                        from .cloud_dictator import CloudDictatorError
                        is_cloud = isinstance(cloud_err, CloudDictatorError)
                    except Exception:
                        is_cloud = False
                    if not is_cloud:
                        raise
                    print(f"[lumo] cloud transcribe failed ({cloud_err}); "
                          f"falling back to local", flush=True)
                    result = self.transcriber.transcribe(
                        audio, sample_rate=self._sample_rate)
            finally:
                self._transcribing_since = None
            dt = time.time() - t0
            raw_text = result["text"]
            lang = result["language"]
            print(f"[lumo] transcribed {duration:.2f}s in {dt:.2f}s  lang={lang}  text={raw_text!r}")
            if not raw_text.strip():
                return

            # === Stage 2: cleanup. Runs outside the MLX lock. May shell out to
            # the `claude` CLI for standard/polished levels (~5-90s) — would
            # have stalled every future dictation if it were still locked.
            # raw/light are local and fast, so this is no-op for Free users.
            level = self._gate_effective_cleanup_level()
            cleanup_cfg = self.cfg.get("cleanup", {})
            model = cleanup_cfg.get("model", DEFAULT_CLEANUP_MODEL)
            if level != "raw":
                t1 = time.time()
                final_text = clean_text(raw_text, level=level, model=model)
                print(f"[lumo] cleanup '{level}' in {time.time() - t1:.2f}s")
            else:
                final_text = raw_text

            # === Stage 3: persist + paste, also lock-free.
            self.store.append_transcript(
                final_text, lang=lang, ts=datetime.now(),
                cleanup_level=level, text_raw=raw_text,
            )
            self._last_text = final_text  # for manual Cmd+Opt+V repaste hotkey
            # CR (2026-06-23) — notify the bank's Transcripts tab that a
            # new line landed, so dictating while the window is open
            # refreshes the list without the user switching tabs.
            try:
                bank = getattr(self, "bank", None)
                shell = getattr(bank, "_web_shell", None) if bank else None
                if shell is not None:
                    shell.bridge.push("transcript-added", {
                        "text_preview": (final_text or "")[:80],
                    })
            except Exception as e:  # noqa: BLE001
                print(f"[lumo] transcript-added push failed (non-fatal): {e}",
                      flush=True)
            target_bundle = getattr(self, "_target_bundle_id", None)
            target_name = getattr(self, "_target_name", None)
            target_pid = getattr(self, "_target_pid", None)
            if target_bundle:
                print(f"[lumo] pasting into {target_name} ({target_bundle}) "
                      f"pid={target_pid}")
            # Paste in a fire-and-forget thread so a slow/timed-out paste (Chrome,
            # full-screen apps) doesn't block the next dictation. The transcript
            # is already saved at this point — paste failure ≠ data loss.
            threading.Thread(
                target=lambda: paste_text(
                    final_text,
                    restore_clipboard=self.cfg["paste"].get("restore_clipboard", True),
                    target_bundle_id=target_bundle,
                    target_name=target_name,
                    target_pid=target_pid,
                ),
                daemon=True,
            ).start()
        except Exception as e:
            print(f"[lumo] process error: {e}")
        finally:
            # Only reset state/overlay if no NEW recording started while we
            # were transcribing. Without this check, a double-tap (or any
            # rapid re-press) lets the stale pipeline's finally clobber the
            # listening state of the new recording — overlay disappears,
            # state goes idle, user thinks Lirelle turned itself off.
            if gen == self._record_generation:
                self._processing = False
                self.set_state("idle")
                try:
                    self.overlay.hide()
                except Exception as e:
                    print(f"[lumo] overlay hide error: {e}")
            else:
                print(f"[lumo] process gen={gen} != current={self._record_generation}, "
                      f"skipping state reset (new recording in progress)")

    # 0.5s is fine for icon updates — humans don't perceive a 500ms lag on the
    # menubar between releasing the hotkey and the icon swapping to
    # 'transcribing'. The previous 0.1s burned a main-thread tick 10x/sec
    # forever, competing with bank scroll and overlay animation.
    @rumps.timer(0.5)
    def _paint(self, _sender) -> None:
        state = self.get_state()
        target_icon = {
            "idle": ICON_IDLE,
            "listening": ICON_LISTENING,
            "transcribing": ICON_TRANSCRIBING,
        }[state]
        if self.icon != target_icon:
            self.icon = target_icon
        status_label = f"Status: {state}"
        if self.menu["Status: idle"].title != status_label:
            self.menu["Status: idle"].title = status_label

        # Watchdog — only fire when the MLX stage itself stalls. CR #9
        # narrowed this from "any time in 'transcribing' state" (which
        # included slow polished cleanup + paste, ~5-90s legitimately)
        # to a heartbeat the pipeline pokes after MLX returns. So
        # _transcribing_since now tracks "stuck inside MLX", and a slow
        # cleanup no longer false-fires the "запись потеряна" notification.
        #
        # CR #5 removed the caller-side _process_lock here; the
        # transcriber owns its own mutex now, so we don't need to rotate
        # anything on the hung path — the watchdog just resets state.
        if state == "transcribing" and getattr(self, "_transcribing_since", None) is not None:
            if time.time() - self._transcribing_since > 60.0:
                print(f"[lumo] watchdog: MLX stage stuck >60s — "
                      f"resetting state, audio is on disk in audio_cache")
                self._processing = False
                self.set_state("idle")
                try:
                    self.overlay.hide()
                except Exception:
                    pass
                try:
                    rumps.notification(
                        "Lirelle",
                        "Транскрипция зависла",
                        "Текущая запись потеряна. Можно диктовать дальше.",
                    )
                except Exception as e:
                    print(f"[lumo] watchdog notification failed: {e}")
                self._transcribing_since = None
        else:
            self._transcribing_since = None

        # Second watchdog — "listening" stuck for a long time means
        # on_release never fired (hotkey release event lost, event-tap
        # got disabled by macOS for being too slow, or the recorder.start
        # race we fixed in _start_recording slipped through some other
        # path).
        #
        # CR (2026-07-04) — was 120s, now 900s (15 min). User reported
        # that long hands-free dictation (voice memo, brainstorm,
        # meeting notes without formal recording) was being ended by
        # this watchdog at exactly the 2-min mark, dropping the tail
        # of what was still an ACTIVE session. Real wedged-hotkey
        # scenarios still get caught eventually; the trade-off is
        # a 15-min ceiling versus killing legit dictation.
        _WATCHDOG_LISTENING_SEC = 900.0
        if state == "listening":
            if not hasattr(self, "_listening_since") or self._listening_since is None:
                self._listening_since = time.time()
            elif time.time() - self._listening_since > _WATCHDOG_LISTENING_SEC:
                # Watchdog fired. Previously we silently discarded the
                # accumulated audio buffer here, which meant if the user
                # had dictated for 90 seconds before something hung, all
                # 90 seconds were lost. Now we go through the SAME path
                # as a normal hotkey release — recorder.stop() returns
                # whatever audio it managed to capture, _process_body
                # tries to transcribe it, and a raw WAV gets dumped to
                # audio_cache/ as a last-resort recovery copy. Worst
                # case the buffer is empty (helper hung before any
                # frames came in) and _process_body's clip-too-short
                # check skips. Best case the user's dictation survives.
                print(f"[lumo] watchdog: stuck in 'listening' "
                      f">{int(_WATCHDOG_LISTENING_SEC)}s — "
                      f"rescuing buffer through normal stop+transcribe path")
                self._record_intent = False
                self._listening_since = None
                # _stop_and_transcribe runs stop()+transcribe on a bg
                # thread, sets state to 'transcribing' (so the capsule
                # shows the spinner instead of staying stuck on
                # 'listening'), and pastes if anything decoded.
                try:
                    self._stop_and_transcribe()
                except Exception as e:
                    print(f"[lumo] watchdog rescue error: {e}")
                    # Fall back to the old hard-reset so the UI doesn't
                    # remain stuck.
                    try: self.recorder.stop()
                    except Exception: pass
                    self.set_state("idle")
                    try: self.overlay.hide()
                    except Exception: pass
                try:
                    rumps.notification(
                        "Lirelle",
                        "Запись зависла — попытка восстановить",
                        "Что успело записаться — расшифровываю.",
                    )
                except Exception as e:
                    print(f"[lumo] watchdog notification failed: {e}")
        else:
            self._listening_since = None

    @property
    def meeting_recorder(self):
        """Shared MeetingRecorder for the whole app. Lazy-init so first
        use pays for the sysaudio_proxy import; subsequent accesses are
        cheap. CR #6 — both menubar and bank Start/Stop buttons go
        through this single instance so they always agree on
        is_recording(), and the menubar path inherits transcriber_provider
        so a menubar-started meeting actually finishes transcribing
        (the old menubar recorder didn't have a provider and stalled at
        ready_for_transcription forever)."""
        if self._meeting_recorder is None:
            from .meeting_recorder import MeetingRecorder
            self._meeting_recorder = MeetingRecorder()
            self._meeting_recorder.transcriber_provider = (
                lambda: getattr(self, "transcriber", None)
            )
        return self._meeting_recorder

    def set_cleanup_level(self, key: str) -> None:
        """Single source of truth for cleanup level changes. Updates the in-memory
        state, the menu bar checkmarks/label, persists to the user config so the
        next launch carries it, and notifies the bank window so its Settings pane
        re-paints. Called by both the menu items and the Settings UI."""
        if key not in {k for k, _ in CLEANUP_LEVELS}:
            return
        self._cleanup_level = key
        for k, item in self._cleanup_items.items():
            item.state = 1 if k == key else 0
        label = f"Cleanup: {key}"
        for existing in list(self.menu):
            if existing.startswith("Cleanup:"):
                self.menu[existing].title = label
                break
        try:
            self.bank.refresh_settings_view()
        except Exception:
            pass
        # CR #4 — also persist to the user config so a menubar change isn't
        # lost on restart (mirror of what the JS updateSetting does). Bypass
        # the bridge so we don't trigger live-apply recursively.
        try:
            from . import web_handlers
            web_handlers.update_setting({"key": "cleanup.level", "value": key})
        except Exception as e:
            print(f"[lumo] persist cleanup.level failed (non-fatal): {e}")
        print(f"[lumo] cleanup level -> {key}")

    def _make_cleanup_setter(self, key: str):
        def setter(sender):  # noqa: ARG001
            self.set_cleanup_level(key)
        return setter

    def _on_paste_last(self) -> None:
        """Re-paste the last transcript into whatever is focused right now.
        Called on Cmd+Opt+V. No target activation — system-wide Cmd+V hits the
        currently focused field directly, so this is the manual fallback for when
        the auto-paste went to the wrong app."""
        if not self._last_text:
            print("[lumo] no last transcript to repaste")
            return
        print(f"[lumo] manual repaste: {self._last_text[:60]!r}")
        threading.Thread(
            target=lambda: paste_text(
                self._last_text,
                restore_clipboard=self.cfg["paste"].get("restore_clipboard", True),
            ),
            daemon=True,
        ).start()

    def _open_bank_window(self, _sender) -> None:
        self.bank.show()

    def _menu_open_cabinet(self, _sender) -> None:
        """Open the web cabinet in the user's default browser. The cabinet
        uses its own magic-link login — the menu shortcut just gets them to
        the right URL fast."""
        import subprocess
        from .licensing import cabinet_url
        try:
            subprocess.Popen(["open", cabinet_url()])
        except Exception as e:
            print(f"[lumo] open cabinet failed: {e}")

    def _menu_toggle_meeting(self, _sender) -> None:
        """Minimal Start/Stop menu hook for the meeting recorder. Full bank
        UI (live duration counter, list of past meetings, transcript/summary
        viewer) comes in a follow-up session — this gives the user a way to
        exercise the capture pipeline end-to-end right now."""
        try:
            from .meeting_recorder import MeetingRecorder
        except Exception as e:
            print(f"[lumo] meeting_recorder import failed: {e}")
            try:
                rumps.notification("Lirelle", "Запись созвона недоступна",
                                   f"Ошибка инициализации: {e}")
            except Exception:
                pass
            return

        # CR #6 — go through the shared meeting_recorder property so the
        # bank window's view of is_recording() always matches the
        # menubar's. Stops the duplicate-recording footgun (clicking
        # bank Start while menubar one is already going) and ensures
        # the transcriber_provider is wired, so a menubar-started
        # meeting actually gets transcribed.
        rec = self.meeting_recorder

        if rec.is_recording():
            # Stop in a bg thread — finalising WAV files + draining helpers
            # can take ~200ms, don't block the menu callback path.
            def _bg_stop():
                d = rec.stop()
                if d:
                    try:
                        rumps.notification(
                            "Lirelle",
                            "Созвон записан",
                            f"Сохранено в {d.name}. Расшифровка и summary "
                            f"появятся автоматически.",
                        )
                    except Exception:
                        pass
                # CR #6 — the previous lambda had `self._meeting_item.title.__class__(setattr(...))`
                # which is a no-op disguised as code (setattr returns None,
                # str.__class__(None) raises). The menu title therefore
                # never reset after stop. Direct setattr via callAfter.
                AppHelper.callAfter(setattr, self._meeting_item, "title",
                                    "🔴 Start meeting recording")
            threading.Thread(target=_bg_stop, daemon=True).start()
            self._meeting_item.title = "⏳ Stopping…"
            return

        # Start. Spawn in bg so the menu doesn't lock up during sysaudio
        # helper bring-up (which may show macOS's Screen Recording prompt
        # the first time — that takes seconds for the user to grant).
        def _bg_start():
            try:
                rec.start()
                AppHelper.callAfter(setattr, self._meeting_item, "title",
                                    "■ Stop meeting recording")
                try:
                    rumps.notification(
                        "Lirelle",
                        "Запись созвона начата",
                        "Захватывается микрофон + системный звук. "
                        "Останови той же кнопкой в меню Lirelle.",
                    )
                except Exception:
                    pass
            except Exception as e:
                print(f"[lumo] meeting start failed: {e}")
                AppHelper.callAfter(
                    lambda: setattr(self._meeting_item, "title",
                                    "🔴 Start meeting recording")
                )
                try:
                    rumps.notification("Lirelle", "Не удалось начать запись", str(e))
                except Exception:
                    pass

        self._meeting_item.title = "⏳ Starting…"
        threading.Thread(target=_bg_start, daemon=True).start()

    def _menu_check_updates(self, _sender) -> None:
        """Fire Sparkle's 'check for updates' user flow. No-op in dev runs
        where the framework isn't bundled — see src/updater.py."""
        try:
            from .updater import check_for_updates_now
            check_for_updates_now()
        except Exception as e:
            print(f"[lumo] update check failed: {e}")

    def _refresh_account_menu_items(self) -> None:
        """Keep the menubar's account line in sync with the local cert. Called
        when the license reloads (after activation, sign-out, refresh). Cheap —
        just rewrites the title string."""
        try:
            from .licensing import current_account_email
            email = current_account_email()
            plan_upper = self._license.plan.upper()
            label = f"◐ {plan_upper} · {email}" if email else "◐ Not signed in"
            if hasattr(self, "_account_status_item"):
                self._account_status_item.title = label
        except Exception as e:
            print(f"[lumo] account menu refresh failed: {e}")

    def _open_bank_folder(self, _sender) -> None:
        import subprocess
        subprocess.Popen(["open", str(self.store.transcripts_dir)])

    def _open_today_log(self, _sender) -> None:
        import subprocess
        day = datetime.now().strftime("%Y-%m-%d")
        path = self.store.transcripts_dir / f"{day}.md"
        if not path.exists():
            rumps.notification("Lirelle", "No log yet", f"Nothing recorded today ({day})")
            return
        subprocess.Popen(["open", str(path)])

    def _startup_refresh_cert(self) -> None:
        """One-shot cert refresh fired from __init__ when the local cert looks
        stale. Runs on a daemon thread so we don't block UI bring-up if the
        server is slow. Silent on failure — the user keeps whatever plan the
        cached cert reports."""
        try:
            from .licensing import refresh_cert, load_license
            ok, info = refresh_cert()
            print(f"[lumo] startup cert refresh: ok={ok} info={info}")
            if ok:
                self._license = load_license()
                if hasattr(self, "_refresh_account_menu_items"):
                    AppHelper.callAfter(self._refresh_account_menu_items)
        except Exception as e:
            print(f"[lumo] startup cert refresh threw: {e}")

    def warmup(self) -> None:
        """Force lazy-load of mlx-whisper weights so the first real dictation is instant."""
        print("[lumo] warming up whisper model …")
        t0 = time.time()
        silence = np.zeros(self._sample_rate, dtype=np.float32)
        self.transcriber.transcribe(silence, sample_rate=self._sample_rate)
        print(f"[lumo] warmup done in {time.time() - t0:.1f}s")


def _safe_install(name: str, hotkey) -> None:
    """Best-effort hotkey install. macOS may refuse keyDown taps if Lirelle doesn't yet
    have Input Monitoring permission — in that case we log + continue rather than
    crashing on launch. User can grant Input Monitoring later and restart."""
    try:
        hotkey.install()
    except Exception as e:
        print(f"[lumo] {name} install failed: {e}")
        print("[lumo] -> grant 'Input Monitoring' in System Settings -> Privacy & Security, then restart Lirelle")


def _install_main_menu() -> None:
    """Standard macOS application menu — gives us About / Hide / Quit (Cmd+Q)
    in the top menu bar (rumps doesn't set this up for status-bar-only apps,
    but we want it because Lirelle runs as a Dock app)."""
    ns = NSApplication.sharedApplication()
    main = NSMenu.alloc().init()

    app_item = NSMenuItem.alloc().init()
    main.addItem_(app_item)
    app_menu = NSMenu.alloc().init()
    app_item.setSubmenu_(app_menu)

    about = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
        "About Lirelle", "orderFrontStandardAboutPanel:", ""
    )
    app_menu.addItem_(about)
    app_menu.addItem_(NSMenuItem.separatorItem())

    hide = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Hide Lirelle", "hide:", "h")
    app_menu.addItem_(hide)
    hide_others = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
        "Hide Others", "hideOtherApplications:", "h"
    )
    # Cmd+Option+H
    hide_others.setKeyEquivalentModifierMask_(1 << 19 | 1 << 20)  # Option | Command
    app_menu.addItem_(hide_others)
    show_all = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
        "Show All", "unhideAllApplications:", ""
    )
    app_menu.addItem_(show_all)
    app_menu.addItem_(NSMenuItem.separatorItem())

    quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
        "Quit Lirelle", "terminate:", "q"
    )
    app_menu.addItem_(quit_item)

    # Edit menu — cut/copy/paste/select-all for text fields
    edit_item = NSMenuItem.alloc().init()
    main.addItem_(edit_item)
    edit_menu = NSMenu.alloc().initWithTitle_("Edit")
    edit_item.setSubmenu_(edit_menu)
    for title, sel, key in [
        ("Undo", "undo:", "z"),
        ("Redo", "redo:", "Z"),
        (None, None, None),
        ("Cut", "cut:", "x"),
        ("Copy", "copy:", "c"),
        ("Paste", "paste:", "v"),
        ("Select All", "selectAll:", "a"),
    ]:
        if title is None:
            edit_menu.addItem_(NSMenuItem.separatorItem())
        else:
            edit_menu.addItem_(
                NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(title, sel, key)
            )

    ns.setMainMenu_(main)
    print("[lumo] main menu installed (Quit ⌘Q available)")


def main() -> None:
    cfg = load_config()

    # Sentry telemetry — runs first so an early crash (model probe, py2app
    # path quirk on a customer's Mac) still gets captured. No-op if no DSN
    # is configured. Privacy guardrails live in telemetry.py: transcript
    # text is scrubbed from any captured event before transmission.
    try:
        from .telemetry import init as _init_telemetry
        _init_telemetry(release="lumo@0.1.0", environment="production")
    except Exception as e:  # noqa: BLE001
        print(f"[lumo] telemetry init failed (continuing without): {e}")

    # Sparkle auto-updater — loads Sparkle.framework from .app/Contents/
    # Frameworks/, starts its scheduled-check timer. No-op when running
    # outside a frozen .app (dev `python -m src.app` path).
    try:
        from .updater import init_updater
        init_updater()
    except Exception as e:  # noqa: BLE001
        print(f"[lumo] updater init failed (continuing without): {e}")

    # First-run readiness probe — pure check, no UI here. The new
    # onboarding_v2 is run AFTER LirelleApp is up (it needs the rumps run
    # loop for WKWebView), so we just log model state at this point.
    # If the model isn't cached, onboarding_v2.show_full_onboarding will
    # drive the download from inside the run loop.
    try:
        from .transcriber import make_transcriber
        _probe = make_transcriber(cfg["whisper"])
        if not _probe.is_model_ready():
            print("[lumo] first run — model not cached; onboarding will handle it")
        else:
            print(f"[lumo] model ready ({type(_probe).__name__})")
    except Exception as e:
        print(f"[lumo] model probe failed (continuing): {e!r}")

    app = LirelleApp(cfg)
    _safe_install("hold-to-talk + double-tap-toggle hotkey", app.hotkey)
    _safe_install("repaste hotkey (Cmd+Opt+V)", app.paste_hotkey)
    _safe_install("open-window hotkey (Cmd+Opt+L)", app.open_window_hotkey)
    threading.Thread(target=app.warmup, daemon=True).start()

    # CR #12 — recover meetings stuck in status='recording' from a prior
    # crash/force-quit/reboot. Fixes their WAV headers in place and kicks
    # retro-transcribe so they don't sit as zombie "RECORDING" badges in
    # the UI forever. Runs on a bg thread so a slow recovery (large
    # WAVs) doesn't delay the run loop.
    def _orphan_recovery():
        try:
            from .meeting_recorder import recover_orphan_meetings
            n = recover_orphan_meetings(
                transcriber_provider=lambda: getattr(app, "transcriber", None))
            if n:
                print(f"[lumo] recovered {n} orphan meeting(s) at startup",
                      flush=True)
        except Exception as e:
            print(f"[lumo] orphan-meeting recovery failed: {e}", flush=True)
    threading.Thread(target=_orphan_recovery, daemon=True).start()

    def _post_launch() -> None:
        # (a) decide whether to run onboarding before the bank window opens.
        # The onboarding window owns its own NSWindow + WebShell; on
        # completion (Done page click, or window close) it calls back into
        # bank.show(). If no onboarding needed → open bank directly.
        try:
            from .onboarding_v2 import (
                should_run_full_onboarding,
                should_show_permissions_only,
                show_full_onboarding,
                show_permissions_only,
            )
            def _after_onboarding():
                app.bank.show()
            if should_run_full_onboarding(cfg):
                print("[lumo] launching onboarding v2 (full flow)", flush=True)
                # Keep a reference on app so the window isn't GC'd while
                # the user is interacting with it.
                app._onboarding_window = show_full_onboarding(
                    app, _after_onboarding)
            elif should_show_permissions_only():
                print("[lumo] launching onboarding v2 (permissions-only)", flush=True)
                app._onboarding_window = show_permissions_only(
                    app, _after_onboarding)
            else:
                app.bank.show()
        except Exception as e:
            print(f"[lumo] onboarding launch failed: {e!r}", flush=True)
            app.bank.show()
        # (b) install the delegate proxy that catches applicationShouldHandleReopen
        ns = NSApplication.sharedApplication()
        inner = ns.delegate()
        proxy = _AppDelegateProxy.alloc().initWithBank_inner_app_(
            app.bank, inner, app)
        app._delegate_proxy = proxy  # retain
        ns.setDelegate_(proxy)
        # (c) standard Mac main menu — gives us Cmd+Q + About + Hide
        _install_main_menu()
        # (d) force the rumps status item visible. rumps 0.4.0 + macOS Sequoia (15.x)
        # sometimes silently produces a zero-width / invisible status item. Force
        # a fixed length and an attributed yellow title as a hard fallback so the
        # menu bar entry is always visible even if the PNG icon doesn't render.
        try:
            from AppKit import NSAttributedString, NSColor, NSFont, NSForegroundColorAttributeName, NSFontAttributeName  # type: ignore
            item = app._nsapp.nsstatusitem
            print(f"[lumo] statusitem={item} length={item.length() if item else None} "
                  f"visible={item.isVisible() if item else None} "
                  f"image={item.image()} title={item.title()!r}")
            if item is not None:
                item.setLength_(28.0)
                try:
                    item.setVisible_(True)
                except Exception:
                    pass
                # if image failed to load, fall back to a yellow bullet via attributed title
                if item.image() is None:
                    gold = NSColor.colorWithCalibratedRed_green_blue_alpha_(
                        0xEB / 255, 0xB6 / 255, 0x38 / 255, 1.0
                    )
                    attrs = {
                        NSForegroundColorAttributeName: gold,
                        NSFontAttributeName: NSFont.systemFontOfSize_(14.0),
                    }
                    item.button().setAttributedTitle_(
                        NSAttributedString.alloc().initWithString_attributes_("●", attrs)
                    )
                    print("[lumo] fallback yellow attributed title installed")
        except Exception as e:
            print(f"[lumo] status item force-visible failed: {e}")
        print("[lumo] delegate proxy installed; auto-opened bank window")

    AppHelper.callLater(0.4, _post_launch)
    app.run()


if __name__ == "__main__":
    main()
