//! macOS desktop PTT backend (SDD-085).
//!
//! Two-level ladder per SAD-073: `MacOSEventTapBackend` when the
//! Input Monitoring (or Accessibility, depending on the macOS
//! release) permission is granted, with `FocusedPttBackend` as
//! the terminal fallback when the permission is denied,
//! undetermined, or revoked.
//!
//! The audio engine must start without blocking on the permission
//! prompt (SRS-198). The backend therefore queries the permission
//! state synchronously, immediately reports the corresponding
//! capability level, and re-queries the permission state on a
//! background thread so a runtime grant or revocation upgrades or
//! downgrades the descriptor through the `descriptor_watch`
//! channel — the supervisor in `chanora_core` forwards the
//! transition to `SessionEvent::PttCapability` and the Flutter
//! capability badge updates without a restart.
//!
//! Live event capture is implemented with `CGEventTapCreate` at
//! the `kCGSessionEventTap` location in listen-only mode (no
//! event injection or modification). Triggering events:
//!
//!   * `kCGEventKeyDown` / `kCGEventKeyUp` — keyboard PTT
//!   * `kCGEventOtherMouseDown` / `kCGEventOtherMouseUp` — mouse
//!     side buttons (Mouse4 / Mouse5)
//!
//! The tap callback runs on the dedicated event-tap worker
//! thread's `CFRunLoop`. It matches the event's key code or
//! mouse-button number against the active `PttBinding` and
//! toggles the audio transmit gate accordingly. The callback
//! never logs raw key codes (DEC-027 privacy invariant).

use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use tokio::sync::watch;
use tracing::{info, warn};

use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass};
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};

// ---------- FFI ----------

#[allow(non_camel_case_types)]
type CFTypeRef = *mut std::ffi::c_void;
type CFMachPortRef = CFTypeRef;
type CFRunLoopRef = CFTypeRef;
type CFRunLoopSourceRef = CFTypeRef;
type CFAllocatorRef = CFTypeRef;
type CFStringRef = CFTypeRef;
type CGEventRef = CFTypeRef;
#[allow(non_camel_case_types)]
type CGEventType = u32;
#[allow(non_camel_case_types)]
type CGEventTapLocation = u32;
#[allow(non_camel_case_types)]
type CGEventTapPlacement = u32;
#[allow(non_camel_case_types)]
type CGEventTapOptions = u32;
#[allow(non_camel_case_types)]
type CGEventTapProxy = *mut std::ffi::c_void;
#[allow(non_camel_case_types)]
type CGEventField = u32;
#[allow(non_camel_case_types)]
type CGEventMask = u64;

// CGEventTapLocation values (CoreGraphics/CGEventTypes.h)
const KCG_SESSION_EVENT_TAP: CGEventTapLocation = 1;
// CGEventTapPlacement values
const KCG_TAIL_APPEND_EVENT_TAP: CGEventTapPlacement = 1;
// CGEventTapOptions
const KCG_EVENT_TAP_OPTION_LISTEN_ONLY: CGEventTapOptions = 1;

// CGEventType (we mask these)
const KCG_EVENT_KEY_DOWN: CGEventType = 10;
const KCG_EVENT_KEY_UP: CGEventType = 11;
const KCG_EVENT_OTHER_MOUSE_DOWN: CGEventType = 25;
const KCG_EVENT_OTHER_MOUSE_UP: CGEventType = 26;
const KCG_EVENT_TAP_DISABLED_BY_TIMEOUT: CGEventType = 0xFFFFFFFE;
const KCG_EVENT_TAP_DISABLED_BY_USER_INPUT: CGEventType = 0xFFFFFFFF;

// CGEventField (CoreGraphics/CGEvent.h)
const KCG_KEYBOARD_EVENT_KEYCODE: CGEventField = 9;
const KCG_MOUSE_EVENT_BUTTON_NUMBER: CGEventField = 3;

#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
    fn CGEventTapCreate(
        tap: CGEventTapLocation,
        place: CGEventTapPlacement,
        options: CGEventTapOptions,
        events_of_interest: CGEventMask,
        callback: extern "C" fn(
            proxy: CGEventTapProxy,
            etype: CGEventType,
            event: CGEventRef,
            user_info: *mut std::ffi::c_void,
        ) -> CGEventRef,
        user_info: *mut std::ffi::c_void,
    ) -> CFMachPortRef;

    fn CGEventGetIntegerValueField(event: CGEventRef, field: CGEventField) -> i64;
    fn CGEventTapEnable(tap: CFMachPortRef, enable: bool);
}

#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
    fn CFMachPortCreateRunLoopSource(
        allocator: CFAllocatorRef,
        port: CFMachPortRef,
        order: isize,
    ) -> CFRunLoopSourceRef;

    fn CFRunLoopGetCurrent() -> CFRunLoopRef;
    fn CFRunLoopAddSource(rl: CFRunLoopRef, source: CFRunLoopSourceRef, mode: CFStringRef);
    fn CFRunLoopRemoveSource(rl: CFRunLoopRef, source: CFRunLoopSourceRef, mode: CFStringRef);
    fn CFRunLoopRun();
    fn CFRunLoopStop(rl: CFRunLoopRef);
    fn CFRelease(cf: CFTypeRef);

    static kCFRunLoopCommonModes: CFStringRef;
}

// ---------- Permission state ----------

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionState {
    Granted,
    Denied,
    Undetermined,
}

impl PermissionState {
    /// Encode as a u8 for `AtomicU8` storage. The encoding lets
    /// the descriptor-watch worker compare-and-swap permission
    /// state across thread boundaries without a Mutex.
    const fn to_u8(self) -> u8 {
        match self {
            Self::Granted => 0,
            Self::Denied => 1,
            Self::Undetermined => 2,
        }
    }

    fn from_u8(v: u8) -> Self {
        match v {
            0 => Self::Granted,
            1 => Self::Denied,
            _ => Self::Undetermined,
        }
    }
}

fn query_permission() -> PermissionState {
    // Live IOHIDCheckAccess query against the IOKit HID subsystem.
    // The Input Monitoring permission ("Listen Event" access type)
    // is what gates CGEventTap from receiving global key/mouse
    // events. Returns the live permission state every call; the
    // periodic re-query loop in `MacOSEventTapBackend::start`
    // republishes the descriptor whenever this value transitions.
    //
    // SAFETY: IOHIDCheckAccess is a thread-safe synchronous IOKit
    // entry point with no side effects beyond reading a system
    // preference. The `#[link]` attribute on the extern block
    // resolves at build time against the IOKit framework. The u32
    // return value maps directly onto the documented
    // IOHIDAccessType enumerator { Granted=0, Denied=1, Unknown=2 }.
    #[link(name = "IOKit", kind = "framework")]
    extern "C" {
        fn IOHIDCheckAccess(request_type: u32) -> u32;
    }
    // kIOHIDRequestTypeListenEvent == 1 per IOHIDLib.h.
    const REQUEST_TYPE_LISTEN_EVENT: u32 = 1;
    let raw = unsafe { IOHIDCheckAccess(REQUEST_TYPE_LISTEN_EVENT) };
    match raw {
        0 => PermissionState::Granted,
        1 => PermissionState::Denied,
        _ => PermissionState::Undetermined,
    }
}

// ---------- Flutter label → macOS keycode / mouse button ----------

/// Resolve a Flutter logical-key label (the same strings the bind
/// dialog hands us) into a macOS Carbon virtual keycode. Returns
/// `None` for unknown labels, modifier-only bindings, or mouse
/// labels — the caller falls back to the mouse-button path for
/// the latter. Privacy: this is the only point in the macOS
/// backend where the platform_key string is decoded, mirroring
/// the Windows keymap module's role.
fn label_to_macos_keycode(label: &str) -> Option<i64> {
    // The keycode constants come from Apple's Carbon HIToolbox
    // `Events.h` (kVK_*). They are the same values CGEventTap
    // reports via `kCGKeyboardEventKeycode`. Coverage focuses on
    // keys that make sensible PTT bindings — letters, digits,
    // function keys, navigation, and the obvious modifiers that
    // can be pressed as a single key.
    let kc = match label {
        // Letters (lower-case Flutter labels first; the bind dialog
        // emits the canonicalised "Key A" form too, hence both).
        "A" | "a" | "Key A" => 0x00,
        "S" | "s" | "Key S" => 0x01,
        "D" | "d" | "Key D" => 0x02,
        "F" | "f" | "Key F" => 0x03,
        "H" | "h" | "Key H" => 0x04,
        "G" | "g" | "Key G" => 0x05,
        "Z" | "z" | "Key Z" => 0x06,
        "X" | "x" | "Key X" => 0x07,
        "C" | "c" | "Key C" => 0x08,
        "V" | "v" | "Key V" => 0x09,
        "B" | "b" | "Key B" => 0x0B,
        "Q" | "q" | "Key Q" => 0x0C,
        "W" | "w" | "Key W" => 0x0D,
        "E" | "e" | "Key E" => 0x0E,
        "R" | "r" | "Key R" => 0x0F,
        "Y" | "y" | "Key Y" => 0x10,
        "T" | "t" | "Key T" => 0x11,
        "O" | "o" | "Key O" => 0x1F,
        "U" | "u" | "Key U" => 0x20,
        "I" | "i" | "Key I" => 0x22,
        "P" | "p" | "Key P" => 0x23,
        "L" | "l" | "Key L" => 0x25,
        "J" | "j" | "Key J" => 0x26,
        "K" | "k" | "Key K" => 0x28,
        "N" | "n" | "Key N" => 0x2D,
        "M" | "m" | "Key M" => 0x2E,
        // Digits (top row)
        "1" | "Digit 1" => 0x12,
        "2" | "Digit 2" => 0x13,
        "3" | "Digit 3" => 0x14,
        "4" | "Digit 4" => 0x15,
        "5" | "Digit 5" => 0x17,
        "6" | "Digit 6" => 0x16,
        "7" | "Digit 7" => 0x1A,
        "8" | "Digit 8" => 0x1C,
        "9" | "Digit 9" => 0x19,
        "0" | "Digit 0" => 0x1D,
        // Whitespace + control
        "Space" | " " => 0x31,
        "Enter" => 0x24,
        "Tab" => 0x30,
        "Backspace" => 0x33,
        "Escape" => 0x35,
        "Delete" => 0x75,
        // Navigation
        "Arrow Up" | "ArrowUp" => 0x7E,
        "Arrow Down" | "ArrowDown" => 0x7D,
        "Arrow Left" | "ArrowLeft" => 0x7B,
        "Arrow Right" | "ArrowRight" => 0x7C,
        "Home" => 0x73,
        "End" => 0x77,
        "Page Up" | "PageUp" => 0x74,
        "Page Down" | "PageDown" => 0x79,
        // Function keys
        "F1" => 0x7A,
        "F2" => 0x78,
        "F3" => 0x63,
        "F4" => 0x76,
        "F5" => 0x60,
        "F6" => 0x61,
        "F7" => 0x62,
        "F8" => 0x64,
        "F9" => 0x65,
        "F10" => 0x6D,
        "F11" => 0x67,
        "F12" => 0x6F,
        // Punctuation that may sit alone on a US layout
        "`" | "Backquote" => 0x32,
        "-" | "Minus" => 0x1B,
        "=" | "Equal" => 0x18,
        "[" | "BracketLeft" => 0x21,
        "]" | "BracketRight" => 0x1E,
        "\\" | "Backslash" => 0x2A,
        ";" | "Semicolon" => 0x29,
        "'" | "Quote" => 0x27,
        "," | "Comma" => 0x2B,
        "." | "Period" => 0x2F,
        "/" | "Slash" => 0x2C,
        _ => return None,
    };
    Some(kc as i64)
}

/// Resolve a Flutter logical-key label naming a mouse side button
/// (Mouse4 / Mouse5) into the macOS `kCGMouseEventButtonNumber`
/// value. macOS numbers buttons starting at 0 (left), 1 (right),
/// 2 (middle); side buttons begin at 3.
fn label_to_macos_mouse_button(label: &str) -> Option<i64> {
    match label {
        "Mouse4" | "Button 4" | "Mouse Button 4" => Some(3),
        "Mouse5" | "Button 5" | "Mouse Button 5" => Some(4),
        _ => None,
    }
}

// ---------- try_select / backend ----------

/// Try to construct the macOS event-tap backend. Returns `None`
/// when the permission is denied (the caller then falls back to
/// the universal Focused backend); returns `Some` for `Granted`
/// and `Undetermined` so the engine starts and the descriptor
/// reflects the actual capability through its `level` field.
pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
    let state = query_permission();
    match state {
        PermissionState::Denied => {
            warn!(
                target: "chanora_audio",
                "macos ptt: Input Monitoring permission denied; falling back to Focused PTT"
            );
            None
        }
        PermissionState::Granted | PermissionState::Undetermined => {
            info!(
                target: "chanora_audio",
                permission = ?state,
                "macos ptt: selecting Event Tap backend"
            );
            Some(Box::new(MacOSEventTapBackend::new(state)))
        }
    }
}

pub struct MacOSEventTapBackend {
    binding: PttBinding,
    gate: Option<AudioTransmitGate>,
    /// Shared permission state — written by the re-query worker
    /// thread, read by `descriptor()` so synchronous descriptor
    /// queries always return the live value.
    permission: Arc<AtomicU8>,
    desc_tx: watch::Sender<PttBackendDescriptor>,
    desc_rx: watch::Receiver<PttBackendDescriptor>,
    stop: Arc<AtomicBool>,
    worker: Option<thread::JoinHandle<()>>,
    permission_worker: Option<thread::JoinHandle<()>>,
    /// `CFRunLoopRef` of the event-tap worker, captured on the
    /// worker thread after `CFRunLoopGetCurrent` so the main
    /// thread can `CFRunLoopStop` it cleanly. Wrapped in
    /// `Arc<Mutex<…>>` because the worker writes once and the
    /// main thread reads.
    worker_runloop: Arc<Mutex<Option<RunLoopHandle>>>,
    /// Shared atomic carrying the keycode (or -1 for "no
    /// keyboard binding") so the C-ABI tap callback can match
    /// without holding any Rust references through the FFI
    /// boundary. Encoded as i32: 0..=255 are macOS keycodes;
    /// -1 means "no keyboard binding active".
    bound_keycode: Arc<AtomicI32>,
    /// Same shape for the bound mouse button number. -1 means
    /// "no mouse binding active". Encodes the
    /// `kCGMouseEventButtonNumber` value (3 = Mouse4, 4 = Mouse5).
    bound_mouse_button: Arc<AtomicI32>,
}

/// `CFRunLoopRef` is a `*mut c_void` and Rust treats it as not
/// `Send`. We never dereference the pointer ourselves — we just
/// hand it to `CFRunLoopStop`, which is documented thread-safe.
/// Wrap it in a transparent newtype with an explicit
/// `unsafe impl Send` so it can travel through the mutex.
struct RunLoopHandle(CFRunLoopRef);
// SAFETY: CFRunLoopRef is a retained pointer to a CFRunLoop. The
// only operation we perform from another thread is CFRunLoopStop,
// which is documented thread-safe.
unsafe impl Send for RunLoopHandle {}

/// Transparent newtype carrying `*mut TapState` across the thread
/// spawn boundary. The eventtap worker is the sole owner of the
/// underlying boxed `TapState` for its entire lifetime; nothing
/// else dereferences it. We mark the newtype `Send` so the
/// spawned closure can take it, and the inner pointer never
/// becomes user-visible outside of the worker.
struct TapStatePtr(*mut TapState);
// SAFETY: The TapState behind this pointer lives in a Box owned
// exclusively by the eventtap worker thread for its lifetime.
// The C callback runs only on that thread; no aliasing across
// threads occurs.
unsafe impl Send for TapStatePtr {}

impl MacOSEventTapBackend {
    fn new(permission: PermissionState) -> Self {
        let initial = PttBackendDescriptor {
            level: match permission {
                PermissionState::Granted => PttCapabilityLevel::L2GlobalHoldToTalk,
                _ => PttCapabilityLevel::L0Focused,
            },
            backend_id: "event-tap",
            bound_input_class: None,
        };
        let (desc_tx, desc_rx) = watch::channel(initial);
        Self {
            binding: PttBinding::none(),
            gate: None,
            permission: Arc::new(AtomicU8::new(permission.to_u8())),
            desc_tx,
            desc_rx,
            stop: Arc::new(AtomicBool::new(false)),
            worker: None,
            permission_worker: None,
            worker_runloop: Arc::new(Mutex::new(None)),
            bound_keycode: Arc::new(AtomicI32::new(-1)),
            bound_mouse_button: Arc::new(AtomicI32::new(-1)),
        }
    }

    /// Build the descriptor for a given permission + binding pair
    /// (used by `descriptor()` and the re-query worker).
    fn build_descriptor(permission: PermissionState, class: PttInputClass) -> PttBackendDescriptor {
        let level = match permission {
            PermissionState::Granted => match class {
                PttInputClass::MouseSideButton => PttCapabilityLevel::L3GlobalWithMouseButtons,
                _ => PttCapabilityLevel::L2GlobalHoldToTalk,
            },
            // Undetermined or Denied (we wouldn't be here for
            // Denied but the match is exhaustive) reports
            // Focused so capability advertising matches actual
            // runtime behaviour (SRS-196 / SRS-198).
            _ => PttCapabilityLevel::L0Focused,
        };
        PttBackendDescriptor {
            level,
            backend_id: "event-tap",
            bound_input_class: match class {
                PttInputClass::None => None,
                PttInputClass::MouseSideButton => Some("mouse-side-button"),
                _ => Some("keyboard"),
            },
        }
    }

    /// Recompute `bound_keycode` / `bound_mouse_button` from the
    /// current binding. Called by `start()` and `rebind()`.
    fn refresh_bound_atomics(&self) {
        let (kc, mb) = match self.binding.input_class {
            PttInputClass::Keyboard => (
                label_to_macos_keycode(&self.binding.platform_key).unwrap_or(-1),
                -1i64,
            ),
            PttInputClass::MouseSideButton => (
                -1i64,
                label_to_macos_mouse_button(&self.binding.platform_key).unwrap_or(-1),
            ),
            PttInputClass::None => (-1, -1),
        };
        self.bound_keycode.store(kc as i32, Ordering::Relaxed);
        self.bound_mouse_button.store(mb as i32, Ordering::Relaxed);
    }
}

impl DesktopPttBackend for MacOSEventTapBackend {
    fn descriptor(&self) -> PttBackendDescriptor {
        let perm = PermissionState::from_u8(self.permission.load(Ordering::Relaxed));
        Self::build_descriptor(perm, self.binding.input_class)
    }

    fn descriptor_watch(&self) -> watch::Receiver<PttBackendDescriptor> {
        self.desc_rx.clone()
    }

    fn start(
        &mut self,
        gate: AudioTransmitGate,
        binding: PttBinding,
    ) -> Result<(), PttBackendError> {
        self.gate = Some(gate.clone());
        self.binding = binding;
        self.refresh_bound_atomics();
        let stop = self.stop.clone();

        // ----- Event-tap worker -----
        //
        // Captures a `Box<TapState>` whose raw pointer is passed
        // to `CGEventTapCreate` as user_info. The C-ABI callback
        // dereferences it on every event to read the bound key /
        // button and toggle the gate. The Box is leaked into the
        // worker's lifetime and dropped when the worker exits.
        let tap_state = Box::new(TapState {
            gate: gate.clone(),
            bound_keycode: self.bound_keycode.clone(),
            bound_mouse_button: self.bound_mouse_button.clone(),
        });
        let tap_state_handle = TapStatePtr(Box::into_raw(tap_state));
        let worker_runloop = self.worker_runloop.clone();
        let worker_stop = stop.clone();
        let handle = thread::Builder::new()
            .name("chanora-eventtap".into())
            .spawn(move || {
                // SAFETY: the worker is the sole owner of the
                // raw pointer inside `tap_state_handle`. The C
                // callback only runs on this thread (CFRunLoop
                // dispatches callbacks synchronously on the
                // owning runloop's thread). When CFRunLoopRun
                // returns we reconstruct the Box to drop the
                // state.
                //
                // Borrow the whole TapStatePtr (not its inner
                // field) so the closure-capture analyser sees the
                // newtype's `unsafe impl Send` rather than the
                // raw `*mut TapState`. The `&` then immediately
                // moves it back to a plain raw pointer for use
                // with the C API.
                let tsp = &tap_state_handle;
                let tap_state_ptr: *mut TapState = tsp.0;
                let events_of_interest: CGEventMask = (1u64 << KCG_EVENT_KEY_DOWN)
                    | (1u64 << KCG_EVENT_KEY_UP)
                    | (1u64 << KCG_EVENT_OTHER_MOUSE_DOWN)
                    | (1u64 << KCG_EVENT_OTHER_MOUSE_UP);
                let port = unsafe {
                    CGEventTapCreate(
                        KCG_SESSION_EVENT_TAP,
                        KCG_TAIL_APPEND_EVENT_TAP,
                        KCG_EVENT_TAP_OPTION_LISTEN_ONLY,
                        events_of_interest,
                        tap_callback,
                        tap_state_ptr as *mut std::ffi::c_void,
                    )
                };
                if port.is_null() {
                    warn!(
                        target: "chanora_audio",
                        "macos ptt: CGEventTapCreate returned null \
                         (permission probably revoked at runtime)"
                    );
                    unsafe {
                        // Drop the Box we leaked above so the
                        // refcount on the gate stays correct.
                        let _ = Box::from_raw(tap_state_ptr);
                    }
                    return;
                }
                let source =
                    unsafe { CFMachPortCreateRunLoopSource(std::ptr::null_mut(), port, 0) };
                if source.is_null() {
                    warn!(
                        target: "chanora_audio",
                        "macos ptt: CFMachPortCreateRunLoopSource returned null"
                    );
                    unsafe {
                        CFRelease(port);
                        let _ = Box::from_raw(tap_state_ptr);
                    }
                    return;
                }
                let runloop = unsafe { CFRunLoopGetCurrent() };
                {
                    let mut g = worker_runloop.lock().unwrap();
                    *g = Some(RunLoopHandle(runloop));
                }
                unsafe {
                    CFRunLoopAddSource(runloop, source, kCFRunLoopCommonModes);
                    CGEventTapEnable(port, true);
                }
                info!(
                    target: "chanora_audio",
                    "macos ptt: event tap armed; entering run loop"
                );

                // CFRunLoopRun blocks until CFRunLoopStop is
                // called from outside (stop() does that).
                unsafe { CFRunLoopRun() };

                // Cleanup. Order matters: disable the tap before
                // removing it from the runloop so no further
                // callbacks fire while we tear down.
                unsafe {
                    CGEventTapEnable(port, false);
                    CFRunLoopRemoveSource(runloop, source, kCFRunLoopCommonModes);
                    CFRelease(source);
                    CFRelease(port);
                    let _ = Box::from_raw(tap_state_ptr);
                }
                let _ = worker_stop; // silence unused warning when stop
                                     // is consulted only by the watchdog
                info!(
                    target: "chanora_audio",
                    "macos ptt: event tap worker exited cleanly"
                );
            })
            .map_err(|e| PttBackendError::Init(format!("eventtap thread: {e}")))?;
        self.worker = Some(handle);

        // ----- Permission re-query worker (SRS-198) -----
        //
        // Polls every 1.5 s; on transition, republishes the
        // descriptor through the watch sender so
        // `chanora_core::ChanoraSession::start_audio`'s forwarder
        // re-emits `SessionEvent::PttCapability` and the Flutter
        // capability badge updates without a restart.
        //
        // Polling rather than KVO / notifications because the
        // macOS Input-Monitoring permission has no public change
        // notification API; polling at 1.5 s is acceptable for a
        // user-action follow-up (the user grants then returns to
        // Chanora, and the badge updates within ~1.5 s).
        let perm_stop = self.stop.clone();
        let perm_atomic = self.permission.clone();
        let perm_desc_tx = self.desc_tx.clone();
        let perm_binding_class = self.binding.input_class;
        let perm_handle = thread::Builder::new()
            .name("chanora-perm-watch".into())
            .spawn(move || {
                let mut last = PermissionState::from_u8(perm_atomic.load(Ordering::Relaxed));
                while !perm_stop.load(Ordering::Relaxed) {
                    thread::sleep(Duration::from_millis(1500));
                    let now = query_permission();
                    if now != last {
                        perm_atomic.store(now.to_u8(), Ordering::Relaxed);
                        let desc = MacOSEventTapBackend::build_descriptor(now, perm_binding_class);
                        let _ = perm_desc_tx.send(desc);
                        info!(
                            target: "chanora_audio",
                            from = ?last,
                            to = ?now,
                            "macos ptt: permission state transition"
                        );
                        last = now;
                    }
                }
            })
            .map_err(|e| PttBackendError::Init(format!("perm-watch thread: {e}")))?;
        self.permission_worker = Some(perm_handle);

        // Publish the initial descriptor synchronously so
        // consumers see the current value immediately rather
        // than after the first re-query tick.
        let _ = self.desc_tx.send(self.descriptor());

        let _ = &gate; // captured by TapState
        Ok(())
    }

    fn stop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);
        // Wake the event-tap worker's CFRunLoop so it can run its
        // teardown and exit. CFRunLoopStop is documented as
        // thread-safe; calling it before the worker has set
        // worker_runloop is harmless because we just don't call
        // it in that branch — the worker will exit when its
        // CGEventTapCreate fails or completes.
        if let Some(rl) = self.worker_runloop.lock().ok().and_then(|mut g| g.take()) {
            unsafe { CFRunLoopStop(rl.0) };
        }
        if let Some(h) = self.worker.take() {
            let _ = h.join();
        }
        if let Some(h) = self.permission_worker.take() {
            let _ = h.join();
        }
        if let Some(g) = self.gate.take() {
            g.set(false);
        }
    }

    fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> {
        self.binding = binding;
        self.refresh_bound_atomics();
        // Re-publish the descriptor so the bound_input_class
        // change (keyboard → mouse-side-button, or vice versa)
        // reaches the UI capability badge promptly.
        let _ = self.desc_tx.send(self.descriptor());
        Ok(())
    }
}

impl Drop for MacOSEventTapBackend {
    fn drop(&mut self) {
        self.stop();
    }
}

// ---------- C-ABI tap callback ----------

/// Per-tap user data. Lives in a Box owned by the eventtap worker
/// thread; the raw pointer is passed to CGEventTapCreate as
/// `user_info`. The callback reads bound key/button atomically;
/// no Rust references cross the FFI boundary.
struct TapState {
    gate: AudioTransmitGate,
    bound_keycode: Arc<AtomicI32>,
    bound_mouse_button: Arc<AtomicI32>,
}

/// CGEventTap callback. Runs on the worker thread's CFRunLoop.
/// Returns the event unchanged (listen-only tap, no modification).
///
/// Privacy invariant (DEC-027): this function reads keycodes and
/// button numbers from the event but never logs them. The only
/// log lines emitted from the macOS PTT backend reference
/// `bound_input_class` and capability level, never the raw key
/// identity.
extern "C" fn tap_callback(
    _proxy: CGEventTapProxy,
    etype: CGEventType,
    event: CGEventRef,
    user_info: *mut std::ffi::c_void,
) -> CGEventRef {
    // CGEventTap can be disabled by the system at runtime (slow
    // callback timeout, user input flood). In both cases we'd
    // need to re-enable the tap; for now we log and the user can
    // restart the app. The CGEvent docs explicitly say returning
    // the event unchanged is the correct no-op for these
    // notification types.
    if etype == KCG_EVENT_TAP_DISABLED_BY_TIMEOUT || etype == KCG_EVENT_TAP_DISABLED_BY_USER_INPUT {
        warn!(
            target: "chanora_audio",
            event = "tap_disabled",
            "macos ptt: event tap disabled by system; PTT now degraded"
        );
        return event;
    }

    // SAFETY: user_info was set by `start()` to the Box::into_raw
    // pointer of a TapState whose lifetime is bound to the
    // eventtap worker thread. The thread is the sole reader of
    // this pointer and only exits after CFRunLoopRun returns,
    // which is before the Box is dropped.
    let state = unsafe { &*(user_info as *const TapState) };

    match etype {
        KCG_EVENT_KEY_DOWN | KCG_EVENT_KEY_UP => {
            let bound = state.bound_keycode.load(Ordering::Relaxed);
            if bound < 0 {
                return event;
            }
            let kc = unsafe { CGEventGetIntegerValueField(event, KCG_KEYBOARD_EVENT_KEYCODE) };
            if kc == bound as i64 {
                let pressed = etype == KCG_EVENT_KEY_DOWN;
                state.gate.set(pressed);
            }
        }
        KCG_EVENT_OTHER_MOUSE_DOWN | KCG_EVENT_OTHER_MOUSE_UP => {
            let bound = state.bound_mouse_button.load(Ordering::Relaxed);
            if bound < 0 {
                return event;
            }
            let btn = unsafe { CGEventGetIntegerValueField(event, KCG_MOUSE_EVENT_BUTTON_NUMBER) };
            if btn == bound as i64 {
                let pressed = etype == KCG_EVENT_OTHER_MOUSE_DOWN;
                state.gate.set(pressed);
            }
        }
        _ => {}
    }
    event
}

#[cfg(test)]
mod tests {
    //! Unit tests for the macOS permission state machine, the
    //! descriptor builder, and the keymap. The live
    //! `IOHIDCheckAccess` / `CGEventTapCreate` calls are covered
    //! by the macOS platform-acceptance pass; here we exercise
    //! the parts that are platform-independent.

    use super::*;

    #[test]
    fn build_descriptor_undetermined_reports_focused() {
        let d = MacOSEventTapBackend::build_descriptor(
            PermissionState::Undetermined,
            PttInputClass::Keyboard,
        );
        assert_eq!(d.level, PttCapabilityLevel::L0Focused);
        assert_eq!(d.backend_id, "event-tap");
    }

    #[test]
    fn build_descriptor_denied_reports_focused() {
        let d = MacOSEventTapBackend::build_descriptor(
            PermissionState::Denied,
            PttInputClass::Keyboard,
        );
        assert_eq!(d.level, PttCapabilityLevel::L0Focused);
    }

    #[test]
    fn build_descriptor_granted_keyboard_reports_l2() {
        let d = MacOSEventTapBackend::build_descriptor(
            PermissionState::Granted,
            PttInputClass::Keyboard,
        );
        assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
        assert_eq!(d.bound_input_class, Some("keyboard"));
    }

    #[test]
    fn build_descriptor_granted_mouse_reports_l3() {
        let d = MacOSEventTapBackend::build_descriptor(
            PermissionState::Granted,
            PttInputClass::MouseSideButton,
        );
        assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons);
        assert_eq!(d.bound_input_class, Some("mouse-side-button"));
    }

    #[test]
    fn build_descriptor_granted_none_reports_l2_keyboard() {
        let d =
            MacOSEventTapBackend::build_descriptor(PermissionState::Granted, PttInputClass::None);
        assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
        assert_eq!(d.bound_input_class, None);
    }

    #[test]
    fn permission_state_atomic_roundtrip() {
        for s in [
            PermissionState::Granted,
            PermissionState::Denied,
            PermissionState::Undetermined,
        ] {
            assert_eq!(PermissionState::from_u8(s.to_u8()), s);
        }
    }

    #[test]
    fn keymap_letters() {
        assert_eq!(label_to_macos_keycode("Space"), Some(0x31));
        assert_eq!(label_to_macos_keycode("A"), Some(0x00));
        assert_eq!(label_to_macos_keycode("Key A"), Some(0x00));
        assert_eq!(label_to_macos_keycode("a"), Some(0x00));
    }

    #[test]
    fn keymap_function_keys() {
        assert_eq!(label_to_macos_keycode("F1"), Some(0x7A));
        assert_eq!(label_to_macos_keycode("F12"), Some(0x6F));
    }

    #[test]
    fn keymap_navigation() {
        assert_eq!(label_to_macos_keycode("Arrow Up"), Some(0x7E));
        assert_eq!(label_to_macos_keycode("ArrowUp"), Some(0x7E));
        assert_eq!(label_to_macos_keycode("Home"), Some(0x73));
    }

    #[test]
    fn keymap_unknown_returns_none() {
        assert_eq!(label_to_macos_keycode("PrtSc"), None);
        assert_eq!(label_to_macos_keycode(""), None);
        assert_eq!(label_to_macos_keycode("Mouse4"), None);
    }

    #[test]
    fn mouse_button_map() {
        assert_eq!(label_to_macos_mouse_button("Mouse4"), Some(3));
        assert_eq!(label_to_macos_mouse_button("Mouse5"), Some(4));
        assert_eq!(label_to_macos_mouse_button("Mouse Button 4"), Some(3));
        assert_eq!(label_to_macos_mouse_button("Space"), None);
    }

    #[test]
    fn runloop_handle_is_send() {
        fn assert_send<T: Send>() {}
        assert_send::<RunLoopHandle>();
    }
}
