//! # `chanora_core`
//!
//! Top-level Rust API and orchestration for the Chanora client.
//!
//! ## Role per SAD §7.2
//!
//! `chanora_core` is the integration point. It owns no protocol,
//! audio, or storage logic directly; instead it composes the
//! subsystem crates (`chanora_protocol`, `chanora_state`,
//! `chanora_audio`, `chanora_storage`, `chanora_diagnostics`)
//! behind a stable, typed API consumed by `chanora_bridge`.
//!
//! ## Invariants
//!
//! * Single active server connection at runtime (DEC-006 / SAD-064).
//! * Protocol-specific types from `tsclientlib` do not cross out of
//!   `chanora_protocol` (SAD-067).
//! * Secret material never lands in `chanora_storage`'s non-secret
//!   side (DEC-013.2 / SS-AUD-001/002).
//!
//! ## A.6 supervisor and reconnect
//!
//! `ChanoraSession` spawns a per-connection supervisor task on a
//! successful [`Self::connect`]. The supervisor:
//!
//! * awaits the `ProtocolClient`'s loss notifier;
//! * on user-driven disconnect, exits silently;
//! * on stream-end or error, re-dials with exponential backoff
//!   (1s, 2s, 5s, 15s, 30s, 60s capped); cancellation-safe via the
//!   per-session `cancel_tx` oneshot;
//! * re-attaches the audio engine to the fresh protocol client if
//!   audio was running prior to the loss.
//!
//! Lifecycle changes are broadcast to subscribers via
//! [`ChanoraSession::subscribe_events`].

#![forbid(unsafe_code)]
#![warn(missing_docs)]

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

use thiserror::Error;
use tokio::sync::{broadcast, oneshot, watch, Mutex};
use tokio::task::JoinHandle;
use tracing::{info, warn};

use chanora_server_prefetch::ServerPrefetcher;
use chanora_state::channel_join::{
    self, AuthoritativeSource, ChannelId as JoinChannelId, ChannelJoinEvent, ChannelJoinState,
    ConnectionEpoch, JoinFailureKind,
};

pub mod ptt;

pub use chanora_audio::{
    AudioBackend, AudioEngine, AudioEngineConfig, AudioProcessingConfig, AudioProcessingStats,
    AudioRoute, AudioTransmitGate, EffectOwner, IosVoiceProcessingMode, PttBackendDescriptor,
    PttCapabilityLevel, ReleaseTailTimer, TransmitMode, TransmitModeSelector, VadBackend,
};
pub use chanora_audio::{PttBinding, PttInputClass};
pub use chanora_diagnostics::{
    DiagnosticExport, InMemoryLogSink, KnownSecretRegistry, ProtocolEventRecorder,
    RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY,
};
pub use chanora_protocol::{
    ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
    MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
};
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};

/// Privacy-safe snapshot of the active PTT capability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttDescriptorSnapshot {
    /// 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<PttBackendDescriptor> for PttDescriptorSnapshot {
    fn from(desc: PttBackendDescriptor) -> Self {
        Self {
            level: desc.level.as_str().to_string(),
            backend_id: desc.backend_id.to_string(),
            bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
        }
    }
}

/// Persisted PTT binding state exposed to callers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedPttBinding {
    /// 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 PersistedPttBinding {
    fn empty() -> Self {
        Self {
            input_class: String::new(),
            key_label: String::new(),
        }
    }
}

/// Errors that can arise during top-level orchestration.
#[derive(Debug, Error)]
pub enum CoreError {
    /// Protocol-layer error.
    #[error("protocol: {0}")]
    Protocol(#[from] chanora_protocol::ProtocolError),
    /// State-synchronisation error.
    #[error("state: {0}")]
    State(#[from] chanora_state::StateError),
    /// Audio-subsystem error.
    #[error("audio: {0}")]
    Audio(#[from] chanora_audio::AudioError),
    /// Storage error.
    #[error("storage: {0}")]
    Storage(#[from] chanora_storage::StorageError),
    /// Diagnostics error.
    #[error("diagnostics: {0}")]
    Diagnostics(#[from] chanora_diagnostics::DiagnosticsError),
    /// A precondition was violated (typically caller bug or
    /// concurrent misuse).
    #[error("invariant violated: {0}")]
    Invariant(&'static str),
    /// No active connection.
    #[error("not connected")]
    NotConnected,
    /// An attempt was made to start a second connection while one
    /// was already active (forbidden by DEC-006).
    #[error("already connected")]
    AlreadyConnected,
    /// Audio engine is not running.
    #[error("audio not started")]
    AudioNotStarted,
    /// PTT controller error.
    #[error("ptt: {0}")]
    Ptt(#[from] ptt::PttControllerError),
}

/// High-level lifecycle event surfaced to subscribers.
///
/// This is the minimal set needed for A.6 (reconnect banner). The
/// full event catalogue lands in A.4.
#[derive(Debug, Clone)]
pub enum SessionEvent {
    /// Initial connect succeeded, or reconnect attempt succeeded.
    Connected {
        /// Server name reported in the snapshot.
        server_name: String,
    },
    /// Connection lost; the 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,
    },
    /// Supervisor gave up after `attempt` failed retries (or the
    /// user explicitly disconnected mid-outage).
    Disconnected {
        /// Reason classification from the protocol layer.
        reason: String,
    },
    /// Audio engine started (e.g. after a successful reconnect with
    /// reattachment).
    AudioStarted,
    /// Audio engine stopped (e.g. before a reconnect cycle, or by
    /// explicit user action).
    AudioStopped,
    /// A snapshot probe observed a change in channel or client
    /// counts. Useful for UI auto-refresh without active polling
    /// from the Dart side. Carries the counts so subscribers can
    /// decide whether to re-fetch.
    SnapshotChanged {
        /// Number of channels in the latest probe snapshot.
        channels: u32,
        /// Number of clients in the latest probe snapshot.
        clients: u32,
    },
    /// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
    /// DEC-023..028). Published when the audio engine starts or
    /// when the active backend transitions (for example macOS
    /// permission state change). Carries only the privacy-safe
    /// descriptor — capability level, backend identifier, bound
    /// input class — per SRS-202 / DEC-027.
    PttCapability {
        /// Stable level name from `PttCapabilityLevel::as_str()`.
        level: String,
        /// Stable backend identifier (e.g. `"focused"`).
        backend_id: String,
        /// Coarse bound input class (e.g. `"keyboard"`); empty when
        /// no binding is active.
        bound_input_class: String,
    },
    /// Voice subsystem state snapshot (SDD-094). Emitted on
    /// `voice_join` / `voice_leave`, transmit-mode changes,
    /// hard-mute toggles, and release-tail edits.
    VoiceState {
        /// True when the user has joined a voice channel via
        /// `voice_join` and the audio engine is running.
        in_channel: bool,
        /// Active transmit mode encoded as
        /// [`chanora_audio::TransmitMode::as_u8`].
        transmit_mode: u8,
        /// 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 from the
        /// `channel_join` reducer projection.
        current_channel_id: Option<u64>,
        /// Non-authoritative pending target channel id from the
        /// reducer projection.
        pending_target_channel_id: Option<u64>,
        /// Whether the reducer currently allows a new join intent.
        can_join: bool,
        /// Whether the reducer currently allows leave intent.
        can_leave: bool,
        /// Join projection synchronization state.
        join_sync_state: VoiceJoinSyncState,
        /// Last stable sanitized join error code, if any.
        join_error_code: Option<VoiceJoinErrorCode>,
    },
    /// iOS audio-session interruption state (SDD-101). Emitted when
    /// interruption begins and when it ends (with the platform hint
    /// indicating whether audio should resume).
    InterruptionState {
        /// True when interruption began, false when interruption ended.
        began: bool,
        /// Platform-provided resume hint. For begin events this is false.
        should_resume: bool,
    },
    /// A text message was 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: MessageTarget,
    },
    /// Human-readable TeamSpeak-style server activity.
    ServerActivity {
        /// Activity line text.
        message: String,
    },
    /// Audio route changed (speaker/earpiece/BT/wired headset).
    AudioRouteChanged {
        /// New audio route.
        route: AudioRoute,
    },
}

/// Bridge-safe mirror of channel-join projection sync state.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinSyncState {
    /// Reducer is ready to accept channel actions.
    Ready,
    /// Reducer is synchronizing against an initial snapshot.
    SynchronizingInitialSnapshot,
    /// Reducer is synchronizing after reconnect.
    SynchronizingReconnect,
}

/// Bridge-safe mirror of stable channel-join error codes.
#[derive(Debug, Clone, Copy)]
pub enum VoiceJoinErrorCode {
    /// 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,
}

/// Coarse OS-reported network state. Populated by the Flutter side
/// via `connectivity_plus`; on platforms where no signal is wired
/// we stay at `Unknown` forever and the supervisor falls back to
/// pure watchdog/backoff behaviour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkState {
    /// No signal seen yet — treat as ambiguous; don't change behaviour.
    Unknown,
    /// OS reports at least one network with internet capability.
    Online,
    /// OS reports no networks available.
    Offline,
}

/// Channel capacity for the broadcast events. Generous because
/// reconnect cycles emit several events per attempt; if subscribers
/// fall behind we'd rather skip than block the supervisor.
const EVENT_CHANNEL_CAPACITY: usize = 64;

/// Network diagnostics snapshot collected across connection lifetimes.
#[derive(Debug, Clone, Default)]
struct NetworkDiagnostics {
    /// Total count of connects (including the initial one).
    connect_count: u64,
    /// Count of disconnects (graceful + loss).
    disconnect_count: u64,
    /// Recent loss reasons (last 8, ring buffer).
    loss_reasons: Vec<String>,
}

impl NetworkDiagnostics {
    fn record_connect(&mut self) {
        self.connect_count = self.connect_count.saturating_add(1);
    }
    fn record_loss(&mut self, reason: &str) {
        self.disconnect_count = self.disconnect_count.saturating_add(1);
        if self.loss_reasons.len() >= 8 {
            self.loss_reasons.remove(0);
        }
        self.loss_reasons.push(reason.to_string());
    }
    fn summary(&self) -> String {
        let mut s = format!(
            "connects: {}\ndisconnects: {}\n",
            self.connect_count, self.disconnect_count
        );
        if !self.loss_reasons.is_empty() {
            s.push_str(&format!(
                "loss_reasons: [{}]\n",
                self.loss_reasons.join(", ")
            ));
        }
        s
    }
}

struct SupervisorInner {
    /// Optional cached AudioEngineConfig — set when start_audio is
    /// first called, used to re-create the engine after a reconnect.
    audio_cfg: Option<AudioEngineConfig>,
    /// True when audio is currently desired (start_audio called, no
    /// stop yet). Drives whether the supervisor reattaches audio on
    /// a successful reconnect.
    audio_running: bool,
}

struct ConnectedState {
    protocol: chanora_protocol::ProtocolClient,
    audio: Option<chanora_audio::AudioEngine>,
    /// Active PTT controller (SDD-088). Owns the platform input
    /// backend, the active binding, and the capability watch
    /// channel that drives `BridgeEvent::PttCapability`. Wired in
    /// `start_audio` after the engine is up; torn down by
    /// `stop_audio` / disconnect.
    ptt_controller: Option<Arc<ptt::PttController>>,
    /// Cancellation signal for the supervisor task. Dropped on
    /// explicit disconnect to break the supervisor out of its
    /// backoff sleep.
    cancel_tx: Option<oneshot::Sender<()>>,
    /// Supervisor task handle. Awaited on disconnect for clean
    /// teardown.
    supervisor: Option<JoinHandle<()>>,
    /// Audio supervision state. Wrapped in Arc<Mutex<_>> so the
    /// supervisor and the public API both see updates.
    sup_inner: Arc<Mutex<SupervisorInner>>,
    join_state: ChannelJoinState,
    /// Per-session channel password cache. Mirrors Qint's behavior at
    /// the protocol boundary: if the user later joins the same channel
    /// without typing a password, retry the last successful password
    /// for that channel. Kept in memory only; durable secret storage is
    /// a separate storage-schema decision.
    channel_passwords: HashMap<u64, String>,
    /// Local microphone mute state used to resolve the single
    /// transmit hard-mute clamp from independent UI mute buttons.
    local_input_muted: bool,
    /// Local speaker mute state. Product rule for P0: speaker muted
    /// also means mic disabled, so this participates in the local
    /// transmit hard-mute clamp without sending server-side deafen.
    local_output_muted: bool,
}

fn normalize_channel_password(password: Option<String>) -> Option<String> {
    password
        .map(|p| p.trim().to_string())
        .filter(|p| !p.is_empty())
}

/// The top-level Chanora session. Owns at most one active server
/// connection (DEC-006).
#[derive(Clone)]
pub struct ChanoraSession {
    inner: Arc<Mutex<Option<ConnectedState>>>,
    events_tx: broadcast::Sender<SessionEvent>,
    /// OS-reported network state. Updated by the bridge from
    /// `connectivity_plus` callbacks. Supervisor observes via
    /// [`watch::Receiver`].
    network_tx: watch::Sender<NetworkState>,
    /// Beta identity persistence. Optional — when `None`, the
    /// per-connect identity is whatever `ConnectConfig::identity`
    /// carries (or a fresh ephemeral one if that is also `None`).
    /// Wired by [`Self::init_storage`].
    identity_store: Arc<Mutex<Option<IdentityFileStore>>>,
    /// Bookmark store backed by SQLite (A.2 + External Beta
    /// extension). Lives alongside the identity file. Wired by
    /// [`Self::init_storage`].
    bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
    /// Invisible server-address prefetch cache. Warmed by Flutter typing
    /// but validated by Rust before Connect can reuse it.
    server_prefetch: ServerPrefetcher,
    /// Single transmit-mode selector for the whole session
    /// lifetime (SAD-083). Re-wired to a fresh
    /// [`AudioTransmitGate`] every time the audio engine starts;
    /// in between it caches the user's chosen mode and hard-mute
    /// so that `set_transmit_mode` / `set_hard_mute` work even
    /// before any audio is running.
    voice_selector: Arc<TransmitModeSelector>,
    /// Release-tail timer (SDD-096). Drives the selector's
    /// `ptt_held` input from PTT key edges.
    release_tail: Arc<ReleaseTailTimer>,
    /// Last PTT binding the user requested via `set_ptt_binding`.
    /// Kept here so it survives the gap between user-saving a
    /// binding (which may happen before any audio is running) and
    /// the audio engine actually coming up. When the engine starts
    /// and creates the `PttController`, the controller is seeded
    /// from this value. Also persisted to the identity store so
    /// the binding survives app restarts.
    pending_binding: Arc<Mutex<Option<PttBinding>>>,
    next_connection_epoch: Arc<Mutex<u64>>,
    /// SRS-100: Network diagnostics snapshot across connection
    /// lifetimes (reconnect counts, loss reasons).
    network_diag: Arc<Mutex<NetworkDiagnostics>>,
    /// SRS-097/098: Protocol event recorder for diagnostic export
    /// and state-sync replay verification.
    event_recorder: Arc<Mutex<chanora_diagnostics::ProtocolEventRecorder>>,
    /// SRS-026: Preferred input device name.
    preferred_input_device: Arc<Mutex<Option<String>>>,
    /// SRS-026: Preferred output device name.
    preferred_output_device: Arc<Mutex<Option<String>>>,
}

impl ChanoraSession {
    /// Construct an empty session. Performs no I/O.
    pub fn new() -> Self {
        let (events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
        let (network_tx, _) = watch::channel(NetworkState::Unknown);
        // Initial selector wired to a standalone gate. Once the
        // audio engine starts, `start_audio` constructs a fresh
        // selector wired to the engine's gate and migrates the
        // cached mode + hard-mute into it.
        let initial_gate = AudioTransmitGate::new(false);
        let selector = Arc::new(TransmitModeSelector::new(initial_gate));
        let release_tail = Arc::new(ReleaseTailTimer::new(
            selector.clone(),
            chanora_audio::DEFAULT_TAIL_MS,
        ));
        Self {
            inner: Arc::new(Mutex::new(None)),
            events_tx,
            network_tx,
            identity_store: Arc::new(Mutex::new(None)),
            bookmark_store: Arc::new(Mutex::new(None)),
            server_prefetch: ServerPrefetcher::new(),
            voice_selector: selector,
            release_tail,
            pending_binding: Arc::new(Mutex::new(None)),
            next_connection_epoch: Arc::new(Mutex::new(1)),
            network_diag: Arc::new(Mutex::new(NetworkDiagnostics::default())),
            event_recorder: Arc::new(Mutex::new(chanora_diagnostics::ProtocolEventRecorder::new(
                256,
            ))),
            preferred_input_device: Arc::new(Mutex::new(None)),
            preferred_output_device: Arc::new(Mutex::new(None)),
        }
    }

    async fn allocate_connection_epoch(&self) -> ConnectionEpoch {
        let mut guard = self.next_connection_epoch.lock().await;
        let epoch = *guard;
        *guard = guard.saturating_add(1);
        ConnectionEpoch(epoch)
    }

    /// Wire a directory-backed identity store. Called by the bridge
    /// during `bridge_init` once Flutter has resolved the platform
    /// app-private storage directory. Subsequent [`Self::connect`]
    /// calls will reuse the stored identity, or generate-and-store
    /// one if none exists yet.
    ///
    /// Beta caveat: the file is *not* encrypted at rest — see
    /// `chanora_storage::IdentityFileStore` for the full gap notice.
    pub async fn init_storage(&self, dir: impl AsRef<std::path::Path>) -> Result<(), CoreError> {
        let dir = dir.as_ref();
        let store = IdentityFileStore::new(dir)?;
        info!(target: "chanora_core", path = ?store.path(), "identity store initialised");

        // Wire the bookmark repository under the same DEK so server
        // passwords are protected at rest (MVP hardening). On any
        // crypto setup failure we fall back to a plain bookmark
        // store — better to retain bookmark functionality than to
        // hard-fail.
        let bookmarks = match store.crypto() {
            Ok(c) => BookmarkRepository::with_crypto(dir, c)?,
            Err(e) => {
                warn!(
                    target: "chanora_core",
                    error = %e,
                    "could not derive DEK for bookmark store; falling back to plaintext"
                );
                BookmarkRepository::new(dir)?
            }
        };
        let encrypts = bookmarks.encrypts_passwords();
        // Restore persisted v1 audio settings (SDD-095/096).
        let persisted_mode = store.get_transmit_mode();
        if let Some(m) = TransmitMode::from_u8(persisted_mode) {
            self.voice_selector.set_mode(m);
        }
        self.release_tail
            .set_tail_ms(store.get_release_tail_ms().min(chanora_audio::MAX_TAIL_MS));
        // Restore persisted PTT binding. The controller doesn't
        // exist yet (no audio engine running), so we stash the
        // binding in `pending_binding`; it gets applied when the
        // controller arms inside `start_audio`.
        let stored_binding = store.get_ptt_binding();
        if !stored_binding.input_class.is_empty() || !stored_binding.platform_key.is_empty() {
            let input_class = match stored_binding.input_class.as_str() {
                "keyboard" => PttInputClass::Keyboard,
                "mouse-side-button" => PttInputClass::MouseSideButton,
                _ => PttInputClass::None,
            };
            let binding = PttBinding {
                input_class,
                platform_key: stored_binding.platform_key,
            };
            *self.pending_binding.lock().await = Some(binding);
        }
        *self.identity_store.lock().await = Some(store);
        *self.bookmark_store.lock().await = Some(bookmarks);
        info!(
            target: "chanora_core",
            encrypts_passwords = encrypts,
            "bookmark store initialised"
        );
        Ok(())
    }

    /// List persisted bookmarks. Returns an empty list if the store
    /// has not been wired or has no entries.
    pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>, CoreError> {
        let guard = self.bookmark_store.lock().await;
        match guard.as_ref() {
            Some(repo) => Ok(repo.list()?),
            None => Ok(Vec::new()),
        }
    }

    /// Insert a bookmark. Returns the assigned id.
    pub async fn add_bookmark(&self, b: Bookmark) -> Result<i64, CoreError> {
        let guard = self.bookmark_store.lock().await;
        let repo = guard
            .as_ref()
            .ok_or(CoreError::Invariant("bookmark store not initialised"))?;
        Ok(repo.add(&b)?)
    }

    /// Update an existing bookmark.
    pub async fn update_bookmark(&self, b: Bookmark) -> Result<(), CoreError> {
        let guard = self.bookmark_store.lock().await;
        let repo = guard
            .as_ref()
            .ok_or(CoreError::Invariant("bookmark store not initialised"))?;
        Ok(repo.update(&b)?)
    }

    /// Delete a bookmark by id.
    pub async fn delete_bookmark(&self, id: i64) -> Result<(), CoreError> {
        let guard = self.bookmark_store.lock().await;
        let repo = guard
            .as_ref()
            .ok_or(CoreError::Invariant("bookmark store not initialised"))?;
        Ok(repo.delete(id)?)
    }

    /// Push an OS connectivity update. Called by the bridge when
    /// `connectivity_plus` fires. Safe to call from any thread.
    pub fn set_network_state(&self, state: NetworkState) {
        // `send_if_modified` would suppress duplicate sends, but
        // `watch::Sender::send` already drops sends with no
        // subscribers gracefully. Using `send_replace` so the value
        // is updated even before any subscriber attaches.
        let _ = self.network_tx.send_replace(state);
    }

    /// Read the latest OS connectivity state. Useful for diagnostics.
    pub fn network_state(&self) -> NetworkState {
        *self.network_tx.borrow()
    }

    /// Subscribe to lifecycle events. The returned receiver fires
    /// on connect / lost / reconnecting / disconnected /
    /// audio-started / audio-stopped transitions. Multiple
    /// subscribers are allowed; each gets its own slow-consumer
    /// behaviour (events drop with [`broadcast::error::RecvError::Lagged`]).
    pub fn subscribe_events(&self) -> broadcast::Receiver<SessionEvent> {
        self.events_tx.subscribe()
    }

    /// Warm server-address resolution for a later user-initiated connect.
    pub async fn prefetch_server(&self, host: String) -> Result<(), CoreError> {
        let host = host.trim().to_lowercase();
        if let Err(err) = self.server_prefetch.prefetch(host.clone()).await {
            warn!(
                target: "chanora_core",
                host = %host,
                error = %err,
                "server prefetch setup failed"
            );
        }
        Ok(())
    }

    async fn apply_prefetched_resolution(&self, cfg: &mut ConnectConfig) {
        cfg.resolved_address = None;
        let host = cfg.address.trim();
        if host.is_empty() {
            return;
        }
        if let Some(addr) = self.server_prefetch.fresh_match(host).await {
            cfg.resolved_address = Some(addr);
        }
    }

    async fn prepare_connect_configs(&self, cfg: ConnectConfig) -> (ConnectConfig, ConnectConfig) {
        let stored_cfg = connect_config_without_prefetched_resolution(&cfg);
        let mut dial_cfg = stored_cfg.clone();
        self.apply_prefetched_resolution(&mut dial_cfg).await;
        (stored_cfg, dial_cfg)
    }

    /// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
    /// if a connection is already active (DEC-006). Audio is not
    /// started automatically; call [`Self::start_audio`] after.
    ///
    /// Identity policy (A.2): if `cfg.identity` is `Some(_)`, use it
    /// verbatim. Otherwise, if [`Self::init_storage`] has been
    /// called and the store already holds an identity, load and
    /// reuse it. Otherwise generate a fresh identity and — if a
    /// store is wired — persist it before dialling so the *same*
    /// UID is presented on every subsequent connect from this
    /// install.
    pub async fn connect(&self, mut cfg: ConnectConfig) -> Result<ServerSnapshot, CoreError> {
        let mut guard = self.inner.lock().await;
        if guard.is_some() {
            return Err(CoreError::AlreadyConnected);
        }

        // Resolve identity from the store before dialling.
        if cfg.identity.is_none() {
            let store_guard = self.identity_store.lock().await;
            if let Some(store) = store_guard.as_ref() {
                match store.load() {
                    Ok(Some(saved)) => {
                        info!(target: "chanora_core", "reusing persisted identity");
                        cfg.identity = Some(saved);
                    }
                    Ok(None) => {
                        let fresh = chanora_protocol::ProtocolClient::generate_identity();
                        if let Err(err) = store.save(&fresh) {
                            warn!(
                                target: "chanora_core",
                                error = %err,
                                "could not persist fresh identity; continuing with in-memory identity"
                            );
                        } else {
                            info!(target: "chanora_core", "generated + persisted fresh identity");
                        }
                        cfg.identity = Some(fresh);
                    }
                    Err(err) => {
                        warn!(
                            target: "chanora_core",
                            error = %err,
                            "identity store read failed; continuing with in-memory identity"
                        );
                        cfg.identity = Some(chanora_protocol::ProtocolClient::generate_identity());
                    }
                }
            }
        }

        let (cfg, dial_cfg) = self.prepare_connect_configs(cfg).await;

        let client = chanora_protocol::ProtocolClient::connect(dial_cfg).await?;
        let snap = client.snapshot().await?;
        let epoch = self.allocate_connection_epoch().await;
        let mut join_state = ChannelJoinState::new(epoch);
        let initial_channel = self
            .find_own_in(&snap)
            .await
            .map(|(_, channel)| JoinChannelId(channel));
        let _ = channel_join::reduce(
            &mut join_state,
            ChannelJoinEvent::SnapshotReady {
                current_channel: initial_channel,
                epoch,
                now: std::time::Instant::now(),
            },
        );

        // Set up the supervisor.
        let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
        let lost_rx = client
            .take_loss_notifier()
            .ok_or(CoreError::Invariant("lost notifier already taken"))?;
        let probe = client.snapshot_probe();

        let sup_inner = Arc::new(Mutex::new(SupervisorInner {
            audio_cfg: None,
            audio_running: false,
        }));

        let supervisor = tokio::spawn(supervisor_loop(SupervisorContext {
            state_arc: self.inner.clone(),
            events_tx: self.events_tx.clone(),
            initial_cfg: cfg.clone(),
            initial_lost_rx: lost_rx,
            initial_probe: probe,
            cancel_rx,
            sup_inner: sup_inner.clone(),
            network_rx: self.network_tx.subscribe(),
            network_diag: self.network_diag.clone(),
            event_recorder: self.event_recorder.clone(),
            voice_selector: self.voice_selector.clone(),
            pending_binding: self.pending_binding.clone(),
            release_tail: self.release_tail.clone(),
            next_connection_epoch: self.next_connection_epoch.clone(),
        }));

        let _ = self.events_tx.send(SessionEvent::Connected {
            server_name: snap.server_name.clone(),
        });

        // Record connect (SRS-100).
        {
            let mut diag = self.network_diag.lock().await;
            diag.record_connect();
        }
        // Record protocol event (SRS-097).
        {
            let mut rec = self.event_recorder.lock().await;
            rec.record_connected(&snap.server_name);
        }

        // Auto-save as a recent server (SRS-085). If a bookmark with
        // the same host+port already exists, update it; otherwise
        // create a new one with a default display name.
        {
            let bm_guard = self.bookmark_store.lock().await;
            if let Some(repo) = bm_guard.as_ref() {
                let host = cfg.address.clone();
                let display = snap.server_name.clone();
                let nickname = cfg.nickname.clone();
                let password = cfg.password.clone();
                let bm = Bookmark {
                    id: 0,
                    display_name: display,
                    host,
                    nickname,
                    password,
                };
                if let Err(e) = repo.upsert_or_add(&bm) {
                    warn!(target: "chanora_core", error = %e, "could not auto-save recent server");
                }
            }
        }

        // Forward inbound chat messages from the protocol adapter
        // to the event broadcast stream. Chat is infrequent (~human
        // typing rate), so a simple loop with try_recv + yield is fine.
        if let Some(chat_rx) = client.take_chat_rx() {
            let ev_tx = self.events_tx.clone();
            tokio::spawn(async move {
                use tokio::time::{sleep, Duration};
                let mut rx = chat_rx;
                loop {
                    match rx.try_recv() {
                        Ok(msg) => {
                            let _ = ev_tx.send(SessionEvent::ChatMessage {
                                sender_id: msg.sender_id.0,
                                sender_name: msg.sender_name,
                                message: msg.message,
                                target: msg.target,
                            });
                        }
                        Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
                        Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
                            sleep(Duration::from_millis(200)).await;
                        }
                    }
                }
            });
        }

        if let Some(activity_rx) = client.take_activity_rx() {
            let ev_tx = self.events_tx.clone();
            tokio::spawn(async move {
                use tokio::time::{sleep, Duration};
                let mut rx = activity_rx;
                loop {
                    match rx.try_recv() {
                        Ok(ServerActivity { message }) => {
                            let _ = ev_tx.send(SessionEvent::ServerActivity { message });
                        }
                        Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
                        Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
                            sleep(Duration::from_millis(200)).await;
                        }
                    }
                }
            });
        }

        *guard = Some(ConnectedState {
            protocol: client,
            audio: None,
            ptt_controller: None,
            cancel_tx: Some(cancel_tx),
            supervisor: Some(supervisor),
            sup_inner,
            join_state,
            channel_passwords: HashMap::new(),
            local_input_muted: false,
            local_output_muted: false,
        });

        // TS3 servers auto-place a newly-connected client into the
        // server's default channel (or whichever channel the
        // identity has a 'joined this channel last time' preference
        // for). The protocol layer reports that channel id in the
        // initial snapshot; we therefore consider the user 'in a
        // channel' immediately after connect, without needing them
        // to invoke voice_join manually.
        //
        // Without this auto-detect, the UI rendered the user as
        // 'connected but not in a channel': no PTT button, no
        // mic/headset AppBar icons, no voice modal sheet entry
        // point. The user could see they were in the default
        // channel in the channel tree, but had no way to talk
        // because all voice controls were gated on _inChannel which
        // was still false.
        //
        // We need to drop the inner lock before calling the public
        // helpers (they re-lock self.inner). Scope the temporary.
        drop(guard);
        if let Some((_my_id, _channel_id)) = self.find_own_in(&snap).await {
            self.voice_selector.set_in_channel(true);
            let projection = {
                let guard = self.inner.lock().await;
                guard
                    .as_ref()
                    .map(|state| channel_join::project(&state.join_state))
            };
            if let Some(projection) = projection {
                self.emit_voice_state(projection).await;
            }
            let session = self.clone();
            tokio::spawn(async move {
                if let Err(audio_err) = session.ensure_audio_running().await {
                    warn!(
                        target: "chanora_core",
                        error = %audio_err,
                        "auto-join default channel: server placed us in a channel but audio engine \
                         failed to start; continuing with no-audio in-channel state"
                    );
                    let _ = session.events_tx.send(SessionEvent::AudioStopped);
                }
            });
        }

        Ok(snap)
    }

    /// Return a fresh snapshot of the current server state.
    pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
        let mut guard = self.inner.lock().await;
        let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
        let snap = state.protocol.snapshot().await?;
        let current_channel = self
            .find_own_in(&snap)
            .await
            .map(|(_, channel)| JoinChannelId(channel));
        let epoch = state.join_state.authoritative.confirmed_epoch;
        let before = channel_join::project(&state.join_state);
        let _ = channel_join::reduce(
            &mut state.join_state,
            ChannelJoinEvent::SnapshotReady {
                current_channel,
                epoch,
                now: std::time::Instant::now(),
            },
        );
        let projection = channel_join::project(&state.join_state);
        let should_emit_voice_state = projection != before;
        self.voice_selector
            .set_in_channel(projection.current_channel.is_some());
        drop(guard);
        if should_emit_voice_state {
            self.emit_voice_state(projection).await;
        }
        Ok(snap)
    }

    /// Fetch richer profile and live connection details for one online client.
    pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, CoreError> {
        let guard = self.inner.lock().await;
        let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
        Ok(state.protocol.client_profile(client_id).await?)
    }

    /// True if a connection is currently active.
    pub async fn is_connected(&self) -> bool {
        self.inner.lock().await.is_some()
    }

    /// Send a text message to the specified target.
    pub async fn send_text_message(
        &self,
        message: String,
        target: MessageTarget,
    ) -> Result<(), CoreError> {
        if message.trim().is_empty() {
            return Ok(());
        }
        let guard = self.inner.lock().await;
        let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
        state.protocol.send_text_message(message, target).await?;
        Ok(())
    }

    /// Get a network diagnostics summary (SRS-100).
    pub async fn network_diagnostics_summary(&self) -> String {
        let diag = self.network_diag.lock().await;
        diag.summary()
    }

    /// Drain and return recorded protocol events (SRS-097).
    pub async fn drain_protocol_events(&self) -> Vec<String> {
        let mut rec = self.event_recorder.lock().await;
        rec.drain()
    }

    /// Snapshot recorded protocol events without clearing them.
    pub async fn protocol_events_snapshot(&self) -> Vec<String> {
        let rec = self.event_recorder.lock().await;
        rec.snapshot()
    }

    /// Record a platform lifecycle event (SRS-138).
    pub async fn record_lifecycle_event(&self, state: &str) {
        let mut rec = self.event_recorder.lock().await;
        rec.record_lifecycle(state);
    }

    /// Start the audio engine attached to the current connection.
    /// Fails if not connected. Idempotent — calling twice replaces
    /// the engine. Stores the config so the supervisor can restart
    /// audio after a reconnect.
    pub async fn start_audio(&self, mut cfg: AudioEngineConfig) -> Result<(), CoreError> {
        // Apply stored device preferences (SRS-026).
        {
            let dev_in = self.preferred_input_device.lock().await;
            let dev_out = self.preferred_output_device.lock().await;
            if cfg.input_device_id.is_none() {
                cfg.input_device_id = dev_in.clone();
            }
            if cfg.output_device_id.is_none() {
                cfg.output_device_id = dev_out.clone();
            }
        }

        let mut guard = self.inner.lock().await;
        let state = guard.as_mut().ok_or(CoreError::NotConnected)?;

        // Build the new engine BEFORE tearing down the old one so a
        // construction failure (e.g. CoreAudio rejects the stream
        // config on iOS, or no mic in a headless smoke run) is
        // recoverable: the old engine + the previously-taken
        // voice_in stay alive, and the next start_audio attempt
        // tries again from the same state. Previously we tore the
        // old engine down first, which consumed voice_in via the
        // `take_voice_in` invariant; a build failure then left the
        // session permanently unable to restart audio without a
        // reconnect (the user saw "voice_in already taken" on the
        // second channel switch).
        let voice_out = state.protocol.voice_out();
        let voice_in = state
            .protocol
            .take_voice_in()
            .ok_or(CoreError::Invariant("voice_in already taken"))?;
        let gate = AudioTransmitGate::new(cfg.ptt_initial);
        cfg.voice_activity_selector = Some(self.voice_selector.clone());
        let new_engine = match chanora_audio::AudioEngine::start_with_gate(
            cfg.clone(),
            voice_out,
            voice_in,
            gate.clone(),
        ) {
            Ok(e) => e,
            Err(e) => {
                // voice_in was consumed by start_with_gate. We
                // cannot return it to the protocol adapter without
                // changing the engine signature. Document the
                // limitation and surface the error honestly; the
                // next reconnect will refresh voice_in. This is
                // strictly better than the previous behaviour
                // (which tore down the WORKING old engine before
                // the new-engine attempt failed).
                warn!(
                    target: "chanora_core",
                    error = %e,
                    "audio engine construction failed; the previous engine \
                     (if any) is intact, but voice_in is now consumed — a \
                     reconnect is required before another start_audio can \
                     succeed"
                );
                return Err(CoreError::from(e));
            }
        };

        // New engine constructed successfully — now safe to tear
        // down the old controller + engine. The controller must be
        // torn down before the engine because its forwarder task
        // references the gate that lives in the old engine.
        if let Some(prev) = state.ptt_controller.take() {
            prev.stop().await;
        }
        if let Some(mut prev) = state.audio.take() {
            prev.stop();
        }

        // Rewire the session's long-lived selector to the new gate
        // (SAD-083). The selector retains cached mode / hard-mute /
        // ptt_held so settings set before audio-start take effect
        // immediately.
        self.voice_selector.replace_gate(gate.clone());
        state.audio = Some(new_engine);

        // Wire the PTT controller (SDD-088). It owns the platform
        // backend, the active binding, and the capability watch
        // channel. The controller routes raw press-edges through
        // the session's release-tail timer (SDD-096) and the
        // transmit-mode selector (SAD-083); the backend never
        // writes the real `transmit_active` directly.
        let controller = ptt::PttController::new(self.release_tail.clone());
        state.ptt_controller = Some(controller.clone());

        // Apply any binding the user saved before audio was running
        // (SDD-094 follow-up). Persistence + caching happen in
        // `set_ptt_binding`; here we forward the cached value to
        // the freshly-armed controller so PTT actually fires.
        if let Some(pending) = self.pending_binding.lock().await.clone() {
            if let Err(e) = controller.set_binding(pending).await {
                warn!(
                    target: "chanora_core",
                    error = %e,
                    "applying pending PTT binding failed; controller stays at default"
                );
            }
        }

        // Record desired state so the supervisor will re-start audio
        // after a reconnect.
        {
            let mut sup = state.sup_inner.lock().await;
            sup.audio_cfg = Some(cfg);
            sup.audio_running = true;
        }
        let _ = self.events_tx.send(SessionEvent::AudioStarted);

        // Publish the current PTT capability so the UI badge can
        // render an honest value (SRS-196 / SDD-091). Backends
        // that resolve asynchronously (notably the Linux portal
        // backend after the user accepts the BindShortcuts
        // dialog) re-publish through the controller's
        // descriptor-watch.
        let initial_desc = controller.descriptor().await;
        let _ = self.events_tx.send(SessionEvent::PttCapability {
            level: initial_desc.level.as_str().to_string(),
            backend_id: initial_desc.backend_id.to_string(),
            bound_input_class: initial_desc.bound_input_class.unwrap_or("").to_string(),
        });
        let mut watch_rx = controller.descriptor_watch();
        let events_tx = self.events_tx.clone();
        tokio::spawn(async move {
            while watch_rx.changed().await.is_ok() {
                let d = watch_rx.borrow_and_update().clone();
                let _ = events_tx.send(SessionEvent::PttCapability {
                    level: d.level.as_str().to_string(),
                    backend_id: d.backend_id.to_string(),
                    bound_input_class: d.bound_input_class.unwrap_or("").to_string(),
                });
            }
        });
        Ok(())
    }

    /// Set push-to-talk state. No-op if audio not started.
    /// Set the local push-to-talk state by hand. Used by the
    /// in-focus Listener path on platforms that lack a global
    /// backend, and by tests. Routes through the release-tail
    /// timer + transmit-mode selector so callers see the same
    /// 200 ms tail and hard-mute semantics as the global backends
    /// (SDD-096 / SAD-083).
    pub async fn set_ptt(&self, active: bool) -> Result<(), CoreError> {
        // Use NotConnected as a soft guard: the in-focus PTT
        // helper only fires while the user is in a session.
        let guard = self.inner.lock().await;
        let _ = guard.as_ref().ok_or(CoreError::NotConnected)?;
        drop(guard);
        if active {
            self.release_tail.key_down();
        } else {
            self.release_tail.key_up();
        }
        Ok(())
    }

    /// Update the active PTT binding (gen2 v0.9.3 / DEC-026).
    ///
    /// This call must succeed even when the audio engine is not
    /// running — users may bind a key before they ever join a voice
    /// channel. We always persist the binding to the identity store,
    /// always store it in `pending_binding`, and only forward it
    /// live to the `PttController` when the controller exists. When
    /// audio next starts the controller picks up the pending binding
    /// (see `start_audio` / `voice_join`).
    pub async fn set_ptt_binding(&self, binding: PttBinding) -> Result<(), CoreError> {
        // 1. Persist to disk (best-effort; missing identity store
        //    just means storage hasn't been wired yet).
        if let Some(store) = self.identity_store.lock().await.as_ref() {
            let class_s = match binding.input_class {
                PttInputClass::None => "",
                PttInputClass::Keyboard => "keyboard",
                PttInputClass::MouseSideButton => "mouse-side-button",
            };
            if let Err(e) =
                store.set_ptt_binding(class_s, &binding.platform_key, &binding.platform_key)
            {
                warn!(
                    target: "chanora_core",
                    error = %e,
                    "could not persist PTT binding"
                );
            }
        }
        // 2. Stash for future controller arming.
        *self.pending_binding.lock().await = Some(binding.clone());
        // 3. Forward live if a controller exists.
        let guard = self.inner.lock().await;
        if let Some(state) = guard.as_ref() {
            if let Some(controller) = state.ptt_controller.as_ref() {
                let desc = controller.set_binding(binding).await?;
                let _ = self.events_tx.send(SessionEvent::PttCapability {
                    level: desc.level.as_str().to_string(),
                    backend_id: desc.backend_id.to_string(),
                    bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
                });
            }
        }
        Ok(())
    }

    /// Returns the active PTT capability descriptor. Useful for the
    /// initial UI render before the first `PttCapability` event arrives.
    /// Returns the universal Focused descriptor when no audio engine is
    /// running.
    pub async fn ptt_descriptor(&self) -> PttDescriptorSnapshot {
        let guard = self.inner.lock().await;
        let desc = match guard.as_ref().and_then(|s| s.ptt_controller.as_ref()) {
            Some(controller) => controller.descriptor().await,
            None => PttBackendDescriptor::focused(),
        };
        desc.into()
    }

    /// Read the persisted PTT binding. Used by the Flutter side at launch
    /// so the badge and the mode line can show the user's saved hotkey
    /// before any audio engine has spun up. Empty strings indicate no
    /// binding has been saved yet.
    pub async fn get_ptt_binding(&self) -> PersistedPttBinding {
        // 1) Prefer the in-memory pending binding (most-recent
        //    save, possibly not yet flushed to disk on a slow FS).
        if let Some(b) = self.pending_binding.lock().await.clone() {
            let class_s = match b.input_class {
                PttInputClass::None => "",
                PttInputClass::Keyboard => "keyboard",
                PttInputClass::MouseSideButton => "mouse-side-button",
            };
            return PersistedPttBinding {
                input_class: class_s.to_string(),
                key_label: b.platform_key,
            };
        }
        // 2) Fall back to the persisted file (case: app just
        //    started, init_storage already ran).
        if let Some(store) = self.identity_store.lock().await.as_ref() {
            let stored_binding = store.get_ptt_binding();
            return PersistedPttBinding {
                input_class: stored_binding.input_class,
                key_label: stored_binding.key_label,
            };
        }
        PersistedPttBinding::empty()
    }

    /// Move our own client to `channel_id`. Optional channel
    /// `password` for password-protected channels (empty string
    /// counts as no password).
    pub async fn move_to_channel(
        &self,
        channel_id: u64,
        password: Option<String>,
    ) -> Result<(), CoreError> {
        let mut guard = self.inner.lock().await;
        let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
        let requested_password = normalize_channel_password(password);
        let password_to_send = requested_password
            .clone()
            .or_else(|| state.channel_passwords.get(&channel_id).cloned());
        state
            .protocol
            .move_to_channel(channel_id, password_to_send)
            .await?;
        if let Some(pw) = requested_password {
            state.channel_passwords.insert(channel_id, pw);
        }
        Ok(())
    }

    /// Update self-mute state. `input` mutes the microphone, `output`
    /// mutes the local speaker. Pass `None` to leave a field
    /// unchanged. Adjusting the local output mute also updates the
    /// audio engine's master output gain so playback silences
    /// immediately, independent of the server's broadcast.
    pub async fn set_self_muted(
        &self,
        input: Option<bool>,
        output: Option<bool>,
    ) -> Result<(), CoreError> {
        let mut guard = self.inner.lock().await;
        let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
        if let Some(muted) = input {
            state.local_input_muted = muted;
        }
        if let Some(muted) = output {
            state.local_output_muted = muted;
        }
        state.protocol.set_muted(input, output).await?;
        if let Some(muted) = output {
            if let Some(audio) = state.audio.as_ref() {
                audio.set_output_muted(muted);
            }
        }
        // Input mute must also stop local outbound voice production
        // so the transmit selector stays in sync with the server-side
        // mic mute. Output mute is playback-only and must not affect
        // the mic gate.
        let mic_disabled = state.local_input_muted;
        self.voice_selector.set_hard_mute(mic_disabled);
        Ok(())
    }

    /// Set master output gain (0.0 = silent, 1.0 = unity). Errors
    /// only when audio is not started.
    pub async fn set_output_gain(&self, gain: f32) -> Result<(), CoreError> {
        let guard = self.inner.lock().await;
        let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
        let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
        audio.set_output_gain(gain);
        Ok(())
    }

    /// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
    /// mutes. No-op if audio is not started or client has no active
    /// voice queue.
    pub async fn set_client_volume(&self, client_id: u64, volume: f32) -> Result<(), CoreError> {
        let guard = self.inner.lock().await;
        let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
        let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
        audio.set_client_volume(client_id, volume);
        Ok(())
    }

    /// Read audio engine statistics: (frames_sent, frames_received, transmit_active).
    pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> {
        let guard = self.inner.lock().await;
        let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
        let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
        Ok((
            audio.frames_sent(),
            audio.frames_received(),
            audio.transmit_active(),
        ))
    }

    /// Apply the Rust-owned P1 audio-processing configuration.
    pub async fn set_audio_processing_config(
        &self,
        config: AudioProcessingConfig,
    ) -> Result<(), CoreError> {
        let guard = self.inner.lock().await;
        let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
        let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
        #[cfg(any(target_os = "ios", target_os = "macos"))]
        let new_config = config.clone();
        #[cfg(any(target_os = "ios", target_os = "macos"))]
        let current = audio.audio_processing_config_snapshot();
        audio.set_audio_processing_config(config)?;
        #[cfg(any(target_os = "ios", target_os = "macos"))]
        {
            if current.route != new_config.route
                || current.ios_mode != new_config.ios_mode
                || current.processing_backend != new_config.processing_backend
            {
                audio.ios_restart_voice_unit()?;
            }
        }
        Ok(())
    }

    /// Read the current audio-processing configuration.
    ///
    /// Returns the live config snapshot from the audio engine, or a
    /// default config when no session / audio engine is active.
    pub async fn get_audio_processing_config(&self) -> Result<AudioProcessingConfig, CoreError> {
        let guard = self.inner.lock().await;
        if let Some(state) = guard.as_ref() {
            if let Some(audio) = state.audio.as_ref() {
                return Ok(audio.audio_processing_config_snapshot());
            }
        }
        Ok(AudioProcessingConfig::default())
    }

    /// Read P1 audio-processing diagnostics.
    pub async fn audio_processing_stats(&self) -> Result<AudioProcessingStats, CoreError> {
        let guard = self.inner.lock().await;
        let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
        let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
        Ok(audio.audio_processing_stats())
    }

    /// Best-effort audio-processing diagnostics for user exports.
    ///
    /// This intentionally never waits for the connection mutex. During
    /// server join the connect path owns that mutex while awaiting network
    /// I/O; diagnostics must stay responsive and omit live audio stats
    /// rather than blocking the UI thread.
    pub fn audio_processing_stats_if_ready(&self) -> Option<AudioProcessingStats> {
        let guard = self.inner.try_lock().ok()?;
        let state = guard.as_ref()?;
        let audio = state.audio.as_ref()?;
        Some(audio.audio_processing_stats())
    }

    /// Configure the preferred Silero ONNX VAD model path on platforms
    /// that ship the ONNX detector.
    ///
    /// This does not require an active connection. Running non-iOS
    /// audio backends can observe the model-path epoch and reload on
    /// the next capture frame when Silero is selected.
    pub async fn set_vad_model_path(&self, path: String) -> Result<(), CoreError> {
        chanora_audio::vad::set_silero_model_path(&path)?;
        Ok(())
    }

    /// Route-change hook. Updates audio processing config, bounces the
    /// platform backend when needed, and notifies Flutter of the new route.
    pub async fn ios_handle_route_change(&self, route: AudioRoute) -> Result<(), CoreError> {
        let guard = self.inner.lock().await;
        if let Some(state) = guard.as_ref() {
            if let Some(audio) = state.audio.as_ref() {
                let config = chanora_audio::route_policy::apply_route_change(
                    &audio.audio_processing_config_snapshot(),
                    route,
                );
                audio.set_audio_processing_config(config)?;
                #[cfg(any(target_os = "ios", target_os = "macos"))]
                {
                    audio.ios_restart_voice_unit()?;
                }
                #[cfg(target_os = "android")]
                {
                    audio.android_restart_voice_unit()?;
                }
            }
        }
        let _ = self
            .events_tx
            .send(SessionEvent::AudioRouteChanged { route });
        Ok(())
    }

    /// Enable or disable async WAV debug dump for the active audio
    /// session (DIAG_002 / DIAG_003). No-op when audio is not started.
    pub async fn set_audio_debug_wav_dump(&self, enabled: bool) -> Result<(), CoreError> {
        let guard = self.inner.lock().await;
        if let Some(state) = guard.as_ref() {
            if let Some(audio) = state.audio.as_ref() {
                let mut config = audio.audio_processing_config_snapshot();
                config.debug_wav_dump_enabled = enabled;
                audio.set_audio_processing_config(config)?;
            }
        }
        Ok(())
    }

    /// iOS media-services-reset hook (SDD-101). Rebuilds the audio
    /// unit using the supplied route so the processing policy is
    /// correct after the OS-level media reset.
    pub async fn ios_handle_media_services_reset(
        &self,
        route: AudioRoute,
    ) -> Result<(), CoreError> {
        self.ios_handle_route_change(route).await
    }

    /// iOS interruption-began hook (SDD-101). No-op when audio is
    /// not running.
    pub async fn ios_handle_interruption_began(&self) -> Result<(), CoreError> {
        let guard = self.inner.lock().await;
        if let Some(state) = guard.as_ref() {
            if let Some(audio) = state.audio.as_ref() {
                audio.ios_pause_voice_unit()?;
            }
        }
        let _ = self.events_tx.send(SessionEvent::InterruptionState {
            began: true,
            should_resume: false,
        });
        Ok(())
    }

    /// iOS interruption-ended hook (SDD-101). Resumes only when
    /// `should_resume` is true.
    pub async fn ios_handle_interruption_ended(
        &self,
        should_resume: bool,
    ) -> Result<(), CoreError> {
        let _ = self.events_tx.send(SessionEvent::InterruptionState {
            began: false,
            should_resume,
        });
        if !should_resume {
            return Ok(());
        }
        let guard = self.inner.lock().await;
        if let Some(state) = guard.as_ref() {
            if let Some(audio) = state.audio.as_ref() {
                audio.ios_resume_voice_unit()?;
            }
        }
        Ok(())
    }

    // ---------- v1 audio + PTT lifecycle (SDD-094/095/096) ----------

    /// Idempotent helper that ensures the audio engine is running
    /// (SDD-094). Starts a fresh engine with the default
    /// [`AudioEngineConfig`] if none is active; otherwise leaves
    /// the current engine in place.
    async fn ensure_audio_running(&self) -> Result<(), CoreError> {
        let need_start = {
            let guard = self.inner.lock().await;
            let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
            state.audio.is_none()
        };
        if need_start {
            self.start_audio(AudioEngineConfig::default()).await?;
        }
        Ok(())
    }

    /// Tear down the audio engine when it is no longer needed
    /// (SDD-094). Called by `voice_leave`. The PTT controller is
    /// torn down with it.
    async fn shutdown_audio_if_idle(&self) {
        let mut guard = self.inner.lock().await;
        if let Some(state) = guard.as_mut() {
            if let Some(controller) = state.ptt_controller.take() {
                controller.stop().await;
            }
            if let Some(mut engine) = state.audio.take() {
                engine.stop();
                let _ = self.events_tx.send(SessionEvent::AudioStopped);
            }
            // Record that audio is no longer desired so the
            // supervisor does not re-arm it on the next reconnect.
            let mut sup = state.sup_inner.lock().await;
            sup.audio_running = false;
        }
    }

    /// Join a voice channel (SDD-094). Moves the user to
    /// `channel_id`, starts the audio engine if needed, marks the
    /// transmit-mode selector as in-channel, and emits
    /// [`SessionEvent::VoiceState`].
    ///
    /// `password` is optional; empty string is treated as none.
    pub async fn voice_join(
        &self,
        channel_id: u64,
        password: Option<String>,
    ) -> Result<(), CoreError> {
        let join_started = std::time::Instant::now();
        info!(
            target: "chanora_core",
            channel_id,
            "voice_join requested"
        );
        let mut guard = self.inner.lock().await;
        let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
        let join_start = std::time::Instant::now();
        let requested = channel_join::reduce(
            &mut state.join_state,
            ChannelJoinEvent::UserJoinRequested {
                target_channel: JoinChannelId(channel_id),
                now: join_start,
            },
        );
        match requested.status {
            channel_join::JoinReduceStatus::Rejected(
                channel_join::JoinIntentRejected::JoinAlreadyPendingDifferentTarget,
            ) => {
                return Err(CoreError::Protocol(
                    chanora_protocol::ProtocolError::ServerRejected {
                        code: 0x7001,
                        message: "join already pending for a different target".to_string(),
                    },
                ));
            }
            channel_join::JoinReduceStatus::Rejected(
                channel_join::JoinIntentRejected::CannotJoinWhileSynchronizing,
            ) => {
                return Err(CoreError::Protocol(
                    chanora_protocol::ProtocolError::ServerRejected {
                        code: 0x7002,
                        message: "join cannot start while synchronizing".to_string(),
                    },
                ));
            }
            channel_join::JoinReduceStatus::CoalescedSameTarget => return Ok(()),
            _ => {}
        }

        let generation = requested
            .projection
            .pending_generation
            .ok_or(CoreError::Invariant(
                "missing pending generation after join request",
            ))?;
        // 1. Send the move command. With send_with_result the
        //    adapter now correlates against the server's typed
        //    error reply, so on rejection (no permission, wrong
        //    password, channel full) we get a concrete
        //    `ProtocolError::ServerRejected` and don't need the
        //    snapshot polling at all.
        let requested_password = normalize_channel_password(password);
        let password_to_send = requested_password
            .clone()
            .or_else(|| state.channel_passwords.get(&channel_id).cloned());
        if let Err(e) = state
            .protocol
            .queue_move_to_channel(channel_id, password_to_send)
            .await
        {
            // TS3 error 0x0302 = `channel_already_in`: we're already
            // in the target channel, so this is a no-op success.
            // Rolling `in_channel` back to false would break PTT
            // because the transmit-mode selector clamps
            // `transmit_active` to false when `in_channel` is false.
            // (SDD-094, SAD-081, SRS-204)
            if matches!(
                &e,
                chanora_protocol::ProtocolError::ServerRejected { code: 0x0302, .. }
            ) {
                info!(
                    target: "chanora_core",
                    channel_id,
                    "voice_join: already in channel (0x0302); treating as success"
                );
                // Fall through to audio-start + snapshot confirmation below.
            } else {
                let _ = channel_join::reduce(
                    &mut state.join_state,
                    ChannelJoinEvent::JoinCommandRejectedBeforeSend {
                        generation,
                        error: JoinFailureKind::Denied,
                    },
                );
                let projection = channel_join::project(&state.join_state);
                let still_in_channel = projection.current_channel.is_some();
                self.voice_selector.set_in_channel(still_in_channel);
                self.emit_voice_state(projection).await;
                return Err(CoreError::Protocol(e));
            }
        }
        info!(
            target: "chanora_core",
            channel_id,
            elapsed_ms = join_started.elapsed().as_millis() as u64,
            "voice_join move_to_channel acknowledged"
        );
        if let Some(pw) = requested_password {
            state.channel_passwords.insert(channel_id, pw);
        }
        let epoch = state.join_state.authoritative.confirmed_epoch;
        let _ = channel_join::reduce(
            &mut state.join_state,
            ChannelJoinEvent::AuthoritativeSelfMove {
                channel: Some(JoinChannelId(channel_id)),
                epoch,
                source: AuthoritativeSource::LiveDelta,
            },
        );
        let projection = channel_join::project(&state.join_state);
        self.voice_selector.set_in_channel(true);
        self.emit_voice_state(projection).await;
        drop(guard);
        info!(target: "chanora_core", channel_id, "voice_join accepted by server");
        let session = self.clone();
        tokio::spawn(async move {
            if let Err(audio_err) = session.ensure_audio_running().await {
                warn!(
                    target: "chanora_core",
                    error = %audio_err,
                    channel_id,
                    "voice_join: deferred audio engine start failed after \
                     server-side join; continuing with no-audio in-channel state"
                );
                let _ = session.events_tx.send(SessionEvent::AudioStopped);
            }
        });
        info!(
            target: "chanora_core",
            channel_id,
            elapsed_ms = join_started.elapsed().as_millis() as u64,
            "voice_join returned to caller"
        );
        Ok(())
    }

    /// Find our own client in a snapshot and return `(client_id,
    /// channel_id)`. Used by `voice_join` to confirm the server
    /// actually applied a channel move.
    async fn find_own_in(&self, snap: &chanora_protocol::ServerSnapshot) -> Option<(u64, u64)> {
        let own_id = snap.own_client_id;
        let me = snap.clients.iter().find(|c| c.id.0 == own_id)?;
        Some((me.id.0, me.channel.0))
    }

    /// Leave the current voice channel (SDD-094). Marks the
    /// selector as out-of-channel (which clamps `transmit_active`
    /// to false), tears down the audio engine, and emits
    /// [`SessionEvent::VoiceState`].
    pub async fn voice_leave(&self) -> Result<(), CoreError> {
        let mut guard = self.inner.lock().await;
        if let Some(state) = guard.as_mut() {
            let _ = channel_join::reduce(
                &mut state.join_state,
                ChannelJoinEvent::UserLeaveRequested {
                    now: std::time::Instant::now(),
                },
            );
            let projection = channel_join::project(&state.join_state);
            self.voice_selector
                .set_in_channel(projection.current_channel.is_some());
            self.emit_voice_state(projection).await;
        }
        drop(guard);
        self.shutdown_audio_if_idle().await;
        Ok(())
    }

    /// Update the active transmit mode (SDD-095). Persists the new
    /// value to the identity store when one is wired, and re-emits
    /// [`SessionEvent::VoiceState`].
    pub async fn set_transmit_mode(&self, mode: TransmitMode) -> Result<(), CoreError> {
        self.voice_selector.set_mode(mode);
        if let Some(store) = self.identity_store.lock().await.as_ref() {
            // Persist best-effort; a missing meta file is a recoverable error.
            if let Err(e) = store.set_transmit_mode(mode.as_u8()) {
                warn!(
                    target: "chanora_core",
                    error = %e,
                    "failed to persist transmit_mode"
                );
            }
        }
        let projection = {
            let guard = self.inner.lock().await;
            guard
                .as_ref()
                .map(|state| channel_join::project(&state.join_state))
        };
        if let Some(projection) = projection {
            self.emit_voice_state(projection).await;
        }
        Ok(())
    }

    /// Read the active transmit mode.
    pub fn transmit_mode(&self) -> TransmitMode {
        self.voice_selector.mode()
    }

    /// Engage or release the hard-mute clamp (SDD-094). Hard-mute
    /// is the final clamp applied by the [`TransmitModeSelector`]
    /// — when engaged, no audio is transmitted regardless of
    /// channel or PTT state.
    pub async fn set_hard_mute(&self, muted: bool) -> Result<(), CoreError> {
        self.voice_selector.set_hard_mute(muted);
        let projection = {
            let guard = self.inner.lock().await;
            guard
                .as_ref()
                .map(|state| channel_join::project(&state.join_state))
        };
        if let Some(projection) = projection {
            self.emit_voice_state(projection).await;
        }
        Ok(())
    }

    /// True if hard-mute is currently engaged.
    pub fn hard_mute(&self) -> bool {
        self.voice_selector.hard_mute()
    }

    /// Set the preferred input device id (SRS-026). Takes effect
    /// on the next audio engine start.
    pub async fn set_input_device(&self, id: Option<String>) -> Result<(), CoreError> {
        *self.preferred_input_device.lock().await = id;
        Ok(())
    }

    /// Set the preferred output device id (SRS-026).
    pub async fn set_output_device(&self, id: Option<String>) -> Result<(), CoreError> {
        *self.preferred_output_device.lock().await = id;
        Ok(())
    }

    /// Read the preferred input device id, if one is pinned.
    pub async fn preferred_input_device(&self) -> Option<String> {
        self.preferred_input_device.lock().await.clone()
    }

    /// Read the preferred output device id, if one is pinned.
    pub async fn preferred_output_device(&self) -> Option<String> {
        self.preferred_output_device.lock().await.clone()
    }

    /// Update the release-tail (SDD-096). Clamped to `0..=500` ms
    /// inclusive. Persists best-effort and re-emits voice state.
    pub async fn set_release_tail_ms(&self, ms: u32) -> Result<(), CoreError> {
        let clamped = ms.min(chanora_audio::MAX_TAIL_MS);
        self.release_tail.set_tail_ms(clamped);
        if let Some(store) = self.identity_store.lock().await.as_ref() {
            if let Err(e) = store.set_release_tail_ms(clamped) {
                warn!(
                    target: "chanora_core",
                    error = %e,
                    "failed to persist release_tail_ms"
                );
            }
        }
        let projection = {
            let guard = self.inner.lock().await;
            guard
                .as_ref()
                .map(|state| channel_join::project(&state.join_state))
        };
        if let Some(projection) = projection {
            self.emit_voice_state(projection).await;
        }
        Ok(())
    }

    /// Current release-tail in milliseconds.
    pub fn release_tail_ms(&self) -> u32 {
        self.release_tail.tail_ms()
    }

    /// Shared handle to the session's [`TransmitModeSelector`].
    /// Exposed for diagnostics / tests; the bridge mutates via the
    /// dedicated `set_*` methods above.
    pub fn transmit_selector(&self) -> Arc<TransmitModeSelector> {
        self.voice_selector.clone()
    }

    /// Shared handle to the session's [`ReleaseTailTimer`]. PTT
    /// input backends drive this timer's `key_down` / `key_up`
    /// edges (SDD-088).
    pub fn release_tail_timer(&self) -> Arc<ReleaseTailTimer> {
        self.release_tail.clone()
    }

    async fn emit_voice_state(&self, projection: channel_join::ChannelJoinProjection) {
        let in_channel = projection.current_channel.is_some();
        let _ = self.events_tx.send(SessionEvent::VoiceState {
            in_channel,
            transmit_mode: self.voice_selector.mode().as_u8(),
            mute: self.voice_selector.hard_mute(),
            release_tail_ms: self.release_tail.tail_ms(),
            current_channel_id: projection.current_channel.map(|id| id.0),
            pending_target_channel_id: projection.pending_target.map(|id| id.0),
            can_join: projection.can_join,
            can_leave: projection.can_leave,
            join_sync_state: map_join_sync_state(projection.sync_state),
            join_error_code: projection.last_join_error.map(map_join_error_code),
        });
    }

    /// Disconnect from the server. No-op if not connected.
    pub async fn disconnect(&self) -> Result<(), CoreError> {
        let mut guard = self.inner.lock().await;
        if let Some(mut state) = guard.take() {
            // Signal the supervisor to exit (cancels any backoff sleep).
            if let Some(tx) = state.cancel_tx.take() {
                let _ = tx.send(());
            }
            if let Some(controller) = state.ptt_controller.take() {
                controller.stop().await;
            }
            if let Some(mut audio) = state.audio.take() {
                audio.stop();
                let _ = self.events_tx.send(SessionEvent::AudioStopped);
            }
            state.protocol.disconnect().await;
            // Wait for the supervisor to wind down so we don't race
            // a redial against the explicit disconnect.
            if let Some(handle) = state.supervisor.take() {
                let _ = handle.await;
            }
            let _ = self.events_tx.send(SessionEvent::Disconnected {
                reason: "user requested".to_string(),
            });
        }
        Ok(())
    }
}

impl Default for ChanoraSession {
    fn default() -> Self {
        Self::new()
    }
}

/// Exponential backoff schedule for reconnect attempts (seconds).
/// After exhausting the schedule the supervisor stays at the last
/// entry. We cap at 60 s so a long outage still has a chance to
/// recover without consuming the device's battery polling tighter.
const BACKOFF_SCHEDULE: &[u32] = &[1, 2, 5, 15, 30, 60];

/// Watchdog probe interval. Every tick the supervisor issues a
/// `snapshot()` RPC against the live protocol client; if the server
/// has silently stopped responding (e.g. evicted us after a long
/// network outage) the probe fails. After
/// [`WATCHDOG_MAX_MISSES`] consecutive failures the supervisor
/// treats the connection as lost and triggers a redial. This is the
/// belt to the `lost_rx`-from-`connection_task` braces: tsclientlib
/// does not always surface a UDP idle-timeout as a stream error, so
/// without this watchdog the client can stay "ghost connected" —
/// UI shows Connected, server has long since removed us.
const WATCHDOG_INTERVAL: Duration = Duration::from_secs(5);
/// Per-probe timeout. Must be shorter than [`WATCHDOG_INTERVAL`] so
/// a stuck probe cannot wedge the supervisor.
const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// Number of consecutive watchdog failures before the supervisor
/// declares the connection lost.
const WATCHDOG_MAX_MISSES: u32 = 3;

struct SupervisorContext {
    state_arc: Arc<Mutex<Option<ConnectedState>>>,
    events_tx: broadcast::Sender<SessionEvent>,
    initial_cfg: ConnectConfig,
    initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
    initial_probe: chanora_protocol::SnapshotProbe,
    cancel_rx: oneshot::Receiver<()>,
    sup_inner: Arc<Mutex<SupervisorInner>>,
    network_rx: watch::Receiver<NetworkState>,
    network_diag: Arc<Mutex<NetworkDiagnostics>>,
    event_recorder: Arc<Mutex<chanora_diagnostics::ProtocolEventRecorder>>,
    voice_selector: Arc<TransmitModeSelector>,
    pending_binding: Arc<Mutex<Option<PttBinding>>>,
    release_tail: Arc<ReleaseTailTimer>,
    next_connection_epoch: Arc<Mutex<u64>>,
}

async fn supervisor_loop(ctx: SupervisorContext) {
    let SupervisorContext {
        state_arc,
        events_tx,
        initial_cfg,
        initial_lost_rx,
        initial_probe,
        mut cancel_rx,
        sup_inner,
        mut network_rx,
        network_diag,
        event_recorder,
        voice_selector,
        pending_binding,
        release_tail,
        next_connection_epoch,
    } = ctx;
    let mut lost_rx = initial_lost_rx;
    let mut probe = initial_probe;
    let mut cfg = initial_cfg;
    // Stable content hash of the last snapshot observed by the
    // watchdog. Used to emit `SessionEvent::SnapshotChanged`
    // whenever the channel or client list mutates in any way —
    // count, ordering, names, or per-client channel membership.
    // Stored as a u64 so the comparison is cheap and the field
    // doesn't grow with the snapshot.
    let mut last_signature: Option<u64> = None;

    loop {
        // Watch the current connection: race the protocol task's
        // own loss notifier against our app-level watchdog. Either
        // signal yields a `DisconnectReason` we then act on.
        let mut watchdog = tokio::time::interval(WATCHDOG_INTERVAL);
        // Skip the immediate tick so the first probe runs one
        // interval after connect, not instantly.
        watchdog.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
        watchdog.tick().await; // consume the immediate tick
        let mut misses: u32 = 0;

        let reason: chanora_protocol::DisconnectReason = loop {
            tokio::select! {
                biased;
                _ = &mut cancel_rx => {
                    info!(target: "chanora_core", "supervisor cancelled by user");
                    return;
                }
                r = &mut lost_rx => {
                    match r {
                        Ok(reason) => break reason,
                        Err(_) => {
                            info!(
                                target: "chanora_core",
                                "supervisor: loss notifier dropped without firing"
                            );
                            return;
                        }
                    }
                }
                changed = network_rx.changed() => {
                    if changed.is_err() {
                        // The session was dropped; nothing more to do.
                        return;
                    }
                    let new_state = *network_rx.borrow_and_update();
                    if new_state == NetworkState::Offline {
                        // OS says we're offline. Don't fabricate a
                        // loss outright — captive portals and
                        // transient flicker can produce false
                        // negatives — but pre-charge the watchdog
                        // so the next probe failure trips it
                        // immediately. This shrinks UI latency from
                        // ~15s to ~5s in the common case.
                        misses = misses.saturating_add(1).min(WATCHDOG_MAX_MISSES - 1);
                        info!(
                            target: "chanora_core",
                            misses,
                            "connectivity: OS reports offline; pre-charging watchdog"
                        );
                    } else if new_state == NetworkState::Online {
                        // Reset on confirmed online so we don't
                        // carry stale Offline charges into the
                        // healthy state.
                        if misses > 0 {
                            info!(
                                target: "chanora_core",
                                "connectivity: OS reports online; clearing watchdog misses"
                            );
                        }
                        misses = 0;
                    }
                }
                _ = watchdog.tick() => {
                    match tokio::time::timeout(WATCHDOG_PROBE_TIMEOUT, probe.probe()).await {
                        Ok(Ok(snap)) => {
                            if misses > 0 {
                                info!(
                                    target: "chanora_core",
                                    misses,
                                    "watchdog: probe recovered"
                                );
                            }
                            misses = 0;
                            // A.4 / MVP: emit on any tree change.
                            // Hash channels (id, parent, order, name)
                            // and clients (id, channel, name) so
                            // in-channel moves and renames surface
                            // alongside count changes.
                            let sig = snapshot_signature(&snap);
                            if last_signature != Some(sig) {
                                last_signature = Some(sig);
                                let _ = events_tx.send(SessionEvent::SnapshotChanged {
                                    channels: snap.channels.len() as u32,
                                    clients: snap.clients.len() as u32,
                                });
                                // Record protocol event (SRS-097).
                                event_recorder.lock().await.record_snapshot_changed(
                                    snap.channels.len(),
                                    snap.clients.len(),
                                );
                            }
                        }
                        Ok(Err(e)) => {
                            misses = misses.saturating_add(1);
                            warn!(
                                target: "chanora_core",
                                misses,
                                error = %e,
                                "watchdog: probe failed"
                            );
                        }
                        Err(_) => {
                            misses = misses.saturating_add(1);
                            warn!(
                                target: "chanora_core",
                                misses,
                                "watchdog: probe timed out"
                            );
                        }
                    }
                    if misses >= WATCHDOG_MAX_MISSES {
                        warn!(
                            target: "chanora_core",
                            misses,
                            "watchdog: declaring connection lost"
                        );
                        break chanora_protocol::DisconnectReason::Error(
                            "watchdog: server stopped responding".to_string(),
                        );
                    }
                }
            }
        };

        match reason {
            chanora_protocol::DisconnectReason::UserRequested => {
                info!(target: "chanora_core", "supervisor: user-requested disconnect; exiting");
                return;
            }
            chanora_protocol::DisconnectReason::StreamEnded
            | chanora_protocol::DisconnectReason::Error(_) => {
                let reason_str = format!("{reason:?}");
                warn!(target: "chanora_core", reason = %reason_str, "connection lost; will reconnect");
                let _ = events_tx.send(SessionEvent::Lost {
                    reason: reason_str.clone(),
                });

                // Record network diagnostic (SRS-100).
                {
                    let mut diag = network_diag.lock().await;
                    diag.record_loss(&reason_str);
                }

                // Stop the audio engine before reconnect — its
                // voice_out_tx points at the dead protocol client.
                // Also drop the dead protocol client itself so
                // tsclientlib closes its socket promptly; the
                // supervisor will install a new one on success.
                {
                    let mut guard = state_arc.lock().await;
                    if let Some(state) = guard.as_mut() {
                        if let Some(controller) = state.ptt_controller.take() {
                            controller.stop().await;
                        }
                        if let Some(mut audio) = state.audio.take() {
                            audio.stop();
                            let _ = events_tx.send(SessionEvent::AudioStopped);
                        }
                    }
                }

                // Reconnect loop.
                let mut attempt: u32 = 0;
                loop {
                    attempt = attempt.saturating_add(1);
                    let delay_secs = BACKOFF_SCHEDULE
                        .get(attempt as usize - 1)
                        .copied()
                        .unwrap_or(*BACKOFF_SCHEDULE.last().unwrap());

                    let _ = events_tx.send(SessionEvent::Reconnecting {
                        attempt,
                        delay_secs,
                    });
                    // Record protocol event (SRS-097).
                    event_recorder
                        .lock()
                        .await
                        .record_reconnecting(attempt as u64, delay_secs as u64);
                    info!(
                        target: "chanora_core",
                        attempt,
                        delay_secs,
                        "reconnect: sleeping before next attempt"
                    );

                    // Sleep with cancellation support. An OS
                    // "online" notification short-circuits the
                    // sleep and resets the attempt counter so the
                    // next outage starts with the smallest backoff
                    // window again.
                    enum SleepOutcome {
                        Elapsed,
                        NetworkUp,
                        Cancelled,
                    }
                    let outcome = tokio::select! {
                        biased;
                        _ = &mut cancel_rx => SleepOutcome::Cancelled,
                        changed = network_rx.changed() => {
                            if changed.is_err() {
                                SleepOutcome::Cancelled
                            } else if *network_rx.borrow_and_update() == NetworkState::Online {
                                SleepOutcome::NetworkUp
                            } else {
                                // Offline / Unknown transition — keep waiting the rest of the slot.
                                tokio::select! {
                                    _ = &mut cancel_rx => SleepOutcome::Cancelled,
                                    _ = tokio::time::sleep(Duration::from_secs(delay_secs as u64)) => SleepOutcome::Elapsed,
                                }
                            }
                        }
                        _ = tokio::time::sleep(Duration::from_secs(delay_secs as u64)) => SleepOutcome::Elapsed,
                    };
                    match outcome {
                        SleepOutcome::Cancelled => {
                            info!(target: "chanora_core", "supervisor cancelled during backoff");
                            return;
                        }
                        SleepOutcome::NetworkUp => {
                            info!(
                                target: "chanora_core",
                                attempt,
                                "connectivity: OS reports online; redialling immediately"
                            );
                            // Reset attempt counter so future losses
                            // start the backoff schedule fresh.
                            attempt = 0;
                        }
                        SleepOutcome::Elapsed => {}
                    }

                    info!(target: "chanora_core", attempt, "reconnect: dialling");
                    match chanora_protocol::ProtocolClient::connect(cfg.clone()).await {
                        Ok(new_client) => {
                            let new_epoch = {
                                let mut guard = next_connection_epoch.lock().await;
                                let epoch = *guard;
                                *guard = guard.saturating_add(1);
                                ConnectionEpoch(epoch)
                            };
                            // Successful reconnect. Snapshot for the event.
                            let (snap_name, snap_current_channel) =
                                match new_client.snapshot().await {
                                    Ok(s) => {
                                        let current_channel = s
                                            .clients
                                            .iter()
                                            .find(|c| c.id.0 == s.own_client_id)
                                            .map(|c| JoinChannelId(c.channel.0));
                                        (s.server_name, current_channel)
                                    }
                                    Err(_) => (String::new(), None),
                                };
                            let new_lost_rx = match new_client.take_loss_notifier() {
                                Some(rx) => rx,
                                None => {
                                    warn!(
                                        target: "chanora_core",
                                        "reconnect: new client missing loss notifier"
                                    );
                                    return;
                                }
                            };
                            let new_probe = new_client.snapshot_probe();

                            // Reattach into the session state.
                            let restart_audio = {
                                let mut guard = state_arc.lock().await;
                                let state = match guard.as_mut() {
                                    Some(s) => s,
                                    None => {
                                        // Session was disposed mid-reconnect.
                                        return;
                                    }
                                };
                                // Replace the dead protocol client with the new one.
                                // The old client's background task either already
                                // exited (loss notifier fired) or will exit when
                                // its request channel drops (watchdog path).
                                let old = std::mem::replace(&mut state.protocol, new_client);
                                drop(old);

                                let _ = channel_join::reduce(
                                    &mut state.join_state,
                                    ChannelJoinEvent::ReconnectStarted {
                                        new_epoch,
                                        now: std::time::Instant::now(),
                                    },
                                );
                                let _ = channel_join::reduce(
                                    &mut state.join_state,
                                    ChannelJoinEvent::SnapshotReady {
                                        current_channel: snap_current_channel,
                                        epoch: new_epoch,
                                        now: std::time::Instant::now(),
                                    },
                                );
                                voice_selector.set_in_channel(
                                    state.join_state.authoritative.current_channel.is_some(),
                                );

                                let sup = sup_inner.lock().await;
                                sup.audio_running && sup.audio_cfg.is_some()
                            };

                            let _ = events_tx.send(SessionEvent::Connected {
                                server_name: snap_name,
                            });

                            // Optionally restart audio.
                            if restart_audio {
                                let audio_cfg = {
                                    let sup = sup_inner.lock().await;
                                    sup.audio_cfg.clone().expect("audio_running ⇒ audio_cfg")
                                };
                                let mut guard = state_arc.lock().await;
                                if let Some(state) = guard.as_mut() {
                                    let voice_out = state.protocol.voice_out();
                                    if let Some(voice_in) = state.protocol.take_voice_in() {
                                        let gate = chanora_audio::AudioTransmitGate::new(
                                            audio_cfg.ptt_initial,
                                        );
                                        match chanora_audio::AudioEngine::start_with_gate(
                                            audio_cfg,
                                            voice_out,
                                            voice_in,
                                            gate.clone(),
                                        ) {
                                            Ok(engine) => {
                                                state.audio = Some(engine);
                                                voice_selector.replace_gate(gate.clone());
                                                // Re-arm the PTT controller against the
                                                // session's release-tail timer (SDD-088 +
                                                // SDD-096). The backend writes the
                                                // controller's synthetic press-edge gate;
                                                // the controller forwards edges into
                                                // `release_tail.key_down/up` which drives
                                                // the selector that writes the real gate.
                                                let controller =
                                                    ptt::PttController::new(release_tail.clone());
                                                state.ptt_controller = Some(controller.clone());
                                                // Re-apply any persisted PTT binding
                                                // so reconnect doesn't silently drop
                                                // the user's hotkey.
                                                if let Some(pending) =
                                                    pending_binding.lock().await.clone()
                                                {
                                                    if let Err(e) =
                                                        controller.set_binding(pending).await
                                                    {
                                                        warn!(
                                                            target: "chanora_core",
                                                            error = %e,
                                                            "re-applying PTT binding after reconnect failed"
                                                        );
                                                    }
                                                }
                                                let _ = events_tx.send(SessionEvent::AudioStarted);
                                                // Re-publish the post-reconnect
                                                // capability (SRS-196 / SDD-091).
                                                let d = controller.descriptor().await;
                                                let _ =
                                                    events_tx.send(SessionEvent::PttCapability {
                                                        level: d.level.as_str().to_string(),
                                                        backend_id: d.backend_id.to_string(),
                                                        bound_input_class: d
                                                            .bound_input_class
                                                            .unwrap_or("")
                                                            .to_string(),
                                                    });
                                            }
                                            Err(e) => {
                                                warn!(
                                                    target: "chanora_core",
                                                    error = %e,
                                                    "audio engine failed to restart after reconnect"
                                                );
                                            }
                                        }
                                    }
                                }
                            }

                            // Loop back to waiting for the next loss.
                            lost_rx = new_lost_rx;
                            probe = new_probe;
                            // Force re-emission of SnapshotChanged
                            // for the freshly reconnected session.
                            last_signature = None;
                            break;
                        }
                        Err(e) => {
                            warn!(
                                target: "chanora_core",
                                attempt,
                                error = %e,
                                "reconnect attempt failed"
                            );
                            let _ = e;
                            cfg = cfg.clone();
                        }
                    }
                }
            }
        }
    }
}

/// Build a stable 64-bit signature of `snap` covering everything
/// the UI would render. Two snapshots with identical channel
/// memberships, names, and orderings produce the same signature;
/// any in-channel move, rename, or reorder produces a different one.
fn snapshot_signature(snap: &ServerSnapshot) -> u64 {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};
    let mut h = DefaultHasher::new();
    // Server-level fields the UI shows.
    snap.server_name.hash(&mut h);
    // Channels — sorted by id so the hash is order-independent of
    // the input vector's iteration order.
    let mut channels: Vec<_> = snap.channels.iter().collect();
    channels.sort_by_key(|c| c.id.0);
    for c in &channels {
        c.id.0.hash(&mut h);
        c.parent.0.hash(&mut h);
        c.order.hash(&mut h);
        c.name.hash(&mut h);
    }
    // Clients — sorted by id.
    let mut clients: Vec<_> = snap.clients.iter().collect();
    clients.sort_by_key(|c| c.id.0);
    for c in &clients {
        c.id.0.hash(&mut h);
        c.channel.0.hash(&mut h);
        c.name.hash(&mut h);
    }
    h.finish()
}

fn map_join_sync_state(sync: channel_join::ChannelJoinSyncState) -> VoiceJoinSyncState {
    match sync {
        channel_join::ChannelJoinSyncState::Ready => VoiceJoinSyncState::Ready,
        channel_join::ChannelJoinSyncState::Synchronizing {
            reason: channel_join::SyncReason::InitialSnapshot,
            ..
        } => VoiceJoinSyncState::SynchronizingInitialSnapshot,
        channel_join::ChannelJoinSyncState::Synchronizing {
            reason: channel_join::SyncReason::Reconnect,
            ..
        } => VoiceJoinSyncState::SynchronizingReconnect,
    }
}

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

fn connect_config_without_prefetched_resolution(cfg: &ConnectConfig) -> ConnectConfig {
    let mut cfg = cfg.clone();
    cfg.resolved_address = None;
    cfg
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn session_can_be_constructed() {
        let _ = ChanoraSession::new();
    }

    #[test]
    fn audio_stats_nonblocking_under_lock() {
        let s = ChanoraSession::new();
        let _guard = s.inner.try_lock().unwrap();

        assert!(s.audio_processing_stats_if_ready().is_none());
    }

    #[test]
    fn audio_stats_absent_when_disconnected() {
        let s = ChanoraSession::new();

        assert!(s.audio_processing_stats_if_ready().is_none());
    }

    #[tokio::test]
    async fn connect_uses_fresh_prefetch() {
        let session = ChanoraSession::new();
        let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();

        let generation = session.server_prefetch.begin_for_test("example.com").await;
        session
            .server_prefetch
            .store_success_for_test(generation, "example.com", addr, std::time::Instant::now())
            .await;

        let mut cfg = ConnectConfig::default();
        cfg.address = " example.com ".to_string();
        session.apply_prefetched_resolution(&mut cfg).await;

        assert_eq!(cfg.resolved_address, Some(addr));
    }

    #[tokio::test]
    async fn connect_clears_untrusted_resolved_address() {
        let session = ChanoraSession::new();
        let mut cfg = ConnectConfig::default();
        cfg.address = "example.com".to_string();
        cfg.resolved_address = Some("127.0.0.1:9987".parse().unwrap());

        session.apply_prefetched_resolution(&mut cfg).await;

        assert_eq!(cfg.resolved_address, None);
    }

    #[tokio::test]
    async fn prefetch_swallows_setup_errors() {
        let session = ChanoraSession::new();
        session
            .server_prefetch
            .fail_next_prefetch_setup_for_test("resolver unavailable")
            .await;

        let result = session
            .prefetch_server(" Example.COM ".to_string())
            .await;

        assert!(result.is_ok());
    }

    #[test]
    fn sanitize_config_clears_resolved_address() {
        let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
        let mut cfg = ConnectConfig::default();
        cfg.address = "example.com".to_string();
        cfg.nickname = "tester".to_string();
        cfg.password = Some("secret".to_string());
        cfg.identity = Some("identity".to_string());
        cfg.ready_timeout = std::time::Duration::from_secs(42);
        cfg.resolved_address = Some(addr);

        let cleaned = connect_config_without_prefetched_resolution(&cfg);

        assert_eq!(cleaned.address, cfg.address);
        assert_eq!(cleaned.nickname, cfg.nickname);
        assert_eq!(cleaned.password, cfg.password);
        assert_eq!(cleaned.identity, cfg.identity);
        assert_eq!(cleaned.ready_timeout, cfg.ready_timeout);
        assert_eq!(cleaned.resolved_address, None);
    }

    #[tokio::test]
    async fn prepare_configs_sanitizes_stored_keeps_dial() {
        let session = ChanoraSession::new();
        let cached: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
        let untrusted: std::net::SocketAddr = "127.0.0.2:9987".parse().unwrap();

        let generation = session.server_prefetch.begin_for_test("example.com").await;
        session
            .server_prefetch
            .store_success_for_test(generation, "example.com", cached, std::time::Instant::now())
            .await;

        let mut cfg = ConnectConfig::default();
        cfg.address = "example.com".to_string();
        cfg.resolved_address = Some(untrusted);

        let (stored_cfg, dial_cfg) = session.prepare_connect_configs(cfg).await;

        assert_eq!(stored_cfg.resolved_address, None);
        assert_eq!(dial_cfg.resolved_address, Some(cached));
    }

    #[tokio::test]
    async fn disconnect_when_not_connected_is_noop() {
        let s = ChanoraSession::new();
        assert!(!s.is_connected().await);
        s.disconnect().await.unwrap();
    }

    #[test]
    fn signature_detects_in_channel_move() {
        use chanora_protocol::{ChannelInfo, ClientInfo};
        let a = ServerSnapshot {
            server_name: "s".into(),
            welcome_message: "".into(),
            platform: "".into(),
            version: "".into(),
            channels: vec![
                ChannelInfo {
                    id: chanora_protocol::ChannelId(1),
                    parent: chanora_protocol::ChannelId(0),
                    name: "a".into(),
                    order: 0,
                    has_password: false,
                    needed_talk_power: None,
                },
                ChannelInfo {
                    id: chanora_protocol::ChannelId(2),
                    parent: chanora_protocol::ChannelId(0),
                    name: "b".into(),
                    order: 1,
                    has_password: false,
                    needed_talk_power: None,
                },
            ],
            clients: vec![ClientInfo {
                id: chanora_protocol::ClientId(10),
                channel: chanora_protocol::ChannelId(1),
                name: "u".into(),
                input_muted: false,
                output_muted: false,
                is_speaking: false,
                is_server_query: false,
                talk_power: 0,
                talk_power_granted: false,
            }],
            own_client_id: 10,
        };
        let mut b = a.clone();
        // User moves from channel 1 → 2. Counts unchanged.
        b.clients[0].channel = chanora_protocol::ChannelId(2);
        assert_ne!(super::snapshot_signature(&a), super::snapshot_signature(&b));
    }

    #[test]
    fn signature_is_stable_under_input_reorder() {
        use chanora_protocol::ChannelInfo;
        let a = ServerSnapshot {
            server_name: "s".into(),
            welcome_message: "".into(),
            platform: "".into(),
            version: "".into(),
            channels: vec![
                ChannelInfo {
                    id: chanora_protocol::ChannelId(2),
                    parent: chanora_protocol::ChannelId(0),
                    name: "b".into(),
                    order: 1,
                    has_password: false,
                    needed_talk_power: None,
                },
                ChannelInfo {
                    id: chanora_protocol::ChannelId(1),
                    parent: chanora_protocol::ChannelId(0),
                    name: "a".into(),
                    order: 0,
                    has_password: false,
                    needed_talk_power: None,
                },
            ],
            clients: vec![],
            own_client_id: 0,
        };
        let mut b = a.clone();
        b.channels.reverse();
        assert_eq!(super::snapshot_signature(&a), super::snapshot_signature(&b));
    }

    #[test]
    fn normalizes_blank_channel_password_to_none() {
        assert_eq!(super::normalize_channel_password(None), None);
        assert_eq!(super::normalize_channel_password(Some("".into())), None);
        assert_eq!(
            super::normalize_channel_password(Some("  \t  ".into())),
            None
        );
    }

    #[test]
    fn trims_channel_password_before_send() {
        assert_eq!(
            super::normalize_channel_password(Some("  secret  ".into())),
            Some("secret".into())
        );
    }

    #[tokio::test]
    async fn empty_address_is_rejected() {
        let s = ChanoraSession::new();
        let r = s.connect(ConnectConfig::default()).await;
        assert!(matches!(
            r,
            Err(CoreError::Protocol(ProtocolError::Invalid(_)))
        ));
    }

    #[tokio::test]
    async fn ptt_without_audio_errors() {
        let s = ChanoraSession::new();
        let r = s.set_ptt(true).await;
        assert!(matches!(r, Err(CoreError::NotConnected)));
    }

    #[tokio::test]
    async fn subscribe_before_connect_works() {
        // Ensures the event broadcast doesn't need a prior connect
        // to be subscribable.
        let s = ChanoraSession::new();
        let _rx = s.subscribe_events();
        assert!(!s.is_connected().await);
    }
}
