//! Public API exposed to Dart via `flutter_rust_bridge`.
//!
//! Naming follows the FRB v2 convention: free functions at the crate
//! API root, with `#[frb(sync)]` for synchronous calls and async fn
//! signatures for async ones. Every input and output is an owned
//! type whose layout is schema-controlled (no `tsclientlib`, no
//! `cpal`, no `Connection` handles).

use std::sync::OnceLock;
use std::time::Duration;

use flutter_rust_bridge::frb;
use tokio::runtime::Runtime;
use tokio::sync::{broadcast, mpsc};
use tracing::{info, warn};

use crate::frb_generated::StreamSink;
use crate::BridgeError;

/// Process-wide tokio runtime used to drive the async core. Created
/// lazily on first use and never torn down — the application's
/// process lifetime is the runtime's lifetime.
fn runtime() -> &'static Runtime {
    static RT: OnceLock<Runtime> = OnceLock::new();
    RT.get_or_init(|| {
        tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .enable_all()
            .thread_name("chanora-rt")
            .build()
            .expect("tokio runtime")
    })
}

fn task_join_error(task: &'static str, error: tokio::task::JoinError) -> BridgeError {
    warn!(
        target: "chanora_bridge",
        task,
        error = %error,
        "runtime task failed; surfacing in diagnostics"
    );
    BridgeError::Unmapped(format!("join: {error}"))
}

enum PlatformAudioEvent {
    RouteChanged(chanora_audio::AudioRoute),
    MediaServicesResetWithRoute(chanora_audio::AudioRoute),
    InterruptionBegan,
    InterruptionEnded { should_resume: bool },
    AudioOutputRoute(chanora_audio::AudioRoute),
    Lifecycle { state: String },
}

impl PlatformAudioEvent {
    async fn process(self) {
        match self {
            Self::RouteChanged(route) => {
                if let Err(e) = session().ios_handle_route_change(route).await {
                    warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed");
                }
            }
            Self::MediaServicesResetWithRoute(route) => {
                if let Err(e) = session().ios_handle_media_services_reset(route).await {
                    warn!(target: "chanora_bridge", error = %e, "iOS media-services reset (with route) handling failed");
                }
            }
            Self::InterruptionBegan => {
                if let Err(e) = session().ios_handle_interruption_began().await {
                    warn!(target: "chanora_bridge", error = %e, "iOS interruption-began handling failed");
                }
            }
            Self::InterruptionEnded { should_resume } => {
                if let Err(e) = session().ios_handle_interruption_ended(should_resume).await {
                    warn!(target: "chanora_bridge", error = %e, "iOS interruption-ended handling failed");
                }
            }
            Self::AudioOutputRoute(route) => {
                if let Err(e) = session().ios_handle_route_change(route).await {
                    warn!(target: "chanora_bridge", error = %e, "audio output route handling failed");
                }
            }
            Self::Lifecycle { state } => {
                session().record_lifecycle_event(&state).await;
            }
        }
    }
}

fn platform_audio_events() -> &'static mpsc::UnboundedSender<PlatformAudioEvent> {
    static TX: OnceLock<mpsc::UnboundedSender<PlatformAudioEvent>> = OnceLock::new();
    TX.get_or_init(|| {
        let (tx, mut rx) = mpsc::unbounded_channel::<PlatformAudioEvent>();
        runtime().spawn(async move {
            while let Some(event) = rx.recv().await {
                event.process().await;
            }
        });
        tx
    })
}

fn dispatch_platform_audio_event(event: PlatformAudioEvent) {
    if let Err(e) = platform_audio_events().send(event) {
        warn!(
            target: "chanora_bridge",
            error = %e,
            "ordered platform audio event dispatch failed"
        );
    }
}

fn install_panic_diagnostic_hook() {
    static INSTALLED: OnceLock<()> = OnceLock::new();
    let _ = INSTALLED.get_or_init(|| {
        std::panic::set_hook(Box::new(|panic_info| {
            let message = panic_info
                .payload()
                .downcast_ref::<&'static str>()
                .map(|s| (*s).to_string())
                .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
                .unwrap_or_else(|| "non-string panic payload".to_string());
            let location = panic_info
                .location()
                .map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()))
                .unwrap_or_else(|| "unknown".to_string());
            warn!(
                target: "chanora_bridge",
                panic_message = %message,
                panic_location = %location,
                "panic captured for diagnostic export"
            );
        }));
    });
}

/// Process-wide session handle. One instance per process is enough
/// for Alpha (DEC-006 single-connection invariant); the Mutex inside
/// `ChanoraSession` enforces the connection-count invariant.
fn session() -> &'static chanora_core::ChanoraSession {
    static S: OnceLock<chanora_core::ChanoraSession> = OnceLock::new();
    S.get_or_init(chanora_core::ChanoraSession::new)
}

/// Process-wide redacted-log sink. Captures the last N tracing
/// records (already redacted) so the user-initiated diagnostic
/// export has something to ship. Lazily created on first access.
fn log_sink() -> &'static chanora_core::InMemoryLogSink {
    static SINK: OnceLock<chanora_core::InMemoryLogSink> = OnceLock::new();
    SINK.get_or_init(|| {
        chanora_core::InMemoryLogSink::new(
            chanora_core::DEFAULT_LOG_CAPACITY,
            chanora_core::Redactor::with_default_policy(),
        )
    })
}

/// Process-wide broadcast channel for [`BridgeEvent::PermissionState`]
/// emissions published by the platform permission JNI hook (SDD-106
/// §5). Kept separate from the core `SessionEvent` stream because
/// permission state is owned by the platform-bound bridge layer, not
/// by `chanora_core` (which is platform-agnostic). The
/// [`events_stream`] task fans this channel and the core session
/// stream into the single Dart-facing sink.
///
/// Capacity (64) is chosen empirically and should match the order of
/// magnitude of other `BridgeEvent` broadcast channels — generous so
/// a slow Dart subscriber misses at most the oldest queued event
/// (`tokio::sync::broadcast` drops oldest, never blocks the
/// producer) rather than impacting the JNI thread that produced it.
fn permission_events() -> &'static broadcast::Sender<BridgeEvent> {
    static TX: OnceLock<broadcast::Sender<BridgeEvent>> = OnceLock::new();
    TX.get_or_init(|| broadcast::channel(64).0)
}

/// Publish a permission-state event onto the bridge's permission
/// channel and clamp the audio engine's transmit selector
/// accordingly (SDD-106 §5/§6, SRS-209).
///
/// Called by the platform-side JNI hook (see
/// [`crate::permission_jni`]). The call is non-blocking under
/// normal operation: the transmit-selector clamp uses `AtomicU8`
/// (see `chanora_audio::TransmitModeSelector::set_permission_state`)
/// and the broadcast send drops oldest on a full channel rather
/// than parking the JNI thread. The function never panics under
/// normal operation; the only theoretical panic source is internal
/// `tokio::sync::broadcast` invariants, and any such panic would be
/// caught by the outer `catch_unwind` in the JNI entry point.
/// A missing subscriber or a closed selector is logged at `warn`
/// level and otherwise ignored.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub(crate) fn publish_permission_state(permission: String, state: PermissionStateKind) {
    // 1. Authoritative audio-engine clamp (SDD-106 §6). Only the
    //    microphone permission drives the transmit gate; other
    //    permissions (e.g. POST_NOTIFICATIONS per SDD-107 §6) ride
    //    the same event surface but do not affect transmit.
    if permission == "android.permission.RECORD_AUDIO" {
        let selector = session().transmit_selector();
        // SDD-106 §5: selector.set_permission_state uses
        // AtomicU8::store which is non-blocking; the JNI thread is
        // not parked. Safe to call from publishPermissionState.
        selector.set_permission_state(state.to_permission_gate());
    }

    // 2. Fan out to Dart subscribers. Best-effort: a send error
    //    means no current subscriber (Dart side not yet attached
    //    or already torn down) which is fine.
    let _ = permission_events().send(BridgeEvent::PermissionState { permission, state });
}

/// Default tracing filter. Suppresses the chatty
/// `tsproto::resend` and `tsproto::packet_codec` paths that
/// flood the diagnostic export during transient packet loss;
/// users can still raise verbosity via `RUST_LOG=info`.
const DEFAULT_LOG_FILTER: &str =
    "info,tsproto::resend=error,tsproto::packet_codec=error,tsclientlib=error,ts_bookkeeping::messages::s2c=error";

/// Initialise the bridge. Must be called once on Dart side before
/// any other API call. Sets up panic logging.
#[frb(init)]
pub fn bridge_init() {
    flutter_rust_bridge::setup_default_user_utils();

    // Always install the redacted in-memory log sink — diagnostic
    // export depends on it (DEC-016: user-initiated only, never
    // auto-upload). It runs alongside whatever platform sink
    // exists below; both consume the same `tracing` events.
    let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone()).with_sanitizer();

    // On Android, also fan tracing output out to logcat so a user can
    // see protocol/audio diagnostics via `adb logcat -s chanora`.
    #[cfg(target_os = "android")]
    {
        use tracing_subscriber::layer::SubscriberExt;
        use tracing_subscriber::util::SubscriberInitExt;
        let android_layer = tracing_android::layer("chanora").ok();
        let filter = tracing_subscriber::EnvFilter::try_from_default_env()
            .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER));
        let _ = tracing_subscriber::registry()
            .with(filter)
            .with(android_layer)
            .with(redact_layer)
            .try_init();
    }

    #[cfg(not(target_os = "android"))]
    {
        use tracing_subscriber::layer::SubscriberExt;
        use tracing_subscriber::util::SubscriberInitExt;
        let fmt_layer = tracing_subscriber::fmt::layer().with_target(true);
        let filter = tracing_subscriber::EnvFilter::try_from_default_env()
            .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER));

        // Also tee tracing output to a rotating file in the OS's
        // standard log directory so developers and beta testers can
        // hand-inspect output without launching from a terminal.
        // The path is platform-conventional:
        //   * Linux:   ~/.local/state/app.chanora/chanora_flutter/chanora.log
        //   * macOS:   ~/Library/Logs/app.chanora.chanora_flutter/chanora.log
        //   * Windows: %LOCALAPPDATA%\app.chanora\chanora_flutter\logs\chanora.log
        // Best-effort: if the directory cannot be created or the
        // file cannot be opened, the layer is silently dropped and
        // stderr remains the only sink.
        let file_writer = open_log_file();
        if let Some(file) = file_writer {
            let file_layer = tracing_subscriber::fmt::layer()
                .with_target(true)
                .with_ansi(false)
                .with_writer(std::sync::Mutex::new(file));
            let _ = tracing_subscriber::registry()
                .with(filter)
                .with(fmt_layer)
                .with(file_layer)
                .with(redact_layer)
                .try_init();
        } else {
            let _ = tracing_subscriber::registry()
                .with(filter)
                .with(fmt_layer)
                .with(redact_layer)
                .try_init();
        }
    }

    install_panic_diagnostic_hook();
    info!(target: "chanora_bridge", "bridge initialised");
    if let Some(p) = log_file_path() {
        info!(target: "chanora_bridge", path = %p.display(), "log file path");
    }
}

/// Return the platform-conventional log-file path as a string, or
/// an empty string if the platform does not have one (mobile).
#[frb(sync)]
pub fn log_file_path_str() -> String {
    log_file_path()
        .map(|p| p.display().to_string())
        .unwrap_or_default()
}

/// Resolve the platform-conventional log-file path. Returns `None`
/// if the platform conventions can't be honoured (e.g. neither
/// `LOCALAPPDATA` nor `HOME` is set).
fn log_file_path() -> Option<std::path::PathBuf> {
    #[cfg(target_os = "windows")]
    {
        let base = std::env::var_os("LOCALAPPDATA").or_else(|| std::env::var_os("APPDATA"))?;
        Some(
            std::path::PathBuf::from(base)
                .join("app.chanora")
                .join("chanora_flutter")
                .join("logs")
                .join("chanora.log"),
        )
    }
    #[cfg(target_os = "macos")]
    {
        let home = std::env::var_os("HOME")?;
        Some(
            std::path::PathBuf::from(home)
                .join("Library")
                .join("Logs")
                .join("app.chanora.chanora_flutter")
                .join("chanora.log"),
        )
    }
    #[cfg(all(
        unix,
        not(target_os = "macos"),
        not(target_os = "android"),
        not(target_os = "ios")
    ))]
    {
        let base = std::env::var_os("XDG_STATE_HOME")
            .map(std::path::PathBuf::from)
            .or_else(|| {
                std::env::var_os("HOME")
                    .map(|h| std::path::PathBuf::from(h).join(".local").join("state"))
            })?;
        Some(
            base.join("app.chanora")
                .join("chanora_flutter")
                .join("chanora.log"),
        )
    }
    #[cfg(target_os = "ios")]
    {
        None
    }
    #[cfg(target_os = "android")]
    {
        // Android log file location is set up via the bridge's
        // Java-side init that writes the chosen path into an env
        // var (not currently wired; P1 follow-up). For now we
        // return None and rely on logcat.
        None
    }
}

/// Open the platform log file in append mode, rotating once on
/// startup so each launch begins with a fresh file. Best-effort:
/// returns `None` on any I/O error.
#[cfg(not(target_os = "android"))]
fn open_log_file() -> Option<std::fs::File> {
    let path = log_file_path()?;
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    // Rotate at every launch (not on size). A single session can
    // emit huge logs when a chatty subsystem floods (the original
    // bug that surfaced this: tsclientlib emitting one warning per
    // outbound voice frame while server-side muted). Truncating on
    // every launch keeps per-session disk use bounded by what one
    // session can produce in its lifetime; the on-launch rotate
    // also gives the previous session's log a stable home at
    // `chanora.log.1` for post-mortem inspection.
    //
    // Two generations kept: `chanora.log.1` (previous launch) and
    // `chanora.log.2` (the one before that). Older generations
    // are deleted to keep disk use bounded across many launches.
    if path.exists() {
        let g1 = path.with_extension("log.1");
        let g2 = path.with_extension("log.2");
        // .2 is dropped; .1 becomes .2; current becomes .1.
        let _ = std::fs::remove_file(&g2);
        let _ = std::fs::rename(&g1, &g2);
        let _ = std::fs::rename(&path, &g1);
    }
    // Open fresh (truncate if rename somehow failed so we never
    // append onto a stale file).
    std::fs::OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .open(&path)
        .ok()
}

// ---------- DTOs ----------

/// Channel as seen by Dart. Matches `chanora_protocol::ChannelInfo`
/// but with primitive `u64` ids so the Dart side gets `BigInt`s
/// without any wrapper-type ceremony.
#[derive(Debug, Clone)]
pub struct BridgeChannel {
    /// Stable channel id.
    pub id: u64,
    /// Parent channel id; 0 means top-level.
    pub parent: u64,
    /// Display name.
    pub name: String,
    /// Server-side ordering hint.
    pub order: i64,
    /// True when the server marks the channel as password-protected.
    pub has_password: bool,
    /// Talk power threshold required to speak in this channel.
    /// None means no talk-power restriction.
    pub needed_talk_power: Option<i32>,
}

/// Client as seen by Dart.
#[derive(Debug, Clone)]
pub struct BridgeClient {
    /// Stable client id.
    pub id: u64,
    /// Channel id the client is currently in.
    pub channel: u64,
    /// Nickname.
    pub name: String,
    /// True when this client has muted microphone/input capture.
    pub input_muted: bool,
    /// True when this client has muted speaker/output audio.
    pub output_muted: bool,
    /// True when recent inbound voice activity was observed for this client.
    pub is_speaking: bool,
    /// True for TeamSpeak ServerQuery clients.
    pub is_server_query: bool,
    /// Current talk power value assigned by the server.
    pub talk_power: i32,
    /// True when the server has granted talk power regardless of numeric value.
    pub talk_power_granted: bool,
}

/// Rich profile and live connection details for one online client.
#[derive(Debug, Clone)]
pub struct BridgeClientProfile {
    /// Stable online client id.
    pub id: u64,
    /// Channel the client is currently in.
    pub channel: u64,
    /// Nickname.
    pub name: String,
    /// TeamSpeak unique id.
    pub unique_id: String,
    /// Stable TeamSpeak database id, when visible.
    pub database_id: Option<u64>,
    /// ISO country code, when visible.
    pub country_code: String,
    /// User description, when visible.
    pub description: String,
    /// Client version string.
    pub version: String,
    /// Client platform string.
    pub platform: String,
    /// Account creation time as Unix seconds.
    pub created_unix_seconds: Option<i64>,
    /// Last connection time as Unix seconds.
    pub last_connected_unix_seconds: Option<i64>,
    /// Total historical connections.
    pub connections_total: Option<u64>,
    /// Current online duration in seconds.
    pub online_seconds: Option<i64>,
    /// Current idle time in milliseconds.
    pub idle_milliseconds: Option<i64>,
    /// Current ping in milliseconds.
    pub ping_milliseconds: Option<i64>,
    /// Client address. Empty when permission-gated.
    pub client_address: String,
    /// Resolved server group names.
    pub server_groups: Vec<String>,
    /// Resolved channel group name.
    pub channel_group: String,
    /// TeamSpeak avatar file path suffix.
    pub avatar_path: String,
    /// Downloaded bytes this month.
    pub bytes_downloaded_month: Option<u64>,
    /// Uploaded bytes this month.
    pub bytes_uploaded_month: Option<u64>,
    /// Downloaded bytes across all time.
    pub bytes_downloaded_total: Option<u64>,
    /// Uploaded bytes across all time.
    pub bytes_uploaded_total: Option<u64>,
    /// Client-to-server total packet loss ratio.
    pub packet_loss_client_to_server_total: Option<f32>,
    /// Server-to-client total packet loss ratio.
    pub packet_loss_server_to_client_total: Option<f32>,
}

/// Server snapshot as seen by Dart.
#[derive(Debug, Clone)]
pub struct BridgeSnapshot {
    /// Server name.
    pub server_name: String,
    /// Welcome banner text.
    pub welcome_message: String,
    /// Server platform (e.g. "Linux").
    pub platform: String,
    /// Server version string.
    pub version: String,
    /// Channels currently known.
    pub channels: Vec<BridgeChannel>,
    /// Clients currently known.
    pub clients: Vec<BridgeClient>,
    /// Our own client id. Useful for the UI to highlight our row
    /// in the client list and to know which channel we are in
    /// without trusting the optimistic local state.
    pub own_client_id: u64,
}

impl From<chanora_core::ClientProfile> for BridgeClientProfile {
    fn from(profile: chanora_core::ClientProfile) -> Self {
        Self {
            id: profile.id.0,
            channel: profile.channel.0,
            name: profile.name,
            unique_id: profile.unique_id,
            database_id: profile.database_id,
            country_code: profile.country_code,
            description: profile.description,
            version: profile.version,
            platform: profile.platform,
            created_unix_seconds: profile.created_unix_seconds,
            last_connected_unix_seconds: profile.last_connected_unix_seconds,
            connections_total: profile.connections_total,
            online_seconds: profile.online_seconds,
            idle_milliseconds: profile.idle_milliseconds,
            ping_milliseconds: profile.ping_milliseconds,
            client_address: profile.client_address,
            server_groups: profile.server_groups,
            channel_group: profile.channel_group,
            avatar_path: profile.avatar_path,
            bytes_downloaded_month: profile.bytes_downloaded_month,
            bytes_uploaded_month: profile.bytes_uploaded_month,
            bytes_downloaded_total: profile.bytes_downloaded_total,
            bytes_uploaded_total: profile.bytes_uploaded_total,
            packet_loss_client_to_server_total: profile.packet_loss_client_to_server_total,
            packet_loss_server_to_client_total: profile.packet_loss_server_to_client_total,
        }
    }
}

impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
    fn from(s: chanora_core::ServerSnapshot) -> Self {
        Self {
            server_name: s.server_name,
            welcome_message: s.welcome_message,
            platform: s.platform,
            version: s.version,
            channels: s
                .channels
                .into_iter()
                .map(|c| BridgeChannel {
                    id: c.id.0,
                    parent: c.parent.0,
                    name: c.name,
                    order: c.order,
                    has_password: c.has_password,
                    needed_talk_power: c.needed_talk_power,
                })
                .collect(),
            clients: s
                .clients
                .into_iter()
                .map(|c| BridgeClient {
                    id: c.id.0,
                    channel: c.channel.0,
                    name: c.name,
                    input_muted: c.input_muted,
                    output_muted: c.output_muted,
                    is_speaking: c.is_speaking,
                    is_server_query: c.is_server_query,
                    talk_power: c.talk_power,
                    talk_power_granted: c.talk_power_granted,
                })
                .collect(),
            own_client_id: s.own_client_id,
        }
    }
}

// ---------- Commands ----------

/// Connect to a TeamSpeak-compatible server and return the initial
/// state snapshot. Honours the DEC-006 single-connection invariant
/// via [`BridgeError::AlreadyConnected`].
///
/// `password` is optional — pass an empty string for servers that
/// don't require one.
pub async fn connect(
    host: String,
    nickname: String,
    password: String,
) -> Result<BridgeSnapshot, BridgeError> {
    let cfg = chanora_core::ConnectConfig {
        address: host,
        nickname,
        password: if password.is_empty() {
            None
        } else {
            Some(password)
        },
        identity: None,
        ready_timeout: Duration::from_secs(15),
        resolved_address: None,
    };
    let snap = match runtime()
        .spawn(async move { session().connect(cfg).await })
        .await
        .map_err(|e| task_join_error("connect", e))?
    {
        Ok(snap) => snap,
        Err(chanora_core::CoreError::AlreadyConnected) => runtime()
            .spawn(async { session().snapshot().await })
            .await
            .map_err(|e| task_join_error("connect.snapshot", e))??,
        Err(e) => return Err(e.into()),
    };
    Ok(snap.into())
}

/// Warm server address resolution for the active host field. This is
/// intentionally fire-and-forget from the UI perspective: it schedules
/// Rust-side prefetch work and never opens a TS3 session.
pub async fn prefetch_server(host: String) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().prefetch_server(host).await })
        .await
        .map_err(|e| task_join_error("prefetch_server", e))??;
    Ok(())
}

/// Re-fetch a fresh snapshot from the active connection.
pub async fn snapshot() -> Result<BridgeSnapshot, BridgeError> {
    let snap = runtime()
        .spawn(async { session().snapshot().await })
        .await
        .map_err(|e| task_join_error("snapshot", e))??;
    Ok(snap.into())
}

/// Fetch richer profile and live connection details for one online client.
pub async fn client_profile(client_id: u64) -> Result<BridgeClientProfile, BridgeError> {
    let profile = runtime()
        .spawn(async move { session().client_profile(client_id).await })
        .await
        .map_err(|e| task_join_error("client_profile", e))??;
    Ok(profile.into())
}

/// Disconnect from the server. No-op if not connected.
pub async fn disconnect() -> Result<(), BridgeError> {
    runtime()
        .spawn(async { session().disconnect().await })
        .await
        .map_err(|e| task_join_error("disconnect", e))??;
    Ok(())
}

/// True if a connection is currently active.
pub async fn is_connected() -> bool {
    runtime()
        .spawn(async { session().is_connected().await })
        .await
        .unwrap_or(false)
}

// ---------- Audio commands (Beta) ----------

// `start_audio` was removed per SDD-094 — voice activation now
// flows through `voice_join` / `voice_leave`, which transparently
// drive `AudioEngine::ensure_running` / `shutdown_if_idle`.

/// Handle iOS AVAudioSession route changes (SDD-100).
#[frb(sync)]
pub fn handle_route_change(route: BridgeAudioRoute) {
    dispatch_platform_audio_event(PlatformAudioEvent::RouteChanged(route.into()));
}

/// Handle iOS AVAudioSession media-services reset with the current
/// route class. Called by AppDelegate after rebuilding the session.
///
/// `route_class` is the Swift-side route class string (e.g. "Speaker").
#[frb(sync)]
pub fn handle_media_services_reset_with_route(route_class: String) {
    let route = chanora_audio::AudioRoute::from_route_class(&route_class);
    dispatch_platform_audio_event(PlatformAudioEvent::MediaServicesResetWithRoute(route));
}

/// Handle iOS AVAudioSession interruption begin (SDD-101).
#[frb(sync)]
pub fn handle_interruption_began() {
    dispatch_platform_audio_event(PlatformAudioEvent::InterruptionBegan);
}

/// Handle iOS AVAudioSession interruption end (SDD-101).
#[frb(sync)]
pub fn handle_interruption_ended(should_resume: bool) {
    dispatch_platform_audio_event(PlatformAudioEvent::InterruptionEnded { should_resume });
}

/// Set the focused/on-screen push-to-talk hold state.
///
/// Binding capture chooses which physical key drives PTT, while this
/// command carries the actual press/release edge for fallback focused
/// keyboard handling and touch controls.
pub async fn set_ptt(active: bool) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_ptt(active).await })
        .await
        .map_err(|e| task_join_error("set_ptt", e))??;
    Ok(())
}

// ---------- v1 voice lifecycle (SDD-094/095/096) ----------

/// Voice transmit mode mirror (SDD-095). Schema-controlled enum;
/// the wire encoding matches [`chanora_core::TransmitMode::as_u8`].
#[derive(Debug, Clone, Copy)]
pub enum BridgeTransmitMode {
    /// Push-to-talk (default).
    Ptt,
    /// Continuous transmission while in channel and not muted.
    Continuous,
    /// Voice-activity detection — reserved per DEC-030; v1 behaves
    /// as `Continuous`.
    VoiceActivity,
}

impl From<BridgeTransmitMode> for chanora_core::TransmitMode {
    fn from(m: BridgeTransmitMode) -> Self {
        match m {
            BridgeTransmitMode::Ptt => Self::Ptt,
            BridgeTransmitMode::Continuous => Self::Continuous,
            BridgeTransmitMode::VoiceActivity => Self::VoiceActivity,
        }
    }
}

impl From<chanora_core::TransmitMode> for BridgeTransmitMode {
    fn from(m: chanora_core::TransmitMode) -> Self {
        match m {
            chanora_core::TransmitMode::Ptt => Self::Ptt,
            chanora_core::TransmitMode::Continuous => Self::Continuous,
            chanora_core::TransmitMode::VoiceActivity => Self::VoiceActivity,
        }
    }
}

fn transmit_mode_from_u8(v: u8) -> BridgeTransmitMode {
    chanora_core::TransmitMode::from_u8(v)
        .unwrap_or_default()
        .into()
}

/// Join a voice channel (SDD-094). Moves the user to `channel_id`,
/// brings up the audio engine if needed, and emits
/// `BridgeEvent::VoiceState`. `password` may be empty.
pub async fn voice_join(channel_id: u64, password: String) -> Result<(), BridgeError> {
    let pw = if password.is_empty() {
        None
    } else {
        Some(password)
    };
    runtime()
        .spawn(async move { session().voice_join(channel_id, pw).await })
        .await
        .map_err(|e| task_join_error("voice_join", e))??;
    Ok(())
}

/// Leave the current voice channel (SDD-094). Tears down the audio
/// engine and emits `BridgeEvent::VoiceState`.
pub async fn voice_leave() -> Result<(), BridgeError> {
    runtime()
        .spawn(async { session().voice_leave().await })
        .await
        .map_err(|e| task_join_error("voice_leave", e))??;
    Ok(())
}

/// Set the active transmit mode (SDD-095).
pub async fn set_transmit_mode(mode: BridgeTransmitMode) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_transmit_mode(mode.into()).await })
        .await
        .map_err(|e| task_join_error("set_transmit_mode", e))??;
    Ok(())
}

/// Read the active transmit mode.
pub async fn get_transmit_mode() -> BridgeTransmitMode {
    runtime()
        .spawn(async { session().transmit_mode() })
        .await
        .unwrap_or(chanora_core::TransmitMode::Ptt)
        .into()
}

/// Update the release-tail in milliseconds (SDD-096). Values are
/// clamped to `0..=500` on the Rust side; passing anything larger
/// silently saturates.
pub async fn set_release_tail_ms(ms: u32) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_release_tail_ms(ms).await })
        .await
        .map_err(|e| task_join_error("set_release_tail_ms", e))??;
    Ok(())
}

/// Read the current release-tail in milliseconds.
pub async fn get_release_tail_ms() -> u32 {
    runtime()
        .spawn(async { session().release_tail_ms() })
        .await
        .unwrap_or(200)
}

/// Engage or release the hard-mute clamp (SDD-094). When `true`
/// the audio engine transmits nothing regardless of mode.
pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_hard_mute(muted).await })
        .await
        .map_err(|e| task_join_error("set_hard_mute", e))??;
    Ok(())
}

/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
/// the bridge never carries raw key codes.
#[derive(Debug, Clone, Copy)]
pub enum BridgePttInputClass {
    /// No binding is active.
    None,
    /// A keyboard key.
    Keyboard,
    /// A mouse side button (Mouse4 / Mouse5).
    MouseSideButton,
}

/// Privacy-safe PTT capability descriptor for the UI.
#[derive(Debug, Clone)]
pub struct BridgePttDescriptor {
    /// Stable capability level name.
    pub level: String,
    /// Stable backend identifier.
    pub backend_id: String,
    /// Coarse bound input class; empty when no binding is active.
    pub bound_input_class: String,
}

impl From<chanora_core::PttDescriptorSnapshot> for BridgePttDescriptor {
    fn from(desc: chanora_core::PttDescriptorSnapshot) -> Self {
        Self {
            level: desc.level,
            backend_id: desc.backend_id,
            bound_input_class: desc.bound_input_class,
        }
    }
}

/// Persisted PTT binding display state for the UI.
#[derive(Debug, Clone)]
pub struct BridgePttBinding {
    /// Stable input category string (`""`, `"keyboard"`, or
    /// `"mouse-side-button"`).
    pub input_class: String,
    /// Display-only key label; empty when no binding is active.
    pub key_label: String,
}

impl From<chanora_core::PersistedPttBinding> for BridgePttBinding {
    fn from(binding: chanora_core::PersistedPttBinding) -> Self {
        Self {
            input_class: binding.input_class,
            key_label: binding.key_label,
        }
    }
}

impl From<BridgePttInputClass> for chanora_core::PttInputClass {
    fn from(c: BridgePttInputClass) -> Self {
        match c {
            BridgePttInputClass::None => chanora_core::PttInputClass::None,
            BridgePttInputClass::Keyboard => chanora_core::PttInputClass::Keyboard,
            BridgePttInputClass::MouseSideButton => chanora_core::PttInputClass::MouseSideButton,
        }
    }
}

/// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The
/// platform_key string is opaque to the bridge — it identifies the
/// bound key inside the platform backend and never appears in any
/// log record or diagnostic export (DEC-027 enforced at the
/// diagnostics-sanitizer layer).
pub async fn set_ptt_binding(
    input_class: BridgePttInputClass,
    platform_key: String,
) -> Result<(), BridgeError> {
    let binding = chanora_core::PttBinding {
        input_class: input_class.into(),
        platform_key,
    };
    runtime()
        .spawn(async move { session().set_ptt_binding(binding).await })
        .await
        .map_err(|e| task_join_error("set_ptt_binding", e))??;
    Ok(())
}

/// Read the current PTT capability descriptor. Matches the privacy-safe
/// `BridgeEvent::PttCapability` event shape; useful for the initial UI
/// render before the first event arrives.
pub async fn ptt_descriptor() -> BridgePttDescriptor {
    runtime()
        .spawn(async { session().ptt_descriptor().await })
        .await
        .map(Into::into)
        .unwrap_or_else(|_| BridgePttDescriptor {
            level: String::new(),
            backend_id: String::new(),
            bound_input_class: String::new(),
        })
}

/// Return the persisted PTT binding so the UI can hydrate its display
/// state at launch (e.g. show "PTT: Space" next to the badge before the
/// user re-opens the binding dialog). Empty strings mean no binding has
/// been persisted yet.
pub async fn get_ptt_binding() -> BridgePttBinding {
    runtime()
        .spawn(async { session().get_ptt_binding().await })
        .await
        .map(Into::into)
        .unwrap_or_else(|_| BridgePttBinding {
            input_class: String::new(),
            key_label: String::new(),
        })
}

/// Move our own client to `channel_id`. Optional channel password
/// for password-protected channels — pass an empty string when not
/// required.
pub async fn move_to_channel(channel_id: u64, password: String) -> Result<(), BridgeError> {
    let pw = if password.is_empty() {
        None
    } else {
        Some(password)
    };
    runtime()
        .spawn(async move { session().move_to_channel(channel_id, pw).await })
        .await
        .map_err(|e| task_join_error("move_to_channel", e))??;
    Ok(())
}

/// Toggle self input-mute (microphone) on the server. Independent
/// of push-to-talk: a muted client never transmits regardless of
/// PTT state.
pub async fn set_input_muted(muted: bool) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_self_muted(Some(muted), None).await })
        .await
        .map_err(|e| task_join_error("set_input_muted", e))??;
    Ok(())
}

/// Toggle self output-mute (speaker). Mutes locally *and* informs
/// the server. The server uses this for the channel icon next to
/// the client name; the local mute kicks in immediately even
/// before the server acknowledges.
pub async fn set_output_muted(muted: bool) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_self_muted(None, Some(muted)).await })
        .await
        .map_err(|e| task_join_error("set_output_muted", e))??;
    Ok(())
}

/// Set master output gain. `1.0` is unity, `0.0` is silent. Values
/// above `1.0` amplify and can clip downstream. Errors when audio
/// is not started.
pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_output_gain(gain).await })
        .await
        .map_err(|e| task_join_error("set_output_gain", e))??;
    Ok(())
}

/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op when client has no active voice queue. Volume is
/// applied directly to the tsclientlib AudioQueue and takes effect
/// immediately on the next render callback.
pub async fn set_client_volume(client_id: u64, volume: f32) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_client_volume(client_id, volume).await })
        .await
        .map_err(|e| task_join_error("set_client_volume", e))??;
    Ok(())
}

/// Send a text message to the specified target.
pub async fn send_chat_message(
    message: String,
    target: BridgeMessageTarget,
) -> Result<(), BridgeError> {
    let target_core: chanora_core::MessageTarget = match target {
        BridgeMessageTarget::Server => chanora_core::MessageTarget::Server,
        BridgeMessageTarget::Channel => chanora_core::MessageTarget::Channel,
        BridgeMessageTarget::Client(id) => chanora_core::MessageTarget::Client(id),
        BridgeMessageTarget::Poke(id) => chanora_core::MessageTarget::Poke(id),
    };
    runtime()
        .spawn(async move { session().send_text_message(message, target_core).await })
        .await
        .map_err(|e| task_join_error("send_chat_message", e))??;
    Ok(())
}

/// Statistics from the audio engine.
#[derive(Debug, Clone)]
pub struct BridgeAudioStats {
    /// Number of Opus frames sent since audio started.
    pub frames_sent: u32,
    /// Number of inbound voice packets decoded.
    pub frames_received: u32,
    /// Current push-to-talk state.
    pub ptt_active: bool,
}

/// Bridge route class for P1 audio-processing policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeAudioRoute {
    /// Built-in speakerphone.
    Speaker,
    /// Built-in receiver/earpiece.
    Earpiece,
    /// Wired or USB headset.
    WiredHeadset,
    /// Bluetooth HFP duplex route.
    BluetoothHfp,
    /// Bluetooth A2DP output-only route.
    BluetoothA2dp,
    /// Unknown route.
    Unknown,
}

/// Bridge iOS voice-processing mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeIosVoiceProcessingMode {
    /// Shipping VPIO path.
    PlatformVoiceProcessing,
    /// Experimental Sonora path.
    SonoraExperimental,
}

/// Bridge processing backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeAudioBackend {
    /// Platform voice processing.
    PlatformVoiceProcessing,
    /// Sonora backend.
    Sonora,
    /// WebRTC APM backend.
    WebrtcApm,
    /// No-op backend.
    Noop,
}

/// Bridge VAD backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVadBackend {
    /// Silero ONNX VAD.
    SileroOnnx,
    /// WebRTC fallback VAD.
    WebrtcVad,
    /// Debug energy VAD.
    EnergyDebug,
    /// VAD disabled.
    Disabled,
}

/// Bridge effect owner for AEC/NS/AGC.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeEffectOwner {
    /// Platform-owned effect.
    Platform,
    /// Sonora-owned effect.
    Sonora,
    /// WebRTC APM-owned effect.
    WebrtcApm,
    /// Conservative route-managed setting.
    Conservative,
    /// Disabled.
    Off,
}

/// P1 audio-processing configuration DTO.
#[derive(Debug, Clone)]
pub struct BridgeAudioProcessingConfig {
    /// Route class.
    pub route: BridgeAudioRoute,
    /// iOS voice-processing mode.
    pub ios_mode: BridgeIosVoiceProcessingMode,
    /// Processing backend.
    pub processing_backend: BridgeAudioBackend,
    /// VAD backend.
    pub vad_backend: BridgeVadBackend,
    /// AEC owner.
    pub aec: BridgeEffectOwner,
    /// Noise suppression owner.
    pub ns: BridgeEffectOwner,
    /// AGC owner.
    pub agc: BridgeEffectOwner,
    /// High-pass filter enabled.
    pub hpf_enabled: bool,
    /// Limiter enabled.
    pub limiter_enabled: bool,
    /// VAD hangover in ms.
    pub vad_hangover_ms: u32,
    /// VAD pre-roll in ms.
    pub vad_pre_roll_ms: u32,
    /// Minimum transmit duration in ms.
    pub vad_min_tx_ms: u32,
    /// Debug WAV dump enabled.
    pub debug_wav_dump_enabled: bool,
}

/// P1 audio-processing stats DTO.
#[derive(Debug, Clone)]
pub struct BridgeAudioProcessingStats {
    /// Input dBFS.
    pub input_dbfs: f32,
    /// Render dBFS.
    pub render_dbfs: f32,
    /// Processed capture dBFS.
    pub processed_dbfs: f32,
    /// Latest VAD probability.
    pub vad_probability: f32,
    /// VAD active.
    pub vad_active: bool,
    /// Currently transmitting.
    pub transmitting: bool,
    /// VAD backend.
    pub vad_backend: BridgeVadBackend,
    /// Fallback VAD active.
    pub vad_fallback_active: bool,
    /// Processing backend.
    pub processing_backend: BridgeAudioBackend,
    /// iOS voice-processing mode.
    pub ios_voice_processing_mode: BridgeIosVoiceProcessingMode,
    /// Audio route.
    pub audio_route: BridgeAudioRoute,
    /// Actual sample rate.
    pub actual_sample_rate_hz: u32,
    /// Actual IO buffer frames.
    pub actual_io_buffer_frames: u32,
    /// Input overruns.
    pub input_overruns: u64,
    /// Output underruns.
    pub output_underruns: u64,
    /// Callback xruns.
    pub callback_xruns: u64,
    /// Clipped samples.
    pub clipped_samples: u64,
    /// Number of effectively silent processed capture frames.
    pub zero_frames: u64,
    /// Number of processed capture frames.
    pub capture_frames: u64,
    /// Input callbacks carrying 10 ms of audio.
    pub callbacks_10ms: u64,
    /// Input callbacks carrying 20 ms of audio.
    pub callbacks_20ms: u64,
    /// Input callbacks carrying other sizes.
    pub callbacks_other: u64,
    /// Sonora enabled.
    pub sonora_enabled: bool,
    /// Platform voice processing enabled.
    pub platform_voice_processing_enabled: bool,
}

impl From<BridgeAudioRoute> for chanora_core::AudioRoute {
    fn from(route: BridgeAudioRoute) -> Self {
        match route {
            BridgeAudioRoute::Speaker => Self::Speaker,
            BridgeAudioRoute::Earpiece => Self::Earpiece,
            BridgeAudioRoute::WiredHeadset => Self::WiredHeadset,
            BridgeAudioRoute::BluetoothHfp => Self::BluetoothHfp,
            BridgeAudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
            BridgeAudioRoute::Unknown => Self::Unknown,
        }
    }
}

impl From<chanora_core::AudioRoute> for BridgeAudioRoute {
    fn from(route: chanora_core::AudioRoute) -> Self {
        match route {
            chanora_core::AudioRoute::Speaker => Self::Speaker,
            chanora_core::AudioRoute::Earpiece => Self::Earpiece,
            chanora_core::AudioRoute::WiredHeadset => Self::WiredHeadset,
            chanora_core::AudioRoute::BluetoothHfp => Self::BluetoothHfp,
            chanora_core::AudioRoute::BluetoothA2dp => Self::BluetoothA2dp,
            chanora_core::AudioRoute::Unknown => Self::Unknown,
        }
    }
}

impl From<BridgeIosVoiceProcessingMode> for chanora_core::IosVoiceProcessingMode {
    fn from(mode: BridgeIosVoiceProcessingMode) -> Self {
        match mode {
            BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
            BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
        }
    }
}

impl From<chanora_core::IosVoiceProcessingMode> for BridgeIosVoiceProcessingMode {
    fn from(mode: chanora_core::IosVoiceProcessingMode) -> Self {
        match mode {
            chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => {
                Self::PlatformVoiceProcessing
            }
            chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental,
        }
    }
}

impl From<BridgeAudioBackend> for chanora_core::AudioBackend {
    fn from(backend: BridgeAudioBackend) -> Self {
        match backend {
            BridgeAudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
            BridgeAudioBackend::Sonora => Self::Sonora,
            BridgeAudioBackend::WebrtcApm => Self::WebrtcApm,
            BridgeAudioBackend::Noop => Self::Noop,
        }
    }
}

impl From<chanora_core::AudioBackend> for BridgeAudioBackend {
    fn from(backend: chanora_core::AudioBackend) -> Self {
        match backend {
            chanora_core::AudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing,
            chanora_core::AudioBackend::Sonora => Self::Sonora,
            chanora_core::AudioBackend::WebrtcApm => Self::WebrtcApm,
            chanora_core::AudioBackend::Noop => Self::Noop,
        }
    }
}

impl From<BridgeVadBackend> for chanora_core::VadBackend {
    fn from(backend: BridgeVadBackend) -> Self {
        match backend {
            BridgeVadBackend::SileroOnnx => Self::SileroOnnx,
            BridgeVadBackend::WebrtcVad => Self::WebrtcVad,
            BridgeVadBackend::EnergyDebug => Self::EnergyDebug,
            BridgeVadBackend::Disabled => Self::Disabled,
        }
    }
}

impl From<chanora_core::VadBackend> for BridgeVadBackend {
    fn from(backend: chanora_core::VadBackend) -> Self {
        match backend {
            chanora_core::VadBackend::SileroOnnx => Self::SileroOnnx,
            chanora_core::VadBackend::WebrtcVad => Self::WebrtcVad,
            chanora_core::VadBackend::EnergyDebug => Self::EnergyDebug,
            chanora_core::VadBackend::Disabled => Self::Disabled,
        }
    }
}

impl From<BridgeEffectOwner> for chanora_core::EffectOwner {
    fn from(owner: BridgeEffectOwner) -> Self {
        match owner {
            BridgeEffectOwner::Platform => Self::Platform,
            BridgeEffectOwner::Sonora => Self::Sonora,
            BridgeEffectOwner::WebrtcApm => Self::WebrtcApm,
            BridgeEffectOwner::Conservative => Self::Conservative,
            BridgeEffectOwner::Off => Self::Off,
        }
    }
}

impl From<chanora_core::EffectOwner> for BridgeEffectOwner {
    fn from(owner: chanora_core::EffectOwner) -> Self {
        match owner {
            chanora_core::EffectOwner::Platform => Self::Platform,
            chanora_core::EffectOwner::Sonora => Self::Sonora,
            chanora_core::EffectOwner::WebrtcApm => Self::WebrtcApm,
            chanora_core::EffectOwner::Conservative => Self::Conservative,
            chanora_core::EffectOwner::Off => Self::Off,
        }
    }
}

impl From<BridgeAudioProcessingConfig> for chanora_core::AudioProcessingConfig {
    fn from(config: BridgeAudioProcessingConfig) -> Self {
        Self {
            route: config.route.into(),
            ios_mode: config.ios_mode.into(),
            processing_backend: config.processing_backend.into(),
            vad_backend: config.vad_backend.into(),
            aec: config.aec.into(),
            ns: config.ns.into(),
            agc: config.agc.into(),
            hpf_enabled: config.hpf_enabled,
            limiter_enabled: config.limiter_enabled,
            vad_hangover_ms: config.vad_hangover_ms,
            vad_pre_roll_ms: config.vad_pre_roll_ms,
            vad_min_tx_ms: config.vad_min_tx_ms,
            debug_wav_dump_enabled: config.debug_wav_dump_enabled,
        }
    }
}

impl From<chanora_core::AudioProcessingConfig> for BridgeAudioProcessingConfig {
    fn from(c: chanora_core::AudioProcessingConfig) -> Self {
        Self {
            route: c.route.into(),
            ios_mode: c.ios_mode.into(),
            processing_backend: c.processing_backend.into(),
            vad_backend: c.vad_backend.into(),
            aec: c.aec.into(),
            ns: c.ns.into(),
            agc: c.agc.into(),
            hpf_enabled: c.hpf_enabled,
            limiter_enabled: c.limiter_enabled,
            vad_hangover_ms: c.vad_hangover_ms,
            vad_pre_roll_ms: c.vad_pre_roll_ms,
            vad_min_tx_ms: c.vad_min_tx_ms,
            debug_wav_dump_enabled: c.debug_wav_dump_enabled,
        }
    }
}

impl From<chanora_core::AudioProcessingStats> for BridgeAudioProcessingStats {
    fn from(stats: chanora_core::AudioProcessingStats) -> Self {
        Self {
            input_dbfs: stats.input_dbfs,
            render_dbfs: stats.render_dbfs,
            processed_dbfs: stats.processed_dbfs,
            vad_probability: stats.vad_probability,
            vad_active: stats.vad_active,
            transmitting: stats.transmitting,
            vad_backend: stats.vad_backend.into(),
            vad_fallback_active: stats.vad_fallback_active,
            processing_backend: stats.processing_backend.into(),
            ios_voice_processing_mode: stats.ios_voice_processing_mode.into(),
            audio_route: stats.audio_route.into(),
            actual_sample_rate_hz: stats.actual_sample_rate_hz,
            actual_io_buffer_frames: stats.actual_io_buffer_frames,
            input_overruns: stats.input_overruns,
            output_underruns: stats.output_underruns,
            callback_xruns: stats.callback_xruns,
            clipped_samples: stats.clipped_samples,
            zero_frames: stats.zero_frames,
            capture_frames: stats.capture_frames,
            callbacks_10ms: stats.callbacks_10ms,
            callbacks_20ms: stats.callbacks_20ms,
            callbacks_other: stats.callbacks_other,
            sonora_enabled: stats.sonora_enabled,
            platform_voice_processing_enabled: stats.platform_voice_processing_enabled,
        }
    }
}

// ---------- Diagnostics (A.3) ----------

/// User-initiated diagnostic export. Returns a multi-line text
/// blob, redacted per the production policy, that the user can
/// share or copy. DEC-016 forbids automatic uploads — this is the
/// only path that surfaces logs.
///
/// SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3: when an
/// Android voice session is open this export embeds the
/// `[audio.android]` verification-matrix fragment (requested /
/// achieved stream config, per-effect engagement, latency tier).
/// On non-Android targets or before a voice session opens the
/// section is omitted. Per SDD-090 every field in that section is
/// a device-side technical scalar — no PII admitted.
#[frb(sync)]
pub fn export_diagnostics() -> String {
    let metadata = vec![
        (
            "crate_version".to_string(),
            env!("CARGO_PKG_VERSION").to_string(),
        ),
        ("target_os".to_string(), std::env::consts::OS.to_string()),
        (
            "target_arch".to_string(),
            std::env::consts::ARCH.to_string(),
        ),
    ];
    // SDD-116 item 3: pull the latest Android voice-audio
    // diagnostics snapshot from the process-global slot published
    // by AndroidVoiceUnit::open(). Returns None on non-Android and
    // before any voice session has opened.
    let android_audio_yaml =
        chanora_audio::mobile_voice_backend::current_android_audio_diagnostics()
            .map(|d| d.to_yaml_fragment());
    let audio_health = session().audio_processing_stats_if_ready();
    let network_info = runtime().block_on(async { session().network_diagnostics_summary().await });
    let protocol_events = runtime().block_on(async { session().protocol_events_snapshot().await });
    let android_audio_yaml = android_audio_yaml.map(|mut yaml| {
        if let Some(stats) = audio_health {
            yaml.push_str(&format!(
                "  health:\n\
                 \x20\x20\x20\x20capture_frames: {}\n\
                 \x20\x20\x20\x20zero_frames: {}\n\
                 \x20\x20\x20\x20callbacks_10ms: {}\n\
                 \x20\x20\x20\x20callbacks_20ms: {}\n\
                 \x20\x20\x20\x20callbacks_other: {}\n\
                 \x20\x20\x20\x20callback_xruns: {}\n\
                 \x20\x20\x20\x20clipped_samples: {}\n",
                stats.capture_frames,
                stats.zero_frames,
                stats.callbacks_10ms,
                stats.callbacks_20ms,
                stats.callbacks_other,
                stats.callback_xruns,
                stats.clipped_samples,
            ));
        }
        yaml
    });
    match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) {
        Ok(exp) => exp
            .with_android_audio(android_audio_yaml)
            .with_network_info(Some(network_info))
            .with_protocol_events(protocol_events)
            .to_text(),
        Err(e) => format!("(diagnostic export failed: {e})"),
    }
}

// ---------- Storage (A.2) ----------

/// Wire the identity persistence store to a platform-private
/// directory. Should be called once on app start after Flutter has
/// resolved `getApplicationSupportDirectory()` (or equivalent).
///
/// Subsequent [`connect`] calls will reuse the persisted identity,
/// or generate-and-persist a fresh one on first use. This keeps the
/// server-visible UID stable across app restarts.
///
/// Beta caveat: the identity is stored as a plain file (mode 0600
/// on Unix). It is *not* encrypted at rest. RISK-PoC-002 documents
/// this gap; the v0.4 storage rework lands the proper Secret
/// Service + Android Keystore + iOS Keychain backends.
pub async fn init_storage(dir: String) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().init_storage(&dir).await })
        .await
        .map_err(|e| task_join_error("init_storage", e))??;
    Ok(())
}

/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
#[derive(Debug, Clone)]
pub struct BridgeBookmark {
    /// Row id assigned by SQLite. Use `0` when adding new rows;
    /// the returned id is then meaningful.
    pub id: i64,
    /// User-facing label.
    pub display_name: String,
    /// `hostname[:port]` or TSDNS name.
    pub host: String,
    /// Nickname to use for this bookmark.
    pub nickname: String,
    /// Optional remembered password. Empty string = none.
    pub password: String,
}

impl From<chanora_core::Bookmark> for BridgeBookmark {
    fn from(b: chanora_core::Bookmark) -> Self {
        Self {
            id: b.id,
            display_name: b.display_name,
            host: b.host,
            nickname: b.nickname,
            password: b.password.unwrap_or_default(),
        }
    }
}

impl From<BridgeBookmark> for chanora_core::Bookmark {
    fn from(b: BridgeBookmark) -> Self {
        chanora_core::Bookmark {
            id: b.id,
            display_name: b.display_name,
            host: b.host,
            nickname: b.nickname,
            password: if b.password.is_empty() {
                None
            } else {
                Some(b.password)
            },
        }
    }
}

/// List persisted bookmarks.
pub async fn list_bookmarks() -> Result<Vec<BridgeBookmark>, BridgeError> {
    let v = runtime()
        .spawn(async { session().list_bookmarks().await })
        .await
        .map_err(|e| task_join_error("list_bookmarks", e))??;
    Ok(v.into_iter().map(Into::into).collect())
}

/// Insert a bookmark and return its assigned id. The `id` field on
/// the input is ignored.
pub async fn add_bookmark(b: BridgeBookmark) -> Result<i64, BridgeError> {
    let core_b: chanora_core::Bookmark = b.into();
    let id = runtime()
        .spawn(async move { session().add_bookmark(core_b).await })
        .await
        .map_err(|e| task_join_error("add_bookmark", e))??;
    Ok(id)
}

/// Update an existing bookmark.
pub async fn update_bookmark(b: BridgeBookmark) -> Result<(), BridgeError> {
    let core_b: chanora_core::Bookmark = b.into();
    runtime()
        .spawn(async move { session().update_bookmark(core_b).await })
        .await
        .map_err(|e| task_join_error("update_bookmark", e))??;
    Ok(())
}

/// Delete a bookmark by id.
pub async fn delete_bookmark(id: i64) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().delete_bookmark(id).await })
        .await
        .map_err(|e| task_join_error("delete_bookmark", e))??;
    Ok(())
}

// ---------- Connectivity (A.6.1) ----------

/// Coarse OS-reported network state. Mirrors
/// [`chanora_core::NetworkState`] across the bridge.
#[derive(Debug, Clone, Copy)]
pub enum BridgeNetworkState {
    /// No signal seen yet.
    Unknown,
    /// OS reports a usable network.
    Online,
    /// OS reports no network.
    Offline,
}

impl From<BridgeNetworkState> for chanora_core::NetworkState {
    fn from(s: BridgeNetworkState) -> Self {
        match s {
            BridgeNetworkState::Unknown => chanora_core::NetworkState::Unknown,
            BridgeNetworkState::Online => chanora_core::NetworkState::Online,
            BridgeNetworkState::Offline => chanora_core::NetworkState::Offline,
        }
    }
}

/// Notify the core of the latest OS-reported connectivity state.
/// Called by the Flutter side from `connectivity_plus` callbacks.
/// The core's supervisor uses this to (a) pre-charge the watchdog
/// on Offline and (b) short-circuit reconnect backoff on Online.
#[frb(sync)]
pub fn set_network_state(state: BridgeNetworkState) {
    session().set_network_state(state.into());
}

// ---------- Events (A.6) ----------

/// Lifecycle event surfaced to Dart. Schema-controlled mirror of
/// [`chanora_core::SessionEvent`] — no core types cross the bridge.
#[derive(Debug, Clone)]
pub enum BridgeEvent {
    /// Initial connect succeeded, or a reconnect attempt succeeded.
    Connected {
        /// Server name reported by the server snapshot.
        server_name: String,
    },
    /// Connection lost; supervisor will retry.
    Lost {
        /// Reason classification from the protocol layer.
        reason: String,
    },
    /// Supervisor is sleeping before its next reconnect attempt.
    Reconnecting {
        /// 1-based attempt counter for the current outage.
        attempt: u32,
        /// Seconds the supervisor will sleep before this attempt.
        delay_secs: u32,
    },
    /// Session ended (user-requested disconnect or unrecoverable).
    Disconnected {
        /// Reason classification.
        reason: String,
    },
    /// Audio engine started.
    AudioStarted,
    /// Audio engine stopped.
    AudioStopped,
    /// Snapshot probe observed a change in channel/client counts.
    /// UI uses this to drive an auto-refresh without active
    /// polling.
    SnapshotChanged {
        /// Latest channel count.
        channels: u32,
        /// Latest client count.
        clients: u32,
    },
    /// Detected desktop Push-to-Talk capability (gen2 v0.9.3,
    /// DEC-023..028). The fields carry only privacy-safe values per
    /// DEC-027: the capability level, a stable backend identifier,
    /// and the bound input class. No key codes, scan codes, or
    /// virtual-key values cross this boundary.
    PttCapability {
        /// Stable capability identifier (`"L0Focused"`,
        /// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
        /// `"L3GlobalWithMouseButtons"`, or `"L4DeviceAware"`).
        level: String,
        /// Stable backend identifier (e.g. `"focused"`).
        backend_id: String,
        /// Coarse bound input class (e.g. `"keyboard"`,
        /// `"mouse-side-button"`); empty when no binding is active.
        bound_input_class: String,
    },
    /// Voice subsystem state snapshot (SDD-094). The Flutter
    /// VoiceBar listens to this stream.
    VoiceState {
        /// True when the session is currently joined to a voice
        /// channel and the audio engine is running.
        in_channel: bool,
        /// Active transmit mode.
        transmit_mode: BridgeTransmitMode,
        /// True when the hard-mute clamp is engaged.
        mute: bool,
        /// Current release-tail in milliseconds (0..=500).
        release_tail_ms: u32,
        /// Last confirmed authoritative channel id.
        current_channel_id: Option<u64>,
        /// Non-authoritative pending join target channel id.
        pending_target_channel_id: Option<u64>,
        /// Whether a new join intent is currently allowed.
        can_join: bool,
        /// Whether leave intent is currently allowed.
        can_leave: bool,
        /// Join projection sync state.
        join_sync_state: BridgeVoiceJoinSyncState,
        /// Last stable join error code, if any.
        join_error_code: Option<BridgeVoiceJoinErrorCode>,
    },
    /// iOS audio interruption state (SDD-101).
    InterruptionState {
        /// True when interruption began, false when it ended.
        began: bool,
        /// Resume recommendation from the platform. False on begin.
        should_resume: bool,
    },
    /// SDD-106 §5: resolved platform-level permission state. On
    /// Android this is published by the JNI hook in
    /// [`crate::permission_jni`] whenever
    /// `AndroidPermissionRequester` reports a state transition
    /// (initial grant, denial, permanent denial, or mid-session
    /// revocation). The audio engine's `TransmitModeSelector`
    /// observes the `RECORD_AUDIO` variant of this event as an
    /// authoritative clamp on the transmit gate per SDD-106 §6
    /// (and SRS-209's listen-only fail-safe).
    ///
    /// Carries the canonical Android permission string in
    /// `permission` (e.g. `"android.permission.RECORD_AUDIO"`)
    /// and the resolved state in `state`. No raw user input,
    /// timestamps, or other PII cross this boundary.
    PermissionState {
        /// Canonical Android permission identifier.
        permission: String,
        /// Resolved permission state.
        state: PermissionStateKind,
    },
    /// A text message received from the server.
    ChatMessage {
        /// Client id of the sender.
        sender_id: u64,
        /// Nickname of the sender.
        sender_name: String,
        /// Message content.
        message: String,
        /// Target scope (server/channel/private/poke).
        target: BridgeMessageTarget,
    },
    /// Human-readable server activity surfaced from protocol bookkeeping events.
    ServerActivity {
        /// TeamSpeak-style activity line.
        message: String,
    },
    /// Audio route changed (speaker/earpiece/BT/wired).
    AudioRouteChanged {
        /// The new audio route.
        route: BridgeAudioRoute,
    },
}

/// Bridge message target scope.
#[derive(Debug, Clone, Copy)]
pub enum BridgeMessageTarget {
    /// Broadcast to entire server.
    Server,
    /// Broadcast to current channel.
    Channel,
    /// Private message to a specific client.
    Client(u64),
    /// Poke a specific client.
    Poke(u64),
}

impl From<chanora_core::MessageTarget> for BridgeMessageTarget {
    fn from(t: chanora_core::MessageTarget) -> Self {
        match t {
            chanora_core::MessageTarget::Server => Self::Server,
            chanora_core::MessageTarget::Channel => Self::Channel,
            chanora_core::MessageTarget::Client(id) => Self::Client(id),
            chanora_core::MessageTarget::Poke(id) => Self::Poke(id),
        }
    }
}

/// Bridge mirror of core join projection sync state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVoiceJoinSyncState {
    /// Reducer is ready to accept channel actions.
    Ready,
    /// Reducer is waiting on initial snapshot reconciliation.
    SynchronizingInitialSnapshot,
    /// Reducer is waiting on reconnect snapshot reconciliation.
    SynchronizingReconnect,
}

/// Bridge mirror of stable join error/status codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVoiceJoinErrorCode {
    /// Duplicate same-target join intent was coalesced.
    DuplicateSameTargetCoalesced,
    /// A different target was requested while one is already pending.
    JoinAlreadyPendingDifferentTarget,
    /// Join denied by server policy/permission.
    JoinDenied,
    /// Join failed due to protocol-level error.
    JoinProtocolFailure,
    /// Join failed due to transport/network error.
    JoinNetworkFailure,
    /// Join timed out awaiting confirmation.
    JoinTimeout,
    /// Pending join was superseded by user leave.
    JoinSupersededByLeave,
    /// Stale join outcome was ignored.
    JoinStaleOutcomeIgnored,
    /// Authoritative membership reconciled to different channel.
    JoinReconciledDifferentChannel,
    /// Join command was rejected before send acceptance.
    JoinCommandRejectedBeforeSend,
    /// Join intent rejected while reducer synchronizing.
    JoinCannotStartWhileSynchronizing,
}

/// Schema-controlled mirror of the Kotlin
/// `AndroidPermissionRequester.PermissionState` sealed class
/// (SDD-106 §5). Crosses the bridge as an enum so the Dart side can
/// `switch` on it exhaustively without parsing strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionStateKind {
    /// Permission granted by the user; capture may proceed.
    Granted,
    /// Permission denied (re-promptable).
    Denied,
    /// Permission permanently denied — the UI is expected to
    /// deep-link to system settings (SDD-106 §3).
    PermanentlyDenied,
    /// Any state string that did not match the contract above.
    /// Treated identically to `Denied` by the transmit clamp
    /// (fail-safe per SRS-209).
    Unknown,
}

impl PermissionStateKind {
    /// Map the Kotlin-side `PermissionState.toString()` value to
    /// the bridge enum. Unrecognised strings fall back to
    /// [`PermissionStateKind::Unknown`].
    #[frb(ignore)]
    pub fn from_kotlin_str(s: &str) -> Self {
        match s {
            "Granted" => Self::Granted,
            "Denied" => Self::Denied,
            "PermanentlyDenied" => Self::PermanentlyDenied,
            _ => Self::Unknown,
        }
    }

    /// Mirror into the audio-engine clamp type (SDD-106 §6).
    #[frb(ignore)]
    pub fn to_permission_gate(self) -> chanora_audio::PermissionGate {
        match self {
            Self::Granted => chanora_audio::PermissionGate::Granted,
            Self::Denied => chanora_audio::PermissionGate::Denied,
            Self::PermanentlyDenied => chanora_audio::PermissionGate::PermanentlyDenied,
            Self::Unknown => chanora_audio::PermissionGate::Unknown,
        }
    }
}

impl From<chanora_core::SessionEvent> for BridgeEvent {
    fn from(e: chanora_core::SessionEvent) -> Self {
        match e {
            chanora_core::SessionEvent::Connected { server_name } => {
                BridgeEvent::Connected { server_name }
            }
            chanora_core::SessionEvent::Lost { reason } => BridgeEvent::Lost { reason },
            chanora_core::SessionEvent::Reconnecting {
                attempt,
                delay_secs,
            } => BridgeEvent::Reconnecting {
                attempt,
                delay_secs,
            },
            chanora_core::SessionEvent::Disconnected { reason } => {
                BridgeEvent::Disconnected { reason }
            }
            chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted,
            chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped,
            chanora_core::SessionEvent::SnapshotChanged { channels, clients } => {
                BridgeEvent::SnapshotChanged { channels, clients }
            }
            chanora_core::SessionEvent::PttCapability {
                level,
                backend_id,
                bound_input_class,
            } => BridgeEvent::PttCapability {
                level,
                backend_id,
                bound_input_class,
            },
            chanora_core::SessionEvent::VoiceState {
                in_channel,
                transmit_mode,
                mute,
                release_tail_ms,
                current_channel_id,
                pending_target_channel_id,
                can_join,
                can_leave,
                join_sync_state,
                join_error_code,
            } => BridgeEvent::VoiceState {
                in_channel,
                transmit_mode: transmit_mode_from_u8(transmit_mode),
                mute,
                release_tail_ms,
                current_channel_id,
                pending_target_channel_id,
                can_join,
                can_leave,
                join_sync_state: map_join_sync_state(join_sync_state),
                join_error_code: join_error_code.map(map_join_error_code),
            },
            chanora_core::SessionEvent::InterruptionState {
                began,
                should_resume,
            } => BridgeEvent::InterruptionState {
                began,
                should_resume,
            },
            chanora_core::SessionEvent::ChatMessage {
                sender_id,
                sender_name,
                message,
                target,
            } => BridgeEvent::ChatMessage {
                sender_id,
                sender_name,
                message,
                target: target.into(),
            },
            chanora_core::SessionEvent::ServerActivity { message } => {
                BridgeEvent::ServerActivity { message }
            }
            chanora_core::SessionEvent::AudioRouteChanged { route } => {
                BridgeEvent::AudioRouteChanged {
                    route: route.into(),
                }
            }
        }
    }
}

fn map_join_sync_state(state: chanora_core::VoiceJoinSyncState) -> BridgeVoiceJoinSyncState {
    match state {
        chanora_core::VoiceJoinSyncState::Ready => BridgeVoiceJoinSyncState::Ready,
        chanora_core::VoiceJoinSyncState::SynchronizingInitialSnapshot => {
            BridgeVoiceJoinSyncState::SynchronizingInitialSnapshot
        }
        chanora_core::VoiceJoinSyncState::SynchronizingReconnect => {
            BridgeVoiceJoinSyncState::SynchronizingReconnect
        }
    }
}

fn map_join_error_code(code: chanora_core::VoiceJoinErrorCode) -> BridgeVoiceJoinErrorCode {
    match code {
        chanora_core::VoiceJoinErrorCode::DuplicateSameTargetCoalesced => {
            BridgeVoiceJoinErrorCode::DuplicateSameTargetCoalesced
        }
        chanora_core::VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget => {
            BridgeVoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget
        }
        chanora_core::VoiceJoinErrorCode::JoinDenied => BridgeVoiceJoinErrorCode::JoinDenied,
        chanora_core::VoiceJoinErrorCode::JoinProtocolFailure => {
            BridgeVoiceJoinErrorCode::JoinProtocolFailure
        }
        chanora_core::VoiceJoinErrorCode::JoinNetworkFailure => {
            BridgeVoiceJoinErrorCode::JoinNetworkFailure
        }
        chanora_core::VoiceJoinErrorCode::JoinTimeout => BridgeVoiceJoinErrorCode::JoinTimeout,
        chanora_core::VoiceJoinErrorCode::JoinSupersededByLeave => {
            BridgeVoiceJoinErrorCode::JoinSupersededByLeave
        }
        chanora_core::VoiceJoinErrorCode::JoinStaleOutcomeIgnored => {
            BridgeVoiceJoinErrorCode::JoinStaleOutcomeIgnored
        }
        chanora_core::VoiceJoinErrorCode::JoinReconciledDifferentChannel => {
            BridgeVoiceJoinErrorCode::JoinReconciledDifferentChannel
        }
        chanora_core::VoiceJoinErrorCode::JoinCommandRejectedBeforeSend => {
            BridgeVoiceJoinErrorCode::JoinCommandRejectedBeforeSend
        }
        chanora_core::VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing => {
            BridgeVoiceJoinErrorCode::JoinCannotStartWhileSynchronizing
        }
    }
}

/// Subscribe to lifecycle events. Each call yields a fresh
/// subscription; multiple subscribers are supported. On slow
/// consumers, events are dropped rather than blocking the supervisor
/// (consistent with `tokio::sync::broadcast::Receiver` semantics).
pub fn events_stream(sink: StreamSink<BridgeEvent>) -> Result<(), BridgeError> {
    let mut rx = session().subscribe_events();
    let mut perm_rx = permission_events().subscribe();
    runtime().spawn(async move {
        loop {
            tokio::select! {
                core_evt = rx.recv() => match core_evt {
                    Ok(evt) => {
                        if sink.add(BridgeEvent::from(evt)).is_err() {
                            info!(target: "chanora_bridge", "events_stream: dart sink closed");
                            return;
                        }
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                        warn!(target: "chanora_bridge", "events_stream: lagged, dropped {n} events");
                        continue;
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Closed) => {
                        info!(target: "chanora_bridge", "events_stream: source closed");
                        return;
                    }
                },
                // SDD-106 §5: forward platform permission events
                // onto the same Dart-facing sink so subscribers see
                // a unified stream.
                perm_evt = perm_rx.recv() => match perm_evt {
                    Ok(evt) => {
                        if sink.add(evt).is_err() {
                            info!(target: "chanora_bridge", "events_stream: dart sink closed");
                            return;
                        }
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                        warn!(target: "chanora_bridge", "events_stream: permission stream lagged, dropped {n} events");
                        continue;
                    }
                    Err(tokio::sync::broadcast::error::RecvError::Closed) => {
                        // Permission channel never closes (static OnceLock sender),
                        // but handle defensively.
                        info!(target: "chanora_bridge", "events_stream: permission source closed");
                        return;
                    }
                },
            }
        }
    });
    Ok(())
}

/// Read audio statistics. Errors if no connection or audio not started.
pub async fn audio_stats() -> Result<BridgeAudioStats, BridgeError> {
    let (s, r, p) = runtime()
        .spawn(async { session().audio_stats().await })
        .await
        .map_err(|e| task_join_error("audio_stats", e))??;
    Ok(BridgeAudioStats {
        frames_sent: s,
        frames_received: r,
        ptt_active: p,
    })
}

/// Apply the P1 audio-processing config.
pub async fn set_audio_processing_config(
    config: BridgeAudioProcessingConfig,
) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_audio_processing_config(config.into()).await })
        .await
        .map_err(|e| task_join_error("set_audio_processing_config", e))??;
    Ok(())
}

/// Read the current audio-processing config.
///
/// Returns the live config as last applied to the audio engine.
/// Returns a default config when no session is active.
pub async fn get_audio_processing_config() -> Result<BridgeAudioProcessingConfig, BridgeError> {
    let config = runtime()
        .spawn(async { session().get_audio_processing_config().await })
        .await
        .map_err(|e| task_join_error("get_audio_processing_config", e))??;
    Ok(config.into())
}

/// Read P1 audio-processing diagnostics.
pub async fn audio_processing_stats() -> Result<BridgeAudioProcessingStats, BridgeError> {
    let stats = runtime()
        .spawn(async { session().audio_processing_stats().await })
        .await
        .map_err(|e| task_join_error("audio_processing_stats", e))??;
    Ok(stats.into())
}

/// Audio device info from the platform.
#[derive(Debug, Clone)]
pub struct BridgeAudioDevice {
    /// Stable platform-reported device identifier.
    pub id: String,
    /// Human-readable device name.
    pub name: String,
    /// Additional device details useful for disambiguation.
    pub details: String,
    /// True if the OS reports this as the default device.
    pub is_default: bool,
    /// True if Chanora currently has this device pinned.
    pub is_selected: bool,
}

/// List of available audio devices.
#[derive(Debug, Clone)]
pub struct BridgeAudioDeviceList {
    /// Available input devices.
    pub input_devices: Vec<BridgeAudioDevice>,
    /// Available output devices.
    pub output_devices: Vec<BridgeAudioDevice>,
}

/// List available audio input and output devices from the platform.
pub async fn list_audio_devices() -> Result<BridgeAudioDeviceList, BridgeError> {
    let (list, selected_input, selected_output) = runtime()
        .spawn(async move {
            let selected_input = session().preferred_input_device().await;
            let selected_output = session().preferred_output_device().await;
            let list = chanora_audio::list_audio_devices();
            (list, selected_input, selected_output)
        })
        .await
        .map_err(|e| task_join_error("list_audio_devices", e))?;

    Ok(BridgeAudioDeviceList {
        input_devices: list
            .input_devices
            .into_iter()
            .map(|d| BridgeAudioDevice {
                is_selected: selected_input.as_ref().is_some_and(|id| id == &d.id),
                id: d.id,
                name: d.name,
                details: d.details,
                is_default: d.is_default,
            })
            .collect(),
        output_devices: list
            .output_devices
            .into_iter()
            .map(|d| BridgeAudioDevice {
                is_selected: selected_output.as_ref().is_some_and(|id| id == &d.id),
                id: d.id,
                name: d.name,
                details: d.details,
                is_default: d.is_default,
            })
            .collect(),
    })
}

/// Set the preferred input device by id. Takes effect on next
/// `start_audio`.
pub async fn set_input_device(id: Option<String>) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_input_device(id).await })
        .await
        .map_err(|e| task_join_error("set_input_device", e))??;
    Ok(())
}

/// Set the preferred output device by id.
pub async fn set_output_device(id: Option<String>) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_output_device(id).await })
        .await
        .map_err(|e| task_join_error("set_output_device", e))??;
    Ok(())
}

/// Configure the VAD model path.
pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> {
    if path.trim().is_empty() {
        return Err(BridgeError::InvalidCommand(
            "vad model path must not be empty".to_string(),
        ));
    }
    runtime()
        .spawn(async move { session().set_vad_model_path(path).await })
        .await
        .map_err(|e| task_join_error("set_vad_model_path", e))??;
    Ok(())
}

/// Enable or disable audio debug WAV dumping.
pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeError> {
    runtime()
        .spawn(async move { session().set_audio_debug_wav_dump(enabled).await })
        .await
        .map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))?
        .map_err(|e| BridgeError::Unmapped(format!("enable_audio_debug_wav_dump: {e}")))?;
    Ok(())
}

/// Select the iOS voice-processing mode.
pub async fn set_ios_voice_processing_mode(
    mode: BridgeIosVoiceProcessingMode,
) -> Result<(), BridgeError> {
    let config = BridgeAudioProcessingConfig {
        route: BridgeAudioRoute::Speaker,
        ios_mode: mode,
        processing_backend: match mode {
            BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => {
                BridgeAudioBackend::PlatformVoiceProcessing
            }
            BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::WebrtcApm,
        },
        vad_backend: BridgeVadBackend::SileroOnnx,
        aec: match mode {
            BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
            BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
        },
        ns: match mode {
            BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
            BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
        },
        agc: match mode {
            BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform,
            BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm,
        },
        hpf_enabled: true,
        limiter_enabled: true,
        vad_hangover_ms: 500,
        vad_pre_roll_ms: 160,
        vad_min_tx_ms: 200,
        debug_wav_dump_enabled: false,
    };
    set_audio_processing_config(config).await
}

/// Set the preferred audio output route (Android/iOS).
#[frb(sync)]
pub fn set_audio_output_route(route: BridgeAudioRoute) {
    dispatch_platform_audio_event(PlatformAudioEvent::AudioOutputRoute(route.into()));
}
/// Called from Flutter when the app enters background/foreground.
#[frb(sync)]
pub fn record_lifecycle_event(state: String) {
    dispatch_platform_audio_event(PlatformAudioEvent::Lifecycle { state });
}
