"""macOS permission wizard — first-run + missing-permission recovery.

Runs after Whisper-model onboarding (`run_onboarding`). Probes each
permission Lirelle needs, shows a panel listing the missing ones with
"Open Settings" buttons that deep-link the right pane in System
Settings, and auto-dismisses once all green.

Permissions checked:
  - Microphone           — required for any dictation / meeting recording
  - Accessibility        — required for global hotkey + AppleScript paste
  - Input Monitoring     — required for the modifier-hold CGEventTap
  - Screen Recording     — required for ScreenCaptureKit system audio
                           (meeting mode); dictation works without it

The wizard never BLOCKS launch: if the user dismisses it, the app keeps
running. They can re-trigger it later from the menubar (Lirelle → Check
Permissions…) once that menu item is wired.

This module is import-light at module level so it can be imported from
lumo_main.py without paying for AppKit / Quartz before they're needed.
"""

from __future__ import annotations

import subprocess
from typing import Callable


# ── Status probes ──────────────────────────────────────────────────────


def _mic_status() -> bool:
    """True iff Lirelle has Microphone permission EXPLICITLY GRANTED.

    `AVAuthorizationStatusNotDetermined` (the system has never asked for this
    process) is treated as NOT granted — the wizard must show ❌ so the user
    actually clicks "Open Settings" and grants it. Before 2026-06-16 we
    counted NotDetermined as granted, which made every fresh install show
    all permissions as ✅ from the first launch — a hard bug that masked the
    onboarding pane's whole reason for existing.

    Fallback is now `False` (fail-closed). A failed probe means we can't
    confirm the permission — better to over-ask than to under-ask."""
    try:
        from AVFoundation import (  # type: ignore
            AVCaptureDevice,
            AVAuthorizationStatusAuthorized,
        )
        # AVMediaTypeAudio is the string constant 'soun' across SDK versions.
        status = AVCaptureDevice.authorizationStatusForMediaType_("soun")
        return status == AVAuthorizationStatusAuthorized
    except Exception:
        return False


_CTYPES_LIBS: dict = {}


def _load_ctypes_lib(name: str):
    """Lazy-load a macOS system framework via ctypes. Caches per name so
    we don't hit ctypes.util.find_library on every poll. Returns the
    CDLL or None on failure."""
    if name in _CTYPES_LIBS:
        return _CTYPES_LIBS[name]
    try:
        import ctypes
        import ctypes.util
        path = ctypes.util.find_library(name)
        if not path:
            _CTYPES_LIBS[name] = None
            return None
        lib = ctypes.cdll.LoadLibrary(path)
        _CTYPES_LIBS[name] = lib
        return lib
    except Exception as e:  # noqa: BLE001
        print(f"[permissions] ctypes load {name!r} failed: {e}", flush=True)
        _CTYPES_LIBS[name] = None
        return None


def _accessibility_status() -> bool:
    """LIVE Accessibility status via direct ctypes calls into
    ApplicationServices.framework — bypasses PyObjC bindings entirely.

    Why ctypes instead of PyObjC: v13 testing on the user's second Mac
    showed py2app didn't bundle pyobjc-framework-ApplicationServices
    (we only have Cocoa / Quartz / CoreAudio / AVFoundation /
    CoreMedia / ScreenCaptureKit / WebKit). Every AS import raised
    ModuleNotFoundError and the probe always returned denied. Adding
    the missing pyobjc package would also work but ctypes is more
    robust: macOS system dylibs are ALWAYS at
    /System/Library/Frameworks/, no bundling needed.

    "Test by trying": call AXUIElementCopyAttributeValue against the
    system-wide element. macOS consults TCC on every AX call, so the
    error code reflects the CURRENT grant state without any per-process
    caching. kAXErrorSuccess (0) = granted; -25204 (kAXErrorAPIDisabled)
    = not authorized. Any other error → fail closed (return False)."""
    import ctypes
    try:
        as_lib = _load_ctypes_lib("ApplicationServices")
        cf_lib = _load_ctypes_lib("CoreFoundation")
        if as_lib is None or cf_lib is None:
            print("[permissions] AX live-test: dylib not found", flush=True)
            return False

        # Function signatures (only first-time; CFTypeRef returns are opaque).
        as_lib.AXUIElementCreateSystemWide.restype = ctypes.c_void_p
        as_lib.AXUIElementCopyAttributeValue.argtypes = [
            ctypes.c_void_p, ctypes.c_void_p,
            ctypes.POINTER(ctypes.c_void_p),
        ]
        as_lib.AXUIElementCopyAttributeValue.restype = ctypes.c_int

        cf_lib.CFStringCreateWithCString.argtypes = [
            ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32,
        ]
        cf_lib.CFStringCreateWithCString.restype = ctypes.c_void_p
        cf_lib.CFRelease.argtypes = [ctypes.c_void_p]
        cf_lib.CFRelease.restype = None

        system = as_lib.AXUIElementCreateSystemWide()
        if not system:
            return False
        # 0x08000100 = kCFStringEncodingUTF8
        attr = cf_lib.CFStringCreateWithCString(
            None, b"AXFocusedApplication", 0x08000100,
        )
        out_value = ctypes.c_void_p()
        err = as_lib.AXUIElementCopyAttributeValue(
            system, attr, ctypes.byref(out_value),
        )
        # Release any returned value + the strings/elements we created.
        try:
            if out_value.value:
                cf_lib.CFRelease(out_value)
            if attr:
                cf_lib.CFRelease(attr)
            cf_lib.CFRelease(system)
        except Exception:
            pass

        granted = (err == 0)
        print(f"[permissions] AX live-test err={err} → "
              f"{'granted' if granted else 'denied'}", flush=True)
        return granted
    except Exception as e:
        print(f"[permissions] AX live-test raised: {e}", flush=True)
        return False


def _bootloader_path():
    """Path to the .app bundle's main executable — same loop recorder.py
    uses to find the bootloader for spawning the audio helper. Falls
    back to sys.executable when running unbundled (python -m src.app)."""
    import sys as _sys
    from pathlib import Path as _Path
    here = _Path(__file__).resolve().parent.parent
    if getattr(_sys, "frozen", False):
        for exe_name in ("Lirelle", "Lumo"):
            candidate = here.parent / "MacOS" / exe_name
            if candidate.exists():
                return [str(candidate)]
        return [_sys.executable, str(here / "lumo_main.py")]
    return [_sys.executable, str(here / "lumo_main.py")]


def _spawn_permission_check(name: str, timeout: float = 1.5) -> bool:
    """Run `Lirelle --check-permission <name>` in a fresh subprocess
    and parse the "granted"/"denied" reply. The whole point is to
    escape the parent's cached TCC state — every poll gets a virgin
    process where AXIsProcessTrusted / IOHIDCheckAccess reads the
    current TCC entry instead of the boot-time snapshot.

    Timeout 1.5 s keeps the polling tight; if the helper hangs (e.g.
    on a slow first launch) we report False and let the next poll
    retry rather than blocking the onboarding UI."""
    import subprocess as _sp
    argv = _bootloader_path() + ["--check-permission", name]
    try:
        r = _sp.run(argv, capture_output=True, timeout=timeout, text=True)
    except _sp.TimeoutExpired:
        return False
    except Exception:
        return False
    return r.stdout.strip() == "granted"


_INPUT_MONITORING_CACHE: dict = {"granted": False, "checked_at": 0.0}
_INPUT_MONITORING_CACHE_TTL = 30.0  # re-probe at most every 30 s


def _input_monitoring_status() -> bool:
    """LIVE Input Monitoring status via direct ctypes IOKit call.

    v13 testing showed pyobjc-framework-Quartz 12.2 doesn't expose
    IOHIDCheckAccess (added in later builds). Same fix as Accessibility:
    skip PyObjC, call the dylib directly. IOKit.framework is always at
    /System/Library/Frameworks/IOKit.framework/IOKit on every Mac.

    Constants:
        kIOHIDRequestTypeListenEvent = 1   (Input Monitoring scope)
        kIOHIDAccessTypeGranted      = 0
        kIOHIDAccessTypeDenied       = 1
        kIOHIDAccessTypeUnknown      = 2   (never determined)

    IOHIDCheckAccess reads TCC on every call (no per-process cache,
    confirmed by Apple's documented threat model), so polling the
    onboarding page picks up grants made in System Settings within
    one poll tick."""
    import ctypes
    try:
        iokit = _load_ctypes_lib("IOKit")
        if iokit is None:
            print("[permissions] IM live-test: IOKit dylib not found",
                  flush=True)
            return False
        iokit.IOHIDCheckAccess.argtypes = [ctypes.c_uint32]
        iokit.IOHIDCheckAccess.restype = ctypes.c_uint32
        status = iokit.IOHIDCheckAccess(1)  # kIOHIDRequestTypeListenEvent
        granted = (status == 0)
        print(f"[permissions] IM live-test status={status} → "
              f"{'granted' if granted else 'denied'}", flush=True)
        return granted
    except Exception as e:
        print(f"[permissions] IM live-test raised: {e}", flush=True)
        return False


def _input_monitoring_status_legacy_unused() -> bool:  # pragma: no cover
    """Pre-v12 in-process probe — kept for reference / regression
    investigation only. Suffered from both a per-process TCC cache
    AND a false-positive on listen-only HID taps."""
    import time
    now = time.monotonic()
    if (now - _INPUT_MONITORING_CACHE["checked_at"]) < _INPUT_MONITORING_CACHE_TTL:
        return bool(_INPUT_MONITORING_CACHE["granted"])

    granted = False
    try:
        # IOHIDCheckAccess is the supported macOS 10.15+ API for
        # "does this process have Input Monitoring permission". Returns:
        #   kIOHIDAccessTypeGranted  = 0  → granted
        #   kIOHIDAccessTypeDenied   = 1  → user said no
        #   kIOHIDAccessTypeUnknown  = 2  → never asked → default no
        # CGEventTapCreate-based probes turned out to be useless: a
        # listen-only HID tap returns non-NULL on macOS Sequoia even
        # when Input Monitoring is denied, so we get a false ✓ on the
        # onboarding page and only learn the truth when the hotkey
        # install actually tries to USE the tap later.
        from Quartz import IOHIDCheckAccess  # type: ignore
        # kIOHIDRequestTypeListenEvent = 1 (the constant for "listen to
        # keyboard events globally" which is exactly Input Monitoring).
        # We hard-code the int rather than importing the symbol because
        # PyObjC sometimes drops these from the IOKit binding.
        kIOHIDRequestTypeListenEvent = 1
        kIOHIDAccessTypeGranted = 0
        status = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)
        granted = (status == kIOHIDAccessTypeGranted)
    except Exception as e:  # noqa: BLE001
        # If IOHIDCheckAccess isn't available (old SDK / binding miss),
        # fall back to attempting a hotkey-level tap that's *not*
        # listen-only — those require the real permission.
        try:
            from Quartz import (  # type: ignore
                CGEventTapCreate,
                kCGHIDEventTap,
                kCGHeadInsertEventTap,
                kCGEventTapOptionDefault,
                CGEventMaskBit,
                kCGEventFlagsChanged,
            )
            tap = CGEventTapCreate(
                kCGHIDEventTap, kCGHeadInsertEventTap,
                kCGEventTapOptionDefault,  # not listen-only — real check
                CGEventMaskBit(kCGEventFlagsChanged),
                lambda *_: None, None,
            )
            granted = tap is not None
            if tap is not None:
                try:
                    from CoreFoundation import CFRelease  # type: ignore
                    CFRelease(tap)
                except Exception:
                    pass
        except Exception:
            granted = False

    _INPUT_MONITORING_CACHE["granted"] = granted
    _INPUT_MONITORING_CACHE["checked_at"] = now
    return granted


def _screen_recording_status() -> bool:
    """True iff Lirelle has Screen Recording permission. ScreenCaptureKit
    needs this for system-audio capture. We don't gate the wizard's
    'all green' state on this — the user might not care about meetings
    and we shouldn't force them through. But we surface it so they know.

    Fallback is fail-closed (False)."""
    try:
        from Quartz import CGPreflightScreenCaptureAccess  # type: ignore
        return bool(CGPreflightScreenCaptureAccess())
    except Exception:
        return False


# ── Active prompts (trigger the system dialog) ─────────────────────────


def request_microphone_access() -> None:
    """Wake the macOS microphone consent dialog by exercising every
    AVFoundation + CoreAudio path we have.

    CRITICAL: this function MUST NOT block the main thread. v10 called
    sounddevice.InputStream.read(1600) from the main thread; on a
    machine where mic permission was denied / not-yet-granted the
    read() blocks indefinitely (no audio frames ever arrive), which
    froze the entire Lirelle process — and because the main NSApp
    runloop was suspended, the macOS WindowServer eventually marked
    the app as unresponsive and the whole desktop felt frozen.

    Fix: spawn the probes on a daemon thread and return immediately.
    The dialog (when it appears) is a system modal handled by
    WindowServer, not by our runloop, so it works fine from a worker."""
    import threading
    t = threading.Thread(target=_request_microphone_access_blocking,
                          name="mic-permission-probe", daemon=True)
    t.start()


def _request_microphone_access_blocking() -> None:
    """The actual three-tier probe — runs on a worker thread.

    The textbook API is `AVCaptureDevice.requestAccessForMediaType:` with
    `AVMediaTypeAudio`. In py2app builds it's flaky in two ways:
      • py2app sometimes drops the `AVMediaTypeAudio` symbol from the
        AVFoundation binding, so we fall back to the literal FourCC "soun".
      • macOS occasionally short-circuits the request without surfacing the
        system dialog when the API is called before the app is foregrounded.
        Result: the app never gets added to System Settings → Privacy &
        Security → Microphone and the user can't grant it manually either.

    Tier 2 is the bullet-proof fallback: actually open an AVCaptureSession
    against the default audio device and call startRunning. macOS HAS to
    show the dialog in that case because we're literally trying to USE the
    microphone — there's no way around it. We immediately tear the session
    down so we don't leave a hot audio handle dangling.

    Both tiers are wrapped in try/except so a failure in either is logged
    but doesn't crash the onboarding flow."""
    # Tier 1: the documented API.
    try:
        from AVFoundation import AVCaptureDevice  # type: ignore
        try:
            from AVFoundation import AVMediaTypeAudio  # type: ignore
            media_type = AVMediaTypeAudio
        except ImportError:
            media_type = "soun"
        AVCaptureDevice.requestAccessForMediaType_completionHandler_(
            media_type, lambda _granted: None,
        )
        print(f"[permissions] mic tier-1 request (media_type={media_type!r})",
              flush=True)
    except Exception as e:
        print(f"[permissions] mic tier-1 raised: {e}", flush=True)

    # Tier 2: open a real capture session against the audio device.
    try:
        from AVFoundation import AVCaptureSession, AVCaptureDeviceInput  # type: ignore
        from AVFoundation import AVCaptureDevice  # type: ignore
        try:
            from AVFoundation import AVMediaTypeAudio  # type: ignore
            media_type = AVMediaTypeAudio
        except ImportError:
            media_type = "soun"
        device = AVCaptureDevice.defaultDeviceWithMediaType_(media_type)
        if device is None:
            print("[permissions] mic tier-2: no default audio device", flush=True)
        else:
            try:
                result = AVCaptureDeviceInput.deviceInputWithDevice_error_(device, None)
                if isinstance(result, tuple):
                    audio_input, av_err = result
                else:
                    audio_input, av_err = result, None
            except Exception as e:
                print(f"[permissions] mic tier-2 input creation raised: {e}",
                      flush=True)
                audio_input = None
            if audio_input is None:
                print(f"[permissions] mic tier-2 failed: {av_err}", flush=True)
            else:
                session = AVCaptureSession.alloc().init()
                if session.canAddInput_(audio_input):
                    session.addInput_(audio_input)
                session.startRunning()
                # Give macOS a real beat to actually open the mic and
                # surface the dialog. startRunning+immediate stopRunning
                # closed the device too fast for the system to register
                # the access attempt.
                import time as _t
                _t.sleep(0.2)
                session.stopRunning()
                print("[permissions] mic tier-2: capture session ran 200ms",
                      flush=True)
    except Exception as e:
        print(f"[permissions] mic tier-2 raised: {e}", flush=True)

    # Tier 3: sounddevice (PortAudio). Same stack recorder.py uses in
    # production, but we run it with an explicit timeout-watchdog
    # because a denied mic on macOS causes stream.read() to BLOCK
    # indefinitely (the device "opens" but no frames ever arrive).
    # v10 of this code froze the whole machine because read() was
    # called from the main thread; this function now lives on a worker,
    # AND we cap the read with a 1.5 s wall clock so an unresponsive
    # device fails fast rather than hanging the worker forever.
    try:
        import sounddevice as sd  # type: ignore
        import threading as _th
        import time as _t

        stream = sd.InputStream(samplerate=16000, channels=1, blocksize=160,
                                 dtype="float32")
        stream.start()

        result_box: dict = {"samples": 0, "done": False}

        def _read_worker():
            try:
                data, _overflowed = stream.read(1600)
                result_box["samples"] = int(getattr(data, "size", 0))
            except Exception as e:  # noqa: BLE001
                result_box["error"] = repr(e)
            finally:
                result_box["done"] = True

        rt = _th.Thread(target=_read_worker, name="mic-probe-read",
                         daemon=True)
        rt.start()

        deadline = _t.monotonic() + 1.5  # 1.5 s wall-clock cap
        while not result_box["done"] and _t.monotonic() < deadline:
            _t.sleep(0.05)

        try:
            stream.stop()
        except Exception:
            pass
        try:
            stream.close()
        except Exception:
            pass

        if result_box["done"]:
            print(f"[permissions] mic tier-3: sounddevice read "
                  f"{result_box['samples']} samples", flush=True)
        else:
            print("[permissions] mic tier-3: sounddevice read timeout "
                  "after 1.5 s (no frames arrived — mic likely denied)",
                  flush=True)
    except Exception as e:
        # PortAudioError when permission denied / no device. The PROMPT
        # is what we wanted anyway; the failure is fine.
        print(f"[permissions] mic tier-3 sounddevice raised: {e}",
              flush=True)


def request_screen_recording_access() -> None:
    """Trigger Screen Recording permission flow via
    CGRequestScreenCaptureAccess. Same idea as the mic case: if status is
    NotDetermined, this is what makes macOS surface the app under
    System Settings → Privacy & Security → Screen Recording. macOS 11+."""
    try:
        from Quartz import CGRequestScreenCaptureAccess  # type: ignore
        CGRequestScreenCaptureAccess()
        print("[permissions] screen-recording access requested", flush=True)
    except Exception as e:
        print(f"[permissions] request screen-rec raised: {e}", flush=True)


# ── Deep-link to the right Settings pane ────────────────────────────────


_SETTINGS_LINKS = {
    "mic": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
    "accessibility": "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
    "input_monitoring": "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent",
    "screen_recording": "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
}


def _open_settings(slug: str) -> None:
    url = _SETTINGS_LINKS.get(slug)
    if not url:
        return
    # `open` is the reliable path — NSWorkspace.openURL_ can fail silently
    # if AppKit isn't fully initialised yet (e.g. wizard is shown before
    # the run loop owns NSApp).
    try:
        subprocess.Popen(["open", url])
    except Exception as e:
        print(f"[permissions] open Settings({slug}) failed: {e}", flush=True)


# ── Public API ──────────────────────────────────────────────────────────


PERMISSIONS = [
    # (slug, label, probe, required, why)
    ("mic", "Microphone", _mic_status, True,
     "Lirelle captures your voice for dictation and meeting recording. "
     "Transcription happens in the cloud by default for speed, or fully "
     "on your Mac if you switch on Local Mode."),
    ("accessibility", "Accessibility", _accessibility_status, True,
     "Lirelle uses Accessibility to paste your transcripts into the app "
     "you're working in and to register the dictation hotkey."),
    ("input_monitoring", "Input Monitoring", _input_monitoring_status, True,
     "Lirelle watches for the modifier hotkey (Right Option by default) "
     "so you can hold-to-talk anywhere."),
    ("screen_recording", "Screen Recording", _screen_recording_status, False,
     "Optional. Required only if you want to record meetings (captures "
     "the other side of the call via system audio)."),
]


def probe_all() -> list[dict]:
    """Return current status of every permission. Each entry is
    {slug, label, granted, required, why}."""
    out = []
    for slug, label, probe, required, why in PERMISSIONS:
        try:
            granted = bool(probe())
        except Exception as e:
            print(f"[permissions] probe({slug}) raised: {e}", flush=True)
            granted = True   # err safe
        out.append({
            "slug": slug, "label": label, "granted": granted,
            "required": required, "why": why,
        })
    return out


def all_required_granted() -> bool:
    return all(p["granted"] for p in probe_all() if p["required"])


# ── Panel (AppKit) ──────────────────────────────────────────────────────


def show_wizard_if_needed(after: Callable[[], None] | None = None) -> None:
    """Show the wizard if any REQUIRED permission is missing. Returns
    immediately; the panel is non-blocking. `after` is called on the
    main thread once the panel closes (auto or manual)."""
    missing_required = [p for p in probe_all()
                        if p["required"] and not p["granted"]]
    missing_optional = [p for p in probe_all()
                        if not p["required"] and not p["granted"]]
    if not missing_required and not missing_optional:
        if after:
            try: after()
            except Exception: pass
        return
    _open_panel(missing_required + missing_optional, after)


def _open_panel(missing: list[dict],
                after: Callable[[], None] | None) -> None:
    # Lazy-import AppKit so module-level imports stay cheap.
    from AppKit import (  # type: ignore
        NSApp, NSApplication, NSBackingStoreBuffered, NSColor, NSFont,
        NSFontWeightMedium, NSFontWeightSemibold, NSMakeRect, NSPanel,
        NSTextField, NSButton, NSWindowStyleMaskTitled,
        NSWindowStyleMaskClosable, NSWindowStyleMaskFullSizeContentView,
        NSBezelStyleRounded,
    )
    from Foundation import NSObject  # type: ignore
    import objc                       # type: ignore

    if NSApp() is None:
        NSApplication.sharedApplication()

    # 460x(120 + 90*n) panel.
    width = 480
    row_h = 96
    header_h = 92
    footer_h = 56
    height = header_h + max(1, len(missing)) * row_h + footer_h
    rect = NSMakeRect(0, 0, width, height)
    style = (NSWindowStyleMaskTitled | NSWindowStyleMaskClosable
             | NSWindowStyleMaskFullSizeContentView)
    panel = NSPanel.alloc().initWithContentRect_styleMask_backing_defer_(
        rect, style, NSBackingStoreBuffered, False,
    )
    panel.setTitle_("Lirelle — Permissions")
    panel.setTitlebarAppearsTransparent_(True)
    panel.setReleasedWhenClosed_(False)
    panel.center()
    panel.setBackgroundColor_(
        NSColor.colorWithCalibratedRed_green_blue_alpha_(0.039, 0.039, 0.039, 1.0))

    cv = panel.contentView()

    def label(text, x, y, w, h, size, weight, alpha=1.0):
        f = NSTextField.alloc().initWithFrame_(NSMakeRect(x, y, w, h))
        f.setStringValue_(text)
        f.setBezeled_(False); f.setEditable_(False)
        f.setDrawsBackground_(False); f.setSelectable_(False)
        f.setFont_(NSFont.systemFontOfSize_weight_(size, weight))
        f.setTextColor_(NSColor.colorWithCalibratedRed_green_blue_alpha_(
            0.937, 0.918, 0.894, alpha))
        cv.addSubview_(f)
        return f

    # Header
    label("Lirelle needs a few permissions", 24, height - 56, width - 48, 28,
          18, NSFontWeightSemibold)
    label("Click Open Settings, flip the toggle for Lirelle, then come back.",
          24, height - 80, width - 48, 18, 12, NSFontWeightMedium, alpha=0.65)

    # One row per missing permission. Each row has its own [Open Settings]
    # button + a re-check happens on window focus.
    rows = []
    for i, p in enumerate(missing):
        y = height - header_h - (i + 1) * row_h
        label(p["label"]
              + ("" if p["required"] else "   (optional — meetings only)"),
              24, y + row_h - 32, width - 48 - 130, 18,
              14, NSFontWeightSemibold)
        label(p["why"], 24, y + 8, width - 48 - 130, row_h - 44,
              11.5, NSFontWeightMedium, alpha=0.7)
        rows.append((p["slug"], y + row_h - 38))

    # Footer dismiss button.
    dismiss = NSButton.alloc().initWithFrame_(
        NSMakeRect(width - 24 - 110, 16, 110, 32))
    dismiss.setTitle_("Done")
    dismiss.setBezelStyle_(NSBezelStyleRounded)
    cv.addSubview_(dismiss)

    # Glue object for ObjC callbacks (action selectors).
    class _Glue(NSObject):
        def initWithRows_panel_after_(self, rows_arg, panel_arg, after_arg):
            self = objc.super(_Glue, self).init()
            if self is None:
                return None
            self._rows = rows_arg
            self._panel = panel_arg
            self._after = after_arg
            return self

        def open_(self, sender):
            slug = self._slug_for(sender)
            if slug:
                _open_settings(slug)

        def dismiss_(self, _sender):
            try:
                self._panel.close()
            finally:
                if self._after:
                    try: self._after()
                    except Exception: pass

        def refresh_(self, _sender):
            still_missing = [p for p in probe_all() if not p["granted"]
                             and p["required"]]
            if not still_missing:
                self.dismiss_(None)

        def _slug_for(self, sender):
            for slug, tag_button in self._slug_to_button.items():
                if sender is tag_button:
                    return slug
            return None

    glue = _Glue.alloc().initWithRows_panel_after_(rows, panel, after)
    glue._slug_to_button = {}

    for slug, btn_y in rows:
        btn = NSButton.alloc().initWithFrame_(
            NSMakeRect(width - 24 - 130, btn_y, 130, 28))
        btn.setTitle_("Open Settings")
        btn.setBezelStyle_(NSBezelStyleRounded)
        btn.setTarget_(glue)
        btn.setAction_("open:")
        cv.addSubview_(btn)
        glue._slug_to_button[slug] = btn

    dismiss.setTarget_(glue)
    dismiss.setAction_("dismiss:")

    # Re-check every 1.5s while the panel is up — when the user comes
    # back from Settings the panel auto-closes if everything's green.
    from PyObjCTools.AppHelper import callLater  # type: ignore
    def _tick():
        if not panel.isVisible():
            return
        if all(p["granted"] for p in probe_all() if p["required"]):
            glue.dismiss_(None)
            return
        callLater(1.5, _tick)
    callLater(1.5, _tick)

    # Stash on NSApp to keep alive across the run loop.
    NSApp()._lumo_perm_wizard_glue = glue
    panel.makeKeyAndOrderFront_(None)
    NSApp().activateIgnoringOtherApps_(True)
