//! Linux desktop PTT backend (SDD-086) — live `GlobalShortcuts`
//! portal session flow.
//!
//! Officially-tested target per DEC-025 is **GNOME on Wayland**.
//! On that environment we use the freedesktop
//! `org.freedesktop.portal.GlobalShortcuts` D-Bus interface: the
//! portal hosts the binding-capture dialog, so Chanora itself
//! never reads raw key events. The portal sends `Activated` /
//! `Deactivated` signals that drive the `AudioTransmitGate`.
//!
//! On any other Linux environment (X11, sway, KDE, untested
//! compositor, missing D-Bus) `try_select` returns `None` and the
//! caller falls back to the universal `FocusedPttBackend`.
//!
//! Lifecycle (Q5b — lazy session, single backend instance):
//!
//!   * `try_select` runs a synchronous portal-version probe on
//!     the blocking proxy. Failure → `None` → Focused fallback.
//!   * `start(gate, binding)` spawns a worker tokio task that
//!     owns an async `zbus::Connection`, calls `CreateSession`,
//!     then `BindShortcuts` (sentinel id `"chanora-ptt"`). The
//!     portal opens its own system dialog asking the user to
//!     pick a key; while the dialog is open the engine continues
//!     at L0Focused — the audio path is not blocked.
//!   * Once the user accepts, the task subscribes to
//!     `Activated`/`Deactivated` signals scoped to the session
//!     handle and calls `gate.set(true/false)` accordingly.
//!   * `rebind` re-runs `BindShortcuts` on the same session.
//!   * `stop` (or Drop) signals the worker; the worker closes
//!     the session handle via the `Request::Close` interface
//!     and exits.
//!
//! Privacy posture (DEC-027): the portal returns a
//! `trigger_description` string (a translated human label). We
//! never log it; the backend exposes only the coarse
//! `bound_input_class` derived from a fixed mapping of well-known
//! substrings, plus the stable `backend_id = "gnome-wayland-portal"`.

use std::collections::HashMap;
use std::env;
use std::sync::Arc;

use tokio::sync::{mpsc, watch, Mutex as TokioMutex};
use tracing::{info, warn};
use zbus::blocking::Connection as BlockingConnection;
use zbus::zvariant::{OwnedValue, Value};
use zbus::{proxy, Connection as AsyncConnection};

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

/// Try to construct a `LinuxGnomeWaylandBackend`. Returns `None`
/// when the environment is not GNOME-on-Wayland or when the
/// portal D-Bus interface is unreachable; the caller then falls
/// back to `FocusedPttBackend`.
pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
    if !is_gnome_on_wayland() {
        info!(
            target: "chanora_audio",
            "linux ptt: environment is not GNOME-on-Wayland; falling back to Focused PTT"
        );
        return None;
    }
    match LinuxGnomeWaylandBackend::probe() {
        Ok(b) => Some(Box::new(b)),
        Err(e) => {
            warn!(
                target: "chanora_audio",
                error = %e,
                "linux ptt: portal probe failed; falling back to Focused PTT"
            );
            None
        }
    }
}

fn is_gnome_on_wayland() -> bool {
    let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_default();
    if session_type != "wayland" {
        return false;
    }
    let desktop = env::var("XDG_CURRENT_DESKTOP")
        .unwrap_or_default()
        .to_ascii_lowercase();
    desktop.split(':').any(|s| s == "gnome" || s == "gnome-flashback")
}

// ---------- D-Bus proxies ----------
//
// zbus 5 cannot emit blocking + async on the same trait, so we
// declare two parallel traits sharing the same interface
// metadata. `BlockingGlobalShortcuts` is used only by
// `try_select()` for the cheap version probe; `GlobalShortcuts`
// is used by the worker task on the async runtime.

#[proxy(
    interface = "org.freedesktop.portal.GlobalShortcuts",
    default_service = "org.freedesktop.portal.Desktop",
    default_path = "/org/freedesktop/portal/desktop",
    gen_blocking = true,
    gen_async = false
)]
trait BlockingGlobalShortcuts {
    /// Version property (read on the blocking proxy as a fast
    /// reachability probe).
    #[zbus(property)]
    fn version(&self) -> zbus::Result<u32>;
}

#[proxy(
    interface = "org.freedesktop.portal.GlobalShortcuts",
    default_service = "org.freedesktop.portal.Desktop",
    default_path = "/org/freedesktop/portal/desktop",
    gen_blocking = false,
    gen_async = true
)]
trait GlobalShortcuts {
    /// Create a portal session. The returned object path
    /// identifies a `Request` whose `Response` signal carries the
    /// real session-handle path.
    fn create_session(
        &self,
        options: HashMap<&str, Value<'_>>,
    ) -> zbus::Result<zbus::zvariant::OwnedObjectPath>;

    /// Bind the listed shortcuts on the session. The portal opens
    /// its own system dialog asking the user to pick a key. The
    /// returned object path identifies a `Request` whose
    /// `Response` signal carries the bound shortcuts.
    fn bind_shortcuts(
        &self,
        session_handle: &zbus::zvariant::ObjectPath<'_>,
        shortcuts: &[(&str, HashMap<&str, Value<'_>>)],
        parent_window: &str,
        options: HashMap<&str, Value<'_>>,
    ) -> zbus::Result<zbus::zvariant::OwnedObjectPath>;

    /// Fired when a bound shortcut is pressed. Scoped per session
    /// handle.
    #[zbus(signal)]
    fn activated(
        &self,
        session_handle: zbus::zvariant::ObjectPath<'_>,
        shortcut_id: String,
        timestamp: u64,
        options: HashMap<String, OwnedValue>,
    ) -> zbus::Result<()>;

    /// Fired when a bound shortcut is released.
    #[zbus(signal)]
    fn deactivated(
        &self,
        session_handle: zbus::zvariant::ObjectPath<'_>,
        shortcut_id: String,
        timestamp: u64,
        options: HashMap<String, OwnedValue>,
    ) -> zbus::Result<()>;
}

#[proxy(
    interface = "org.freedesktop.portal.Request",
    default_service = "org.freedesktop.portal.Desktop",
    gen_blocking = false,
    gen_async = true
)]
trait Request {
    /// The portal completes each long-running call by emitting
    /// `Response(u response_code, a{sv} results)`. `response_code`
    /// is `0` for success, `1` for user cancellation, `2` for
    /// other failure.
    #[zbus(signal)]
    fn response(
        &self,
        response: u32,
        results: HashMap<String, OwnedValue>,
    ) -> zbus::Result<()>;

    /// Cancel an in-flight request.
    fn close(&self) -> zbus::Result<()>;
}

/// Session-handle interface — distinct from the per-request
/// `Request` interface above. The portal exposes
/// `org.freedesktop.portal.Session` on the same object path that
/// `CreateSession` returns in its `Response.results.session_handle`.
/// Closing the session releases all bound shortcuts and stops
/// signal delivery.
#[proxy(
    interface = "org.freedesktop.portal.Session",
    default_service = "org.freedesktop.portal.Desktop",
    gen_blocking = false,
    gen_async = true
)]
trait Session {
    /// Close the session and release its bound shortcuts.
    fn close(&self) -> zbus::Result<()>;
}

/// Stable sentinel id for the single PTT shortcut Chanora binds.
const SHORTCUT_ID: &str = "chanora-ptt";

// ---------- Backend ----------

pub struct LinuxGnomeWaylandBackend {
    inner: Arc<TokioMutex<BackendInner>>,
    /// Watch sender; updated by the worker task to publish
    /// descriptor transitions (e.g. portal cancellation downgrade).
    desc_tx: watch::Sender<PttBackendDescriptor>,
    /// Receiver retained so the trait's `descriptor_watch` can
    /// return a fresh subscriber without holding the lock.
    desc_rx: watch::Receiver<PttBackendDescriptor>,
}

struct BackendInner {
    /// Async command channel for the worker task. `None` before
    /// `start()` and after `stop()`.
    cmd_tx: Option<mpsc::UnboundedSender<WorkerCmd>>,
    /// Join handle for the worker; aborted on `stop()` / Drop.
    worker: Option<tokio::task::JoinHandle<()>>,
}

enum WorkerCmd {
    /// Rebind: re-issue `BindShortcuts` on the same session.
    Rebind,
    /// Stop: close the session and exit.
    Stop,
}

impl LinuxGnomeWaylandBackend {
    /// Synchronous probe via the blocking proxy. Cheap; runs once
    /// at `try_select` time before we commit to the live flow.
    fn probe() -> Result<Self, PttBackendError> {
        let conn = BlockingConnection::session()
            .map_err(|e| PttBackendError::Init(format!("session bus: {e}")))?;
        let version = {
            let proxy = BlockingGlobalShortcutsProxy::new(&conn)
                .map_err(|e| PttBackendError::Init(format!("proxy: {e}")))?;
            proxy
                .version()
                .map_err(|e| PttBackendError::Init(format!("portal version: {e}")))?
        };
        info!(
            target: "chanora_audio",
            portal_version = version,
            "linux ptt: GlobalShortcuts portal v{} reachable",
            version
        );

        let initial = PttBackendDescriptor {
            level: PttCapabilityLevel::L0Focused,
            backend_id: "gnome-wayland-portal",
            bound_input_class: None,
        };
        let (desc_tx, desc_rx) = watch::channel(initial);
        Ok(Self {
            inner: Arc::new(TokioMutex::new(BackendInner {
                cmd_tx: None,
                worker: None,
            })),
            desc_tx,
            desc_rx,
        })
    }
}

impl DesktopPttBackend for LinuxGnomeWaylandBackend {
    fn descriptor(&self) -> PttBackendDescriptor {
        self.desc_rx.borrow().clone()
    }

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

    fn start(
        &mut self,
        gate: AudioTransmitGate,
        binding: PttBinding,
    ) -> Result<(), PttBackendError> {
        // Spawn the async worker that owns the live D-Bus
        // connection. `tokio::spawn` requires a runtime in
        // context; the audio engine is constructed from inside
        // the bridge's tokio runtime, so this is satisfied.
        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
        let desc_tx = self.desc_tx.clone();
        let class_hint = binding.input_class;
        let worker = tokio::spawn(async move {
            run_worker(gate, cmd_rx, desc_tx, class_hint).await;
        });
        // Stash command + worker handles. `try_lock` is fine: the
        // backend isn't yet shared, and `start` is called once at
        // engine init.
        let mut inner = self.inner.try_lock().map_err(|_| {
            PttBackendError::Init("backend inner mutex contended".to_string())
        })?;
        // Clean up any prior worker (defensive — `start` is
        // expected to be called exactly once per backend
        // instance).
        if let Some(prev_tx) = inner.cmd_tx.take() {
            let _ = prev_tx.send(WorkerCmd::Stop);
        }
        if let Some(prev_handle) = inner.worker.take() {
            prev_handle.abort();
        }
        inner.cmd_tx = Some(cmd_tx);
        inner.worker = Some(worker);
        let _ = &binding; // captured by class_hint
        Ok(())
    }

    fn stop(&mut self) {
        // Signal the worker and abort the join handle. We take
        // the mutex synchronously since stop() is sync.
        if let Ok(mut inner) = self.inner.try_lock() {
            if let Some(tx) = inner.cmd_tx.take() {
                let _ = tx.send(WorkerCmd::Stop);
            }
            if let Some(h) = inner.worker.take() {
                h.abort();
            }
        }
        // Reset descriptor to the post-stop state so a future
        // engine restart starts from a clean baseline.
        let _ = self.desc_tx.send(PttBackendDescriptor {
            level: PttCapabilityLevel::L0Focused,
            backend_id: "gnome-wayland-portal",
            bound_input_class: None,
        });
    }

    fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> {
        // Honest behaviour: the portal exposes only a fixed
        // shortcut list per session; rebinding re-issues
        // `BindShortcuts` which causes the portal to prompt the
        // user for the new key. The class-hint is bookkeeping
        // only — the portal decides the actual binding.
        if let Ok(inner) = self.inner.try_lock() {
            if let Some(tx) = inner.cmd_tx.as_ref() {
                let _ = tx.send(WorkerCmd::Rebind);
                return Ok(());
            }
        }
        // No worker running: bind on next `start`.
        let _ = binding;
        Ok(())
    }
}

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

// ---------- Worker ----------

async fn run_worker(
    gate: AudioTransmitGate,
    mut cmd_rx: mpsc::UnboundedReceiver<WorkerCmd>,
    desc_tx: watch::Sender<PttBackendDescriptor>,
    initial_class_hint: PttInputClass,
) {
    // Open an async D-Bus session connection. If this fails we
    // emit a warning and exit; the descriptor stays at L0Focused
    // because that's the watch's initial value.
    let conn = match AsyncConnection::session().await {
        Ok(c) => c,
        Err(e) => {
            warn!(
                target: "chanora_audio",
                error = %e,
                "linux ptt: async session-bus connection failed; falling back to Focused"
            );
            return;
        }
    };
    let proxy = match GlobalShortcutsProxy::new(&conn).await {
        Ok(p) => p,
        Err(e) => {
            warn!(
                target: "chanora_audio",
                error = %e,
                "linux ptt: GlobalShortcuts proxy creation failed; falling back to Focused"
            );
            return;
        }
    };

    // Create a session.
    let session_handle = match create_session(&proxy, &conn).await {
        Ok(h) => h,
        Err(e) => {
            warn!(
                target: "chanora_audio",
                error = %e,
                "linux ptt: CreateSession failed; falling back to Focused"
            );
            return;
        }
    };
    info!(
        target: "chanora_audio",
        "linux ptt: portal session created"
    );

    // Bind the initial shortcut. The portal opens its own dialog.
    match bind_shortcut(&proxy, &conn, &session_handle).await {
        Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)),
        Err(BindError::Cancelled) => {
            warn!(
                target: "chanora_audio",
                bind_status = "cancelled",
                "linux ptt: user cancelled BindShortcuts; descriptor stays at L0Focused"
            );
            publish_l0(&desc_tx);
        }
        Err(BindError::Failed(e)) => {
            warn!(
                target: "chanora_audio",
                bind_status = "failed",
                error = %e,
                "linux ptt: BindShortcuts failed; descriptor stays at L0Focused"
            );
            publish_l0(&desc_tx);
        }
    }

    // Subscribe to Activated / Deactivated signals scoped to the
    // session handle, plus pump the command channel.
    let mut activated = match proxy.receive_activated().await {
        Ok(s) => s,
        Err(e) => {
            warn!(
                target: "chanora_audio",
                error = %e,
                "linux ptt: receive_activated subscription failed"
            );
            return;
        }
    };
    let mut deactivated = match proxy.receive_deactivated().await {
        Ok(s) => s,
        Err(e) => {
            warn!(
                target: "chanora_audio",
                error = %e,
                "linux ptt: receive_deactivated subscription failed"
            );
            return;
        }
    };
    use futures_util::StreamExt;
    loop {
        tokio::select! {
            cmd = cmd_rx.recv() => {
                match cmd {
                    Some(WorkerCmd::Rebind) => {
                        match bind_shortcut(&proxy, &conn, &session_handle).await {
                            Ok(class) => publish_bound(&desc_tx, class.unwrap_or(initial_class_hint)),
                            Err(BindError::Cancelled) => publish_l0(&desc_tx),
                            Err(BindError::Failed(_)) => publish_l0(&desc_tx),
                        }
                    }
                    Some(WorkerCmd::Stop) | None => {
                        // Close the portal session via the
                        // `org.freedesktop.portal.Session.Close`
                        // method on the session-handle object
                        // path. This is distinct from
                        // `Request::Close` (which would cancel a
                        // pending Request, not release the
                        // bound shortcuts). Best-effort: failures
                        // here are not user-visible — the worker
                        // is exiting either way, and the portal
                        // will eventually reap orphaned sessions
                        // when the calling unique D-Bus name
                        // disconnects.
                        if let Ok(builder) = SessionProxy::builder(&conn).path(session_handle.clone()) {
                            if let Ok(sp) = builder.build().await {
                                let _ = sp.close().await;
                            }
                        }
                        gate.set(false);
                        return;
                    }
                }
            }
            signal = activated.next() => {
                if let Some(s) = signal {
                    if let Ok(args) = s.args() {
                        if args.session_handle.as_str() == session_handle.as_str()
                            && args.shortcut_id == SHORTCUT_ID
                        {
                            gate.set(true);
                        }
                    }
                }
            }
            signal = deactivated.next() => {
                if let Some(s) = signal {
                    if let Ok(args) = s.args() {
                        if args.session_handle.as_str() == session_handle.as_str()
                            && args.shortcut_id == SHORTCUT_ID
                        {
                            gate.set(false);
                        }
                    }
                }
            }
        }
    }
}

async fn create_session(
    proxy: &GlobalShortcutsProxy<'_>,
    conn: &AsyncConnection,
) -> zbus::Result<zbus::zvariant::OwnedObjectPath> {
    use rand::Rng;
    let token: u32 = rand::thread_rng().gen_range(0..u32::MAX);
    let handle_token = format!("chanora{}", token);
    let session_token = format!("chanora_session_{}", token);
    let mut opts = HashMap::new();
    opts.insert("handle_token", Value::from(handle_token.as_str()));
    opts.insert("session_handle_token", Value::from(session_token.as_str()));
    let request_path = proxy.create_session(opts).await?;
    let response = await_response(conn, &request_path).await?;
    let session_handle = response
        .get("session_handle")
        .and_then(|v| <&str>::try_from(v).ok())
        .map(|s| s.to_string())
        .ok_or_else(|| zbus::Error::Failure("CreateSession returned no session_handle".to_string()))?;
    Ok(zbus::zvariant::OwnedObjectPath::try_from(session_handle)
        .map_err(|e| zbus::Error::Failure(format!("session_handle path parse: {e}")))?)
}

enum BindError {
    Cancelled,
    Failed(String),
}

async fn bind_shortcut(
    proxy: &GlobalShortcutsProxy<'_>,
    conn: &AsyncConnection,
    session_handle: &zbus::zvariant::OwnedObjectPath,
) -> Result<Option<PttInputClass>, BindError> {
    // Shortcut entry: id, options-dict. We pass a description
    // ("Push-to-talk") so the portal dialog labels the prompt
    // for the user; the actual key is chosen by the user inside
    // the dialog.
    let mut shortcut_opts: HashMap<&str, Value<'_>> = HashMap::new();
    shortcut_opts.insert("description", Value::from("Chanora push-to-talk"));
    let shortcuts: Vec<(&str, HashMap<&str, Value<'_>>)> = vec![(SHORTCUT_ID, shortcut_opts)];

    let request_path = proxy
        .bind_shortcuts(&session_handle.as_ref(), &shortcuts, "", HashMap::new())
        .await
        .map_err(|e| BindError::Failed(format!("BindShortcuts call: {e}")))?;
    let response = await_response(conn, &request_path)
        .await
        .map_err(|e| match e {
            zbus::Error::Failure(s) if s.contains("cancelled") => BindError::Cancelled,
            other => BindError::Failed(format!("BindShortcuts response: {other}")),
        })?;
    // The portal returns a `shortcuts` array of (id, properties).
    // We only inspect the `trigger_description` field, classify
    // it heuristically into keyboard or mouse-side-button, and
    // discard the raw string (DEC-027).
    let class = response
        .get("shortcuts")
        .and_then(|v| classify_shortcuts_value(v));
    Ok(class)
}

/// Wait for the `Response` signal on `request_path`. Returns the
/// `results` dict on success (response code 0) or an Error
/// otherwise.
async fn await_response(
    conn: &AsyncConnection,
    request_path: &zbus::zvariant::OwnedObjectPath,
) -> zbus::Result<HashMap<String, OwnedValue>> {
    let req = RequestProxy::builder(conn)
        .path(request_path.clone())?
        .build()
        .await?;
    use futures_util::StreamExt;
    let mut stream = req.receive_response().await?;
    if let Some(signal) = stream.next().await {
        let args = signal.args()?;
        match args.response {
            0 => Ok(args.results.clone()),
            1 => Err(zbus::Error::Failure(
                "portal request cancelled by user".to_string(),
            )),
            other => Err(zbus::Error::Failure(format!(
                "portal request failed with code {other}"
            ))),
        }
    } else {
        Err(zbus::Error::Failure(
            "portal Response signal channel closed".to_string(),
        ))
    }
}

/// Heuristically classify the portal's `shortcuts` reply into a
/// `PttInputClass`. The portal returns an `a(sa{sv})` value; each
/// element is `(shortcut_id, properties)` where `properties` may
/// contain `trigger_description` (a translated human label).
///
/// Per DEC-027 the raw string is never logged. We look for one
/// of two well-known substrings — `mouse` indicates a side-button
/// binding; anything else is treated as keyboard.
fn classify_shortcuts_value(v: &OwnedValue) -> Option<PttInputClass> {
    let sliced: Vec<(String, HashMap<String, OwnedValue>)> =
        Vec::<(String, HashMap<String, OwnedValue>)>::try_from(v.clone()).ok()?;
    for (id, props) in sliced {
        if id == SHORTCUT_ID {
            let class = props
                .get("trigger_description")
                .and_then(|d| <&str>::try_from(d).ok())
                .map(|s| {
                    if s.to_ascii_lowercase().contains("mouse") {
                        PttInputClass::MouseSideButton
                    } else {
                        PttInputClass::Keyboard
                    }
                })
                .unwrap_or(PttInputClass::Keyboard);
            return Some(class);
        }
    }
    None
}

fn publish_bound(tx: &watch::Sender<PttBackendDescriptor>, class: PttInputClass) {
    let bound = match class {
        PttInputClass::MouseSideButton => Some("mouse-side-button"),
        PttInputClass::None => None,
        _ => Some("keyboard"),
    };
    let level = match class {
        PttInputClass::MouseSideButton => PttCapabilityLevel::L3GlobalWithMouseButtons,
        _ => PttCapabilityLevel::L2GlobalHoldToTalk,
    };
    let _ = tx.send(PttBackendDescriptor {
        level,
        backend_id: "gnome-wayland-portal",
        bound_input_class: bound,
    });
}

fn publish_l0(tx: &watch::Sender<PttBackendDescriptor>) {
    let _ = tx.send(PttBackendDescriptor {
        level: PttCapabilityLevel::L0Focused,
        backend_id: "gnome-wayland-portal",
        bound_input_class: None,
    });
}

#[cfg(test)]
mod tests {
    //! Unit tests for the diagnostics-safe classifier and the
    //! descriptor watch publishers. The live portal session
    //! flow itself needs a D-Bus session and is covered by the
    //! `tests/linux_portal_smoke.rs` ignored integration test.

    use super::*;
    use std::collections::HashMap;
    use zbus::zvariant::{OwnedValue, Value};

    fn shortcut_entry(id: &str, trigger: Option<&str>) -> (String, HashMap<String, OwnedValue>) {
        let mut props: HashMap<String, OwnedValue> = HashMap::new();
        if let Some(t) = trigger {
            props.insert(
                "trigger_description".to_string(),
                OwnedValue::try_from(Value::from(t)).expect("trigger value"),
            );
        }
        (id.to_string(), props)
    }

    fn shortcuts_owned_value(entries: Vec<(String, HashMap<String, OwnedValue>)>) -> OwnedValue {
        // Build the portal's `a(sa{sv})` reply shape. Use
        // `Value::from` over the Vec; zvariant emits the array
        // signature from the tuple.
        let val = Value::from(entries);
        OwnedValue::try_from(val).expect("shortcuts owned value")
    }

    #[test]
    fn classify_returns_none_when_shortcut_id_missing() {
        let v = shortcuts_owned_value(vec![shortcut_entry("other-id", Some("Ctrl+Alt+P"))]);
        assert_eq!(classify_shortcuts_value(&v), None);
    }

    #[test]
    fn classify_returns_keyboard_for_typical_trigger_description() {
        let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, Some("Ctrl+Alt+P"))]);
        assert_eq!(
            classify_shortcuts_value(&v),
            Some(PttInputClass::Keyboard)
        );
    }

    #[test]
    fn classify_returns_keyboard_when_trigger_description_missing() {
        let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, None)]);
        assert_eq!(
            classify_shortcuts_value(&v),
            Some(PttInputClass::Keyboard)
        );
    }

    #[test]
    fn classify_detects_mouse_substring() {
        let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, Some("Mouse Button 4"))]);
        assert_eq!(
            classify_shortcuts_value(&v),
            Some(PttInputClass::MouseSideButton)
        );
    }

    #[test]
    fn classify_is_case_insensitive_on_mouse_substring() {
        let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, Some("MOUSE5"))]);
        assert_eq!(
            classify_shortcuts_value(&v),
            Some(PttInputClass::MouseSideButton)
        );
    }

    #[test]
    fn publish_bound_keyboard_publishes_L2_with_keyboard_class() {
        let initial = PttBackendDescriptor {
            level: PttCapabilityLevel::L0Focused,
            backend_id: "gnome-wayland-portal",
            bound_input_class: None,
        };
        let (tx, mut rx) = watch::channel(initial.clone());
        // Skip the initial value.
        rx.borrow_and_update();
        publish_bound(&tx, PttInputClass::Keyboard);
        let d = rx.borrow_and_update().clone();
        assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
        assert_eq!(d.backend_id, "gnome-wayland-portal");
        assert_eq!(d.bound_input_class, Some("keyboard"));
    }

    #[test]
    fn publish_bound_mouse_publishes_L3() {
        let initial = PttBackendDescriptor {
            level: PttCapabilityLevel::L0Focused,
            backend_id: "gnome-wayland-portal",
            bound_input_class: None,
        };
        let (tx, mut rx) = watch::channel(initial);
        rx.borrow_and_update();
        publish_bound(&tx, PttInputClass::MouseSideButton);
        let d = rx.borrow_and_update().clone();
        assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons);
        assert_eq!(d.bound_input_class, Some("mouse-side-button"));
    }

    #[test]
    fn publish_bound_none_publishes_L2_keyboard_default() {
        // `PttInputClass::None` falls through the `_ => Keyboard`
        // arm in `publish_bound`. The descriptor level is the
        // keyboard default; bound_input_class is `Some("keyboard")`
        // because the descriptor's bound match treats anything
        // other than `MouseSideButton` / `None` as keyboard, and
        // `PttInputClass::None` ends up at the keyboard arm of
        // `bound` via the `_` arm before reaching `None`.
        // We document the actual mapping here so a future change
        // of the match arm order is caught.
        let initial = PttBackendDescriptor::focused();
        let (tx, mut rx) = watch::channel(initial);
        rx.borrow_and_update();
        publish_bound(&tx, PttInputClass::None);
        let d = rx.borrow_and_update().clone();
        // The `bound` match's `PttInputClass::None => None` arm
        // is selected. Level still resolves to L2 because the
        // outer match's `_ => L2GlobalHoldToTalk` covers the
        // `None` case.
        assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
        assert_eq!(d.bound_input_class, None);
    }

    #[test]
    fn publish_l0_clears_descriptor() {
        // Seed with a Global descriptor so we can observe the
        // downgrade.
        let initial = PttBackendDescriptor {
            level: PttCapabilityLevel::L2GlobalHoldToTalk,
            backend_id: "gnome-wayland-portal",
            bound_input_class: Some("keyboard"),
        };
        let (tx, mut rx) = watch::channel(initial);
        rx.borrow_and_update();
        publish_l0(&tx);
        let d = rx.borrow_and_update().clone();
        assert_eq!(d.level, PttCapabilityLevel::L0Focused);
        assert_eq!(d.backend_id, "gnome-wayland-portal");
        assert_eq!(d.bound_input_class, None);
    }

    #[test]
    fn shortcut_id_is_stable() {
        // The shortcut id is shipped to the portal and persists
        // across portal restarts; flipping it would silently
        // orphan any pre-existing binding.
        assert_eq!(SHORTCUT_ID, "chanora-ptt");
    }
}
