use crate::audio::clip_player::{
    ClipPlayer, SharedClipStatus, format_time as format_clip_time, progress as clip_progress,
    seek_target, status_snapshot,
};
use crate::audio::eq::{EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN, EqSettings};
use crate::audio::{AudioDevice, enumerate_audio_devices};
use crate::config::{
    AppConfig, AudioProfile, NetworkMode, RecordingMode, RoomLayout, ShareBuffering, SharePlayer,
    ShareQuality,
};
use crate::core::{
    CoreController,
    messages::{CoreCommand, UiEvent},
};
use crate::hotkeys::{HotkeyAction, HotkeyContext, KeyBinding, format_binding};
use crate::network::PeerState;
use crate::notify::{self, Sound};
use crate::presence::PresenceMode;
use crate::theme::{AppTheme, Palette};
use crate::widget::context_input::{context_input, locked_value};
use crate::widget::selectable_text::selectable_rich_text;

mod sendqueue;
use sendqueue::{LocalSend, SendPacer, SendStatus};

use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
use iced::widget::text_input;
use iced::widget::{
    Canvas, Column, button, canvas, checkbox, column, container, mouse_area, pick_list,
    progress_bar, radio, responsive, row, scrollable, slider, span, stack, text, tooltip,
};
use iced::{
    Background, Border, Color, Element, Event, Point, Rectangle, Renderer, Size, Subscription,
    Task, Theme, keyboard, mouse,
};
use iroh::EndpointId;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, OnceLock};
use tokio::sync::Mutex;

static UI_RX: OnceLock<Mutex<Option<tokio::sync::mpsc::Receiver<UiEvent>>>> = OnceLock::new();

const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
const GIT_SHORT: &str = env!("PEERSPEAK_GIT_SHORT");

fn app_build_label() -> String {
    if GIT_SHORT == "unknown" {
        format!("PeerSpeak v{APP_VERSION}")
    } else {
        format!("PeerSpeak v{APP_VERSION} ({GIT_SHORT})")
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
    Home,
    Room,
    Settings,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsCategory {
    Audio,
    Hotkeys,
    Recording,
    Profile,
    Appearance,
    Network,
    Advanced,
    Notifications,
    Games,
}

impl SettingsCategory {
    const ALL: [SettingsCategory; 9] = [
        SettingsCategory::Audio,
        SettingsCategory::Hotkeys,
        SettingsCategory::Recording,
        SettingsCategory::Profile,
        SettingsCategory::Appearance,
        SettingsCategory::Network,
        SettingsCategory::Advanced,
        SettingsCategory::Notifications,
        SettingsCategory::Games,
    ];

    fn label(self) -> &'static str {
        match self {
            SettingsCategory::Audio => "Audio",
            SettingsCategory::Hotkeys => "Hotkeys",
            SettingsCategory::Recording => "Recording",
            SettingsCategory::Profile => "Profile",
            SettingsCategory::Appearance => "Appearance",
            SettingsCategory::Network => "Network",
            SettingsCategory::Advanced => "Advanced",
            SettingsCategory::Notifications => "Notifications",
            SettingsCategory::Games => "Games",
        }
    }

    fn hint(self) -> &'static str {
        match self {
            SettingsCategory::Audio => "Devices, mic gate, echo",
            SettingsCategory::Hotkeys => "Focused keyboard shortcuts",
            SettingsCategory::Recording => "Mixed and stem capture",
            SettingsCategory::Profile => "Avatar and identity",
            SettingsCategory::Appearance => "Layout and theme",
            SettingsCategory::Network => "Relay and privacy mode",
            SettingsCategory::Advanced => "Screen sharing",
            SettingsCategory::Notifications => "Chimes and sounds",
            SettingsCategory::Games => "Detection, presence, backgrounds",
        }
    }
}

impl std::fmt::Display for SettingsCategory {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.label())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareMaxHeightChoice {
    Source,
    P720,
    P1080,
    P1440,
}

impl ShareMaxHeightChoice {
    const ALL: [ShareMaxHeightChoice; 4] = [
        ShareMaxHeightChoice::Source,
        ShareMaxHeightChoice::P720,
        ShareMaxHeightChoice::P1080,
        ShareMaxHeightChoice::P1440,
    ];

    fn from_config(value: Option<u32>) -> Self {
        match value {
            Some(720) => ShareMaxHeightChoice::P720,
            Some(1080) => ShareMaxHeightChoice::P1080,
            Some(1440) => ShareMaxHeightChoice::P1440,
            _ => ShareMaxHeightChoice::Source,
        }
    }

    fn to_config(self) -> Option<u32> {
        match self {
            ShareMaxHeightChoice::Source => None,
            ShareMaxHeightChoice::P720 => Some(720),
            ShareMaxHeightChoice::P1080 => Some(1080),
            ShareMaxHeightChoice::P1440 => Some(1440),
        }
    }
}

impl std::fmt::Display for ShareMaxHeightChoice {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            ShareMaxHeightChoice::Source => "Source",
            ShareMaxHeightChoice::P720 => "720p",
            ShareMaxHeightChoice::P1080 => "1080p",
            ShareMaxHeightChoice::P1440 => "1440p",
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareFramerateChoice {
    Preset,
    Fps15,
    Fps24,
    Fps30,
    Fps60,
}

impl ShareFramerateChoice {
    const ALL: [ShareFramerateChoice; 5] = [
        ShareFramerateChoice::Preset,
        ShareFramerateChoice::Fps15,
        ShareFramerateChoice::Fps24,
        ShareFramerateChoice::Fps30,
        ShareFramerateChoice::Fps60,
    ];

    fn from_config(value: Option<u32>) -> Self {
        match value {
            Some(15) => ShareFramerateChoice::Fps15,
            Some(24) => ShareFramerateChoice::Fps24,
            Some(30) => ShareFramerateChoice::Fps30,
            Some(60) => ShareFramerateChoice::Fps60,
            _ => ShareFramerateChoice::Preset,
        }
    }

    fn to_config(self) -> Option<u32> {
        match self {
            ShareFramerateChoice::Preset => None,
            ShareFramerateChoice::Fps15 => Some(15),
            ShareFramerateChoice::Fps24 => Some(24),
            ShareFramerateChoice::Fps30 => Some(30),
            ShareFramerateChoice::Fps60 => Some(60),
        }
    }
}

impl std::fmt::Display for ShareFramerateChoice {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            ShareFramerateChoice::Preset => "Preset default",
            ShareFramerateChoice::Fps15 => "15 fps",
            ShareFramerateChoice::Fps24 => "24 fps",
            ShareFramerateChoice::Fps30 => "30 fps",
            ShareFramerateChoice::Fps60 => "60 fps",
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareMaxViewersChoice {
    Auto,
    One,
    Two,
    Three,
    Four,
    Five,
    Six,
    Seven,
    Eight,
}

impl ShareMaxViewersChoice {
    const ALL: [ShareMaxViewersChoice; 9] = [
        ShareMaxViewersChoice::Auto,
        ShareMaxViewersChoice::One,
        ShareMaxViewersChoice::Two,
        ShareMaxViewersChoice::Three,
        ShareMaxViewersChoice::Four,
        ShareMaxViewersChoice::Five,
        ShareMaxViewersChoice::Six,
        ShareMaxViewersChoice::Seven,
        ShareMaxViewersChoice::Eight,
    ];

    fn from_config(value: Option<u32>) -> Self {
        match value {
            Some(1) => ShareMaxViewersChoice::One,
            Some(2) => ShareMaxViewersChoice::Two,
            Some(3) => ShareMaxViewersChoice::Three,
            Some(4) => ShareMaxViewersChoice::Four,
            Some(5) => ShareMaxViewersChoice::Five,
            Some(6) => ShareMaxViewersChoice::Six,
            Some(7) => ShareMaxViewersChoice::Seven,
            Some(8) => ShareMaxViewersChoice::Eight,
            _ => ShareMaxViewersChoice::Auto,
        }
    }

    fn to_config(self) -> Option<u32> {
        match self {
            ShareMaxViewersChoice::Auto => None,
            ShareMaxViewersChoice::One => Some(1),
            ShareMaxViewersChoice::Two => Some(2),
            ShareMaxViewersChoice::Three => Some(3),
            ShareMaxViewersChoice::Four => Some(4),
            ShareMaxViewersChoice::Five => Some(5),
            ShareMaxViewersChoice::Six => Some(6),
            ShareMaxViewersChoice::Seven => Some(7),
            ShareMaxViewersChoice::Eight => Some(8),
        }
    }
}

impl std::fmt::Display for ShareMaxViewersChoice {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            ShareMaxViewersChoice::Auto => "Auto",
            ShareMaxViewersChoice::One => "1",
            ShareMaxViewersChoice::Two => "2",
            ShareMaxViewersChoice::Three => "3",
            ShareMaxViewersChoice::Four => "4",
            ShareMaxViewersChoice::Five => "5",
            ShareMaxViewersChoice::Six => "6",
            ShareMaxViewersChoice::Seven => "7",
            ShareMaxViewersChoice::Eight => "8",
        })
    }
}

const SHARE_CACHE_MB_OPTIONS: [u32; 8] = [1, 2, 4, 8, 16, 32, 64, 128];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HomeLayoutMode {
    FocusedEmpty,
    ThreeColumn,
    Stacked,
}

fn home_layout_mode(width: f32, has_recents: bool, has_friends: bool) -> HomeLayoutMode {
    if width < 900.0 {
        HomeLayoutMode::Stacked
    } else if !has_recents && !has_friends {
        HomeLayoutMode::FocusedEmpty
    } else if width >= 1280.0 {
        HomeLayoutMode::ThreeColumn
    } else {
        HomeLayoutMode::Stacked
    }
}

/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
/// messages from peers' for colouring.
#[derive(Debug, Clone)]
struct ChatEntry {
    name: String,
    text: String,
    mine: bool,
    /// Sender's node id string, used to key their avatar colour (W4). `None` only
    /// for any future system-generated lines.
    from: Option<String>,
    /// Optional file attachment descriptor. The bytes (if fetched) live in
    /// `AppState.attachments` keyed by `(author, attachment.id)`; the entry only
    /// holds the descriptor so history stays cheap.
    attachment: Option<crate::files::ChatAttachment>,
    /// Byte ranges of `text`'s validated clickable links, computed ONCE by
    /// [`push_chat`] (construction sites leave it empty) so each redraw slices
    /// cached ranges instead of rescanning and re-validating every message.
    /// At most [`crate::sanitize::CHAT_MSG_MAX_LINKS`] entries.
    links: Vec<std::ops::Range<usize>>,
    /// Local-only send bookkeeping (chat-hardening Phase 5): `Some` exactly on
    /// our own locally echoed messages, `None` on peers'. Never on the wire.
    local_send: Option<LocalSend>,
}

/// Everything needed to (re)dispatch one local send (chat-hardening Phase 5).
/// Held in `AppState.send_payloads` from submit until the send is Broadcast, so
/// a queued release or a Retry rebuilds the exact same `CoreCommand`.
#[derive(Debug, Clone)]
enum PendingSend {
    Text(String),
    File {
        text: String,
        attachment: crate::files::ChatAttachment,
        data: Arc<Vec<u8>>,
    },
}

/// Fetch state of a chat attachment's bytes (session-only). Absence from the
/// cache means NOT FETCHED (auto-fetch skipped or never attempted) — the UI
/// renders a Load/Download button for it, never a loading label (Phase 3B).
#[derive(Debug, Clone)]
enum AttachmentState {
    /// A fetch task is running for this key (core announced it, or the user
    /// clicked). Suppresses duplicate fetches on repeated clicks.
    Loading,
    /// Bytes in hand (image decoded-valid, or a file ready to save). Shared so
    /// the cache, save tasks, and the serve path hold one allocation.
    Ready(std::sync::Arc<Vec<u8>>),
    /// Fetch or decode failed; carries a short reason for the UI.
    Failed(String),
    /// The bytes were fetched but could not be retained within the cache
    /// budgets (or were dropped under budget pressure after any pending
    /// save/play was serviced). Rendered like NotFetched: load-on-demand.
    Evicted,
}

/// Identifies one fetched attachment by BOTH the authoring peer and the
/// attachment id. The id is attacker-chosen, so a malicious peer can reuse a
/// victim's id to alias displayed/saved content; keying on the author too means
/// each chat line resolves only its own sender's bytes (Tier C F-12).
type AttachmentKey = (EndpointId, crate::files::AttachmentId);

/// Cap on retained attachment results so an insider streaming distinct
/// attachments can't grow the cache without bound (Tier C F-02). Sized well
/// above any realistic on-screen image working set.
const ATTACHMENT_CACHE_CAP: usize = 64;

/// Budget for retained ENCODED attachment bytes (the original file bytes kept
/// for Save). The count cap alone would admit 64 × 25 MiB = 1.6 GiB; this keeps
/// the worst case at a few large attachments plus the normal small working set
/// (Phase 3A).
const ATTACHMENT_CACHE_ENCODED_BUDGET: usize = 96 * 1024 * 1024;

/// Budget for DECODED preview bytes (estimated RGBA cost `w*h*4` of each inline
/// preview, counted even though iced may copy/upload internally). Previews are
/// capped at 1600 px per side (≈10 MiB RGBA worst case), so this comfortably
/// covers the on-screen set while bounding an image-spam session (Phase 3A).
const ATTACHMENT_CACHE_DECODED_BUDGET: usize = 64 * 1024 * 1024;

/// One cached attachment result: its fetch state plus, for ready images, the
/// pre-built iced image handle (built once on arrival, not per redraw — the
/// e917c53 flicker fix). `handle` is `None` for files and failures.
///
/// `encoded` / `decoded` are this entry's weights against the cache byte
/// budgets: the retained encoded length and the preview's estimated RGBA cost.
/// Both are zero for non-`Ready` states.
#[derive(Debug, Clone)]
struct AttachmentEntry {
    state: AttachmentState,
    handle: Option<PreviewHandle>,
    encoded: usize,
    decoded: usize,
}

/// Bounded store of fetched chat-attachment results, keyed by [`AttachmentKey`].
///
/// Eviction is insertion-order (oldest first), NOT true LRU: iced's `view`
/// borrows `&self`, so the render read path cannot reorder an access-ordered
/// cache. With a generous cap the newest entries — the ones actually on screen —
/// are always retained, so a normal session never evicts; only an abusive stream
/// of distinct attachments hits the bound (Tier C F-02).
///
/// Construct via [`AttachmentCache::new`] — there is deliberately no `Default`,
/// because a zero cap would make `insert` evict endlessly.
#[derive(Debug)]
struct AttachmentCache {
    entries: HashMap<AttachmentKey, AttachmentEntry>,
    /// Keys in insertion order; the front is the eviction candidate. Holds
    /// exactly the present keys (one entry each), so it is bounded by `cap`.
    order: VecDeque<AttachmentKey>,
    cap: usize,
    /// Byte budgets and their running totals (Phase 3A). Totals are exactly the
    /// sum of the present entries' weights — every remove/replace subtracts the
    /// old weights before new ones are counted.
    encoded_budget: usize,
    decoded_budget: usize,
    encoded_total: usize,
    decoded_total: usize,
}

impl AttachmentCache {
    fn new(cap: usize, encoded_budget: usize, decoded_budget: usize) -> Self {
        Self {
            entries: HashMap::new(),
            order: VecDeque::new(),
            cap: cap.max(1),
            encoded_budget,
            decoded_budget,
            encoded_total: 0,
            decoded_total: 0,
        }
    }

    /// Insert or replace an entry, keeping the count cap AND both byte budgets
    /// satisfied by evicting oldest entries first. Replacing an existing key
    /// keeps its position (and so its age); its old weights are subtracted
    /// before the new ones are checked.
    ///
    /// Returns `false` if the entry ALONE exceeds a byte budget: it is not
    /// retained (and an existing entry under the key is dropped), leaving no
    /// entry for `key`. The caller services any immediate need from the bytes
    /// in hand and stores an [`AttachmentState::Evicted`] marker instead.
    fn insert(
        &mut self,
        key: AttachmentKey,
        state: AttachmentState,
        handle: Option<PreviewHandle>,
    ) -> bool {
        let encoded = match &state {
            AttachmentState::Ready(data) => data.len(),
            _ => 0,
        };
        let decoded = handle_decoded_cost(&state, &handle);
        // Replacement: subtract the old weights first so they don't count
        // against the new entry's fit.
        if let Some(old) = self.entries.get(&key) {
            self.encoded_total -= old.encoded;
            self.decoded_total -= old.decoded;
        }
        if encoded > self.encoded_budget || decoded > self.decoded_budget {
            // Individually overweight: never retained, budgets never exceeded.
            if self.entries.remove(&key).is_some() {
                self.order.retain(|k| k != &key);
            }
            return false;
        }
        let replacing = self.entries.contains_key(&key);
        // Evict oldest (skipping the key being replaced, which keeps its slot)
        // until the count cap and both budgets accommodate the new entry.
        while self.would_overflow(replacing, encoded, decoded) {
            let Some(victim) = self.order.iter().find(|k| **k != key).copied() else {
                break;
            };
            self.remove_entry(&victim);
        }
        if !replacing {
            self.order.push_back(key);
        }
        self.encoded_total += encoded;
        self.decoded_total += decoded;
        self.entries.insert(
            key,
            AttachmentEntry {
                state,
                handle,
                encoded,
                decoded,
            },
        );
        true
    }

    fn would_overflow(&self, replacing: bool, encoded: usize, decoded: usize) -> bool {
        let count_full = !replacing && self.entries.len() >= self.cap;
        count_full
            || self.encoded_total + encoded > self.encoded_budget
            || self.decoded_total + decoded > self.decoded_budget
    }

    fn remove_entry(&mut self, key: &AttachmentKey) {
        if let Some(old) = self.entries.remove(key) {
            self.encoded_total -= old.encoded;
            self.decoded_total -= old.decoded;
            self.order.retain(|k| k != key);
        }
    }

    fn get(&self, key: &AttachmentKey) -> Option<&AttachmentState> {
        self.entries.get(key).map(|e| &e.state)
    }

    fn handle(&self, key: &AttachmentKey) -> Option<&iced::widget::image::Handle> {
        self.entries
            .get(key)
            .and_then(|e| e.handle.as_ref().map(|h| &h.handle))
    }

    fn is_ready(&self, key: &AttachmentKey) -> bool {
        matches!(
            self.entries.get(key),
            Some(AttachmentEntry {
                state: AttachmentState::Ready(_),
                ..
            })
        )
    }

    /// Whether a fetch for this key is already running (dedup for clicks).
    fn is_loading(&self, key: &AttachmentKey) -> bool {
        matches!(self.get(key), Some(AttachmentState::Loading))
    }

    fn clear(&mut self) {
        self.entries.clear();
        self.order.clear();
        self.encoded_total = 0;
        self.decoded_total = 0;
    }

    #[cfg(test)]
    fn len(&self) -> usize {
        self.entries.len()
    }
}

/// The decoded-budget weight of an entry: the preview's RGBA estimate for a
/// ready image (`w*h*4` via the handle's known preview dimensions), else zero.
/// The handle is built from the downscaled preview, so its dimensions are the
/// preview's — but iced's `Handle` doesn't expose them, so the caller threads
/// the cost through [`PreviewHandle`].
fn handle_decoded_cost(state: &AttachmentState, handle: &Option<PreviewHandle>) -> usize {
    match (state, handle) {
        (AttachmentState::Ready(_), Some(h)) => h.decoded_cost,
        _ => 0,
    }
}

/// An iced image handle built from a downscaled preview, paired with the
/// preview's decoded RGBA cost (iced doesn't expose handle dimensions).
#[derive(Debug, Clone)]
struct PreviewHandle {
    handle: iced::widget::image::Handle,
    decoded_cost: usize,
}

/// Build the inline preview handle (and its decoded weight) for image bytes, or
/// `None` when the bytes don't validate as a displayable image. The renderer
/// only ever receives the ≤1600 px downscale; originals stay encoded-only.
fn build_preview_handle(bytes: &[u8]) -> Option<PreviewHandle> {
    let p = crate::files::decode_preview(bytes)?;
    let decoded_cost = crate::files::preview_rgba_cost(p.width, p.height);
    Some(PreviewHandle {
        handle: iced::widget::image::Handle::from_rgba(p.width, p.height, p.rgba),
        decoded_cost,
    })
}

/// Cap on retained chat history so a long call can't grow it without bound.
const CHAT_HISTORY_MAX: usize = 300;

/// Cap on TOTAL retained sanitized chat text bytes, enforced alongside
/// [`CHAT_HISTORY_MAX`] (300 ceiling-length messages would otherwise retain
/// ~2.4 MiB and make every redraw/selection walk it). The entry cap bounds the
/// common case; this bounds a max-length-message flood (chat-hardening Phase 4).
const CHAT_HISTORY_MAX_TEXT_BYTES: usize = 512 * 1024;

/// Which room-screen divider a drag is resizing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DividerKind {
    /// Vertical divider between the Participants and Controls panels (resizes the
    /// Participants panel width).
    Panels,
    /// Horizontal divider between the main row and the Chat dock (resizes the
    /// Chat dock height).
    Chat,
    /// Vertical divider between Chat and Controls in the 3-column layout (resizes
    /// the Controls panel width).
    Controls,
    /// Vertical divider on the left edge of the Chat drawer (resizes the drawer
    /// width) in the drawer layout.
    ChatDrawer,
    /// Vertical divider on the left edge of the Playlist drawer (resizes the
    /// drawer width).
    PlaylistDrawer,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EqBand {
    Low,
    Mid,
    High,
}

/// Minimum width of the Participants panel (px).
const PARTICIPANTS_MIN_W: f32 = 200.0;
/// Minimum width reserved for the Controls panel when resizing Participants (px).
const CONTROLS_MIN_W: f32 = 220.0;
/// Minimum height of the Chat dock (px).
const CHAT_MIN_H: f32 = 110.0;
/// Minimum height reserved above the Chat dock (header + main row) when resizing
/// the dock (px).
const ABOVE_CHAT_MIN_H: f32 = 300.0;
/// Thickness of a draggable divider (px).
const DIVIDER_THICKNESS: f32 = 8.0;
/// Upper bound for waiting on orderly core shutdown before letting the window exit.
const SHUTDOWN_TIMEOUT_SECS: u64 = 5;
/// How long a room-level clock-skew warning remains visible without dismissal.
const CLOCK_SKEW_WARNING_VISIBLE_SECS: u64 = 12;

/// Clamp the Participants panel width so neither it nor the Controls panel drops
/// below its minimum, given the current window width.
fn clamp_participants_width(width: f32, window_w: f32) -> f32 {
    let max = (window_w - CONTROLS_MIN_W).max(PARTICIPANTS_MIN_W);
    width.clamp(PARTICIPANTS_MIN_W, max)
}

/// Clamp the Chat dock height so neither it nor the area above it drops below its
/// minimum, given the current window height.
fn clamp_chat_height(height: f32, window_h: f32) -> f32 {
    let max = (window_h - ABOVE_CHAT_MIN_H).max(CHAT_MIN_H);
    height.clamp(CHAT_MIN_H, max)
}

/// Minimum width of the Chat column / drawer (px).
const CHAT_MIN_W: f32 = 200.0;

/// Clamp the Controls panel width (3-column layout) so neither it nor the rest of
/// the row drops below its minimum, given the current window width.
fn clamp_controls_width(width: f32, window_w: f32) -> f32 {
    // Leave room for the Participants panel + a minimum Chat column.
    let max = (window_w - PARTICIPANTS_MIN_W - CHAT_MIN_W).max(CONTROLS_MIN_W);
    width.clamp(CONTROLS_MIN_W, max)
}

/// Clamp the Chat drawer width (drawer layout) so neither it nor the rest of the
/// row drops below its minimum, given the current window width.
fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 {
    let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W);
    width.clamp(CHAT_MIN_W, max)
}

/// Clamp the Playlist drawer width so neither it nor the room body drops below
/// its minimum, given the current window width.
fn clamp_playlist_drawer_width(width: f32, window_w: f32) -> f32 {
    let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W);
    width.clamp(CHAT_MIN_W, max)
}

#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum AppMessage {
    NicknameChanged(String),
    TicketInputChanged(String),
    RoomNameChanged(String),
    JoinPressed,
    CreatePressed,
    LeavePressed,
    ToggleMutePressed,
    ToggleDeafenPressed,
    UiEventReceived(UiEvent),
    CopyToClipboard,
    /// Copy an arbitrary string to the clipboard (e.g. the full node ID).
    CopyText(String),
    /// No-op message for controlled read-only selectable fields.
    Noop,
    TogglePtt(bool),
    StartHotkeyCapture(HotkeyAction),
    ClearHotkey(HotkeyAction),
    PeerVolumeChanged(EndpointId, f32),
    PeerPanChanged(EndpointId, f32),
    PeerGateChanged(EndpointId, f32),
    PeerEqChanged(EndpointId, EqBand, f32),
    /// Show or hide the secondary audio controls on one participant card.
    TogglePeerAdvancedAudio(EndpointId),
    /// Toggle local mute of a peer (silence them just for us).
    TogglePeerMute(EndpointId),
    InputDeviceSelected(AudioDevice),
    OutputDeviceSelected(AudioDevice),
    /// Live input-gain drag (applies immediately, persisted on release).
    InputVolumeChanged(f32),
    /// Live output-gain drag (applies immediately, persisted on release).
    OutputVolumeChanged(f32),
    /// Persist the current config to disk (slider release).
    PersistConfig,
    NoiseGateChanged(f32),
    /// Live value while dragging the gate handle on the meter — updates the gate
    /// immediately but does not persist (saved once on release via NoiseGateChanged).
    NoiseGateDragging(f32),
    NetworkModeSelected(NetworkMode),
    AudioProfileSelected(AudioProfile),
    RecordingModeSelected(RecordingMode),
    ScreenShareQualitySelected(ShareQuality),
    ScreenSharePlayerSelected(SharePlayer),
    ScreenShareBufferingSelected(ShareBuffering),
    ScreenShareMaxHeightSelected(ShareMaxHeightChoice),
    ScreenShareFramerateSelected(ShareFramerateChoice),
    ScreenShareMaxViewersSelected(ShareMaxViewersChoice),
    ScreenShareCacheMbSelected(u32),
    ScreenShareBitrateChanged(String),
    ToggleScreenShareForceSoftwareEncode(bool),
    ToggleScreenShareHardwareDecode(bool),
    ScreenShareExtraMpvArgsChanged(String),
    ScreenShareExtraHostArgsChanged(String),
    /// Choose the friends presence posture (W7): invisible / normal / discoverable.
    PresenceModeSelected(PresenceMode),
    /// Friends list (W7 P5): add-form edits, add, remove, and local rename.
    FriendAddIdChanged(String),
    FriendAddNameChanged(String),
    AddFriend,
    /// Add a peer you're in a call with to your friends list (W7), using their
    /// live presence name + address so they're reachable immediately.
    AddFriendFromRoom(EndpointId),
    RemoveFriend(EndpointId),
    RenameFriend(EndpointId, String),
    /// Manually rescan all friends' presence now (the ⟳ button), instead of waiting
    /// for the 60s scheduler tick. Statuses update as the probe replies arrive.
    RefreshFriends,
    /// Join the gathering a friend is in (W7 B2), via the member ticket their
    /// presence reply carried. Mirrors a manual ticket join.
    JoinFriendRoom(String),
    /// Rejoin a room from the recents list (W7 P5), via its stored ticket. Mirrors
    /// a manual ticket join; best-effort (works only while the room is still live).
    JoinRecent(String),
    /// Drop a room from the recents list (W7 P5), the × on a recent entry.
    RemoveRecent(String),
    EventOccurred(Event),
    NavigateToSettings,
    NavigateBack,
    SelectSettingsCategory(SettingsCategory),
    ToggleNotifications(bool),
    ToggleEchoCancellation(bool),
    CustomSoundPathChanged(Sound, String),
    /// Open a native WAV picker for one notification event.
    BrowseCustomSound(Sound),
    /// Result of the notification WAV picker (`None` = cancelled).
    CustomSoundFilePicked(Sound, Option<std::path::PathBuf>),
    /// Toggle the per-sound enable flag for a single chime (W6).
    ToggleSoundEnabled(Sound, bool),
    /// Open / cancel the "Regenerate identity?" confirm modal (W7).
    OpenRegenerateIdentityConfirm,
    CloseRegenerateIdentityConfirm,
    /// Confirmed: mint a fresh persistent identity, discarding the old one.
    ConfirmRegenerateIdentity,
    ToggleMicTest(bool),
    /// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
    ToggleRecording,
    /// Live edits to the chat input line.
    ChatInputChanged(String),
    /// Open the native picker to attach a file to the chat.
    PickAttachmentFile,
    /// Result of the attach picker: (filename, bytes), or None if cancelled.
    /// Picker result: `None` = cancelled; `Some((name, None))` = file over the
    /// attachment cap or unreadable; `Some((name, Some(bytes)))` = bytes in hand.
    AttachmentFilePicked(Option<(String, Option<Vec<u8>>)>),
    /// Save (downloading first if needed) a received attachment to disk. Carries
    /// the full `(author, id)` key so the correct sender's bytes are fetched and
    /// saved even if another peer reused the same attachment id (Tier C F-12).
    SaveAttachment(AttachmentKey),
    /// Fetch an image that wasn't auto-fetched (over the auto-size cap, budget-
    /// skipped, or evicted) for inline display (Phase 3B). Manual, so it may use
    /// the full attachment cap; duplicates are suppressed via the Loading state.
    LoadImage(AttachmentKey),
    /// Open the click-to-enlarge image lightbox for this attachment (author + id).
    OpenImageLightbox(AttachmentKey),
    /// Close the image lightbox overlay.
    CloseImageLightbox,
    /// Result of the async save dialog: a status line to show, or None if cancelled.
    AttachmentSaved(Option<String>),
    /// Fetch (if needed) and start an inline audio attachment. Carries the full
    /// `(author, id)` key (see [`AppMessage::SaveAttachment`]).
    PlayAudio(AttachmentKey),
    PauseAudio,
    ResumeAudio,
    SeekAudio(crate::files::AttachmentId, f32),
    /// Adjust the universal inline-clip playback volume (`1.0` = unity) from the
    /// master slider. Persisted to config; applied live only in universal mode.
    SetClipVolume(f32),
    /// Adjust volume from a clip's own row slider. In universal mode this drives
    /// the shared level; otherwise it sets just that clip's in-memory level.
    SetClipVolumeFor(crate::files::AttachmentId, f32),
    /// Toggle whether one universal level governs every clip (checked) or each
    /// clip keeps its own level (unchecked).
    ToggleUniversalClipVolume(bool),
    /// Switch the music panel tab (Personal/Public).
    MusicSelectTab(MusicTab),
    /// Open the native multi-file picker to add audio files to the playlist.
    MusicBrowse,
    /// Picked audio file paths to append to the playlist (None = cancelled).
    MusicFilesPicked(Option<Vec<std::path::PathBuf>>),
    /// Play the playlist track at this index.
    MusicPlayIndex(usize),
    /// Toggle play/pause of the current music track.
    MusicPlayPause,
    /// Advance to the next / previous playlist track.
    MusicNext,
    MusicPrev,
    /// Seek the current music track to this 0.0..=1.0 fraction.
    MusicSeek(f32),
    /// Set the local music playback volume (and persist it).
    MusicSetVolume(f32),
    /// Set the tuned-in source's music playback volume (and persist it).
    MusicSetSourceVolume(f32),
    /// Show / hide the room now-playing player bar (persisted).
    TogglePlayerBar,
    /// Open / close the full playlist drawer.
    TogglePlaylistDrawer,
    /// Remove the playlist track at this index.
    MusicRemove(usize),
    /// Move a personal playlist track one slot up/down.
    MusicMoveUp(usize),
    MusicMoveDown(usize),
    /// Toggle whether our local playlist track is advertised for tune-in.
    MusicToggleBroadcast(bool),
    /// Tune the music sink into a peer's advertised music timeline.
    MusicListen(EndpointId),
    /// Stop listening to a peer's music timeline.
    MusicStopListen,
    /// Redraw cadence while an inline clip is active.
    AudioTick,
    /// Send the current chat input line (Enter or the Send button).
    ChatSubmit,
    /// Drain cadence for the outbound chat queue while it is non-empty
    /// (Phase 5 pacing): dispatches queued sends as pacer tokens refill.
    ChatSendTick,
    /// Re-dispatch a Failed local send (the "Retry" button), by local send id.
    RetryChatSend(u64),
    /// Open a clicked chat link in the system browser (A13).
    OpenUrl(String),
    /// A room divider was dragged by the given pixel delta along its drag axis
    /// (horizontal for the Panels divider, vertical for the Chat divider).
    DividerDragged(DividerKind, f32),
    /// Open / close the room-layout picker popup.
    OpenLayoutPicker,
    CloseLayoutPicker,
    /// Open / close the live hotkey reference popup.
    OpenHotkeyInfo,
    CloseHotkeyInfo,
    /// Open / close the "screen sharing needs pixelpass" explainer popup (A11).
    OpenPixelpassHelp,
    ClosePixelpassHelp,
    /// Dismiss the room-level clock-skew warning banner.
    DismissClockSkewWarning,
    /// Auto-clear cadence while the clock-skew warning banner is visible.
    ClockSkewWarningTick,
    /// Slow re-render so the friends "scanned Nm ago" indicator advances over time.
    RescanLabelTick,
    /// Choose a room layout (applied live + persisted, closes the popup).
    SelectRoomLayout(RoomLayout),
    /// Choose a UI theme (applied live + persisted).
    SelectTheme(AppTheme),
    /// Choose our avatar (monogram or a preset); applied live + persisted (W4).
    SelectAvatar(crate::avatar::Avatar),
    /// Open the native file picker to choose a custom avatar image (W4 Phase 3).
    PickAvatarFile,
    /// Result of the avatar file picker: the chosen file's raw bytes, or `None`
    /// if the user cancelled.
    AvatarFilePicked(Option<Vec<u8>>),
    /// Open the native file picker to choose a custom UI background image (W16).
    PickBackgroundFile,
    /// Result of the background file picker: the chosen file's raw bytes, or
    /// `None` if the user cancelled.
    BackgroundFilePicked(Option<Vec<u8>>),
    /// Clear the custom background, reverting to the theme background (W16).
    RemoveBackground,
    /// Set the background legibility scrim strength (0.0..=1.0) (W16).
    SetBackgroundDim(f32),
    /// Toggle the Chat drawer open/closed (drawer layout).
    ToggleDrawerChat,
    /// Start/stop sharing our own screen. When not sharing, opens the audio
    /// picker (A23) instead of starting immediately; when sharing, stops.
    ToggleScreenShare,
    /// Close the screen-share audio picker without sharing.
    CloseSharePicker,
    /// Select which app's audio to share in the picker: `Some(name)` for one app,
    /// `None` for the whole desktop ("All system audio").
    SelectShareAudioApp(Option<String>),
    /// Session-only quality preset for the next share start.
    SelectShareQualityOverride(ShareQuality),
    /// Confirm the picker: start the share with the currently selected audio app.
    ConfirmShareScreen,
    /// Watch a peer's screen share, identified by their pixelpass ticket.
    WatchShare(String),
    /// Result of asynchronously enqueueing the core shutdown command.
    ShutdownCommandSent(bool),
    /// Fallback close if the core does not acknowledge shutdown promptly.
    ShutdownTimeout,
    // --- Game detection (W17/W18) ---
    /// Toggle broadcasting the detected game to peers (opt-in, default off).
    ToggleGamePresence(bool),
    /// Choose the manual detection override (Auto / None / the current game).
    GameOverrideSelected(GameOverrideChoice),
    /// Add-mapping form edits (executable basename → display name).
    GameMapExeChanged(String),
    GameMapNameChanged(String),
    /// Commit the add-mapping form into the process map.
    AddGameMapping,
    /// Remove a process→name mapping by its executable key.
    RemoveGameMapping(String),
    /// Open the native picker to set a per-game background for the given game id.
    PickGameBackground(String),
    /// Result of the per-game background picker: (game id, chosen bytes or None).
    GameBackgroundPicked(String, Option<Vec<u8>>),
    /// Clear a per-game background mapping by game id.
    RemoveGameBackground(String),
}

/// The manual game-detection override as shown in the Settings picker. Maps to a
/// [`crate::game::ManualOverride`] using the app's currently-detected game for the
/// `Current` choice (so "force this game" carries the live id + name).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameOverrideChoice {
    /// Trust auto-detection (default).
    Auto,
    /// Force "not playing" — never broadcast a game.
    None,
    /// Pin the game currently detected (only offered while something is detected).
    Current,
}

impl std::fmt::Display for GameOverrideChoice {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            GameOverrideChoice::Auto => "Auto-detect",
            GameOverrideChoice::None => "None (don't show a game)",
            GameOverrideChoice::Current => "Pin current game",
        })
    }
}

fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
    iced::stream::channel(
        100,
        |mut output: iced::futures::channel::mpsc::Sender<UiEvent>| async move {
            if let Some(rx_lock) = UI_RX.get() {
                let mut guard = rx_lock.lock().await;
                if let Some(mut rx) = guard.take() {
                    use iced::futures::sink::SinkExt;
                    while let Some(event) = rx.recv().await {
                        let _ = output.send(event).await;
                    }
                }
            }
        },
    )
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClockSkewBanner {
    skew_secs: u64,
    peer_ahead: bool,
    expires_at: std::time::Instant,
}

/// One entry in the personal music playlist (Phase 1: a local file).
#[derive(Debug, Clone)]
struct MusicTrack {
    /// Absolute path on disk. Persisted (as a String) in config.
    path: std::path::PathBuf,
    /// Display name derived from the file name.
    name: String,
}

/// Which music tab is shown. Phase 1: only `Personal` is functional; `Public`
/// is a stub placeholder for the Phase 2 tune-in view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MusicTab {
    Personal,
    Public,
}

pub struct AppState {
    name: String,
    ticket_input: String,
    /// The optional cosmetic room label typed on the home "Create" card (W7).
    /// Carried in the minted ticket so joiners inherit it; empty = unnamed room.
    room_name_input: String,
    status_message: String,
    /// When the last manual friends "Rescan" pass completed, for the live
    /// "scanned Nm ago" indicator in the Friends panel. `None` until the first
    /// rescan this session. Only the on-demand button updates this, not the 15s
    /// auto-refresh.
    last_rescan: Option<std::time::Instant>,
    self_id: String,
    ticket: String,
    is_muted: bool,
    is_deafened: bool,
    ptt_enabled: bool,
    ptt_active: bool,
    hotkey_capture: Option<HotkeyAction>,
    input_devices: Vec<AudioDevice>,
    output_devices: Vec<AudioDevice>,
    selected_input: Option<AudioDevice>,
    selected_output: Option<AudioDevice>,
    config: AppConfig,
    /// Decoded bytes of the custom background image (W16), cached so `view()`
    /// doesn't read the file from disk on every redraw. Loaded on startup and
    /// refreshed when the background is changed/removed. `None` = no custom bg.
    background_image: Option<bytes::Bytes>,
    /// The locally-detected running game (game detection), as reported by core.
    /// Drives the per-game background (W18) and a local "Playing …" indicator.
    /// `None` = nothing detected. Independent of whether we broadcast it to peers
    /// (that's `config.game_presence_enabled`).
    current_game: Option<crate::game::DetectedGame>,
    /// Add-mapping form state in the Games settings: the executable basename and
    /// display name being entered for a new process→name mapping.
    game_map_exe_input: String,
    game_map_name_input: String,
    /// Current manual-override selection shown in the Games settings picker.
    /// Session-only (not persisted); defaults to Auto each launch.
    game_override: GameOverrideChoice,
    peers: HashMap<EndpointId, PeerState>,
    audio_levels: HashMap<EndpointId, f32>,
    /// Latest per-peer connection transparency info (direct/relay, RTT, window
    /// loss/bitrate), replaced wholesale by each `UiEvent::ConnectionStats`
    /// (~1/sec). A peer with no entry has no live audio link right now.
    conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
    /// Peers we've locally muted (their audio isn't mixed into our output).
    locally_muted: HashSet<EndpointId>,
    /// Participant cards whose volume/pan/gate/EQ foldout is open. Session-only:
    /// a fresh room starts compact, regardless of the previous room's UI state.
    peer_audio_expanded: HashSet<EndpointId>,
    /// When we joined the current room, for the in-room call-duration timer.
    call_started: Option<std::time::Instant>,
    /// Whether a local call recording is in progress (confirmed by the core).
    recording: bool,
    /// When the current recording started, for the header REC timer.
    recording_started: Option<std::time::Instant>,
    /// Room text-chat history (newest last) and the pending input line.
    chat_messages: Vec<ChatEntry>,
    chat_input: String,
    /// Fetched/failed bytes + cached image handles for chat attachments, keyed by
    /// `(author, id)` and bounded. Session-only (cleared on leave); never
    /// persisted. (Tier C F-02 bound + F-12 author keying.)
    attachments: AttachmentCache,
    /// When `Some`, the image lightbox overlay is open showing this attachment
    /// (click-to-enlarge from the chat panel). Closed = `None`.
    image_lightbox: Option<AttachmentKey>,
    /// Attachments the user asked to save before the bytes arrived; when the
    /// fetch completes a save dialog is opened for them. Keyed by `(author, id)`
    /// so a same-id attachment from a different sender can't trigger the save.
    pending_saves: HashSet<AttachmentKey>,
    /// Clip ids waiting for the existing attachment fetch path to return bytes.
    pending_plays: HashSet<crate::files::AttachmentId>,
    /// Monotonic id source for local chat sends (Phase 5). Never reset within a
    /// run, so a stale result after a room reset can't alias a new entry.
    next_send_id: u64,
    /// Monotonic clock base for the send pacer (wall clocks can jump).
    send_clock: std::time::Instant,
    /// Sender-side pacer mirroring the receivers' per-author chat budget.
    send_pacer: SendPacer,
    /// Local send ids waiting for a pacer token, oldest first ("queued…").
    send_queue: VecDeque<u64>,
    /// Dispatch payload for each in-flight local send, keyed by local id. Holds
    /// everything needed to (re)dispatch: it backs both queued release and the
    /// Retry button. For file sends the `Arc` is one clone of the same
    /// allocation the cache/serve store share. Retained from submit until the
    /// send is Broadcast (or the room resets); a Failed send keeps its payload
    /// so Retry can re-dispatch without re-reading the file.
    send_payloads: HashMap<u64, PendingSend>,
    /// Filename-hinted audio whose fetched bytes or decoder validation failed;
    /// these entries fall back to the normal file chip.
    invalid_audio: HashSet<crate::files::AttachmentId>,
    /// Independent system-default-device player for chat clips. It never enters
    /// the call capture/mixer path.
    clip_player: ClipPlayer,
    clip_status: SharedClipStatus,
    /// Per-clip playback gain used when universal clip volume is disabled
    /// (`config.clip_volume_universal == false`). In-memory only; absent clips
    /// default to unity. Universal mode ignores this and uses `config.clip_volume`.
    clip_volumes: HashMap<crate::files::AttachmentId, f32>,
    /// Dedicated player for the music playlist (separate from `clip_player`, which
    /// serves chat-audio attachments, so the two never interrupt each other).
    music_player: ClipPlayer,
    music_status: SharedClipStatus,
    /// Personal playlist, loaded from `config.music_playlist` at startup.
    music_playlist: Vec<MusicTrack>,
    /// Index into `music_playlist` of the track currently loaded in `music_player`.
    music_current: Option<usize>,
    /// True while a music track is meant to be playing — used to distinguish a
    /// natural track end (auto-advance) from a user stop/pause in `AudioTick`.
    music_active: bool,
    /// Selected music tab.
    music_tab: MusicTab,
    /// Persisted preference: let others tune into our current personal track.
    music_broadcasting: bool,
    /// Files-plane id for the track we're currently serving/broadcasting.
    music_broadcast_id: Option<crate::files::AttachmentId>,
    /// Files-plane id/size for the track currently advertised as upcoming.
    music_broadcast_next: Option<(usize, crate::files::AttachmentId, u64)>,
    /// Peer whose shared music timeline currently owns the music sink.
    music_listening_to: Option<EndpointId>,
    /// Source track id currently loaded in the music sink.
    music_listen_loaded: Option<crate::files::AttachmentId>,
    /// Source track id currently being fetched.
    music_listen_inflight: Option<crate::files::AttachmentId>,
    /// Prefetched next-track bytes from the tuned-in source.
    music_prefetch: Option<(crate::files::AttachmentId, Vec<u8>)>,
    /// Source next-track id currently being prefetched.
    music_prefetch_inflight: Option<crate::files::AttachmentId>,
    /// Last known window size, tracked so divider clamps stay valid on resize.
    /// (The divider positions themselves are persisted in `config`.)
    window_size: Size,
    /// Whether the room-layout picker popup is open (launch + in-call screens).
    layout_picker_open: bool,
    /// Whether the hotkey reference popup is open.
    hotkey_info_open: bool,
    /// Whether the pixelpass screen-share explainer popup is open (A11).
    pixelpass_help_open: bool,
    /// Whether the screen-share audio picker is open (A23). Opened by Share
    /// Screen when not already sharing; lets the user capture one app's audio
    /// instead of the whole desktop (which echoes the call back to viewers).
    share_picker_open: bool,
    /// Apps currently producing audio, shown in the share picker. Populated from
    /// `UiEvent::AudioAppsListed` after the picker requests an enumeration.
    share_audio_apps: Vec<String>,
    /// The picker's current selection: `Some(name)` = capture that app's audio,
    /// `None` = "All system audio" (whole desktop; may echo the call).
    share_audio_selection: Option<String>,
    /// Session-only quality override for the next screen-share start.
    share_quality_selection: ShareQuality,
    /// A share start is in flight: `ConfirmShareScreen` was sent but the core
    /// hasn't yet replied with `ScreenShareStarted`/an error. Blocks reopening
    /// the picker (and re-confirming) during that startup window. Cleared on
    /// `ScreenShareStarted`, `ScreenShareStopped`, or any `Error`.
    share_starting: bool,
    /// While sharing a specific app's audio (A23 strict mode): `true` when that
    /// app's audio has stopped (or hasn't started yet), so viewers currently hear
    /// silence. Drives a transient warning. Always `false` for whole-desktop
    /// shares (pixelpass emits no `app_audio` events then) and when not sharing.
    share_audio_dropped: bool,
    /// Whether the current share is a specific-app capture (vs whole-desktop).
    /// Set from the confirmed selection on `ScreenShareStarted`, cleared on
    /// stop/reset. Gates applying `ShareAudioActive`, so a late event from a
    /// just-killed host can't flip the warning on a new whole-desktop share or
    /// after stop (audit P3, unscoped events).
    share_audio_app_active: bool,
    /// Whether the resolved pixelpass supports `--strict-audio` (per-app audio).
    /// `false` ⇒ the picker offers whole-desktop only, because a per-app share
    /// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
    /// until the core's `AudioAppsListed` reports otherwise.
    share_app_audio_supported: bool,
    /// Room-level warning for a validly signed peer whose gossip timestamp falls
    /// outside the replay freshness window. The peer is not yet in the roster, so
    /// this is not attached to a participant card.
    clock_skew_warning: Option<ClockSkewBanner>,
    /// Whether the Chat drawer is open (drawer layout only).
    drawer_chat_open: bool,
    /// Whether the Playlist drawer is open beside the room body.
    playlist_drawer_open: bool,
    /// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
    mic_level: f32,
    /// Whether the standalone (off-call) mic test stream is running.
    mic_test_active: bool,
    /// Peers whose audio link is currently down (initial connect or reconnect).
    connecting: HashSet<EndpointId>,
    /// Peers we've had a live link to at least once — used to say "Reconnecting"
    /// rather than "Connecting" the second time around.
    ever_connected: HashSet<EndpointId>,
    controller: Arc<CoreController>,
    current_screen: Screen,
    settings_category: SettingsCategory,
    /// Whether we're currently sharing our own screen (confirmed by the core).
    self_sharing: bool,
    /// Whether the `pixelpass` binary is available, gating the Share controls.
    pixelpass_available: bool,
    /// Our persistent node id (W7), known from startup regardless of room state
    /// (distinct from `self_id`, which is room-scoped). `None` until the core
    /// reports it via `IdentityStatus`.
    self_node_id: Option<String>,
    /// Whether our identity is persisted to disk. `false` = degraded ephemeral
    /// fallback (the key file couldn't be read/written) → the UI shows a warning,
    /// because the id won't survive the next launch and friends will stop
    /// recognising us. Defaults `true` (optimistic until told otherwise).
    identity_persisted: bool,
    /// The reason the identity isn't persisted, for the warning explainer.
    identity_error: Option<String>,
    /// Whether the "Regenerate identity?" confirm modal is open.
    regenerate_identity_confirm_open: bool,
    /// Saved friends (W7) — a READ-ONLY MIRROR of the core-owned store, refreshed by
    /// `UiEvent::FriendsUpdated`. The GUI no longer loads/saves it; add/remove/rename
    /// go to core as commands.
    friends: crate::friends::FriendStore,
    /// True when core couldn't load `friends.json` (malformed) and is in a degraded
    /// read-only state — the GUI disables edits + warns so we don't clobber it (A16).
    friends_read_only: bool,
    /// Latest live presence per friend (W7 B2), from `UiEvent::FriendPresence`. A
    /// missing entry = treat as offline/unknown.
    friend_presence: std::collections::HashMap<EndpointId, crate::presence::FriendPresence>,
    /// "Add friend" form inputs: their node id (hex) and an optional name.
    friend_add_id: String,
    friend_add_name: String,
    /// Inline feedback for the add-friend form (e.g. a bad id), cleared on edit.
    friend_add_error: Option<String>,
    /// Window close has been requested and the GUI is waiting for core teardown.
    closing: bool,
}

impl AppState {
    fn reset_room_state(&mut self) {
        self.clip_player.stop();
        self.music_player.stop();
        self.music_current = None;
        self.music_active = false;
        self.music_broadcast_id = None;
        self.music_broadcast_next = None;
        self.music_listening_to = None;
        self.music_listen_loaded = None;
        self.music_listen_inflight = None;
        self.music_prefetch = None;
        self.music_prefetch_inflight = None;
        self.peers.clear();
        self.audio_levels.clear();
        self.conn_stats.clear();
        self.locally_muted.clear();
        self.peer_audio_expanded.clear();
        self.chat_messages.clear();
        // Unsent queue + retry bytes die with the room's transcript. The pacer
        // and id counter deliberately survive: receivers' per-author buckets
        // persist across our rejoin, and monotonic ids keep any in-flight
        // result from aliasing a post-reset entry.
        self.send_queue.clear();
        self.send_payloads.clear();
        self.chat_input.clear();
        self.attachments.clear();
        self.image_lightbox = None;
        self.pending_saves.clear();
        self.pending_plays.clear();
        self.invalid_audio.clear();
        self.connecting.clear();
        self.ever_connected.clear();
        self.recording = false;
        self.recording_started = None;
        self.call_started = None;
        self.mic_level = 0.0;
        self.self_sharing = false;
        self.share_picker_open = false;
        self.share_audio_apps.clear();
        self.share_audio_selection = None;
        self.share_quality_selection = self.config.screen_share.quality;
        self.share_starting = false;
        self.share_audio_dropped = false;
        self.share_audio_app_active = false;
        self.share_app_audio_supported = true;
        self.clock_skew_warning = None;
    }

    /// Monotonic milliseconds for the send pacer. `Instant`-based so a wall-clock
    /// jump can never rewind or fast-forward the pacing budget.
    fn send_now_ms(&self) -> u64 {
        self.send_clock.elapsed().as_millis() as u64
    }

    /// Point the most recent local echo with this id at a new send status. Returns
    /// whether such an entry still exists (it may have been evicted from history or
    /// dropped by a room reset) so callers can release orphaned payloads.
    fn set_send_status(&mut self, id: u64, status: SendStatus) -> bool {
        if let Some(entry) = self
            .chat_messages
            .iter_mut()
            .rev()
            .find(|m| m.local_send.as_ref().is_some_and(|s| s.id == id))
        {
            if let Some(ls) = entry.local_send.as_mut() {
                ls.status = status;
            }
            true
        } else {
            false
        }
    }

    /// Hand one payload to the core, marking the entry Pending on success or
    /// Failed if the core loop is gone (a dead reliable channel). The payload is
    /// retained either way — a Retry can still re-dispatch after a dead-core
    /// failure once the loop is back.
    fn dispatch_send(&mut self, id: u64, payload: &PendingSend) {
        let ok = match payload {
            PendingSend::Text(text) => self.controller.send(CoreCommand::SendChat {
                local_id: id,
                text: text.clone(),
            }),
            PendingSend::File {
                text,
                attachment,
                data,
            } => self.controller.send(CoreCommand::SendChatFile {
                local_id: id,
                text: text.clone(),
                attachment: attachment.clone(),
                data: data.clone(),
            }),
        };
        self.set_send_status(
            id,
            if ok {
                SendStatus::Pending
            } else {
                SendStatus::Failed("core unavailable".to_string())
            },
        );
    }

    /// Land a core `ChatSendResult` on the matching local echo (Phase 5).
    /// Success (`error = None`) means our signed frame reached the swarm — NOT a
    /// delivery receipt — and the payload is done. A failure keeps the payload
    /// so Retry can re-dispatch, unless the entry is already gone (history
    /// eviction / room reset), in which case the payload is dropped so its map
    /// can't leak. Either way an id with no matching entry is a harmless no-op.
    /// Returns `true` only when a successful result matched a live local echo,
    /// which is the boundary used for the outgoing-message notification.
    fn apply_send_result(&mut self, local_id: u64, error: Option<String>) -> bool {
        match error {
            None => {
                let matched = self.set_send_status(local_id, SendStatus::Broadcast);
                self.send_payloads.remove(&local_id);
                matched
            }
            Some(e) => {
                if !self.set_send_status(local_id, SendStatus::Failed(e)) {
                    self.send_payloads.remove(&local_id);
                }
                false
            }
        }
    }

    /// Decide a fresh (or retried) send's fate: dispatch immediately only when
    /// nothing is already queued AND the pacer — mirroring the receivers'
    /// per-author budget — grants a token now; otherwise queue it as "queued…"
    /// so it can never overtake an earlier message or exceed what receivers
    /// admit. The payload is stored for release/retry.
    fn submit_send(&mut self, id: u64, payload: PendingSend) {
        self.send_payloads.insert(id, payload.clone());
        let now = self.send_now_ms();
        if self.send_queue.is_empty() && self.send_pacer.try_send(now) {
            self.dispatch_send(id, &payload);
        } else {
            self.set_send_status(id, SendStatus::Queued);
            self.send_queue.push_back(id);
        }
    }

    fn custom_sound_path(&self, sound: Sound) -> &str {
        let opt = match sound {
            Sound::SelfJoin => &self.config.custom_sound_self_join,
            Sound::PeerJoin => &self.config.custom_sound_peer_join,
            Sound::PeerLeave => &self.config.custom_sound_peer_leave,
            Sound::ReconnectAttempt => &self.config.custom_sound_reconnect_attempt,
            Sound::Reconnected => &self.config.custom_sound_reconnected,
            Sound::SelfLeave => &self.config.custom_sound_self_leave,
            Sound::MicToggle => &self.config.custom_sound_mic_toggle,
            Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
            Sound::ChatSent => &self.config.custom_sound_chat_sent,
            Sound::ChatReceived => &self.config.custom_sound_chat_received,
            Sound::ContactOnline => &self.config.custom_sound_contact_online,
            Sound::ContactOffline => &self.config.custom_sound_contact_offline,
        };
        opt.as_deref().unwrap_or("")
    }

    fn set_custom_sound_path(&mut self, sound: Sound, path: Option<String>) {
        match sound {
            Sound::SelfJoin => self.config.custom_sound_self_join = path,
            Sound::PeerJoin => self.config.custom_sound_peer_join = path,
            Sound::PeerLeave => self.config.custom_sound_peer_leave = path,
            Sound::ReconnectAttempt => self.config.custom_sound_reconnect_attempt = path,
            Sound::Reconnected => self.config.custom_sound_reconnected = path,
            Sound::SelfLeave => self.config.custom_sound_self_leave = path,
            Sound::MicToggle => self.config.custom_sound_mic_toggle = path,
            Sound::ReconnectFailed => self.config.custom_sound_reconnect_failed = path,
            Sound::ChatSent => self.config.custom_sound_chat_sent = path,
            Sound::ChatReceived => self.config.custom_sound_chat_received = path,
            Sound::ContactOnline => self.config.custom_sound_contact_online = path,
            Sound::ContactOffline => self.config.custom_sound_contact_offline = path,
        }
    }
}

impl Default for AppState {
    fn default() -> Self {
        let (ui_tx, ui_rx) = tokio::sync::mpsc::channel(100);
        let controller = Arc::new(CoreController::new(ui_tx));
        let _ = UI_RX.set(Mutex::new(Some(ui_rx)));

        let mut config = AppConfig::load();
        // The window opens at the restored size (see `run_gui`); clamp the
        // persisted divider positions against THAT size, not a hardcoded default,
        // so they stay valid for the window we're actually about to show.
        let (ww, wh) = (config.window_width, config.window_height);
        config.participants_width = clamp_participants_width(config.participants_width, ww);
        config.chat_height = clamp_chat_height(config.chat_height, wh);
        config.controls_width = clamp_controls_width(config.controls_width, ww);
        config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww);
        config.playlist_drawer_width =
            clamp_playlist_drawer_width(config.playlist_drawer_width, ww);
        notify::set_enabled(config.notifications_enabled);
        for sound in Sound::ALL {
            notify::set_sound_enabled(sound, config.sound_enabled(sound));
        }
        let _ = controller.send(CoreCommand::SetNoiseGateThreshold(
            config.noise_gate_threshold,
        ));
        let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
        let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume));
        let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode));
        let _ = controller.send(CoreCommand::SetAudioProfile(config.audio_profile));
        let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
        let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
        let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
        // Game detection (W17/W18): seed the opt-in broadcast flag + the user's
        // process→name mappings from config. The manual override starts at Auto.
        let _ = controller.send(CoreCommand::SetGamePresenceEnabled(
            config.game_presence_enabled,
        ));
        let _ = controller.send(CoreCommand::SetGameProcessMap(
            config.game_process_map.clone(),
        ));
        for (peer, settings) in &config.peer_eq {
            if let Ok(id) = peer.parse::<EndpointId>() {
                let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
            }
        }
        for (peer, pan) in &config.peer_pan {
            if let Ok(id) = peer.parse::<EndpointId>() {
                let _ = controller.send(CoreCommand::SetPeerPan(id, *pan));
            }
        }
        for (peer, volume) in &config.peer_volume {
            if let Ok(id) = peer.parse::<EndpointId>() {
                let _ = controller.send(CoreCommand::SetPeerVolume(id, *volume));
            }
        }
        for (peer, threshold) in &config.peer_gate {
            if let Ok(id) = peer.parse::<EndpointId>() {
                let _ = controller.send(CoreCommand::SetPeerGate(id, *threshold));
            }
        }
        let pixelpass_available =
            crate::screenshare::is_available(config.pixelpass_path.as_deref());
        let all_devices = enumerate_audio_devices();
        let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect();
        let output_devices: Vec<_> = all_devices
            .iter()
            .filter(|d| !d.is_input)
            .cloned()
            .collect();

        let selected_input = input_devices
            .iter()
            .find(|d| d.name == config.input_device)
            .cloned();
        let selected_output = output_devices
            .iter()
            .find(|d| d.name == config.output_device)
            .cloned();

        let background_image = load_background_bytes(&config);
        let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume);
        let (music_player, music_status) = ClipPlayer::new(config.music_volume);
        let music_broadcasting = config.music_broadcast;
        let share_quality_selection = config.screen_share.quality;
        let music_playlist = config
            .music_playlist
            .iter()
            .map(|p| {
                let path = std::path::PathBuf::from(p);
                let name = path
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
                    .unwrap_or_else(|| p.clone());
                MusicTrack { path, name }
            })
            .collect();

        Self {
            // Pre-fill the nickname with the last one used (or "Peer" by default).
            name: config.username.clone(),
            ticket_input: "".to_string(),
            room_name_input: "".to_string(),
            status_message: "Ready to connect".to_string(),
            last_rescan: None,
            self_id: "".to_string(),
            ticket: "".to_string(),
            is_muted: false,
            is_deafened: false,
            ptt_enabled: false,
            ptt_active: false,
            hotkey_capture: None,
            input_devices,
            output_devices,
            selected_input,
            selected_output,
            config,
            background_image,
            current_game: None,
            game_map_exe_input: String::new(),
            game_map_name_input: String::new(),
            game_override: GameOverrideChoice::Auto,
            peers: HashMap::new(),
            audio_levels: HashMap::new(),
            conn_stats: HashMap::new(),
            locally_muted: HashSet::new(),
            peer_audio_expanded: HashSet::new(),
            call_started: None,
            recording: false,
            recording_started: None,
            chat_messages: Vec::new(),
            attachments: AttachmentCache::new(
                ATTACHMENT_CACHE_CAP,
                ATTACHMENT_CACHE_ENCODED_BUDGET,
                ATTACHMENT_CACHE_DECODED_BUDGET,
            ),
            image_lightbox: None,
            pending_saves: HashSet::new(),
            pending_plays: HashSet::new(),
            next_send_id: 0,
            send_clock: std::time::Instant::now(),
            send_pacer: SendPacer::new(0),
            send_queue: VecDeque::new(),
            send_payloads: HashMap::new(),
            invalid_audio: HashSet::new(),
            clip_player,
            clip_status,
            clip_volumes: HashMap::new(),
            music_player,
            music_status,
            music_playlist,
            music_current: None,
            music_active: false,
            music_tab: MusicTab::Personal,
            music_broadcasting,
            music_broadcast_id: None,
            music_broadcast_next: None,
            music_listening_to: None,
            music_listen_loaded: None,
            music_listen_inflight: None,
            music_prefetch: None,
            music_prefetch_inflight: None,
            chat_input: String::new(),
            window_size: Size::new(ww, wh),
            layout_picker_open: false,
            hotkey_info_open: false,
            pixelpass_help_open: false,
            share_picker_open: false,
            share_audio_apps: Vec::new(),
            share_audio_selection: None,
            share_quality_selection,
            share_starting: false,
            share_audio_dropped: false,
            share_audio_app_active: false,
            share_app_audio_supported: true,
            clock_skew_warning: None,
            drawer_chat_open: false,
            playlist_drawer_open: false,
            mic_level: 0.0,
            mic_test_active: false,
            connecting: HashSet::new(),
            ever_connected: HashSet::new(),
            controller,
            current_screen: Screen::Home,
            settings_category: SettingsCategory::Audio,
            self_sharing: false,
            pixelpass_available,
            self_node_id: None,
            identity_persisted: true,
            identity_error: None,
            regenerate_identity_confirm_open: false,
            // Core owns the friends store now; the GUI starts empty and fills in
            // from the FriendsUpdated event core emits at startup.
            friends: crate::friends::FriendStore::default(),
            friends_read_only: false,
            friend_presence: std::collections::HashMap::new(),
            friend_add_id: String::new(),
            friend_add_name: String::new(),
            friend_add_error: None,
            closing: false,
        }
    }
}

fn theme(state: &AppState) -> Theme {
    state.config.theme.base_theme()
}

/// Read the custom background PNG (W16) from disk into memory, if one is set and
/// readable. Called once on startup and whenever the background changes, so the
/// per-frame `view()` never touches the filesystem. A missing/unreadable file
/// silently yields `None` (the UI falls back to the theme background).
fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
    let path = config.background.as_deref()?;
    std::fs::read(path).ok().map(bytes::Bytes::from)
}

/// The effective background for the current state: the per-game override (W18) when
/// the running `game` has a mapping in `config.game_backgrounds`, otherwise the
/// single custom background (W16). A mapped-but-missing/unreadable per-game file
/// falls back to the default WITHOUT forgetting the mapping (the file may return).
fn effective_background_bytes(
    config: &AppConfig,
    game: Option<&crate::game::DetectedGame>,
) -> Option<bytes::Bytes> {
    if let Some(g) = game
        && let Some(path) = config.game_backgrounds.get(&g.id)
        && let Ok(bytes) = std::fs::read(path)
    {
        return Some(bytes::Bytes::from(bytes));
    }
    load_background_bytes(config)
}

pub fn run_gui() -> iced::Result {
    crate::log_msg(&format!("{} starting", app_build_label()));
    // Restore the last window size (saved on close). Position is restored too,
    // but only on X11 — Wayland's xdg-shell gives clients no way to set their own
    // position, so we center there and leave placement to the compositor.
    let saved = AppConfig::load();
    let init_size = iced::Size::new(saved.window_width, saved.window_height);
    let init_position = initial_window_position(
        saved.window_x,
        saved.window_y,
        saved.window_width.round() as i32,
        saved.window_height.round() as i32,
        is_wayland(),
    );
    iced::application(AppState::default, update, view_with_background)
        .title("PeerSpeak P2P Voice Chat")
        .theme(theme)
        .subscription(subscription)
        .window(iced::window::Settings {
            // Restored from config (defaults 900×760: taller so the bottom chat
            // dock doesn't squeeze the controls column). Layout is responsive.
            size: init_size,
            position: init_position,
            // App/taskbar icon (mainly used on X11/XWayland; native Wayland takes
            // the icon from the .desktop file matched by app_id instead).
            icon: window_icon(),
            // app_id must match the .desktop basename so Wayland compositors
            // (e.g. KWin) attach our launcher icon to the window. The field is
            // Linux-only in iced (X11/Wayland); see platform_specific_settings().
            platform_specific: platform_specific_settings(),
            // We save the final size ourselves on CloseRequested, then exit.
            exit_on_close_request: false,
            ..Default::default()
        })
        .run()
}

/// Window `PlatformSpecific` settings. `application_id` (used by X11/Wayland to
/// match our `.desktop` launcher icon) only exists in iced on Linux, so it is
/// set there and left at defaults on Windows.
#[cfg(target_os = "linux")]
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
    iced::window::settings::PlatformSpecific {
        application_id: "peerspeak".to_string(),
        ..Default::default()
    }
}

#[cfg(not(target_os = "linux"))]
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
    iced::window::settings::PlatformSpecific::default()
}

/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
fn window_icon() -> Option<iced::window::Icon> {
    const RGBA: &[u8] = include_bytes!("../../assets/icons/peerspeak-128.rgba");
    iced::window::icon::from_rgba(RGBA.to_vec(), 128, 128).ok()
}

/// True when running under a Wayland compositor (winit will use its Wayland
/// backend). Mirrors winit's own selection: it prefers Wayland when
/// `WAYLAND_DISPLAY` is set, otherwise falls back to X11 via `DISPLAY`.
fn is_wayland() -> bool {
    std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
}

/// Decide the initial window position from the saved coordinates.
///
/// Position restore only works on **X11** — Wayland's xdg-shell gives clients no
/// way to place their own window, so we center there and let the compositor
/// decide. Returns `Centered` when we're on Wayland or have no saved position.
fn initial_window_position(
    saved_x: Option<i32>,
    saved_y: Option<i32>,
    win_w: i32,
    win_h: i32,
    is_wayland: bool,
) -> iced::window::Position {
    match (saved_x, saved_y) {
        (Some(x), Some(y)) if !is_wayland => {
            clamp_window_position(x, y, win_w, win_h, screen_bounds())
        }
        _ => iced::window::Position::Centered,
    }
}

#[derive(Debug, Clone, Copy)]
struct ScreenBounds {
    x: i32,
    y: i32,
    width: i32,
    height: i32,
}

const MIN_VISIBLE_WINDOW_MARGIN: i32 = 48;
const WINDOW_POSITION_SANITY_LIMIT: i64 = 32_000;

fn screen_bounds() -> Option<ScreenBounds> {
    // iced 0.14 only exposes monitor size through window tasks after a window
    // exists, not the startup virtual desktop bounds needed here. Keep the pure
    // clamp ready for when dependency-free bounds can be supplied.
    None
}

fn clamp_window_position(
    saved_x: i32,
    saved_y: i32,
    win_w: i32,
    win_h: i32,
    bounds: Option<ScreenBounds>,
) -> iced::window::Position {
    let specific = |x, y| iced::window::Position::Specific(iced::Point::new(x as f32, y as f32));
    let win_w = win_w.max(1);
    let win_h = win_h.max(1);

    if let Some(bounds) = bounds {
        if bounds.width <= 0
            || bounds.height <= 0
            || !has_min_visible_overlap(saved_x, win_w, bounds.x, bounds.width)
            || !has_min_visible_overlap(saved_y, win_h, bounds.y, bounds.height)
        {
            return iced::window::Position::Centered;
        }

        return specific(
            clamp_window_position_axis(saved_x, win_w, bounds.x, bounds.width),
            clamp_window_position_axis(saved_y, win_h, bounds.y, bounds.height),
        );
    }

    if i64::from(saved_x).abs() > WINDOW_POSITION_SANITY_LIMIT
        || i64::from(saved_y).abs() > WINDOW_POSITION_SANITY_LIMIT
    {
        return iced::window::Position::Centered;
    }

    specific(saved_x, saved_y)
}

fn has_min_visible_overlap(start: i32, len: i32, bounds_start: i32, bounds_len: i32) -> bool {
    let required = MIN_VISIBLE_WINDOW_MARGIN.min(len).min(bounds_len).max(1);
    let start = i64::from(start);
    let end = start + i64::from(len);
    let bounds_start = i64::from(bounds_start);
    let bounds_end = bounds_start + i64::from(bounds_len);
    let overlap = end.min(bounds_end) - start.max(bounds_start);

    overlap >= i64::from(required)
}

fn clamp_window_position_axis(start: i32, len: i32, bounds_start: i32, bounds_len: i32) -> i32 {
    if len >= bounds_len {
        return bounds_start;
    }

    let bounds_start = i64::from(bounds_start);
    let max_start = bounds_start + i64::from(bounds_len - len);

    i64::from(start).clamp(bounds_start, max_start) as i32
}

fn subscription(state: &AppState) -> Subscription<AppMessage> {
    let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
    let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
    let clip_playing = status_snapshot(&state.clip_status).playing_id.is_some();
    let music_playing = status_snapshot(&state.music_status).playing_id.is_some();
    let audio_sub = if clip_playing
        || music_playing
        || state.music_active
        || state.music_listening_to.is_some()
    {
        iced::time::every(std::time::Duration::from_millis(250)).map(|_| AppMessage::AudioTick)
    } else {
        Subscription::none()
    };
    let clock_skew_sub = if state.clock_skew_warning.is_some() {
        iced::time::every(std::time::Duration::from_secs(1))
            .map(|_| AppMessage::ClockSkewWarningTick)
    } else {
        Subscription::none()
    };
    // Advance the friends "scanned Nm ago" indicator. Minute granularity, so a 30s
    // tick keeps it within ~30s of accurate; only runs once a rescan has happened.
    let rescan_label_sub = if state.last_rescan.is_some() {
        iced::time::every(std::time::Duration::from_secs(30)).map(|_| AppMessage::RescanLabelTick)
    } else {
        Subscription::none()
    };
    // Drain the outbound chat queue while it has waiting messages (Phase 5
    // pacing). A queued message is released only as the pacer refills (~1/s), so
    // a 250ms poll is responsive without busy-looping; it stops entirely once
    // the queue empties.
    let send_queue_sub = if state.send_queue.is_empty() {
        Subscription::none()
    } else {
        iced::time::every(std::time::Duration::from_millis(250)).map(|_| AppMessage::ChatSendTick)
    };
    Subscription::batch(vec![
        core_sub,
        event_sub,
        audio_sub,
        clock_skew_sub,
        rescan_label_sub,
        send_queue_sub,
    ])
}

fn shutdown_timeout_task() -> Task<AppMessage> {
    Task::perform(
        async {
            let (tx, rx) = iced::futures::channel::oneshot::channel();
            std::thread::spawn(move || {
                std::thread::sleep(std::time::Duration::from_secs(SHUTDOWN_TIMEOUT_SECS));
                let _ = tx.send(());
            });
            let _ = rx.await;
        },
        |_| AppMessage::ShutdownTimeout,
    )
}

/// Reconnect-chime edge trigger for `UiEvent::PeerConnecting`. Marks the peer as
/// connecting and returns `Some(Sound::ReconnectAttempt)` exactly once per outage:
/// only when the peer had a live link before (a genuine reconnect, not a first
/// dial) AND we weren't already in the connecting state (so the supervisor's
/// repeated redials while still down don't re-chime). Pure so the once-per-
/// disconnect behavior is unit-testable without a GUI or audio.
fn reconnect_attempt_chime(
    connecting: &mut HashSet<EndpointId>,
    ever_connected: &HashSet<EndpointId>,
    id: EndpointId,
) -> Option<Sound> {
    let is_reconnect_attempt = ever_connected.contains(&id);
    let was_already_connecting = connecting.contains(&id);
    connecting.insert(id);
    (is_reconnect_attempt && !was_already_connecting).then_some(Sound::ReconnectAttempt)
}

/// Reconnect-chime edge trigger for `UiEvent::PeerConnected`. Clears the connecting
/// state, records that we've linked with this peer at least once, and returns
/// `Some(Sound::Reconnected)` only if it had connected before (a true reconnect, not
/// the first link). Pure so the logic is unit-testable.
fn reconnected_chime(
    connecting: &mut HashSet<EndpointId>,
    ever_connected: &mut HashSet<EndpointId>,
    id: EndpointId,
) -> Option<Sound> {
    let was_reconnect = ever_connected.contains(&id);
    connecting.remove(&id);
    ever_connected.insert(id);
    was_reconnect.then_some(Sound::Reconnected)
}

/// Return the landing-page contact chime for one definitive presence update.
/// An initial online result is an arrival (so contacts already online at app
/// startup are announced), while an initial offline result is silent. Online
/// includes both plain `Online` and `InRoom`; moving between those two states is
/// not a connection transition. Updates continue to populate the presence map
/// off-home, but notification sounds are intentionally limited to the home page.
fn friend_presence_notification(
    screen: Screen,
    previous: Option<&crate::presence::FriendPresence>,
    next: &crate::presence::FriendPresence,
) -> Option<Sound> {
    if screen != Screen::Home {
        return None;
    }

    let online = |presence: &crate::presence::FriendPresence| {
        matches!(
            presence,
            crate::presence::FriendPresence::Online
                | crate::presence::FriendPresence::InRoom { .. }
        )
    };
    match (previous.map(online), online(next)) {
        (None | Some(false), true) => Some(Sound::ContactOnline),
        (Some(true), false) => Some(Sound::ContactOffline),
        _ => None,
    }
}

/// Convert a native picker result into the persisted notification path. The
/// dialog filter is advisory on some desktops, so enforce WAV here as well.
/// `None` (cancel) and a non-WAV selection leave the existing setting untouched.
fn selected_wav_path(picked: Option<std::path::PathBuf>) -> Option<String> {
    let path = picked?;
    let is_wav = path
        .extension()
        .and_then(|ext| ext.to_str())
        .is_some_and(|ext| ext.eq_ignore_ascii_case("wav"));
    is_wav.then(|| path.to_string_lossy().into_owned())
}

fn in_call(state: &AppState) -> bool {
    !state.ticket.is_empty()
}

fn toggle_mute(state: &mut AppState) {
    if !in_call(state) {
        return;
    }
    let _ = state.controller.send(CoreCommand::ToggleMute);
    state.is_muted = !state.is_muted;
    notify::play(
        Sound::MicToggle,
        state.config.custom_sound_mic_toggle.as_deref(),
    );
}

fn toggle_deafen(state: &mut AppState) {
    if !in_call(state) {
        return;
    }
    let _ = state.controller.send(CoreCommand::ToggleDeafen);
    state.is_deafened = !state.is_deafened;
    notify::play(
        Sound::MicToggle,
        state.config.custom_sound_mic_toggle.as_deref(),
    );
}

fn handle_hotkey_pressed(state: &mut AppState, action: HotkeyAction) {
    match action {
        HotkeyAction::ToggleMute => toggle_mute(state),
        HotkeyAction::ToggleDeafen => toggle_deafen(state),
        HotkeyAction::OpenSettings => {
            state.current_screen = Screen::Settings;
            state.hotkey_info_open = false;
            state.layout_picker_open = false;
        }
        HotkeyAction::PushToTalk => {
            if in_call(state) && state.ptt_enabled && !state.ptt_active {
                state.ptt_active = true;
                let _ = state.controller.send(CoreCommand::SetPttActive(true));
            }
        }
        HotkeyAction::LeaveRoom => {
            if in_call(state) {
                let _ = state.controller.send(CoreCommand::Leave);
            }
        }
    }
}

fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32 {
    let pan = pan.clamp(-1.0, 1.0);
    let key = id.to_string();
    if pan.abs() <= 0.001 {
        config.peer_pan.remove(&key);
    } else {
        config.peer_pan.insert(key, pan);
    }
    pan
}

/// Store the per-peer listener volume, clamped to the slider range. Unity gain
/// (`1.0`) is the implicit default, so an at-unity entry is removed rather than
/// stored to keep the config tidy. Returns the clamped value.
fn set_peer_volume_config(config: &mut AppConfig, id: EndpointId, volume: f32) -> f32 {
    let volume = volume.clamp(0.0, 2.0);
    let key = id.to_string();
    if (volume - 1.0).abs() <= 0.001 {
        config.peer_volume.remove(&key);
    } else {
        config.peer_volume.insert(key, volume);
    }
    volume
}

/// Store the per-peer listener noise-gate threshold, clamped to the slider
/// range. `0.0` means the gate is off, so an at-zero entry is removed rather
/// than stored. Returns the clamped value.
fn set_peer_gate_config(config: &mut AppConfig, id: EndpointId, threshold: f32) -> f32 {
    let threshold = threshold.clamp(0.0, METER_MAX);
    let key = id.to_string();
    if threshold <= 0.0 {
        config.peer_gate.remove(&key);
    } else {
        config.peer_gate.insert(key, threshold);
    }
    threshold
}

fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings {
    config
        .peer_eq
        .get(&id.to_string())
        .copied()
        .unwrap_or_default()
        .clamped()
}

fn set_peer_eq_config(
    config: &mut AppConfig,
    id: EndpointId,
    band: EqBand,
    gain_db: f32,
) -> EqSettings {
    let key = id.to_string();
    let mut settings = config.peer_eq.get(&key).copied().unwrap_or_default();
    let gain_db = gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX);
    match band {
        EqBand::Low => settings.low_gain_db = gain_db,
        EqBand::Mid => settings.mid_gain_db = gain_db,
        EqBand::High => settings.high_gain_db = gain_db,
    }
    settings = settings.clamped();
    if settings.is_flat() {
        config.peer_eq.remove(&key);
    } else {
        config.peer_eq.insert(key, settings);
    }
    settings
}

fn pan_label(pan: f32) -> String {
    let pan = pan.clamp(-1.0, 1.0);
    if pan.abs() <= 0.01 {
        "Center".to_string()
    } else if pan < 0.0 {
        format!("L {:.0}%", pan.abs() * 100.0)
    } else {
        format!("R {:.0}%", pan * 100.0)
    }
}

/// Connection badge text on the peer card: path type + RTT ("Direct · 12 ms").
fn conn_badge_label(info: &crate::core::connstats::PeerConnInfo) -> String {
    let kind = if info.relay { "Relay" } else { "Direct" };
    format!("{kind} · {} ms", info.rtt_ms)
}

/// First tooltip line: path type + remote address ("Direct (1.2.3.4:5)" /
/// "Relay (https://relay.example./)").
fn conn_tooltip_path(info: &crate::core::connstats::PeerConnInfo) -> String {
    let kind = if info.relay { "Relay" } else { "Direct" };
    format!("{kind} ({})", info.remote_addr)
}

/// Loss line for the tooltip. `None` (first poll / idle window) reads as clean.
fn conn_loss_label(loss_pct: Option<f32>) -> String {
    match loss_pct {
        Some(pct) => format!("Loss {:.1}% (last second)", pct.clamp(0.0, 100.0)),
        None => "Loss — (last second)".to_string(),
    }
}

/// One direction of the bitrate line ("32 kbps", "1.5 Mbps", or "—" until a
/// full poll window has elapsed on the current path).
fn conn_rate_label(kbps: Option<f32>) -> String {
    match kbps {
        None => "—".to_string(),
        Some(k) if k >= 1000.0 => format!("{:.1} Mbps", k / 1000.0),
        Some(k) => format!("{k:.0} kbps"),
    }
}

fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
    match message {
        AppMessage::NicknameChanged(val) => {
            state.name = val;
        }
        AppMessage::TicketInputChanged(val) => {
            state.ticket_input = val;
        }
        AppMessage::RoomNameChanged(val) => {
            state.room_name_input = val;
        }
        AppMessage::JoinPressed => {
            let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
            let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
            if !state.ticket_input.is_empty() {
                state.status_message = "Joining room...".to_string();
                // Clear any prior room's UI state now, at initiation, so an early
                // `PeerJoined` for the new room (which can beat `RoomJoined`) isn't
                // wiped. From Home this is a no-op (already cleared on leave).
                state.reset_room_state();
                // Remember this nickname for next launch.
                state.config.username = state.name.clone();
                state.config.save();
                // Core releases any standalone mic monitor on join.
                state.mic_test_active = false;
                let _ = state.controller.send(CoreCommand::Join {
                    name: state.name.clone(),
                    ticket: state.ticket_input.clone(),
                    room_name: String::new(), // joining: the label comes from the ticket
                    input_device,
                    output_device,
                    echo_cancellation: state.config.echo_cancellation_enabled,
                    avatar: state.config.avatar.clone(),
                });
            }
        }
        AppMessage::CreatePressed => {
            let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
            let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
            state.status_message = "Creating room...".to_string();
            // Clear any prior room's UI state at initiation (see JoinPressed).
            state.reset_room_state();
            // Remember this nickname for next launch.
            state.config.username = state.name.clone();
            state.config.save();
            // Core releases any standalone mic monitor on join.
            state.mic_test_active = false;
            let _ = state.controller.send(CoreCommand::Join {
                name: state.name.clone(),
                ticket: "create".to_string(),
                room_name: state.room_name_input.clone(),
                input_device,
                output_device,
                echo_cancellation: state.config.echo_cancellation_enabled,
                avatar: state.config.avatar.clone(),
            });
        }
        AppMessage::LeavePressed => {
            let _ = state.controller.send(CoreCommand::Leave);
        }
        AppMessage::ToggleScreenShare => {
            if state.self_sharing {
                let _ = state.controller.send(CoreCommand::StopScreenShare);
            } else if !state.share_starting {
                // Open the audio picker instead of sharing immediately, so the
                // user chooses which app's audio to capture rather than the whole
                // desktop (which echoes the call back to viewers, A23). Default
                // selection is "All system audio" (None). Kick off a fresh
                // enumeration so the list reflects what's playing right now.
                // Suppressed while a start is already in flight (`share_starting`)
                // so the picker can't be reopened during the startup window.
                state.share_picker_open = true;
                state.share_audio_selection = None;
                // NB: do NOT reset `share_quality_selection` here. It is the
                // per-call override set by the inline quality dropdown next to
                // the Share button, and the picker has no quality control of its
                // own — resetting it would silently discard the user's pick
                // before `ConfirmShareScreen` reads it.
                state.share_audio_apps.clear();
                let _ = state.controller.send(CoreCommand::ListAudioApps);
            }
        }
        AppMessage::CloseSharePicker => {
            state.share_picker_open = false;
        }
        AppMessage::SelectShareAudioApp(app) => {
            state.share_audio_selection = app;
        }
        AppMessage::SelectShareQualityOverride(quality) => {
            state.share_quality_selection = quality;
        }
        AppMessage::ConfirmShareScreen => {
            // Only a confirm from an open picker starts a share; a stray confirm
            // (or one arriving while a start is already in flight) is ignored, so
            // we can't double-send StartScreenShare.
            if state.share_picker_open && !state.share_starting {
                state.share_picker_open = false;
                state.share_starting = true;
                let audio_app = state.share_audio_selection.clone();
                let settings = state.config.screen_share.clone();
                let quality = state.share_quality_selection;
                let _ = state.controller.send(CoreCommand::StartScreenShare {
                    audio_app,
                    settings,
                    quality,
                });
                state.status_message = "Starting screen share…".to_string();
            }
        }
        AppMessage::WatchShare(ticket) => {
            let settings = state.config.screen_share.clone();
            let _ = state
                .controller
                .send(CoreCommand::ViewShare { ticket, settings });
            state.status_message = "Opening screen share…".to_string();
        }
        AppMessage::ToggleMutePressed => {
            toggle_mute(state);
        }
        AppMessage::ToggleDeafenPressed => {
            toggle_deafen(state);
        }
        AppMessage::UiEventReceived(event) => {
            match event {
                UiEvent::RoomJoined { ticket, self_id } => {
                    // NB: do NOT clear peers here. `PeerJoined` rides a separate
                    // channel sender (the gossip event task) and routinely arrives
                    // BEFORE this `RoomJoined` (which the core emits only after audio
                    // + echo-cancel setup), so clearing here would wipe a peer that
                    // already announced → an empty roster. Room-scoped state is reset
                    // at join *initiation* instead (see the Join* handlers).
                    // Remember this gathering for one-click rejoin (W7 P5). The
                    // emitted ticket is the canonical room door (topic + member
                    // addr + label); push_recent de-dupes by topic and persists.
                    let label = crate::network::PeerSpeakTicket::label_of(&ticket);
                    let now = std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)
                        .map(|d| d.as_secs())
                        .unwrap_or(0);
                    crate::recents::push_recent(
                        &mut state.config.recents,
                        label,
                        ticket.clone(),
                        now,
                    );
                    state.config.save();
                    state.ticket = ticket;
                    state.self_id = self_id;
                    state.status_message = "Connected".to_string();
                    state.current_screen = Screen::Room;
                    state.call_started = Some(std::time::Instant::now());
                    // The core tore down any standalone mic monitor when joining;
                    // the in-call meter now drives mic_level.
                    state.mic_test_active = false;
                    notify::play(
                        Sound::SelfJoin,
                        state.config.custom_sound_self_join.as_deref(),
                    );
                }
                UiEvent::RoomLeft => {
                    state.reset_room_state();
                    state.ticket = "".to_string();
                    state.status_message = "Ready to connect".to_string();
                    state.current_screen = Screen::Home;
                    notify::play(
                        Sound::SelfLeave,
                        state.config.custom_sound_self_leave.as_deref(),
                    );
                }
                UiEvent::RoomReset => {
                    state.reset_room_state();
                    state.ticket = "".to_string();
                    state.status_message = "Ready to connect".to_string();
                    state.current_screen = Screen::Home;
                }
                UiEvent::PeerJoined {
                    id,
                    state: peer_state,
                } => {
                    state.peers.insert(id, peer_state);
                    notify::play(
                        Sound::PeerJoin,
                        state.config.custom_sound_peer_join.as_deref(),
                    );
                }
                UiEvent::PeerLeft { id } => {
                    state.peers.remove(&id);
                    state.audio_levels.remove(&id);
                    state.locally_muted.remove(&id);
                    state.peer_audio_expanded.remove(&id);
                    state.connecting.remove(&id);
                    state.ever_connected.remove(&id);
                    if state.music_listening_to == Some(id) {
                        state.music_listening_to = None;
                        state.music_player.stop();
                        state.music_listen_loaded = None;
                        state.music_listen_inflight = None;
                    }
                    notify::play(
                        Sound::PeerLeave,
                        state.config.custom_sound_peer_leave.as_deref(),
                    );
                }
                // Core-only recovery phase: presentation for this state lands in
                // the separate UI follow-up. In particular, do not play the
                // terminal ReconnectFailed chime here.
                UiEvent::PeerRecoveryStarted { .. } => {}
                UiEvent::PeerConnectionFailed { id } => {
                    state.peers.remove(&id);
                    state.audio_levels.remove(&id);
                    state.locally_muted.remove(&id);
                    state.peer_audio_expanded.remove(&id);
                    state.connecting.remove(&id);
                    state.ever_connected.remove(&id);
                    notify::play(
                        Sound::ReconnectFailed,
                        state.config.custom_sound_reconnect_failed.as_deref(),
                    );
                }
                UiEvent::PeerUpdated {
                    id,
                    state: peer_state,
                } => {
                    state.peers.insert(id, peer_state);
                    if state.music_listening_to == Some(id) {
                        reconcile_listen(state);
                    }
                }
                UiEvent::PeerConnecting { id } => {
                    if let Some(sound) =
                        reconnect_attempt_chime(&mut state.connecting, &state.ever_connected, id)
                    {
                        notify::play(
                            sound,
                            state.config.custom_sound_reconnect_attempt.as_deref(),
                        );
                    }
                }
                UiEvent::PeerConnected { id } => {
                    if let Some(sound) =
                        reconnected_chime(&mut state.connecting, &mut state.ever_connected, id)
                    {
                        notify::play(sound, state.config.custom_sound_reconnected.as_deref());
                    }
                }
                UiEvent::AudioLevels(levels) => {
                    for (id, val) in levels {
                        state.audio_levels.insert(id, val);
                    }
                }
                UiEvent::ConnectionStats(infos) => {
                    // Full replacement: a peer missing from this round has no
                    // live link, so its (stale) badge must go away too.
                    state.conn_stats = infos.into_iter().collect();
                }
                UiEvent::MicLevel(level) => {
                    state.mic_level = level;
                }
                UiEvent::RecordingStarted { path } => {
                    state.recording = true;
                    state.recording_started = Some(std::time::Instant::now());
                    state.status_message = format!("Recording → {path}");
                }
                UiEvent::RecordingStopped { path } => {
                    state.recording = false;
                    state.recording_started = None;
                    state.status_message = format!("Saved recording → {path}");
                }
                UiEvent::ChatSendResult { local_id, error } => {
                    // Land the honest outcome on the matching local echo (Phase
                    // 5). `error = None` = our signed frame reached the swarm,
                    // NOT a delivery receipt. On success the payload is done; on
                    // failure it's retained for Retry — unless the entry is gone
                    // (history eviction / room reset), in which case drop it so
                    // the payload map can't leak.
                    if state.apply_send_result(local_id, error) {
                        notify::play(
                            Sound::ChatSent,
                            state.config.custom_sound_chat_sent.as_deref(),
                        );
                    }
                }
                UiEvent::ChatMessage {
                    from,
                    name,
                    text,
                    attachment,
                } => {
                    // Incoming peer content is untrusted — sanitize name + text.
                    // Both were already sanitized at gossip ingress; this UI-edge
                    // pass is defense in depth (both sanitizers are idempotent).
                    // (The attachment filename was already sanitized in core.)
                    let text = crate::sanitize::sanitize_chat(&text);
                    // Keep the message if it has visible text OR an attachment (an
                    // image with no caption is still a real message).
                    if !text.is_empty() || attachment.is_some() {
                        // The author label arrives ROSTER-BOUND from the core
                        // (chat-hardening Phase 2) — the wire name never reaches
                        // here. The strict name sanitizer is idempotent defense
                        // in depth, like the body pass above.
                        let name = crate::sanitize::sanitize_name(&name);
                        push_chat(
                            &mut state.chat_messages,
                            ChatEntry {
                                name,
                                text,
                                mine: false,
                                from: Some(from),
                                attachment,
                                links: Vec::new(),
                                local_send: None,
                            },
                        );
                        notify::play(
                            Sound::ChatReceived,
                            state.config.custom_sound_chat_received.as_deref(),
                        );
                    }
                }
                UiEvent::AttachmentReady { from, id, data } => {
                    // Bytes arrived for this specific (author, id). For images
                    // the ≤1600px preview handle is built once here (not per
                    // redraw); only its downscale ever reaches the renderer.
                    let key = (from, id);
                    let handle = build_preview_handle(&data);
                    let needs_save = state.pending_saves.remove(&key);
                    let needs_play = state.pending_plays.remove(&id);
                    let retained =
                        state
                            .attachments
                            .insert(key, AttachmentState::Ready(data.clone()), handle);
                    if !retained {
                        // Individually over-budget: any immediate save/play is
                        // serviced from the bytes in hand below, and the entry
                        // is exposed as evicted, never exceeding the budgets.
                        state
                            .attachments
                            .insert(key, AttachmentState::Evicted, None);
                    }
                    if needs_play {
                        play_audio_data(state, id, &data);
                    }
                    if needs_save {
                        return save_attachment_data_task(state, key, data);
                    }
                }
                UiEvent::AttachmentFetchStarted { from, id } => {
                    // A fetch task exists for this key; show a real loading
                    // state (and dedup further clicks). Never demote Ready.
                    let key = (from, id);
                    if !state.attachments.is_ready(&key) {
                        state
                            .attachments
                            .insert(key, AttachmentState::Loading, None);
                    }
                }
                UiEvent::AttachmentFailed { from, id, error } => {
                    let key = (from, id);
                    state.pending_saves.remove(&key);
                    state.pending_plays.remove(&id);
                    state
                        .attachments
                        .insert(key, AttachmentState::Failed(error.clone()), None);
                    state.status_message = format!("Attachment failed: {error}");
                }
                UiEvent::MusicReady { from, id, data } => {
                    if state.music_listening_to == Some(from)
                        && state.music_listen_inflight == Some(id)
                    {
                        state.music_listen_inflight = None;
                        if crate::files::is_probably_audio(&data) {
                            state.music_player.play(id, data);
                            state.music_player.set_volume(effective_music_volume(state));
                            state.music_listen_loaded = Some(id);
                            reconcile_listen(state);
                        } else {
                            state.music_player.stop();
                            state.music_listen_loaded = None;
                            state.status_message =
                                "Shared track is not valid supported audio.".to_string();
                        }
                    }
                }
                UiEvent::MusicPrefetched { from, id, data } => {
                    if state.music_listening_to == Some(from)
                        && state.music_prefetch_inflight == Some(id)
                    {
                        state.music_prefetch_inflight = None;
                        if crate::files::is_probably_audio(&data) {
                            state.music_prefetch = Some((id, data));
                        }
                    }
                }
                UiEvent::MusicFetchFailed { from, id, error } => {
                    if state.music_listening_to == Some(from)
                        && state.music_listen_inflight == Some(id)
                    {
                        state.music_listen_inflight = None;
                        state.status_message = format!("Music fetch failed: {error}");
                    } else if state.music_listening_to == Some(from)
                        && state.music_prefetch_inflight == Some(id)
                    {
                        state.music_prefetch_inflight = None;
                    }
                }
                UiEvent::AudioAppsListed {
                    apps,
                    app_audio_supported,
                } => {
                    // Only meaningful while the picker is open; if the user
                    // already cancelled, drop it.
                    if state.share_picker_open {
                        state.share_app_audio_supported = app_audio_supported;
                        if app_audio_supported {
                            // Keep the current selection if it still exists in the
                            // refreshed list, else fall back to "All system audio".
                            if let Some(sel) = &state.share_audio_selection
                                && !apps.iter().any(|a| a == sel)
                            {
                                state.share_audio_selection = None;
                            }
                            state.share_audio_apps = apps;
                        } else {
                            // Older pixelpass: per-app capture would hard-fail
                            // (--strict-audio unknown). Force whole-desktop only.
                            state.share_audio_apps.clear();
                            state.share_audio_selection = None;
                        }
                    }
                }
                UiEvent::ScreenShareStarted => {
                    state.self_sharing = true;
                    state.share_starting = false;
                    state.share_audio_dropped = false;
                    // Remember whether this share captures a specific app, so we
                    // only apply `app_audio` warnings to app shares (P3).
                    state.share_audio_app_active = state.share_audio_selection.is_some();
                    // Defensive: ensure no picker lingers across a successful start.
                    state.share_picker_open = false;
                    state.status_message = "Sharing your screen".to_string();
                }
                UiEvent::ScreenShareStopped => {
                    state.self_sharing = false;
                    state.share_starting = false;
                    state.share_audio_dropped = false;
                    state.share_audio_app_active = false;
                    state.status_message = "Screen share stopped".to_string();
                }
                UiEvent::ShareAudioActive(active) => {
                    // Per-app audio routed/lost. Apply only while we're actually
                    // sharing a specific app: a late event from a just-killed host
                    // must not flip the warning after stop or on a whole-desktop
                    // share (audit P3, unscoped events).
                    if state.self_sharing && state.share_audio_app_active {
                        state.share_audio_dropped = !active;
                    }
                }
                UiEvent::ClockSkewWarning {
                    skew_secs,
                    peer_ahead,
                } => {
                    show_clock_skew_warning(
                        state,
                        skew_secs,
                        peer_ahead,
                        std::time::Instant::now(),
                    );
                }
                UiEvent::IdentityStatus {
                    node_id,
                    persisted,
                    error,
                } => {
                    state.self_node_id = Some(node_id);
                    state.identity_persisted = persisted;
                    state.identity_error = error;
                }
                UiEvent::FriendsUpdated { friends, read_only } => {
                    // Core owns the store; mirror its snapshot. Drop presence for
                    // anyone no longer a friend so the UI doesn't show a stale dot.
                    let ids: HashSet<EndpointId> = friends.iter().map(|f| f.id).collect();
                    state.friend_presence.retain(|id, _| ids.contains(id));
                    state.friends.friends = friends;
                    state.friends_read_only = read_only;
                }
                UiEvent::FriendPresence { id, presence } => {
                    let sound = friend_presence_notification(
                        state.current_screen,
                        state.friend_presence.get(&id),
                        &presence,
                    );
                    state.friend_presence.insert(id, presence);
                    if let Some(sound) = sound {
                        notify::play(sound, Some(state.custom_sound_path(sound)));
                    }
                }
                UiEvent::FriendsRescanned => {
                    // The manual pass finished. Stamp the time for the live "scanned
                    // Nm ago" indicator, and replace the transient "Rescanning…"
                    // banner — but only if it's still showing (don't clobber a status
                    // the user has since triggered, e.g. by joining a room).
                    state.last_rescan = Some(std::time::Instant::now());
                    if state.status_message == "Rescanning friends…" {
                        state.status_message = "Friends rescanned.".to_string();
                    }
                }
                UiEvent::PresenceModeReverted { mode } => {
                    // Core corrected the committed presence mode. Mirror + persist so
                    // the picker reflects the discovery state the endpoint actually has.
                    state.config.presence_mode = mode;
                    state.config.save();
                    state.status_message = if mode == PresenceMode::Normal {
                        "Discoverable timed out — back to Normal".to_string()
                    } else {
                        format!("Presence mode stayed {mode}")
                    };
                }
                UiEvent::GameChanged(detected) => {
                    // The locally-detected game changed: switch the per-game
                    // background (W18) if one is mapped, else fall back to the
                    // default. Presence broadcasting is handled in core, gated by
                    // the opt-in toggle; this is purely local presentation.
                    state.current_game = detected;
                    state.background_image =
                        effective_background_bytes(&state.config, state.current_game.as_ref());
                }
                UiEvent::ShutdownComplete => {
                    if state.closing {
                        return iced::exit();
                    }
                }
                UiEvent::Error(err) => {
                    // A failed share start (spawn error) surfaces here, not via
                    // ScreenShareStopped, so clear the in-flight flag to let the
                    // user retry instead of being wedged.
                    state.share_starting = false;
                    state.status_message = format!("Error: {}", err);
                }
            }
        }
        AppMessage::CopyToClipboard => {
            if !state.ticket.is_empty() {
                return iced::clipboard::write(state.ticket.clone());
            }
        }
        AppMessage::CopyText(s) => {
            return iced::clipboard::write(s);
        }
        AppMessage::Noop => {}
        AppMessage::TogglePtt(enabled) => {
            state.ptt_enabled = enabled;
            let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
        }
        AppMessage::StartHotkeyCapture(action) => {
            state.hotkey_capture = Some(action);
            state.hotkey_info_open = false;
        }
        AppMessage::ClearHotkey(action) => {
            state.config.hotkeys.set_binding(action, None);
            state.config.save();
        }
        AppMessage::PeerVolumeChanged(id, vol) => {
            let vol = set_peer_volume_config(&mut state.config, id, vol);
            let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
        }
        AppMessage::PeerPanChanged(id, pan) => {
            let pan = set_peer_pan_config(&mut state.config, id, pan);
            let _ = state.controller.send(CoreCommand::SetPeerPan(id, pan));
        }
        AppMessage::PeerGateChanged(id, threshold) => {
            let threshold = set_peer_gate_config(&mut state.config, id, threshold);
            let _ = state
                .controller
                .send(CoreCommand::SetPeerGate(id, threshold));
        }
        AppMessage::PeerEqChanged(id, band, gain_db) => {
            let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
            let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
        }
        AppMessage::TogglePeerAdvancedAudio(id) => {
            if !state.peer_audio_expanded.remove(&id) && state.peers.contains_key(&id) {
                state.peer_audio_expanded.insert(id);
            }
        }
        AppMessage::TogglePeerMute(id) => {
            let now_muted = if state.locally_muted.contains(&id) {
                state.locally_muted.remove(&id);
                false
            } else {
                state.locally_muted.insert(id);
                true
            };
            let _ = state
                .controller
                .send(CoreCommand::SetPeerMuted(id, now_muted));
        }
        AppMessage::InputDeviceSelected(dev) => {
            state.config.input_device = dev.name.clone();
            state.config.save();
            state.selected_input = Some(dev);
        }
        AppMessage::OutputDeviceSelected(dev) => {
            state.config.output_device = dev.name.clone();
            state.config.save();
            state.selected_output = Some(dev);
        }
        AppMessage::InputVolumeChanged(vol) => {
            // Live apply; disk write deferred to release (PersistConfig).
            state.config.input_volume = vol;
            let _ = state.controller.send(CoreCommand::SetInputVolume(vol));
        }
        AppMessage::OutputVolumeChanged(vol) => {
            state.config.output_volume = vol;
            let _ = state.controller.send(CoreCommand::SetOutputVolume(vol));
        }
        AppMessage::PersistConfig => {
            state.config.save();
        }
        AppMessage::NoiseGateChanged(val) => {
            state.config.noise_gate_threshold = val;
            state.config.save();
            let _ = state
                .controller
                .send(CoreCommand::SetNoiseGateThreshold(val));
        }
        AppMessage::NoiseGateDragging(val) => {
            // Live drag: apply immediately, defer the disk write to release.
            state.config.noise_gate_threshold = val;
            let _ = state
                .controller
                .send(CoreCommand::SetNoiseGateThreshold(val));
        }
        AppMessage::NetworkModeSelected(mode) => {
            state.config.network_mode = mode;
            state.config.save();
            // Applied on the next join, since the endpoint is rebuilt then.
            let _ = state.controller.send(CoreCommand::SetNetworkMode(mode));
        }
        AppMessage::AudioProfileSelected(profile) => {
            state.config.audio_profile = profile;
            state.config.save();
            // Applies live to the running encoder, and to the next call.
            let _ = state.controller.send(CoreCommand::SetAudioProfile(profile));
        }
        AppMessage::RecordingModeSelected(mode) => {
            state.config.recording_mode = mode;
            state.config.save();
            // Takes effect on the next recording start.
            let _ = state.controller.send(CoreCommand::SetRecordingMode(mode));
        }
        AppMessage::ScreenShareQualitySelected(quality) => {
            state.config.screen_share.quality = quality;
            state.share_quality_selection = quality;
            state.config.save();
        }
        AppMessage::ScreenSharePlayerSelected(player) => {
            state.config.screen_share.player = player;
            state.config.save();
        }
        AppMessage::ScreenShareBufferingSelected(buffering) => {
            state.config.screen_share.buffering = buffering;
            state.config.save();
        }
        AppMessage::ScreenShareMaxHeightSelected(choice) => {
            state.config.screen_share.max_height = choice.to_config();
            state.config.save();
        }
        AppMessage::ScreenShareFramerateSelected(choice) => {
            state.config.screen_share.framerate = choice.to_config();
            state.config.save();
        }
        AppMessage::ScreenShareMaxViewersSelected(choice) => {
            state.config.screen_share.max_viewers = choice.to_config();
            state.config.save();
        }
        AppMessage::ScreenShareCacheMbSelected(cache_mb) => {
            state.config.screen_share.cache_mb = cache_mb;
            state.config.save();
        }
        AppMessage::ScreenShareBitrateChanged(value) => {
            let trimmed = value.trim();
            if trimmed.is_empty() {
                state.config.screen_share.bitrate_mbps = None;
                state.config.save();
            } else if let Ok(mbps) = trimmed.parse::<u32>() {
                state.config.screen_share.bitrate_mbps = Some(mbps);
                state.config.save();
            }
        }
        AppMessage::ToggleScreenShareForceSoftwareEncode(enabled) => {
            state.config.screen_share.force_software_encode = enabled;
            state.config.save();
        }
        AppMessage::ToggleScreenShareHardwareDecode(enabled) => {
            state.config.screen_share.hardware_decode = enabled;
            state.config.save();
        }
        AppMessage::ScreenShareExtraMpvArgsChanged(args) => {
            state.config.screen_share.extra_mpv_args = args;
            state.config.save();
        }
        AppMessage::ScreenShareExtraHostArgsChanged(args) => {
            state.config.screen_share.extra_host_args = args;
            state.config.save();
        }
        AppMessage::PresenceModeSelected(mode) => {
            state.config.presence_mode = mode;
            state.config.save();
            // Push to core, which gates the live listener + ping scheduler (B2).
            let _ = state.controller.send(CoreCommand::SetPresenceMode(mode));
        }
        AppMessage::FriendAddIdChanged(val) => {
            state.friend_add_id = val;
            state.friend_add_error = None;
        }
        AppMessage::FriendAddNameChanged(val) => {
            state.friend_add_name = val;
            state.friend_add_error = None;
        }
        AppMessage::AddFriend => {
            // Core owns the store; the GUI just validates the id locally, then sends
            // a command. The mirror (and the cleared form) update on FriendsUpdated.
            if state.friends_read_only {
                state.friend_add_error =
                    Some("Friends list is read-only (couldn't load friends.json).".to_string());
            } else {
                let id_str = state.friend_add_id.trim();
                match id_str.parse::<EndpointId>() {
                    Ok(id) if state.friends.contains(&id) => {
                        state.friend_add_error = Some("Already in your friends list.".to_string());
                    }
                    Ok(id) => {
                        let name = {
                            let n = state.friend_add_name.trim();
                            if n.is_empty() {
                                short_id(id_str)
                            } else {
                                n.to_string()
                            }
                        };
                        let _ = state.controller.send(CoreCommand::AddFriend {
                            id,
                            name,
                            addr: None,
                        });
                        state.friend_add_id.clear();
                        state.friend_add_name.clear();
                        state.friend_add_error = None;
                    }
                    Err(_) => {
                        state.friend_add_error =
                            Some("That doesn't look like a valid node ID.".to_string());
                    }
                }
            }
        }
        AppMessage::AddFriendFromRoom(id) => {
            // Add a co-participant: pull their name + live address from the room
            // roster so the friend is reachable right away (no wait for a future
            // call to seed last_addr, unlike a bare add-by-id). No-op if read-only
            // or already a friend; idempotent in core regardless.
            if !state.friends_read_only
                && !state.friends.contains(&id)
                && let Some(peer) = state.peers.get(&id)
            {
                let name = {
                    let n = crate::sanitize::sanitize_name(&peer.name);
                    if n.is_empty() {
                        short_id(&id.to_string())
                    } else {
                        n
                    }
                };
                let _ = state.controller.send(CoreCommand::AddFriend {
                    id,
                    name,
                    addr: Some(peer.addr.clone()),
                });
            }
        }
        AppMessage::RemoveFriend(id) => {
            if !state.friends_read_only {
                let _ = state.controller.send(CoreCommand::RemoveFriend(id));
            }
        }
        AppMessage::RenameFriend(id, new_name) => {
            if !state.friends_read_only {
                let _ = state
                    .controller
                    .send(CoreCommand::RenameFriend(id, new_name));
            }
        }
        AppMessage::RefreshFriends => {
            // Kick an immediate presence pass; the list updates as replies land and
            // the banner clears on the FriendsRescanned completion event. While
            // Invisible we probe no one, so say why instead of a banner that resolves
            // with nothing changed.
            if state.config.presence_mode == PresenceMode::Invisible {
                state.status_message =
                    "You're invisible — turn on presence to scan friends.".to_string();
            } else {
                let _ = state.controller.send(CoreCommand::RefreshFriends);
                state.status_message = "Rescanning friends…".to_string();
            }
        }
        AppMessage::JoinFriendRoom(ticket) => {
            // Join via the friend's member ticket (same path as a manual join).
            let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
            let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
            state.status_message = "Joining your friend's room...".to_string();
            // Clear any prior room's UI state at initiation (see JoinPressed).
            state.reset_room_state();
            state.config.username = state.name.clone();
            state.config.save();
            state.mic_test_active = false;
            let _ = state.controller.send(CoreCommand::Join {
                name: state.name.clone(),
                ticket,
                room_name: String::new(), // joining: the label comes from the ticket
                input_device,
                output_device,
                echo_cancellation: state.config.echo_cancellation_enabled,
                avatar: state.config.avatar.clone(),
            });
        }
        AppMessage::JoinRecent(ticket) => {
            // Rejoin a remembered room (same path as a manual ticket join). It's
            // best-effort: the door only admits us while the room is still live.
            let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
            let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
            state.status_message = "Rejoining a recent room...".to_string();
            // Clear any prior room's UI state at initiation (see JoinPressed).
            state.reset_room_state();
            state.config.username = state.name.clone();
            state.config.save();
            state.mic_test_active = false;
            let _ = state.controller.send(CoreCommand::Join {
                name: state.name.clone(),
                ticket,
                room_name: String::new(), // rejoining: the label comes from the ticket
                input_device,
                output_device,
                echo_cancellation: state.config.echo_cancellation_enabled,
                avatar: state.config.avatar.clone(),
            });
        }
        AppMessage::RemoveRecent(ticket) => {
            crate::recents::remove_recent(&mut state.config.recents, &ticket);
            state.config.save();
        }
        AppMessage::SelectSettingsCategory(category) => {
            state.settings_category = category;
        }
        AppMessage::ToggleNotifications(enabled) => {
            state.config.notifications_enabled = enabled;
            state.config.save();
            notify::set_enabled(enabled);
        }
        AppMessage::ToggleEchoCancellation(enabled) => {
            state.config.echo_cancellation_enabled = enabled;
            state.config.save();
            // Applied on the next join, since the audio graph is rebuilt then.
        }
        AppMessage::CustomSoundPathChanged(sound, path) => {
            let path_opt = if path.trim().is_empty() {
                None
            } else {
                Some(path)
            };
            state.set_custom_sound_path(sound, path_opt);
        }
        AppMessage::BrowseCustomSound(sound) => {
            let initial_dir = {
                let current = state.custom_sound_path(sound);
                (!current.trim().is_empty())
                    .then(|| notify::expand_tilde(current))
                    .and_then(|path| path.parent().map(std::path::Path::to_path_buf))
                    .filter(|path| path.is_dir())
            };
            return Task::perform(
                async move {
                    let mut dialog = rfd::AsyncFileDialog::new()
                        .add_filter("WAV audio", &["wav"])
                        .set_title("Choose a notification sound");
                    if let Some(dir) = initial_dir {
                        dialog = dialog.set_directory(dir);
                    }
                    dialog
                        .pick_file()
                        .await
                        .map(|handle| handle.path().to_path_buf())
                },
                move |picked| AppMessage::CustomSoundFilePicked(sound, picked),
            );
        }
        AppMessage::CustomSoundFilePicked(sound, picked) => {
            if let Some(path) = selected_wav_path(picked) {
                state.set_custom_sound_path(sound, Some(path));
                state.config.save();
            }
        }
        AppMessage::ToggleSoundEnabled(sound, enabled) => {
            state.config.set_sound_enabled(sound, enabled);
            state.config.save();
            notify::set_sound_enabled(sound, enabled);
        }
        AppMessage::OpenRegenerateIdentityConfirm => {
            state.regenerate_identity_confirm_open = true;
        }
        AppMessage::CloseRegenerateIdentityConfirm => {
            state.regenerate_identity_confirm_open = false;
        }
        AppMessage::OpenImageLightbox(key) => {
            state.image_lightbox = Some(key);
        }
        AppMessage::CloseImageLightbox => {
            state.image_lightbox = None;
        }
        AppMessage::ConfirmRegenerateIdentity => {
            state.regenerate_identity_confirm_open = false;
            // The core mints + persists the new key and replies with a fresh
            // IdentityStatus (which updates self_node_id / persisted here).
            let _ = state.controller.send(CoreCommand::RegenerateIdentity);
        }
        AppMessage::ToggleRecording => {
            // Optimistic intent; the core flips `recording` for real via the
            // Recording{Started,Stopped} events (so a failed start won't lie).
            let _ = state
                .controller
                .send(CoreCommand::SetRecording(!state.recording));
        }
        AppMessage::ChatInputChanged(val) => {
            // Cap (never normalize) while editing, so an oversized paste can't
            // grow the retained input past the chat ceilings but the visible
            // text doesn't jump under the user's cursor.
            state.chat_input = crate::sanitize::cap_chat_input(val);
        }
        AppMessage::DividerDragged(kind, delta) => {
            // Apply live; the final position is persisted on drag release (the
            // divider publishes PersistConfig then) to avoid per-pixel disk writes.
            match kind {
                DividerKind::Panels => {
                    state.config.participants_width = clamp_participants_width(
                        state.config.participants_width + delta,
                        state.window_size.width,
                    );
                }
                DividerKind::Chat => {
                    // Dragging the divider down (positive delta) gives the main row
                    // more room and shrinks the dock below it, so subtract.
                    state.config.chat_height = clamp_chat_height(
                        state.config.chat_height - delta,
                        state.window_size.height,
                    );
                }
                DividerKind::Controls => {
                    // Controls sits on the right; dragging the divider right (positive
                    // delta) gives Chat more room and shrinks Controls.
                    state.config.controls_width = clamp_controls_width(
                        state.config.controls_width - delta,
                        state.window_size.width,
                    );
                }
                DividerKind::ChatDrawer => {
                    // The drawer sits on the right; dragging its left-edge divider
                    // left (negative delta) widens the drawer.
                    state.config.chat_drawer_width = clamp_chat_drawer_width(
                        state.config.chat_drawer_width - delta,
                        state.window_size.width,
                    );
                }
                DividerKind::PlaylistDrawer => {
                    // The drawer sits on the right; dragging its left-edge divider
                    // left (negative delta) widens the drawer.
                    state.config.playlist_drawer_width = clamp_playlist_drawer_width(
                        state.config.playlist_drawer_width - delta,
                        state.window_size.width,
                    );
                }
            }
        }
        AppMessage::OpenLayoutPicker => {
            state.layout_picker_open = true;
            state.hotkey_info_open = false;
        }
        AppMessage::CloseLayoutPicker => {
            state.layout_picker_open = false;
        }
        AppMessage::OpenHotkeyInfo => {
            state.hotkey_info_open = true;
            state.layout_picker_open = false;
        }
        AppMessage::CloseHotkeyInfo => {
            state.hotkey_info_open = false;
        }
        AppMessage::OpenPixelpassHelp => {
            state.pixelpass_help_open = true;
        }
        AppMessage::ClosePixelpassHelp => {
            state.pixelpass_help_open = false;
        }
        AppMessage::DismissClockSkewWarning => {
            state.clock_skew_warning = None;
        }
        AppMessage::ClockSkewWarningTick => {
            clear_expired_clock_skew_warning(state, std::time::Instant::now());
        }
        AppMessage::RescanLabelTick => {
            // No state change; the tick exists purely to re-render the relative
            // "scanned Nm ago" label as time passes.
        }
        AppMessage::SelectRoomLayout(layout) => {
            state.config.room_layout = layout;
            state.config.save();
            state.layout_picker_open = false;
        }
        AppMessage::SelectTheme(theme) => {
            state.config.theme = theme;
            state.config.save();
        }
        AppMessage::SelectAvatar(avatar) => {
            state.config.avatar = avatar.clone();
            state.config.save();
            // Re-announce to the room if we're in a call (no-op otherwise).
            let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
        }
        AppMessage::PickAvatarFile => {
            // Open the native picker off the UI thread; the result comes back as
            // AvatarFilePicked. Filter to the formats we can actually decode.
            return Task::perform(
                async {
                    let handle = rfd::AsyncFileDialog::new()
                        .add_filter("Images", &["png", "jpg", "jpeg"])
                        .set_title("Choose an avatar image")
                        .pick_file()
                        .await;
                    match handle {
                        Some(h) => Some(h.read().await),
                        None => None,
                    }
                },
                AppMessage::AvatarFilePicked,
            );
        }
        AppMessage::AvatarFilePicked(picked) => {
            if let Some(bytes) = picked {
                match crate::avatar::process_upload(&bytes) {
                    Ok(avatar) => {
                        state.config.avatar = avatar.clone();
                        state.config.save();
                        state.status_message = "Avatar updated.".to_string();
                        let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
                    }
                    Err(e) => {
                        state.status_message = e;
                    }
                }
            }
        }
        AppMessage::PickBackgroundFile => {
            // Native picker off the UI thread; result returns as BackgroundFilePicked.
            return Task::perform(
                async {
                    let handle = rfd::AsyncFileDialog::new()
                        .add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
                        .set_title("Choose a background image")
                        .pick_file()
                        .await;
                    match handle {
                        Some(h) => Some(h.read().await),
                        None => None,
                    }
                },
                AppMessage::BackgroundFilePicked,
            );
        }
        AppMessage::BackgroundFilePicked(picked) => {
            if let Some(bytes) = picked {
                match crate::background::process_background(&bytes) {
                    Ok(png) => match AppConfig::background_path() {
                        Some(path) => {
                            if let Some(dir) = path.parent() {
                                let _ = std::fs::create_dir_all(dir);
                            }
                            match std::fs::write(&path, &png) {
                                Ok(()) => {
                                    state.config.background =
                                        Some(path.to_string_lossy().into_owned());
                                    state.config.save();
                                    // Refresh the in-memory cache from the bytes we
                                    // just wrote (avoids re-reading from disk).
                                    state.background_image = Some(bytes::Bytes::from(png));
                                    state.status_message = "Background updated.".to_string();
                                }
                                Err(e) => {
                                    state.status_message = format!("Couldn't save background: {e}");
                                }
                            }
                        }
                        None => {
                            state.status_message =
                                "Couldn't find a config directory to save the background."
                                    .to_string();
                        }
                    },
                    Err(e) => {
                        state.status_message = e;
                    }
                }
            }
        }
        AppMessage::RemoveBackground => {
            // Best-effort delete of our stored copy; clear the config + cache.
            if let Some(path) = AppConfig::background_path() {
                let _ = std::fs::remove_file(path);
            }
            state.config.background = None;
            state.config.save();
            state.background_image = None;
            state.status_message = "Background removed.".to_string();
        }
        AppMessage::SetBackgroundDim(dim) => {
            state.config.background_dim = dim.clamp(0.0, 1.0);
            state.config.save();
        }
        AppMessage::ToggleGamePresence(enabled) => {
            state.config.game_presence_enabled = enabled;
            state.config.save();
            // Core publishes/clears our game immediately (D8).
            let _ = state
                .controller
                .send(CoreCommand::SetGamePresenceEnabled(enabled));
        }
        AppMessage::GameOverrideSelected(choice) => {
            state.game_override = choice;
            let override_ = match choice {
                GameOverrideChoice::Auto => crate::game::ManualOverride::Auto,
                GameOverrideChoice::None => crate::game::ManualOverride::ForceNone,
                // "Pin current" carries the live detection; nothing detected = Auto.
                GameOverrideChoice::Current => match &state.current_game {
                    Some(g) => crate::game::ManualOverride::Force(g.clone()),
                    None => crate::game::ManualOverride::Auto,
                },
            };
            let _ = state
                .controller
                .send(CoreCommand::SetGameOverride(override_));
        }
        AppMessage::GameMapExeChanged(val) => {
            state.game_map_exe_input = val;
        }
        AppMessage::GameMapNameChanged(val) => {
            state.game_map_name_input = val;
        }
        AppMessage::AddGameMapping => {
            let exe = crate::game::normalize_exe(&state.game_map_exe_input);
            let name = state.game_map_name_input.trim().to_string();
            if !exe.is_empty() && !name.is_empty() {
                state.config.game_process_map.insert(exe, name);
                state.config.save();
                state.game_map_exe_input.clear();
                state.game_map_name_input.clear();
                let _ = state.controller.send(CoreCommand::SetGameProcessMap(
                    state.config.game_process_map.clone(),
                ));
            }
        }
        AppMessage::RemoveGameMapping(exe) => {
            if state.config.game_process_map.remove(&exe).is_some() {
                state.config.save();
                let _ = state.controller.send(CoreCommand::SetGameProcessMap(
                    state.config.game_process_map.clone(),
                ));
            }
        }
        AppMessage::PickGameBackground(game_id) => {
            // Native picker off the UI thread; result tagged with the game id.
            return Task::perform(
                async {
                    let handle = rfd::AsyncFileDialog::new()
                        .add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
                        .set_title("Choose a per-game background")
                        .pick_file()
                        .await;
                    match handle {
                        Some(h) => Some(h.read().await),
                        None => None,
                    }
                },
                move |bytes| AppMessage::GameBackgroundPicked(game_id, bytes),
            );
        }
        AppMessage::GameBackgroundPicked(game_id, picked) => {
            if let Some(bytes) = picked {
                match crate::background::process_background(&bytes) {
                    Ok(png) => match AppConfig::game_background_path(&game_id) {
                        Some(path) => {
                            if let Some(dir) = path.parent() {
                                let _ = std::fs::create_dir_all(dir);
                            }
                            match std::fs::write(&path, &png) {
                                Ok(()) => {
                                    state.config.game_backgrounds.insert(
                                        game_id.clone(),
                                        path.to_string_lossy().into_owned(),
                                    );
                                    state.config.save();
                                    // Apply at once if it's the running game.
                                    if state.current_game.as_ref().map(|g| g.id.as_str())
                                        == Some(game_id.as_str())
                                    {
                                        state.background_image = effective_background_bytes(
                                            &state.config,
                                            state.current_game.as_ref(),
                                        );
                                    }
                                    state.status_message = "Game background updated.".to_string();
                                }
                                Err(e) => {
                                    state.status_message =
                                        format!("Couldn't save game background: {e}");
                                }
                            }
                        }
                        None => {
                            state.status_message =
                                "Couldn't find a config directory to save the background."
                                    .to_string();
                        }
                    },
                    Err(e) => {
                        state.status_message = e;
                    }
                }
            }
        }
        AppMessage::RemoveGameBackground(game_id) => {
            if let Some(path) = AppConfig::game_background_path(&game_id) {
                let _ = std::fs::remove_file(path);
            }
            if state.config.game_backgrounds.remove(&game_id).is_some() {
                state.config.save();
                if state.current_game.as_ref().map(|g| g.id.as_str()) == Some(game_id.as_str()) {
                    state.background_image =
                        effective_background_bytes(&state.config, state.current_game.as_ref());
                }
                state.status_message = "Game background removed.".to_string();
            }
        }
        AppMessage::ToggleDrawerChat => {
            state.drawer_chat_open = !state.drawer_chat_open;
        }
        AppMessage::TogglePlayerBar => {
            state.config.show_player_bar = !state.config.show_player_bar;
            if !state.config.show_player_bar {
                state.playlist_drawer_open = false;
            }
            state.config.save();
        }
        AppMessage::TogglePlaylistDrawer => {
            state.playlist_drawer_open = !state.playlist_drawer_open;
        }
        AppMessage::ChatSubmit => {
            let text = crate::sanitize::sanitize_chat(&state.chat_input);
            if !text.is_empty() {
                // Local echo (gossip suppresses our own author, so it won't come
                // back). The send is dispatched-or-queued through the pacer so a
                // fast burst is trickled at the rate receivers actually admit,
                // and the entry carries honest send status (Phase 5).
                let id = state.next_send_id;
                state.next_send_id += 1;
                push_chat(
                    &mut state.chat_messages,
                    ChatEntry {
                        name: format!("{} (You)", state.name),
                        text: text.clone(),
                        mine: true,
                        from: Some(state.self_id.clone()),
                        attachment: None,
                        links: Vec::new(),
                        local_send: Some(LocalSend {
                            id,
                            status: SendStatus::Pending,
                        }),
                    },
                );
                state.submit_send(id, PendingSend::Text(text));
                state.chat_input.clear();
            }
        }
        AppMessage::ChatSendTick => {
            let now = state.send_now_ms();
            let ready = sendqueue::release_ready(&mut state.send_queue, &mut state.send_pacer, now);
            for id in ready {
                if let Some(payload) = state.send_payloads.get(&id).cloned() {
                    state.dispatch_send(id, &payload);
                }
            }
        }
        AppMessage::RetryChatSend(id) => {
            // Re-dispatch a Failed send from its retained payload. Guard against a
            // double click while it is already queued/in-flight.
            if !state.send_queue.contains(&id)
                && let Some(payload) = state.send_payloads.get(&id).cloned()
            {
                state.submit_send(id, payload);
            }
        }
        AppMessage::PickAttachmentFile => {
            // Native picker off the UI thread; returns (filename, bytes). The
            // read is BOUNDED (metadata precheck + cap+1 read, Phase 3C) — an
            // oversized pick reports `(name, None)` without buffering the file.
            return Task::perform(
                async {
                    let handle = rfd::AsyncFileDialog::new()
                        .set_title("Attach a file to the chat")
                        .pick_file()
                        .await;
                    match handle {
                        Some(h) => {
                            let name = h.file_name();
                            let path = h.path().to_path_buf();
                            let bytes = tokio::task::spawn_blocking(move || {
                                crate::files::read_file_capped(&path)
                            })
                            .await
                            .ok()
                            .and_then(|r| r.ok())
                            .flatten();
                            Some((name, bytes))
                        }
                        None => None,
                    }
                },
                AppMessage::AttachmentFilePicked,
            );
        }
        AppMessage::AttachmentFilePicked(picked) => {
            if let Some((name, bytes)) = picked {
                let Some(bytes) = bytes else {
                    state.status_message = format!(
                        "Can't attach {name} — unreadable or larger than {}.",
                        crate::files::human_size(crate::files::MAX_ATTACHMENT_BYTES)
                    );
                    return Task::none();
                };
                let size = bytes.len() as u64;
                if !crate::files::size_within_cap(size) {
                    state.status_message = format!(
                        "File too large — max {}.",
                        crate::files::human_size(crate::files::MAX_ATTACHMENT_BYTES)
                    );
                    return Task::none();
                }
                let bytes = Arc::new(bytes);
                let kind = crate::files::classify(&bytes);
                // Random 32-byte handle for this attachment.
                let id: crate::files::AttachmentId = rand::random();
                let att = crate::files::ChatAttachment {
                    name: crate::files::sanitize_filename(&name),
                    size,
                    kind,
                    id,
                };
                // Keep our own bytes locally so we see our own attachment inline
                // immediately (others fetch it off the file plane). Cache under
                // our own (author, id) key so the render path — which keys the
                // line by `from` = self_id — finds them. The same Arc backs the
                // cache, the command queue, and the serve store (Phase 3C).
                if let Ok(self_eid) = state.self_id.parse::<EndpointId>() {
                    let key = (self_eid, id);
                    let handle = (kind == crate::files::AttachmentKind::Image)
                        .then(|| build_preview_handle(&bytes))
                        .flatten();
                    state
                        .attachments
                        .insert(key, AttachmentState::Ready(bytes.clone()), handle);
                }
                let send_id = state.next_send_id;
                state.next_send_id += 1;
                push_chat(
                    &mut state.chat_messages,
                    ChatEntry {
                        name: format!("{} (You)", state.name),
                        text: String::new(),
                        mine: true,
                        from: Some(state.self_id.clone()),
                        attachment: Some(att.clone()),
                        links: Vec::new(),
                        local_send: Some(LocalSend {
                            id: send_id,
                            status: SendStatus::Pending,
                        }),
                    },
                );
                state.submit_send(
                    send_id,
                    PendingSend::File {
                        text: String::new(),
                        attachment: att,
                        data: bytes,
                    },
                );
            }
        }
        AppMessage::SaveAttachment(key) => {
            // If we already have the bytes, save now; if a fetch is already
            // running, just record the save intent (no duplicate fetch task);
            // otherwise fetch from the sender and save when AttachmentReady
            // arrives (pending_saves). The key's author half is the exact
            // sender of the clicked line.
            if state.attachments.is_ready(&key) {
                return save_attachment_task(state, key);
            } else if state.attachments.is_loading(&key) {
                state.pending_saves.insert(key);
            } else if let Some(att) = find_attachment_source(state, key) {
                state.pending_saves.insert(key);
                state
                    .attachments
                    .insert(key, AttachmentState::Loading, None);
                state.status_message = format!("Downloading {}…", att.name);
                let _ = state.controller.send(CoreCommand::FetchAttachment {
                    from: key.0,
                    attachment: att,
                });
            }
        }
        AppMessage::LoadImage(key) => {
            // Manual inline-image fetch for a skipped/evicted image. The
            // Loading state both drives the label and suppresses duplicates.
            if !state.attachments.is_ready(&key)
                && !state.attachments.is_loading(&key)
                && let Some(att) = find_attachment_source(state, key)
            {
                state
                    .attachments
                    .insert(key, AttachmentState::Loading, None);
                state.status_message = format!("Loading {}…", att.name);
                let _ = state.controller.send(CoreCommand::FetchAttachment {
                    from: key.0,
                    attachment: att,
                });
            }
        }
        AppMessage::AttachmentSaved(msg) => {
            if let Some(m) = msg {
                state.status_message = m;
            }
        }
        AppMessage::PlayAudio(key) => {
            if state.attachments.is_ready(&key) {
                play_ready_audio(state, key);
            } else if state.attachments.is_loading(&key) {
                // A fetch is already running (e.g. a Save click); just record
                // the play intent for when the bytes arrive.
                state.pending_plays.insert(key.1);
            } else if let Some(att) = find_attachment_source(state, key) {
                // Repeated clicks while the transfer is pending must not launch
                // duplicate fetches. (pending_plays is id-keyed — it's coupled to
                // the id-keyed clip player; same-id collisions are cosmetic.)
                if state.pending_plays.insert(key.1) {
                    state
                        .attachments
                        .insert(key, AttachmentState::Loading, None);
                    state.status_message = format!("Loading {}…", att.name);
                    let _ = state.controller.send(CoreCommand::FetchAttachment {
                        from: key.0,
                        attachment: att,
                    });
                }
            }
        }
        AppMessage::PauseAudio => state.clip_player.pause(),
        AppMessage::ResumeAudio => state.clip_player.resume(),
        AppMessage::SeekAudio(id, fraction) => {
            let clip = status_snapshot(&state.clip_status);
            if clip.playing_id == Some(id)
                && let Some(total) = clip.total
            {
                state.clip_player.seek(seek_target(fraction, total));
            }
        }
        AppMessage::SetClipVolume(volume) => {
            // Master slider: always stores the universal level, but only the
            // active player is nudged when universal mode is actually on.
            let volume = volume.clamp(0.0, 2.0);
            state.config.clip_volume = volume;
            state.config.save();
            if state.config.clip_volume_universal {
                state.clip_player.set_volume(volume);
            }
        }
        AppMessage::SetClipVolumeFor(id, volume) => {
            let volume = volume.clamp(0.0, 2.0);
            if state.config.clip_volume_universal {
                state.config.clip_volume = volume;
                state.config.save();
                state.clip_player.set_volume(volume);
            } else {
                state.clip_volumes.insert(id, volume);
                // Only the clip the user is dragging should react immediately.
                if status_snapshot(&state.clip_status).playing_id == Some(id) {
                    state.clip_player.set_volume(volume);
                }
            }
        }
        AppMessage::ToggleUniversalClipVolume(on) => {
            state.config.clip_volume_universal = on;
            state.config.save();
            // Reapply the now-effective level to whatever is currently playing.
            if let Some(id) = status_snapshot(&state.clip_status).playing_id {
                state
                    .clip_player
                    .set_volume(effective_clip_volume(state, id));
            }
        }
        AppMessage::MusicSelectTab(tab) => {
            state.music_tab = tab;
        }
        AppMessage::MusicBrowse => {
            return Task::perform(
                async {
                    let handles = rfd::AsyncFileDialog::new()
                        .add_filter(
                            "Audio & playlists",
                            &["mp3", "flac", "ogg", "oga", "wav", "m3u", "m3u8", "pls"],
                        )
                        .set_title("Add music to your playlist")
                        .pick_files()
                        .await;
                    handles.map(|hs| hs.into_iter().map(|h| h.path().to_path_buf()).collect())
                },
                AppMessage::MusicFilesPicked,
            );
        }
        AppMessage::MusicFilesPicked(picked) => {
            if let Some(paths) = picked {
                for path in paths {
                    if let Some(kind) = crate::playlist::playlist_kind(&path) {
                        match std::fs::read_to_string(&path) {
                            Ok(contents) => {
                                let base_dir =
                                    path.parent().unwrap_or_else(|| std::path::Path::new("."));
                                for entry in
                                    crate::playlist::parse_playlist(&contents, base_dir, kind)
                                {
                                    let name = entry
                                        .file_name()
                                        .map(|n| n.to_string_lossy().into_owned())
                                        .unwrap_or_else(|| entry.to_string_lossy().into_owned());
                                    state.music_playlist.push(MusicTrack { path: entry, name });
                                }
                            }
                            Err(e) => {
                                state.status_message =
                                    format!("Couldn't read playlist '{}': {e}", path.display());
                            }
                        }
                    } else {
                        let name = path
                            .file_name()
                            .map(|n| n.to_string_lossy().into_owned())
                            .unwrap_or_else(|| path.to_string_lossy().into_owned());
                        state.music_playlist.push(MusicTrack { path, name });
                    }
                }
                persist_music_playlist(state);
            }
        }
        AppMessage::MusicPlayIndex(i) => {
            play_music_index(state, i);
        }
        AppMessage::MusicPlayPause => {
            let snap = status_snapshot(&state.music_status);
            if snap.playing_id.is_some() {
                if snap.paused {
                    state.music_player.resume();
                    state.music_active = true;
                    broadcast_music_timeline_at(state, false, snap.position);
                } else {
                    state.music_player.pause();
                    state.music_active = false;
                    broadcast_music_timeline_at(state, true, snap.position);
                }
            } else if let Some(i) = state.music_current.or(if state.music_playlist.is_empty() {
                None
            } else {
                Some(0)
            }) {
                play_music_index(state, i);
            }
        }
        AppMessage::MusicNext => {
            if let Some(next) = music_step(state, 1) {
                play_music_index(state, next);
            }
        }
        AppMessage::MusicPrev => {
            if let Some(prev) = music_step(state, -1) {
                play_music_index(state, prev);
            }
        }
        AppMessage::MusicSeek(fraction) => {
            let snap = status_snapshot(&state.music_status);
            if let Some(total) = snap.total {
                let target = seek_target(fraction, total);
                state.music_player.seek(target);
                broadcast_music_timeline_at(state, snap.paused, target);
            }
        }
        AppMessage::MusicSetVolume(volume) => {
            let volume = volume.clamp(0.0, 2.0);
            state.config.music_volume = volume;
            if state.music_listening_to.is_none() {
                state.music_player.set_volume(volume);
            }
            state.config.save();
        }
        AppMessage::MusicSetSourceVolume(volume) => {
            let volume = volume.clamp(0.0, 2.0);
            if let Some(peer) = state.music_listening_to {
                state
                    .config
                    .music_source_volume
                    .insert(peer.to_string(), volume);
                state.music_player.set_volume(volume);
                state.config.save();
            }
        }
        AppMessage::MusicRemove(i) => {
            if i < state.music_playlist.len() {
                if state.music_current == Some(i) {
                    state.music_player.stop();
                    state.music_current = None;
                    state.music_active = false;
                    stop_music_broadcast(state);
                }
                state.music_playlist.remove(i);
                if state.music_current.map(|c| c > i).unwrap_or(false) {
                    state.music_current = state.music_current.map(|c| c - 1);
                }
                persist_music_playlist(state);
            }
        }
        AppMessage::MusicMoveUp(i) => {
            if i > 0 {
                music_swap(state, i, i.wrapping_sub(1));
            }
        }
        AppMessage::MusicMoveDown(i) => {
            if i + 1 < state.music_playlist.len() {
                music_swap(state, i, i + 1);
            }
        }
        AppMessage::MusicToggleBroadcast(enabled) => {
            state.music_broadcasting = enabled;
            state.config.music_broadcast = enabled;
            state.config.save();
            if enabled {
                if state.music_listening_to.is_none()
                    && status_snapshot(&state.music_status).playing_id.is_some()
                {
                    let snap = status_snapshot(&state.music_status);
                    broadcast_current_music_file(state, snap.paused, snap.position);
                }
            } else {
                stop_music_broadcast(state);
            }
        }
        AppMessage::MusicListen(peer) => {
            state.music_listening_to = Some(peer);
            state.music_player.stop();
            state.music_active = false;
            state.music_current = None;
            state.music_listen_loaded = None;
            state.music_listen_inflight = None;
            state.music_prefetch = None;
            state.music_prefetch_inflight = None;
            stop_music_broadcast(state);
            reconcile_listen(state);
        }
        AppMessage::MusicStopListen => {
            state.music_listening_to = None;
            state.music_player.stop();
            state.music_listen_loaded = None;
            state.music_listen_inflight = None;
            state.music_prefetch = None;
            state.music_prefetch_inflight = None;
        }
        AppMessage::AudioTick => {
            let clip = status_snapshot(&state.clip_status);
            if let Some(failure) = clip.failure {
                if failure.invalid_audio {
                    state.invalid_audio.insert(failure.id);
                }
                state.pending_plays.remove(&failure.id);
                state.status_message = format!("Audio playback failed: {}", failure.error);
                state.clip_player.stop();
            }
            reconcile_listen(state);
            if state.music_active && state.music_listening_to.is_none() {
                let snap = status_snapshot(&state.music_status);
                if snap.playing_id.is_none() && !snap.paused {
                    if let Some(next) = music_step(state, 1) {
                        play_music_index(state, next);
                    } else {
                        state.music_active = false;
                        stop_music_broadcast(state);
                    }
                }
            }
        }
        AppMessage::OpenUrl(url) => {
            // Defence in depth: only ever hand policy-clean web URLs to the
            // opener. The link span's href came from `link_ranges`, which only
            // emits candidates passing `is_safe_web_url`, but re-check the SAME
            // parsed policy here (http/https scheme + real host + no userinfo)
            // so this handler can't be widened into launching arbitrary
            // schemes/args even if called directly. Each opener receives the
            // URL as a single argv entry (no shell), so there's no injection
            // surface:
            //   - Unix: `xdg-open <url>`.
            //   - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
            //     default browser without going through `cmd`/`start`, which would
            //     otherwise re-parse `&` in query strings.
            if crate::sanitize::is_safe_web_url(&url) {
                let spawned = {
                    #[cfg(unix)]
                    {
                        std::process::Command::new("xdg-open").arg(&url).spawn()
                    }
                    #[cfg(windows)]
                    {
                        std::process::Command::new("rundll32")
                            .args(["url.dll,FileProtocolHandler", &url])
                            .spawn()
                    }
                };
                if let Err(e) = spawned {
                    crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
                }
            }
        }
        AppMessage::ToggleMicTest(enabled) => {
            state.mic_test_active = enabled;
            if !enabled {
                state.mic_level = 0.0;
            }
            let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
            let _ = state.controller.send(CoreCommand::SetMicMonitor {
                enabled,
                input_device,
            });
        }
        AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
            if state.image_lightbox.is_some()
                && key == keyboard::Key::Named(keyboard::key::Named::Escape)
            {
                // Esc closes the image lightbox (takes priority over hotkeys so a
                // user-bound Escape can't fire while the overlay is up).
                state.image_lightbox = None;
            } else if let Some(action) = state.hotkey_capture.take() {
                if let Some(binding) = KeyBinding::from_key(&key) {
                    state.config.hotkeys.set_binding(action, Some(binding));
                    state.config.save();
                } else {
                    state.hotkey_capture = Some(action);
                }
            } else if let Some(action) = state.config.hotkeys.lookup_key(
                &key,
                HotkeyContext {
                    in_call: in_call(state),
                },
            ) {
                handle_hotkey_pressed(state, action);
            }
        }
        AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased {
            key, ..
        })) => {
            if state.config.hotkeys.lookup_key(
                &key,
                HotkeyContext {
                    in_call: in_call(state),
                },
            ) == Some(HotkeyAction::PushToTalk)
                && state.ptt_enabled
                && state.ptt_active
            {
                state.ptt_active = false;
                let _ = state.controller.send(CoreCommand::SetPttActive(false));
            }
        }
        AppMessage::EventOccurred(Event::Window(iced::window::Event::Resized(size))) => {
            state.window_size = size;
            // Remember the size in-memory; it's written to disk once on close.
            // Guard against bogus tiny/zero sizes some compositors emit transiently.
            if size.width >= 200.0 && size.height >= 200.0 {
                state.config.window_width = size.width;
                state.config.window_height = size.height;
            }
            // Keep divider positions valid for the new window dimensions. (Saved
            // with the next drag-release or other config write; not worth a disk
            // write on every resize tick.)
            state.config.participants_width =
                clamp_participants_width(state.config.participants_width, size.width);
            state.config.chat_height = clamp_chat_height(state.config.chat_height, size.height);
            state.config.controls_width =
                clamp_controls_width(state.config.controls_width, size.width);
            state.config.chat_drawer_width =
                clamp_chat_drawer_width(state.config.chat_drawer_width, size.width);
            state.config.playlist_drawer_width =
                clamp_playlist_drawer_width(state.config.playlist_drawer_width, size.width);
        }
        AppMessage::EventOccurred(Event::Window(iced::window::Event::Moved(position))) => {
            // Remember the position in-memory; written to disk once on close.
            // Negative coords are valid (a monitor left of/above the primary), so
            // we don't clamp. On Wayland iced doesn't report position, so this
            // arm simply never fires there and window_x/y stay None.
            state.config.window_x = Some(position.x as i32);
            state.config.window_y = Some(position.y as i32);
        }
        AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => {
            if state.closing {
                return Task::none();
            }
            // We took over the close path (exit_on_close_request:false) so we can
            // persist the final window size + position and give core a chance to
            // leave the room/finalize recordings before quitting.
            state.config.save();
            state.closing = true;
            state.status_message = "Shutting down...".to_string();
            let tx = state.controller.command_sender();
            return Task::batch(vec![
                Task::perform(
                    async move { tx.send(CoreCommand::Shutdown).await.is_ok() },
                    AppMessage::ShutdownCommandSent,
                ),
                shutdown_timeout_task(),
            ]);
        }
        AppMessage::EventOccurred(_) => {}
        AppMessage::ShutdownCommandSent(sent) => {
            if !sent {
                return iced::exit();
            }
        }
        AppMessage::ShutdownTimeout => {
            if state.closing {
                return iced::exit();
            }
        }
        AppMessage::NavigateToSettings => {
            state.current_screen = Screen::Settings;
            state.layout_picker_open = false;
            state.hotkey_info_open = false;
        }
        AppMessage::NavigateBack => {
            state.config.save();
            state.hotkey_capture = None;
            // Release the mic when leaving Settings if the test was running.
            if state.mic_test_active {
                state.mic_test_active = false;
                state.mic_level = 0.0;
                let _ = state.controller.send(CoreCommand::SetMicMonitor {
                    enabled: false,
                    input_device: None,
                });
            }
            if state.ticket.is_empty() {
                state.current_screen = Screen::Home;
            } else {
                state.current_screen = Screen::Room;
            }
        }
    }
    Task::none()
}

/// One-line explanation of a network posture for the settings picker.
fn network_mode_hint(mode: NetworkMode) -> &'static str {
    match mode {
        NetworkMode::RelayNoDiscovery => {
            "n0 relay for NAT traversal; no presence published to n0 DNS."
        }
        NetworkMode::N0Full => "n0 relay + DNS discovery. Most reliable, most metadata shared.",
        NetworkMode::DirectOnly => "Fully serverless. May fail behind strict/CGNAT networks.",
    }
}

/// One-line explanation of an audio/network profile for the settings picker (W12).
fn audio_profile_hint(profile: AudioProfile) -> &'static str {
    match profile {
        AudioProfile::LowLatency => {
            "Lowest delay, no loss recovery. Best on a clean LAN or wired link."
        }
        AudioProfile::Balanced => "Default: voice quality with light loss recovery.",
        AudioProfile::BadNetwork => {
            "Most resilient on a lossy/congested link: heavier loss recovery, lower bitrate."
        }
    }
}

/// One-line explanation of a recording mode for the settings picker.
fn recording_mode_hint(mode: RecordingMode) -> &'static str {
    match mode {
        RecordingMode::Mixed => "One WAV: your mic blended with everyone you hear.",
        RecordingMode::Multitrack => {
            "One WAV per person + your mic, sample-aligned — mix it yourself."
        }
        RecordingMode::Both => "Per-person stems + your mic AND a ready-made mixed WAV.",
    }
}

/// One-line explanation of a presence posture for the settings picker (W7).
fn presence_mode_hint(mode: PresenceMode) -> &'static str {
    match mode {
        PresenceMode::Invisible => "Answer no one — appear offline to everyone, even friends.",
        PresenceMode::Normal => "Answer friends only, from your saved address. No presence beacon.",
        PresenceMode::Discoverable => {
            "Also publish so friends can still find you after you change networks (opt-in)."
        }
    }
}

fn share_quality_hint(quality: ShareQuality) -> &'static str {
    match quality {
        ShareQuality::Auto => "Use pixelpass bandwidth pre-flight; falls back to Medium.",
        ShareQuality::Low => "Lower bandwidth: up to 480p, about 1 Mbps.",
        ShareQuality::Medium => "Balanced preset: up to 720p, about 2.5 Mbps.",
        ShareQuality::High => "Sharper preset: up to 1080p, about 4 Mbps.",
        ShareQuality::Source => "Native source resolution, about 6 Mbps.",
    }
}

fn share_player_hint(player: SharePlayer) -> &'static str {
    match player {
        SharePlayer::Mpv => "Try mpv first, then VLC if mpv is unavailable.",
        SharePlayer::Vlc => "Try VLC first, then mpv if VLC is unavailable.",
    }
}

fn share_buffering_hint(buffering: ShareBuffering) -> &'static str {
    match buffering {
        ShareBuffering::LowLatency => "Small buffers for interactive screen sharing.",
        ShareBuffering::Smooth => "Larger cache/readahead for steadier playback.",
    }
}

/// Formats a call duration as `m:ss` (or `h:mm:ss` past an hour).
fn format_duration(total_secs: u64) -> String {
    let h = total_secs / 3600;
    let m = (total_secs % 3600) / 60;
    let s = total_secs % 60;
    if h > 0 {
        format!("{h}:{m:02}:{s:02}")
    } else {
        format!("{m}:{s:02}")
    }
}

/// A compact relative-time label for the friends "last scanned" indicator, e.g.
/// `just now`, `2m ago`, `1h 2m ago`, `3d ago`. Minute granularity (the indicator
/// re-renders on a slow tick), so anything under a minute reads "just now". Pure
/// so the formatting is unit-testable without a clock.
fn format_relative_ago(elapsed: std::time::Duration) -> String {
    let secs = elapsed.as_secs();
    if secs < 60 {
        return "just now".to_string();
    }
    let mins = secs / 60;
    if mins < 60 {
        return format!("{mins}m ago");
    }
    let hours = mins / 60;
    if hours < 24 {
        let rem_m = mins % 60;
        return if rem_m == 0 {
            format!("{hours}h ago")
        } else {
            format!("{hours}h {rem_m}m ago")
        };
    }
    let days = hours / 24;
    let rem_h = hours % 24;
    if rem_h == 0 {
        format!("{days}d ago")
    } else {
        format!("{days}d {rem_h}h ago")
    }
}

fn format_clock_skew_duration(skew_secs: u64) -> String {
    let minutes = skew_secs.max(1).saturating_add(59) / 60;
    if minutes == 1 {
        "1 minute".to_string()
    } else {
        format!("{minutes} minutes")
    }
}

fn show_clock_skew_warning(
    state: &mut AppState,
    skew_secs: u64,
    peer_ahead: bool,
    now: std::time::Instant,
) {
    state.clock_skew_warning = Some(ClockSkewBanner {
        skew_secs,
        peer_ahead,
        expires_at: now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
    });
}

fn clear_expired_clock_skew_warning(state: &mut AppState, now: std::time::Instant) {
    if state
        .clock_skew_warning
        .is_some_and(|warning| now >= warning.expires_at)
    {
        state.clock_skew_warning = None;
    }
}

/// First 8 characters of an id string for compact display. Panic-free: takes
/// chars (not a byte slice), so a short or non-ASCII id can never panic the
/// render (security finding S1) — ids are long ASCII hex today, but this guards
/// the slice regardless.
fn short_id(id: &str) -> String {
    id.chars().take(8).collect()
}

/// Append a chat line, computing its cached link ranges, then trimming the
/// oldest entries while history exceeds EITHER the entry cap or the total
/// text-byte budget so a long call (or a max-length-message flood) can't grow
/// the buffer without bound. The just-pushed entry is never evicted (a single
/// message's 8 KiB ceiling sits far below the byte budget). Eviction only drops
/// `ChatEntry` values: the attachment byte cache in `AppState.attachments` is
/// bounded by its own budgets and is deliberately NOT touched here, so an open
/// Save/Play on an evicted line keeps its bytes-in-hand (the save dialog then
/// falls back to the "download" default name).
fn push_chat(messages: &mut Vec<ChatEntry>, mut entry: ChatEntry) {
    entry.links = crate::sanitize::link_ranges(&entry.text);
    messages.push(entry);
    let mut total: usize = messages.iter().map(|m| m.text.len()).sum();
    let mut overflow = 0;
    while messages.len() - overflow > CHAT_HISTORY_MAX
        || (total > CHAT_HISTORY_MAX_TEXT_BYTES && messages.len() - overflow > 1)
    {
        total -= messages[overflow].text.len();
        overflow += 1;
    }
    messages.drain(..overflow);
}

/// Pick the default save-dialog filename for an attachment, matched by the FULL
/// `(author, id)` key — not the bare id — so a peer reusing another sender's id
/// can't supply the filename (and extension) for a different line (Tier C F-12
/// metadata residual). Falls back to "download" if the line is gone. Matches own
/// and received lines alike (our own `from` = `self_id` parses to the key author).
fn attachment_default_name(messages: &[ChatEntry], key: AttachmentKey) -> String {
    messages
        .iter()
        .find_map(|m| {
            let att = m.attachment.as_ref()?;
            let from = m.from.as_ref()?.parse::<EndpointId>().ok()?;
            (att.id == key.1 && from == key.0).then(|| att.name.clone())
        })
        .unwrap_or_else(|| "download".to_string())
}

/// Find the chat-attachment descriptor for an exact `(author, id)` key among
/// received (non-own) messages. Matching on the author too — not just the id —
/// means a peer reusing another sender's id can't redirect the fetch to the
/// wrong line (Tier C F-12).
fn find_attachment_source(
    state: &AppState,
    key: AttachmentKey,
) -> Option<crate::files::ChatAttachment> {
    state.chat_messages.iter().find_map(|m| {
        let att = m.attachment.as_ref()?;
        let from = m.from.as_ref()?;
        if att.id == key.1 && !m.mine && from.parse::<EndpointId>().ok() == Some(key.0) {
            Some(att.clone())
        } else {
            None
        }
    })
}

/// Validate cached bytes and hand them to the independent clip player. A false
/// filename hint falls back to the generic file chip without reaching rodio.
/// Resolve the gain to use for clip `id`: the shared universal level, or the
/// clip's own stored level (defaulting to unity) when universal mode is off.
fn effective_clip_volume(state: &AppState, id: crate::files::AttachmentId) -> f32 {
    if state.config.clip_volume_universal {
        state.config.clip_volume
    } else {
        state.clip_volumes.get(&id).copied().unwrap_or(1.0)
    }
}

fn play_ready_audio(state: &mut AppState, key: AttachmentKey) {
    let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else {
        return;
    };
    let data = data.clone();
    play_audio_data(state, key.1, &data);
}

/// Play audio from bytes in hand — used both for cached entries and for a
/// just-fetched attachment too large to retain (Phase 3A overweight path).
fn play_audio_data(state: &mut AppState, id: crate::files::AttachmentId, data: &Arc<Vec<u8>>) {
    if crate::files::is_probably_audio(data) {
        // The clip player's command channel owns its bytes; one copy here at
        // the click, the cache keeps the shared original.
        let bytes = data.as_ref().clone();
        state.invalid_audio.remove(&id);
        state.clip_player.play(id, bytes);
        // Apply this clip's effective gain; the command lands after Play so it
        // takes effect on the freshly connected player.
        state
            .clip_player
            .set_volume(effective_clip_volume(state, id));
    } else {
        state.invalid_audio.insert(id);
        state.status_message = "This attachment is not valid supported audio.".to_string();
    }
}

/// Synthesize a stable per-slot AttachmentId for the music player's status
/// bookkeeping (music tracks are local files, not real attachments). Encoding the
/// playlist index keeps ids distinct per slot; only used internally by the player.
fn music_slot_id(index: usize) -> crate::files::AttachmentId {
    let mut id = [0u8; 32];
    id[..8].copy_from_slice(&(index as u64).to_le_bytes());
    id
}

/// Compute the next playlist index in `dir` (+1/-1), wrapping, or None if empty.
fn music_step(state: &AppState, dir: isize) -> Option<usize> {
    let len = state.music_playlist.len();
    if len == 0 {
        return None;
    }
    let cur = state.music_current.unwrap_or(0) as isize;
    Some((cur + dir).rem_euclid(len as isize) as usize)
}

fn persist_music_playlist(state: &mut AppState) {
    state.config.music_playlist = state
        .music_playlist
        .iter()
        .map(|t| t.path.to_string_lossy().into_owned())
        .collect();
    state.config.save();
}

fn music_swap(state: &mut AppState, a: usize, b: usize) {
    if a == b || a >= state.music_playlist.len() || b >= state.music_playlist.len() {
        return;
    }
    state.music_playlist.swap(a, b);
    if state.music_current == Some(a) {
        state.music_current = Some(b);
    } else if state.music_current == Some(b) {
        state.music_current = Some(a);
    }
    persist_music_playlist(state);
}

fn effective_music_volume(state: &AppState) -> f32 {
    if let Some(peer) = state.music_listening_to {
        state
            .config
            .music_source_volume
            .get(&peer.to_string())
            .copied()
            .unwrap_or(state.config.music_volume)
    } else {
        state.config.music_volume
    }
}

fn can_broadcast_music(state: &AppState) -> bool {
    state.music_broadcasting && state.music_listening_to.is_none()
}

/// Text shown on the now-playing player bar. Pure for testing.
fn now_playing_label(listening_to: Option<&str>, current_track: Option<&str>) -> String {
    match (listening_to, current_track) {
        (Some(peer), _) => format!("Tuned in to {peer}"),
        (None, Some(track)) => track.to_string(),
        (None, None) => "Nothing playing".to_string(),
    }
}

fn stop_music_broadcast(state: &mut AppState) {
    let old_current = state.music_broadcast_id.take();
    let old_next = state.music_broadcast_next.take().map(|(_, id, _)| id);
    forget_stale_music_tracks(state, old_current, old_next, None, None);
    state.music_broadcast_id = None;
    state.music_broadcast_next = None;
    let _ = state.controller.send(CoreCommand::SetMusicPresence(None));
}

fn forget_stale_music_tracks(
    state: &AppState,
    old_current: Option<crate::files::AttachmentId>,
    old_next: Option<crate::files::AttachmentId>,
    new_current: Option<crate::files::AttachmentId>,
    new_next: Option<crate::files::AttachmentId>,
) {
    for id in [old_current, old_next].into_iter().flatten() {
        if Some(id) != new_current && Some(id) != new_next {
            let _ = state.controller.send(CoreCommand::ForgetMusicTrack(id));
        }
    }
}

fn music_presence_for(
    id: crate::files::AttachmentId,
    name: String,
    size: u64,
    paused: bool,
    position: std::time::Duration,
    next_id: Option<crate::files::AttachmentId>,
    next_size: Option<u64>,
) -> crate::network::MusicPresence {
    crate::network::MusicPresence {
        id,
        name: crate::files::sanitize_filename(&name),
        size,
        paused,
        anchor_ms: now_ms(),
        position_ms: position.as_millis() as u64,
        next_id,
        next_size,
    }
}

struct MusicPresenceUpdate {
    id: crate::files::AttachmentId,
    name: String,
    size: u64,
    paused: bool,
    position: std::time::Duration,
    next_id: Option<crate::files::AttachmentId>,
    next_size: Option<u64>,
}

fn send_music_presence(state: &AppState, update: MusicPresenceUpdate) {
    let _ = state
        .controller
        .send(CoreCommand::SetMusicPresence(Some(music_presence_for(
            update.id,
            update.name,
            update.size,
            update.paused,
            update.position,
            update.next_id,
            update.next_size,
        ))));
}

fn broadcast_track(
    state: &mut AppState,
    current_index: usize,
    name: String,
    bytes: &[u8],
    paused: bool,
    position: std::time::Duration,
) {
    if !can_broadcast_music(state) {
        return;
    }
    let old_current = state.music_broadcast_id;
    let old_next = state.music_broadcast_next;
    let reuse_current = old_next
        .filter(|(idx, _, _)| *idx == current_index)
        .map(|(_, id, _)| id);
    let id = reuse_current.unwrap_or_else(rand::random);
    if reuse_current.is_none() {
        let _ = state.controller.send(CoreCommand::ServeMusicTrack {
            id,
            data: Arc::new(bytes.to_vec()),
        });
    }
    state.music_broadcast_id = Some(id);

    let mut next_id = None;
    let mut next_size = None;
    state.music_broadcast_next = None;
    if state.music_playlist.len() >= 2
        && let Some(next_index) = music_step(state, 1)
        && next_index != current_index
        && let Some(next_track) = state.music_playlist.get(next_index)
        && let Ok(next_bytes) = std::fs::read(&next_track.path)
        && crate::files::is_probably_audio(&next_bytes)
    {
        let id: crate::files::AttachmentId = rand::random();
        let size = next_bytes.len() as u64;
        let _ = state.controller.send(CoreCommand::ServeMusicTrack {
            id,
            data: Arc::new(next_bytes),
        });
        next_id = Some(id);
        next_size = Some(size);
        state.music_broadcast_next = Some((next_index, id, size));
    }

    forget_stale_music_tracks(
        state,
        old_current,
        old_next.map(|(_, id, _)| id),
        Some(id),
        next_id,
    );
    send_music_presence(
        state,
        MusicPresenceUpdate {
            id,
            name,
            size: bytes.len() as u64,
            paused,
            position,
            next_id,
            next_size,
        },
    );
}

fn broadcast_current_music_file(state: &mut AppState, paused: bool, position: std::time::Duration) {
    let Some(index) = state.music_current else {
        return;
    };
    let Some(track) = state.music_playlist.get(index) else {
        return;
    };
    match std::fs::read(&track.path) {
        Ok(bytes) if crate::files::is_probably_audio(&bytes) => {
            broadcast_track(state, index, track.name.clone(), &bytes, paused, position);
        }
        Ok(_) | Err(_) => stop_music_broadcast(state),
    }
}

fn broadcast_music_timeline_at(state: &AppState, paused: bool, position: std::time::Duration) {
    if !can_broadcast_music(state) {
        return;
    }
    let Some(id) = state.music_broadcast_id else {
        return;
    };
    let Some(index) = state.music_current else {
        return;
    };
    let Some(track) = state.music_playlist.get(index) else {
        return;
    };
    let Ok(meta) = std::fs::metadata(&track.path) else {
        return;
    };
    let (next_id, next_size) = state
        .music_broadcast_next
        .map(|(_, id, size)| (Some(id), Some(size)))
        .unwrap_or((None, None));
    send_music_presence(
        state,
        MusicPresenceUpdate {
            id,
            name: track.name.clone(),
            size: meta.len(),
            paused,
            position,
            next_id,
            next_size,
        },
    );
}

/// Read a playlist track's bytes from disk and start the music player on it.
fn play_music_index(state: &mut AppState, index: usize) {
    let Some(track) = state.music_playlist.get(index) else {
        return;
    };
    let path = track.path.clone();
    let name = track.name.clone();
    match std::fs::read(&path) {
        Ok(bytes) if crate::files::is_probably_audio(&bytes) => {
            let broadcast_bytes = bytes.clone();
            state.music_current = Some(index);
            state.music_active = true;
            state.music_player.play(music_slot_id(index), bytes);
            state.music_player.set_volume(effective_music_volume(state));
            broadcast_track(
                state,
                index,
                name,
                &broadcast_bytes,
                false,
                std::time::Duration::ZERO,
            );
        }
        Ok(_) => {
            state.music_player.stop();
            state.music_current = Some(index);
            state.music_active = false;
            stop_music_broadcast(state);
            state.status_message = format!("'{}' is not a supported audio file.", name);
        }
        Err(e) => {
            state.music_player.stop();
            state.music_current = Some(index);
            state.music_active = false;
            stop_music_broadcast(state);
            state.status_message = format!("Couldn't read '{}': {e}", name);
        }
    }
}

fn reconcile_listen(state: &mut AppState) {
    let Some(peer) = state.music_listening_to else {
        return;
    };
    let Some(music) = state.peers.get(&peer).and_then(|p| p.music.clone()) else {
        if state.music_listen_loaded.is_some()
            || state.music_listen_inflight.is_some()
            || state.music_prefetch.is_some()
            || state.music_prefetch_inflight.is_some()
        {
            state.music_player.stop();
            state.music_listen_loaded = None;
            state.music_listen_inflight = None;
            state.music_prefetch = None;
            state.music_prefetch_inflight = None;
            state.status_message = "Source stopped broadcasting".to_string();
        }
        return;
    };

    if Some(music.id) != state.music_listen_loaded {
        if let Some((id, data)) = state
            .music_prefetch
            .take()
            .filter(|(id, _)| *id == music.id)
        {
            state.music_player.play(id, data);
            state.music_player.set_volume(effective_music_volume(state));
            state.music_listen_loaded = Some(id);
            state.music_listen_inflight = None;
        } else if Some(music.id) != state.music_listen_inflight {
            state.music_player.stop();
            state.music_listen_loaded = None;
            state.music_listen_inflight = Some(music.id);
            state.status_message = format!("Loading {}…", music.name);
            let _ = state.controller.send(CoreCommand::FetchMusic {
                from: peer,
                id: music.id,
                size: music.size,
            });
            return;
        }
    }

    if Some(music.id) != state.music_listen_loaded {
        return;
    }

    let snap = status_snapshot(&state.music_status);
    let mut expected = if music.paused {
        music.position_ms
    } else {
        music
            .position_ms
            .saturating_add(now_ms().saturating_sub(music.anchor_ms))
    };
    if let Some(total) = snap.total {
        expected = expected.min(total.as_millis() as u64);
    }
    let expected_duration = std::time::Duration::from_millis(expected);

    if music.paused && !snap.paused {
        state.music_player.pause();
    } else if !music.paused && snap.paused {
        state.music_player.resume();
        state.music_player.seek(expected_duration);
    }

    let local_ms = snap.position.as_millis() as u64;
    if !music.paused && local_ms.abs_diff(expected) > 300 {
        state.music_player.seek(expected_duration);
    }

    if let (Some(next_id), Some(next_size)) = (music.next_id, music.next_size) {
        let cached = state
            .music_prefetch
            .as_ref()
            .is_some_and(|(id, _)| *id == next_id);
        if Some(next_id) != state.music_listen_loaded
            && !cached
            && Some(next_id) != state.music_prefetch_inflight
        {
            state.music_prefetch_inflight = Some(next_id);
            let _ = state.controller.send(CoreCommand::PrefetchMusic {
                from: peer,
                id: next_id,
                size: next_size,
            });
        }
    }
}

/// Write a ready attachment's bytes to a user-chosen location via a native save
/// dialog. The default filename comes from the (already-sanitized) descriptor.
/// Build a Task that opens the native save dialog off the UI thread and writes
/// the (already-fetched) attachment bytes to the chosen path. No-op task if the
/// bytes aren't ready.
///
/// MUST be async (`rfd::AsyncFileDialog`): the blocking `rfd::FileDialog` spins
/// its own GTK loop, and invoking it from inside iced's running event loop with
/// the Linux `xdg-desktop-portal`/GTK backend wedges the dialog — Save/Cancel
/// stop responding. The file *picker* paths already use the async variant; this
/// is the one save path that must match.
fn save_attachment_task(state: &AppState, key: AttachmentKey) -> Task<AppMessage> {
    let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else {
        return Task::none();
    };
    let data = data.clone();
    save_attachment_data_task(state, key, data)
}

/// The dialog+write half of a save, from bytes in hand — also used when a
/// just-fetched attachment was too large to retain in the cache (Phase 3A).
fn save_attachment_data_task(
    state: &AppState,
    key: AttachmentKey,
    data: Arc<Vec<u8>>,
) -> Task<AppMessage> {
    let default_name = attachment_default_name(&state.chat_messages, key);
    Task::perform(
        async move {
            let handle = rfd::AsyncFileDialog::new()
                .set_file_name(default_name)
                .set_title("Save attachment")
                .save_file()
                .await;
            match handle {
                Some(h) => match std::fs::write(h.path(), data.as_slice()) {
                    Ok(()) => Some(format!("Saved {}", h.path().display())),
                    Err(e) => Some(format!("Save failed: {e}")),
                },
                None => None,
            }
        },
        AppMessage::AttachmentSaved,
    )
}

fn horizontal_space() -> iced::widget::Space {
    iced::widget::Space::new().width(iced::Length::Fill)
}

fn vertical_space(height: f32) -> iced::widget::Space {
    iced::widget::Space::new().height(height)
}

/// The Friends panel (W7) — moved from Settings to the home screen. One card with
/// the live friends list (presence status + one-click Join + inline rename/remove),
/// the add-by-node-ID form, and the presence posture selector. Self-contained:
/// recomputes the palette + the few style helpers it needs so it doesn't depend on
/// `view`'s locals.
// The home-screen "Connect" card (left panel): create / join a room. Extracted
// into its own free fn (mirroring `friends_panel`) so the responsive home layout
// can rebuild it per layout pass and place it in either a row or a column.
fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
    let pal = state.config.theme.palette();
    let color_crust = pal.crust;
    let color_mantle = pal.mantle;
    let color_surface = pal.surface;
    let color_text = pal.text;
    let color_subtext = pal.subtext;
    let color_blue = pal.blue;
    let color_lavender = pal.lavender;

    let c_style = move |bg: Color, b_color: Color, radius: f32| {
        move |_theme: &Theme| container::Style {
            text_color: Some(color_text),
            background: Some(Background::Color(bg)),
            border: Border {
                color: b_color,
                width: if b_color == Color::TRANSPARENT {
                    0.0
                } else {
                    1.0
                },
                radius: radius.into(),
            },
            ..Default::default()
        }
    };
    let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| {
        move |_theme: &Theme, status: button::Status| {
            let active_bg = match status {
                button::Status::Hovered => hover_bg,
                _ => bg,
            };
            button::Style {
                background: Some(Background::Color(active_bg)),
                text_color: text_c,
                border: Border {
                    color: Color::TRANSPARENT,
                    width: 0.0,
                    radius: radius.into(),
                },
                ..Default::default()
            }
        }
    };
    let t_style = move |_theme: &Theme, _status: text_input::Status| text_input::Style {
        background: Background::Color(color_crust),
        border: Border {
            color: color_surface,
            width: 1.0,
            radius: 6.0.into(),
        },
        icon: color_subtext,
        placeholder: pal.overlay,
        value: color_text,
        selection: color_blue,
    };

    let logo = text("PEERSPEAK").size(38).color(color_blue);
    let subtitle = text("NAT-traversing full-mesh voice chat")
        .size(16)
        .color(color_subtext);
    let build_label = text(app_build_label()).size(11).color(color_subtext);

    let nickname_input = column![
        text("Nickname").size(14).color(color_subtext),
        vertical_space(4.0),
        context_input("Enter nickname...", &state.name)
            .on_input(AppMessage::NicknameChanged)
            .style(t_style)
            .padding(10)
    ];

    // Optional cosmetic room label (W7) above the Create button: it rides in the
    // minted ticket so everyone who joins inherits "in <name>". Enter also creates.
    let create_group = column![
        context_input("Room name (optional)", &state.room_name_input)
            .on_input(AppMessage::RoomNameChanged)
            .on_submit(AppMessage::CreatePressed)
            .style(t_style)
            .padding(10),
        vertical_space(8.0),
        button(btn_content(
            IconKind::Create,
            "Create New Room",
            color_crust
        ))
        .on_press(AppMessage::CreatePressed)
        .style(b_style(color_blue, color_lavender, color_crust, 8.0))
        .padding(12)
        .width(iced::Length::Fill),
    ];

    let join_group = column![
        text("Join Existing Room").size(14).color(color_subtext),
        vertical_space(4.0),
        context_input("Paste room ticket here...", &state.ticket_input)
            .on_input(AppMessage::TicketInputChanged)
            .style(t_style)
            .padding(10),
        vertical_space(8.0),
        button(
            text("Join Room")
                .size(16)
                .align_x(iced::alignment::Horizontal::Center)
        )
        .on_press(AppMessage::JoinPressed)
        .style(b_style(color_surface, color_blue, color_text, 8.0))
        .padding(12)
        .width(iced::Length::Fill)
    ];

    let status = text(&state.status_message).size(14).color(color_subtext);

    container(
        column![
            logo,
            subtitle,
            build_label,
            vertical_space(20.0),
            nickname_input,
            vertical_space(16.0),
            create_group,
            vertical_space(16.0),
            text("— OR —")
                .size(12)
                .color(color_surface)
                .align_x(iced::alignment::Horizontal::Center),
            vertical_space(16.0),
            join_group,
            vertical_space(10.0),
            status
        ]
        .spacing(10)
        .align_x(iced::alignment::Horizontal::Center),
    )
    .style(c_style(color_mantle, color_surface, 12.0))
    .padding(32)
    .width(420)
    .into()
}

/// The "Recent rooms" card (W7 P5): a one-click rejoin list, in its own card so a
/// growing history never reflows the Connect card's Create/Join controls. Always
/// shown (with an empty-state hint when there's no history yet), mirroring the
/// Friends card so the feature is discoverable on a fresh install.
fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
    let pal = state.config.theme.palette();
    let color_crust = pal.crust;
    let color_mantle = pal.mantle;
    let color_surface = pal.surface;
    let color_text = pal.text;
    let color_subtext = pal.subtext;
    let color_maroon = pal.maroon;

    let c_style = move |bg: Color, b_color: Color, radius: f32| {
        move |_theme: &Theme| container::Style {
            text_color: Some(color_text),
            background: Some(Background::Color(bg)),
            border: Border {
                color: b_color,
                width: if b_color == Color::TRANSPARENT {
                    0.0
                } else {
                    1.0
                },
                radius: radius.into(),
            },
            ..Default::default()
        }
    };
    let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| {
        move |_theme: &Theme, status: button::Status| {
            let active_bg = match status {
                button::Status::Hovered => hover_bg,
                _ => bg,
            };
            button::Style {
                background: Some(Background::Color(active_bg)),
                text_color: text_c,
                border: Border {
                    color: Color::TRANSPARENT,
                    width: 0.0,
                    radius: radius.into(),
                },
                ..Default::default()
            }
        }
    };

    let empty = state.config.recents.is_empty();
    let content: Element<'_, AppMessage> = if empty {
        column![
            text("RECENT ROOMS").size(14).color(color_subtext),
            text("No recent rooms yet.").size(12).color(color_subtext),
        ]
        .spacing(4)
        .into()
    } else {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let mut rows = column![].spacing(6).width(iced::Length::Fill);
        for r in &state.config.recents {
            let label = {
                let n = crate::sanitize::sanitize_name(&r.name);
                if n.is_empty() {
                    "Untitled room".to_string()
                } else {
                    n
                }
            };
            let when = crate::recents::relative_time(now, r.joined_at);
            let entry = button(
                row![
                    text(label).size(14).color(color_text),
                    horizontal_space(),
                    text(when).size(11).color(color_subtext),
                ]
                .align_y(iced::alignment::Vertical::Center),
            )
            .on_press(AppMessage::JoinRecent(r.ticket.clone()))
            .style(b_style(color_crust, color_surface, color_text, 6.0))
            .padding(8)
            .width(iced::Length::Fill);
            rows = rows.push(
                row![
                    entry,
                    button(text("✕").size(12))
                        .on_press(AppMessage::RemoveRecent(r.ticket.clone()))
                        .style(b_style(color_surface, color_maroon, color_text, 6.0))
                        .padding(8),
                ]
                .spacing(6)
                .align_y(iced::alignment::Vertical::Center),
            );
        }

        column![
            text("RECENT ROOMS").size(18).color(color_text),
            text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.")
                .size(11)
                .color(color_subtext),
            vertical_space(10.0),
            rows,
        ]
        .spacing(6)
        .into()
    };

    container(content)
        .style(c_style(
            if empty { color_crust } else { color_mantle },
            color_surface,
            8.0,
        ))
        .padding(if empty { 16 } else { 24 })
        .width(if empty { 340 } else { 380 })
        .into()
}

fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
    let pal = state.config.theme.palette();
    let color_crust = pal.crust;
    let color_mantle = pal.mantle;
    let color_surface = pal.surface;
    let color_overlay = pal.overlay;
    let color_text = pal.text;
    let color_subtext = pal.subtext;
    let color_blue = pal.blue;
    let color_red = pal.red;
    let color_maroon = pal.maroon;
    let color_green = pal.green;
    let has_friends = !state.friends.list().is_empty();

    let c_style = move |bg: Color, b_color: Color, radius: f32| {
        move |_theme: &Theme| container::Style {
            text_color: Some(color_text),
            background: Some(Background::Color(bg)),
            border: Border {
                color: b_color,
                width: if b_color == Color::TRANSPARENT {
                    0.0
                } else {
                    1.0
                },
                radius: radius.into(),
            },
            ..Default::default()
        }
    };
    let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| {
        move |_theme: &Theme, status: button::Status| {
            let active_bg = match status {
                button::Status::Hovered => hover_bg,
                _ => bg,
            };
            button::Style {
                background: Some(Background::Color(active_bg)),
                text_color: text_c,
                border: Border {
                    color: Color::TRANSPARENT,
                    width: 0.0,
                    radius: radius.into(),
                },
                ..Default::default()
            }
        }
    };
    let t_style = move |_theme: &Theme, _status: text_input::Status| text_input::Style {
        background: Background::Color(color_crust),
        border: Border {
            color: color_surface,
            width: 1.0,
            radius: 6.0.into(),
        },
        icon: color_subtext,
        placeholder: color_overlay,
        value: color_text,
        selection: color_blue,
    };

    // The live friends list: status dot, inline rename, short id, remove.
    let mut friend_rows = column![].spacing(6).width(iced::Length::Fill);
    if !has_friends {
        friend_rows = friend_rows.push(text("No friends yet.").size(12).color(color_subtext));
    } else {
        for f in state.friends.list() {
            let fid = f.id;
            let id_short = format!("{}…", short_id(&f.id.to_string()));
            // Live presence (W7 B2); a missing entry = offline. In-room → Join.
            let status: Element<AppMessage> = match state.friend_presence.get(&fid) {
                Some(crate::presence::FriendPresence::InRoom { name, ticket }) => {
                    let label = if name.is_empty() {
                        "in a room".to_string()
                    } else {
                        format!("in {name}")
                    };
                    row![
                        text(label).size(11).color(color_green),
                        button(text("Join").size(12))
                            .on_press(AppMessage::JoinFriendRoom(ticket.clone()))
                            .style(b_style(color_surface, color_blue, color_text, 6.0))
                            .padding(6),
                    ]
                    .spacing(6)
                    .align_y(iced::alignment::Vertical::Center)
                    .into()
                }
                Some(crate::presence::FriendPresence::Online) => {
                    text("● online").size(11).color(color_green).into()
                }
                // An explicit Offline (probe failed / no address) and a missing
                // entry (not yet probed) both render as offline.
                Some(crate::presence::FriendPresence::Offline) | None => {
                    text("○ offline").size(11).color(color_subtext).into()
                }
            };
            friend_rows = friend_rows.push(
                row![
                    context_input("name", &f.name)
                        .on_input(move |v| AppMessage::RenameFriend(fid, v))
                        .style(t_style)
                        .padding(6)
                        .width(iced::Length::Fixed(140.0)),
                    status,
                    horizontal_space(),
                    text(id_short).size(11).color(color_subtext),
                    button(text("✕").size(12))
                        .on_press(AppMessage::RemoveFriend(fid))
                        .style(b_style(color_surface, color_maroon, color_text, 6.0))
                        .padding(6),
                ]
                .spacing(8)
                .align_y(iced::alignment::Vertical::Center),
            );
        }
    }

    let friend_add_error: Element<AppMessage> = match &state.friend_add_error {
        Some(e) => text(e).size(11).color(color_red).into(),
        None => column![].into(),
    };
    // A16: read-only when core couldn't load friends.json (never overwrite it).
    let readonly_warning: Element<AppMessage> = if state.friends_read_only {
        text("⚠ friends.json couldn't load — read-only so it isn't overwritten. Fix or remove it, then restart.")
            .size(11)
            .color(color_red)
            .into()
    } else {
        column![].into()
    };

    let add_form = column![
        context_input("Friend's node ID", &state.friend_add_id)
            .on_input(AppMessage::FriendAddIdChanged)
            .style(t_style)
            .padding(6),
        vertical_space(6.0),
        row![
            context_input("Name (optional)", &state.friend_add_name)
                .on_input(AppMessage::FriendAddNameChanged)
                .style(t_style)
                .padding(6)
                .width(iced::Length::Fill),
            button(text("Add").size(13))
                .on_press(AppMessage::AddFriend)
                .style(b_style(color_surface, color_blue, color_text, 6.0))
                .padding(8),
        ]
        .spacing(8)
        .align_y(iced::alignment::Vertical::Center),
        friend_add_error,
    ]
    .spacing(0)
    .width(iced::Length::Fill);

    // Presence posture: a compact dropdown (mirrors the Settings NetworkMode
    // picker) with a one-line explainer for the current choice below it.
    let presence_picker = column![
        pick_list(
            &PresenceMode::ALL[..],
            Some(state.config.presence_mode),
            AppMessage::PresenceModeSelected,
        )
        .width(iced::Length::Fill),
        text(presence_mode_hint(state.config.presence_mode))
            .size(11)
            .color(color_subtext),
    ]
    .spacing(4)
    .width(iced::Length::Fill);
    let intro: Element<'_, AppMessage> = if has_friends {
        text("Who's online — click Join to hop into a friend's room.")
            .size(11)
            .color(color_subtext)
            .into()
    } else {
        column![].into()
    };
    // Header: title + a "scanned Nm ago" indicator + a manual "Rescan" button.
    // Presence is also auto-refreshed every 15s, but the button forces an immediate
    // pass for instant feedback. Only shown when there are friends to scan. The
    // indicator advances live via the RescanLabelTick subscription.
    let scanned_label: Element<'_, AppMessage> = match state.last_rescan {
        Some(t) => text(format!("scanned {}", format_relative_ago(t.elapsed())))
            .size(11)
            .color(color_subtext)
            .into(),
        None => column![].into(),
    };
    let title_row: Element<'_, AppMessage> = if has_friends {
        row![
            text("FRIENDS").size(18).color(color_text),
            horizontal_space(),
            scanned_label,
            button(text("⟳ Rescan").size(12))
                .on_press(AppMessage::RefreshFriends)
                .style(b_style(color_surface, color_blue, color_text, 6.0))
                .padding(6),
        ]
        .spacing(8)
        .align_y(iced::alignment::Vertical::Center)
        .into()
    } else {
        text("FRIENDS").size(14).color(color_text).into()
    };

    container(
        column![
            title_row,
            intro,
            vertical_space(if has_friends { 10.0 } else { 4.0 }),
            readonly_warning,
            friend_rows,
            vertical_space(if has_friends { 12.0 } else { 8.0 }),
            text("Add a friend").size(13).color(color_subtext),
            add_form,
            vertical_space(if has_friends { 14.0 } else { 10.0 }),
            text("Your presence").size(13).color(color_subtext),
            presence_picker,
        ]
        .spacing(6),
    )
    .style(c_style(color_mantle, color_surface, 12.0))
    .padding(if has_friends { 24 } else { 18 })
    .width(if has_friends { 460 } else { 360 })
    .into()
}

/// Wrap the main [`view`] with the custom background layer (W16). When a
/// background image is set, render `stack![ image(Cover), scrim, ui ]` so the
/// photo sits behind the whole UI with a legibility scrim (the theme base colour
/// at `background_dim` alpha) between them; otherwise return the UI untouched. The
/// three screen roots go transparent (`root_bg` in `view`) so the image shows
/// through the gaps between panels. This is the registered top-level view.
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
    let content = view(state);
    // Compose the base UI (optionally over a background image), then route the
    // whole thing through the lightbox overlay so a click-to-enlarge image paints
    // on top of everything (background + content).
    let composed: Element<'_, AppMessage> = if let Some(bytes) = state.background_image.clone() {
        let pal = state.config.theme.palette();
        let dim = state.config.background_dim;
        let image_layer = iced::widget::image(cached_image_handle(bytes))
            .content_fit(iced::ContentFit::Cover)
            .width(iced::Length::Fill)
            .height(iced::Length::Fill);
        let scrim = container(
            iced::widget::Space::new()
                .width(iced::Length::Fill)
                .height(iced::Length::Fill),
        )
        .width(iced::Length::Fill)
        .height(iced::Length::Fill)
        .style(move |_: &Theme| container::Style {
            background: Some(Background::Color(crate::background::scrim_color(
                pal.base, dim,
            ))),
            ..Default::default()
        });
        iced::widget::stack![image_layer, scrim, content]
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .into()
    } else {
        content
    };
    with_image_lightbox(composed, state)
}

fn view(state: &AppState) -> Element<'_, AppMessage> {
    // Theme colours — sourced from the active palette (see `src/theme.rs`), so
    // all styling below re-themes when the user picks a different theme.
    let pal = state.config.theme.palette();
    let color_crust = pal.crust;
    let color_mantle = pal.mantle;
    let color_base = pal.base;
    let color_surface = pal.surface;
    let color_overlay = pal.overlay;
    let color_text = pal.text;
    let color_subtext = pal.subtext;
    let color_blue = pal.blue;
    let color_lavender = pal.lavender;
    let color_red = pal.red;
    let color_maroon = pal.maroon;
    let color_green = pal.green;
    let color_yellow = pal.yellow;

    // The window backdrop fill for the three screen roots. When a custom
    // background image is set (W16), the root goes transparent so the image +
    // scrim layered behind by `view_with_background` shows through the gaps
    // between panels; otherwise it's the usual opaque `crust`.
    let root_bg = if state.background_image.is_some() {
        Color::TRANSPARENT
    } else {
        color_crust
    };

    // Style Helpers
    let c_style = move |bg: Color, b_color: Color, radius: f32| {
        move |_theme: &Theme| container::Style {
            text_color: Some(color_text),
            background: Some(Background::Color(bg)),
            border: Border {
                color: b_color,
                width: if b_color == Color::TRANSPARENT {
                    0.0
                } else {
                    1.0
                },
                radius: radius.into(),
            },
            ..Default::default()
        }
    };

    let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| {
        move |_theme: &Theme, status: button::Status| {
            let active_bg = match status {
                button::Status::Hovered => hover_bg,
                _ => bg,
            };
            button::Style {
                background: Some(Background::Color(active_bg)),
                text_color: text_c,
                border: Border {
                    color: Color::TRANSPARENT,
                    width: 0.0,
                    radius: radius.into(),
                },
                ..Default::default()
            }
        }
    };

    let t_style = move |_theme: &Theme, _status: text_input::Status| text_input::Style {
        background: Background::Color(color_crust),
        border: Border {
            color: color_surface,
            width: 1.0,
            radius: 6.0.into(),
        },
        icon: color_subtext,
        placeholder: color_overlay,
        value: color_text,
        selection: color_blue,
    };

    // The Hotkeys info button is always available (hotkeys are app-wide). The
    // room-only player button is hidden off-room; the layout button is hidden on Home.
    let info_button = tooltip(
        button(icon(IconKind::Info, 18.0, color_text))
            .on_press(AppMessage::OpenHotkeyInfo)
            .style(b_style(color_surface, color_blue, color_text, 6.0))
            .padding(8),
        container(text("Hotkeys").size(11).color(color_text))
            .padding(8)
            .style(c_style(color_crust, color_surface, 6.0)),
        iced::widget::tooltip::Position::Bottom,
    )
    .gap(8);

    let player_bar_button: Element<'_, AppMessage> = if state.current_screen == Screen::Room {
        let active = state.config.show_player_bar;
        let bg = if active { color_blue } else { color_surface };
        let fg = if active { color_crust } else { color_text };
        tooltip(
            button(text("♪").size(15))
                .on_press(AppMessage::TogglePlayerBar)
                .style(b_style(bg, color_blue, fg, 6.0))
                .padding(8),
            container(text("Player bar").size(11).color(color_text))
                .padding(8)
                .style(c_style(color_crust, color_surface, 6.0)),
            iced::widget::tooltip::Position::Bottom,
        )
        .gap(8)
        .into()
    } else {
        iced::widget::Space::new().width(0.0).height(0.0).into()
    };

    let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home {
        iced::widget::Space::new().width(0.0).height(0.0).into()
    } else {
        tooltip(
            button(
                Canvas::new(LayoutIcon { fg: color_text })
                    .width(iced::Length::Fixed(18.0))
                    .height(iced::Length::Fixed(18.0)),
            )
            .on_press(AppMessage::OpenLayoutPicker)
            .style(b_style(color_surface, color_blue, color_text, 6.0))
            .padding(8),
            container(text("Room layout").size(11).color(color_text))
                .padding(8)
                .style(c_style(color_crust, color_surface, 6.0)),
            iced::widget::tooltip::Position::Bottom,
        )
        .gap(8)
        .into()
    };

    let top_bar = row![
        horizontal_space(),
        player_bar_button,
        info_button,
        layout_button,
        button(
            row![
                icon(IconKind::Settings, 15.0, color_text),
                text("Settings").size(14),
            ]
            .spacing(6)
            .align_y(iced::alignment::Vertical::Center)
        )
        .on_press(AppMessage::NavigateToSettings)
        .style(b_style(color_surface, color_blue, color_text, 6.0))
        .padding(8)
    ]
    .width(iced::Length::Fill)
    .padding(10)
    .spacing(8);

    if state.current_screen == Screen::Settings {
        let path_field = |label: &'static str, sound: Sound| {
            let path = state.custom_sound_path(sound);
            let validation_widget = match notify::validate_custom_path(path) {
                None => text(""),
                Some(true) => text("✓ File found").size(10).color(color_green),
                Some(false) => text("✗ File not found").size(10).color(color_red),
            };

            // Per-sound enable checkbox (W6). Interactive only while the master
            // toggle is on — without it every chime is muted anyway, so we drop
            // `on_toggle` to render it greyed out and inert.
            let mut enable = checkbox(state.config.sound_enabled(sound)).size(16);
            if state.config.notifications_enabled {
                enable = enable.on_toggle(move |on| AppMessage::ToggleSoundEnabled(sound, on));
            }

            column![
                row![
                    enable,
                    text(label).size(12).color(color_subtext),
                    horizontal_space(),
                    validation_widget,
                ]
                .spacing(6)
                .align_y(iced::alignment::Vertical::Center),
                row![
                    context_input("Default (embedded)...", path)
                        .on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
                        .style(t_style)
                        .padding(8)
                        .width(iced::Length::Fill),
                    button(text("Browse…").size(11))
                        .on_press(AppMessage::BrowseCustomSound(sound))
                        .style(b_style(color_surface, color_blue, color_text, 5.0))
                        .padding([8, 10]),
                ]
                .spacing(6)
                .width(iced::Length::Fill)
            ]
            .spacing(4)
            .width(iced::Length::Fill)
        };

        // Live mic level meter for gate calibration. Shares the gate slider's
        // 0..0.1 scale so you can read your voice against the threshold directly.
        // During a call the in-call meter feeds it; otherwise a "Test mic" toggle
        // spins up a standalone capture stream.
        let in_call = !state.ticket.is_empty();
        let mic_test_control: Element<'_, AppMessage> = if in_call {
            text("Live (in call)").size(11).color(color_green).into()
        } else {
            let (mic_kind, label, bg) = if state.mic_test_active {
                (IconKind::Stop, "Stop mic test", color_red)
            } else {
                (IconKind::Mic, "Test mic", color_surface)
            };
            button(
                row![icon(mic_kind, 13.0, color_text), text(label).size(12),]
                    .spacing(5)
                    .align_y(iced::alignment::Vertical::Center),
            )
            .on_press(AppMessage::ToggleMicTest(!state.mic_test_active))
            .style(b_style(bg, color_blue, color_text, 6.0))
            .padding(6)
            .into()
        };
        // Unified meter + draggable gate (Discord/OBS-style): the live mic level
        // fills the bar and the yellow handle is the gate threshold, dragged
        // directly on the same axis. Green fill = above the gate (transmitting),
        // dim = below it (muted). Live status word reinforces the colour.
        let gate_thresh = state.config.noise_gate_threshold;
        let speaking = state.mic_level >= 0.001;
        let passing = speaking && state.mic_level >= gate_thresh;
        let (status_label, status_color) = if !speaking {
            ("○ Idle", color_subtext)
        } else if passing {
            ("● Transmitting", color_green)
        } else {
            ("● Muted by gate", color_red)
        };
        let gate_meter = Canvas::new(GateMeter {
            level: state.mic_level,
            threshold: gate_thresh,
            track: color_crust,
            border: color_surface,
            fill_on: color_green,
            fill_off: color_surface,
            handle: Color::from_rgb8(255, 40, 40),
            handle_edge: color_crust,
        })
        .width(iced::Length::Fill)
        .height(iced::Length::Fixed(20.0));
        let mic_meter = column![
            gate_meter,
            row![
                text(status_label).size(12).color(status_color),
                horizontal_space(),
                text(format!("gate {:.1}%", gate_thresh * 100.0))
                    .size(11)
                    .color(color_subtext),
                horizontal_space(),
                mic_test_control,
            ]
            .align_y(iced::alignment::Vertical::Center)
            .spacing(8),
        ]
        .spacing(6)
        .width(iced::Length::Fill);

        // Inline room-layout chooser (Settings shows the thumbnails outright, no
        // popup button). Same SelectRoomLayout message, applied live + persisted.
        let layout_choice = |layout: RoomLayout, label: &'static str| -> Element<'_, AppMessage> {
            let selected = state.config.room_layout == layout;
            let tile = Canvas::new(LayoutThumb {
                layout,
                selected,
                base: color_base,
                surface: color_surface,
                overlay: color_overlay,
                border: if selected { color_blue } else { color_surface },
            })
            .width(iced::Length::Fixed(132.0))
            .height(iced::Length::Fixed(86.0));
            column![
                button(tile)
                    .on_press(AppMessage::SelectRoomLayout(layout))
                    .padding(2)
                    .style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
                text(label)
                    .size(11)
                    .color(if selected { color_blue } else { color_subtext }),
            ]
            .spacing(4)
            .align_x(iced::alignment::Horizontal::Center)
            .into()
        };

        // Inline theme chooser — a clickable palette-preview swatch per theme.
        // Same SelectTheme message, applied live + persisted.
        let theme_choice = |t: AppTheme| -> Element<'_, AppMessage> {
            let selected = state.config.theme == t;
            let tile = Canvas::new(ThemeSwatch {
                palette: t.palette(),
                selected,
                border: if selected { color_blue } else { color_surface },
            })
            .width(iced::Length::Fixed(120.0))
            .height(iced::Length::Fixed(64.0));
            column![
                button(tile)
                    .on_press(AppMessage::SelectTheme(t))
                    .padding(2)
                    .style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
                text(t.label())
                    .size(11)
                    .color(if selected { color_blue } else { color_subtext }),
            ]
            .spacing(4)
            .align_x(iced::alignment::Horizontal::Center)
            .into()
        };
        // 10 themes laid out as two rows of five (no flex-wrap in iced 0.14).
        let theme_row1: Vec<Element<'_, AppMessage>> = AppTheme::ALL[0..5]
            .iter()
            .map(|&t| theme_choice(t))
            .collect();
        let theme_row2: Vec<Element<'_, AppMessage>> = AppTheme::ALL[5..]
            .iter()
            .map(|&t| theme_choice(t))
            .collect();
        // Title comes from the "Theme" section header (added below), so this body
        // is just the swatch rows + hint.
        let theme_section = column![
            iced::widget::Row::with_children(theme_row1).spacing(12),
            iced::widget::Row::with_children(theme_row2).spacing(12),
            text("Colour theme for the whole UI. Applies live.")
                .size(11)
                .color(color_subtext),
        ]
        .spacing(10)
        .width(iced::Length::Fill);

        let remove_background: Element<'_, AppMessage> = if state.config.background.is_some() {
            button(text("Remove background").size(13))
                .on_press(AppMessage::RemoveBackground)
                .style(b_style(color_surface, color_blue, color_text, 6.0))
                .padding(8)
                .into()
        } else {
            iced::widget::Space::new().width(0.0).height(0.0).into()
        };
        let background_section = column![
            row![
                button(text("Choose image…").size(13))
                    .on_press(AppMessage::PickBackgroundFile)
                    .style(b_style(color_surface, color_blue, color_text, 6.0))
                    .padding(8),
                remove_background,
            ].spacing(8),
            text(format!("Background dimming: {:.0}%", state.config.background_dim * 100.0))
                .size(11)
                .color(color_subtext),
            slider(0.0..=1.0, state.config.background_dim, AppMessage::SetBackgroundDim)
                .step(0.05),
            text("Set a picture from your computer as the app background. Auto-resized; a dimming overlay keeps text readable. Applies live.")
                .size(11)
                .color(color_subtext),
        ]
        .spacing(10)
        .width(iced::Length::Fill);

        // Inline avatar chooser (W4): the monogram fallback plus the bundled
        // presets, each a clickable tile. Same SelectAvatar message, applied live
        // + persisted (and re-announced to the room).
        let avatar_choice = |a: crate::avatar::Avatar, label: String| -> Element<'_, AppMessage> {
            let selected = state.config.avatar == a;
            let preview = avatar_view(&a, &state.name, &state.self_id, 52.0);
            column![
                button(
                    container(preview)
                        .center_x(iced::Length::Fixed(60.0))
                        .center_y(iced::Length::Fixed(60.0))
                )
                .on_press(AppMessage::SelectAvatar(a))
                .padding(2)
                .style(b_style(
                    if selected {
                        color_surface
                    } else {
                        Color::TRANSPARENT
                    },
                    color_surface,
                    color_text,
                    8.0,
                )),
                text(label)
                    .size(11)
                    .color(if selected { color_blue } else { color_subtext }),
            ]
            .spacing(4)
            .align_x(iced::alignment::Horizontal::Center)
            .into()
        };
        let mut avatar_tiles: Vec<Element<'_, AppMessage>> = Vec::new();
        // Show the current custom avatar (if any) as the first, selected tile.
        if matches!(state.config.avatar, crate::avatar::Avatar::Custom(_)) {
            avatar_tiles.push(avatar_choice(
                state.config.avatar.clone(),
                "Custom".to_string(),
            ));
        }
        avatar_tiles.push(avatar_choice(
            crate::avatar::Avatar::Monogram,
            "Monogram".to_string(),
        ));
        for i in 0..crate::avatar::PRESET_COUNT {
            avatar_tiles.push(avatar_choice(
                crate::avatar::Avatar::Preset(i),
                format!("Preset {}", i + 1),
            ));
        }
        // Wrap into rows of four (no flex-wrap in iced 0.14) so the tiles don't
        // run off a narrow Settings panel.
        let mut tile_rows: Vec<Element<'_, AppMessage>> = Vec::new();
        let mut tiles_iter = avatar_tiles.into_iter();
        loop {
            let chunk: Vec<Element<'_, AppMessage>> = tiles_iter.by_ref().take(4).collect();
            if chunk.is_empty() {
                break;
            }
            tile_rows.push(iced::widget::Row::with_children(chunk).spacing(12).into());
        }
        let upload_btn = button(text("Upload image…").size(13))
            .on_press(AppMessage::PickAvatarFile)
            .style(b_style(color_surface, color_blue, color_text, 6.0))
            .padding(8);
        let avatar_section = column![
            iced::widget::Column::with_children(tile_rows).spacing(12),
            upload_btn,
            text("Shown next to your name in the room and chat. Custom images are PNG/JPEG, auto-resized. Applies live.")
                .size(11)
                .color(color_subtext),
        ]
        .spacing(10)
        .width(iced::Length::Fill);

        // Reusable category header: a coloured title with a thin full-width
        // divider beneath, so each class of settings reads as its own section.
        let section_header = |title: &'static str| -> Element<'_, AppMessage> {
            column![
                text(title).size(16).color(color_blue),
                container(text(""))
                    .width(iced::Length::Fill)
                    .height(iced::Length::Fixed(1.0))
                    .style(c_style(color_surface, Color::TRANSPARENT, 0.0)),
            ]
            .spacing(6)
            .width(iced::Length::Fill)
            .into()
        };

        // Spacing between one category and the next.
        let section_gap = 18.0;

        let screen_share = &state.config.screen_share;
        let screen_share_bitrate = screen_share
            .bitrate_mbps
            .map(|v| v.to_string())
            .unwrap_or_default();
        let screen_share_section = column![
            column![
                text("Host encoding").size(13).color(color_subtext),
                row![
                    column![
                        text("Quality").size(12).color(color_subtext),
                        pick_list(
                            &ShareQuality::ALL[..],
                            Some(screen_share.quality),
                            AppMessage::ScreenShareQualitySelected,
                        ).width(iced::Length::Fill),
                        text(share_quality_hint(screen_share.quality)).size(11).color(color_subtext),
                    ].spacing(4).width(iced::Length::Fill),
                    column![
                        text("Max resolution").size(12).color(color_subtext),
                        pick_list(
                            &ShareMaxHeightChoice::ALL[..],
                            Some(ShareMaxHeightChoice::from_config(screen_share.max_height)),
                            AppMessage::ScreenShareMaxHeightSelected,
                        ).width(iced::Length::Fill),
                        text("Source keeps the captured display height.").size(11).color(color_subtext),
                    ].spacing(4).width(iced::Length::Fill),
                ].spacing(16).width(iced::Length::Fill),
                row![
                    column![
                        text("Framerate").size(12).color(color_subtext),
                        pick_list(
                            &ShareFramerateChoice::ALL[..],
                            Some(ShareFramerateChoice::from_config(screen_share.framerate)),
                            AppMessage::ScreenShareFramerateSelected,
                        ).width(iced::Length::Fill),
                        text("Preset default lets pixelpass choose.").size(11).color(color_subtext),
                    ].spacing(4).width(iced::Length::Fill),
                    column![
                        text("Bitrate Mbps").size(12).color(color_subtext),
                        context_input("preset default", &screen_share_bitrate)
                            .on_input(AppMessage::ScreenShareBitrateChanged)
                            .style(t_style)
                            .padding(8)
                            .width(iced::Length::Fill),
                        text("Blank uses the quality preset.").size(11).color(color_subtext),
                    ].spacing(4).width(iced::Length::Fill),
                ].spacing(16).width(iced::Length::Fill),
                row![
                    column![
                        text("Max viewers").size(12).color(color_subtext),
                        pick_list(
                            &ShareMaxViewersChoice::ALL[..],
                            Some(ShareMaxViewersChoice::from_config(screen_share.max_viewers)),
                            AppMessage::ScreenShareMaxViewersSelected,
                        ).width(iced::Length::Fill),
                        text("Auto uses pixelpass' connection-aware recommendation.").size(11).color(color_subtext),
                    ].spacing(4).width(iced::Length::Fill),
                    column![
                        checkbox(screen_share.force_software_encode)
                            .label("Force software encode")
                            .on_toggle(AppMessage::ToggleScreenShareForceSoftwareEncode),
                        text("Passes --no-hwencode to pixelpass.").size(11).color(color_subtext),
                    ].spacing(8).width(iced::Length::Fill),
                ].spacing(16).width(iced::Length::Fill),
            ].spacing(10).width(iced::Length::Fill),
            vertical_space(section_gap),
            column![
                text("Viewer playback").size(13).color(color_subtext),
                row![
                    column![
                        text("Player").size(12).color(color_subtext),
                        pick_list(
                            &SharePlayer::ALL[..],
                            Some(screen_share.player),
                            AppMessage::ScreenSharePlayerSelected,
                        ).width(iced::Length::Fill),
                        text(share_player_hint(screen_share.player)).size(11).color(color_subtext),
                    ].spacing(4).width(iced::Length::Fill),
                    column![
                        text("Buffering").size(12).color(color_subtext),
                        pick_list(
                            &ShareBuffering::ALL[..],
                            Some(screen_share.buffering),
                            AppMessage::ScreenShareBufferingSelected,
                        ).width(iced::Length::Fill),
                        text(share_buffering_hint(screen_share.buffering)).size(11).color(color_subtext),
                    ].spacing(4).width(iced::Length::Fill),
                ].spacing(16).width(iced::Length::Fill),
                row![
                    column![
                        text("Cache MB").size(12).color(color_subtext),
                        pick_list(
                            &SHARE_CACHE_MB_OPTIONS[..],
                            Some(screen_share.cache_mb),
                            AppMessage::ScreenShareCacheMbSelected,
                        ).width(iced::Length::Fill),
                        text("mpv demuxer cache size (mpv only).").size(11).color(color_subtext),
                    ].spacing(4).width(iced::Length::Fill),
                    column![
                        checkbox(screen_share.hardware_decode)
                            .label("Hardware video decode")
                            .on_toggle(AppMessage::ToggleScreenShareHardwareDecode),
                        text("GPU decode (mpv --hwdec=auto / VLC hardware decode). Off avoids the known frame-freeze bug.").size(11).color(color_subtext),
                    ].spacing(8).width(iced::Length::Fill),
                ].spacing(16).width(iced::Length::Fill),
            ].spacing(10).width(iced::Length::Fill),
            vertical_space(section_gap),
            column![
                text("Extra mpv args").size(12).color(color_subtext),
                context_input("--no-osc --vd-lavc-threads=2", &screen_share.extra_mpv_args)
                    .on_input(AppMessage::ScreenShareExtraMpvArgsChanged)
                    .style(t_style)
                    .padding(8)
                    .width(iced::Length::Fill),
                text("⚠ Advanced — may break playback").size(11).color(color_yellow),
                text("Extra pixelpass args").size(12).color(color_subtext),
                context_input("--relay https://relay.example/", &screen_share.extra_host_args)
                    .on_input(AppMessage::ScreenShareExtraHostArgsChanged)
                    .style(t_style)
                    .padding(8)
                    .width(iced::Length::Fill),
                text("⚠ Advanced — may break playback").size(11).color(color_yellow),
            ].spacing(6).width(iced::Length::Fill),
            text("Applies the next time you start or watch a screen share. These are local preferences only.").size(11).color(color_subtext),
        ]
        .spacing(10)
        .width(iced::Length::Fill);

        // One recording-mode radio with a hover tooltip explaining it. (iced's
        // pick_list can't host per-option tooltips, so the modes are radios.)
        let mode_radio = |mode: RecordingMode, label: &'static str| -> Element<'_, AppMessage> {
            tooltip(
                radio(
                    label,
                    mode,
                    Some(state.config.recording_mode),
                    AppMessage::RecordingModeSelected,
                ),
                container(text(recording_mode_hint(mode)).size(11).color(color_text))
                    .padding(8)
                    .max_width(300.0)
                    .style(c_style(color_crust, color_surface, 6.0)),
                iced::widget::tooltip::Position::Right,
            )
            .gap(8)
            .into()
        };

        let mut hotkey_rows = Column::new().spacing(8).width(iced::Length::Fill);
        for action in HotkeyAction::ALL {
            let capturing = state.hotkey_capture == Some(action);
            let binding = if capturing {
                "Press a key...".to_string()
            } else {
                format_binding(state.config.hotkeys.binding(action))
            };
            hotkey_rows = hotkey_rows.push(
                row![
                    column![
                        text(action.label()).size(13).color(color_text),
                        text(match action.tier() {
                            crate::hotkeys::HotkeyTier::AppWide => "App-wide",
                            crate::hotkeys::HotkeyTier::RoomOnly => "Room-only",
                        })
                        .size(10)
                        .color(color_subtext),
                    ]
                    .spacing(2)
                    .width(iced::Length::Fill),
                    container(text(binding).size(12).color(if capturing {
                        color_yellow
                    } else {
                        color_subtext
                    }))
                    .width(iced::Length::Fixed(110.0))
                    .align_x(iced::alignment::Horizontal::Right),
                    button(text("Set").size(12))
                        .on_press(AppMessage::StartHotkeyCapture(action))
                        .style(b_style(color_surface, color_blue, color_text, 6.0))
                        .padding(6),
                    button(text("Clear").size(12))
                        .on_press(AppMessage::ClearHotkey(action))
                        .style(b_style(color_surface, color_red, color_text, 6.0))
                        .padding(6),
                ]
                .spacing(10)
                .align_y(iced::alignment::Vertical::Center),
            );
        }
        let hotkey_conflicts = state.config.hotkeys.conflicts();
        let conflict_block: Element<'_, AppMessage> = if hotkey_conflicts.is_empty() {
            vertical_space(0.0).into()
        } else {
            let mut lines = Column::new().spacing(4);
            for conflict in hotkey_conflicts {
                lines = lines.push(
                    text(format!(
                        "Conflict: {} is assigned to {} and {}.",
                        conflict.binding.label(),
                        conflict.first.label(),
                        conflict.second.label()
                    ))
                    .size(11)
                    .color(color_red),
                );
            }
            lines.into()
        };
        let hotkey_section = column![
            hotkey_rows,
            conflict_block,
            text("Shortcuts work only while the PeerSpeak window has focus. Unset actions are ignored.")
                .size(11)
                .color(color_subtext),
        ]
        .spacing(8)
        .width(iced::Length::Fill);

        // --- Identity (W7) ---
        // Your persistent node id + a Regenerate control. When the key isn't
        // persisted (disk/permission failure → ephemeral fallback) we show a
        // standing red warning, because the id won't survive the next launch and
        // friends will stop recognising you.
        let id_display = state
            .self_node_id
            .as_deref()
            .map(|id| format!("{}…", short_id(id)))
            .unwrap_or_else(|| "(starting…)".to_string());
        let identity_warning: Element<AppMessage> = if state.identity_persisted {
            column![].into()
        } else {
            let reason = state
                .identity_error
                .as_deref()
                .unwrap_or("the key file could not be read or written");
            container(
                column![
                    text("⚠ Identity not saved").size(13).color(color_red),
                    text(format!(
                        "Your identity couldn't be saved to disk ({reason}). It won't \
                         survive the next launch, so your friends will stop recognising \
                         you. Check free space and permissions on ~/.config/peerspeak/."
                    ))
                    .size(12)
                    .color(color_subtext),
                ]
                .spacing(4),
            )
            .padding(10)
            .width(iced::Length::Fill)
            .style(move |_t: &Theme| container::Style {
                background: Some(Background::Color(Color {
                    a: 0.12,
                    ..color_red
                })),
                border: Border {
                    color: color_red,
                    width: 1.0,
                    radius: 8.0.into(),
                },
                ..Default::default()
            })
            .into()
        };
        // The ID line exposes the full value in a locked selectable field while
        // keeping the one-click Copy button for fast whole-ID copy.
        let id_row: Element<AppMessage> = match state.self_node_id.clone() {
            Some(full) => row![
                text("ID:").size(13).color(color_text),
                locked_value(&full, AppMessage::Noop)
                    .width(iced::Length::Fixed(260.0))
                    .size(13)
                    .padding(4),
                button(
                    row![
                        icon(IconKind::Copy, 13.0, color_text),
                        text("Copy").size(12),
                    ]
                    .spacing(5)
                    .align_y(iced::alignment::Vertical::Center)
                )
                .on_press(AppMessage::CopyText(full.clone()))
                .style(b_style(color_surface, color_blue, color_text, 6.0))
                .padding(6),
            ]
            .spacing(10)
            .align_y(iced::alignment::Vertical::Center)
            .into(),
            None => text(format!("ID: {id_display}"))
                .size(13)
                .color(color_text)
                .into(),
        };
        let identity_section = column![
            text(
                "Your permanent ID — friends recognise you by this. It stays the \
                  same across launches; regenerate only to start fresh as a new \
                  identity (friends who saved the old one will no longer reach you)."
            )
            .size(12)
            .color(color_subtext),
            id_row,
            button(text("Regenerate identity").size(13))
                .on_press(AppMessage::OpenRegenerateIdentityConfirm)
                .style(b_style(color_surface, color_maroon, color_text, 6.0))
                .padding(8),
            identity_warning,
        ]
        .spacing(8)
        .width(iced::Length::Fill);

        // Presence + Friends moved to the home screen (see `friends_panel`).

        let settings_body: Element<'_, AppMessage> = match state.settings_category {
            SettingsCategory::Audio => column![
                section_header("Audio Devices"),
                row![
                    column![
                        text("Input Device").size(12).color(color_subtext),
                        pick_list(
                            &state.input_devices[..],
                            state.selected_input.as_ref(),
                            AppMessage::InputDeviceSelected,
                        ).width(iced::Length::Fill),
                        text(format!("Input Volume (mic): {:.0}%", state.config.input_volume * 100.0)).size(11).color(color_subtext),
                        slider(0.0..=2.0, state.config.input_volume, AppMessage::InputVolumeChanged)
                            .step(0.05)
                            .on_release(AppMessage::PersistConfig),
                    ].spacing(8).width(iced::Length::Fill),
                    column![
                        text("Output Device").size(12).color(color_subtext),
                        pick_list(
                            &state.output_devices[..],
                            state.selected_output.as_ref(),
                            AppMessage::OutputDeviceSelected,
                        ).width(iced::Length::Fill),
                        text(format!("Output Volume: {:.0}%", state.config.output_volume * 100.0)).size(11).color(color_subtext),
                        slider(0.0..=2.0, state.config.output_volume, AppMessage::OutputVolumeChanged)
                            .step(0.05)
                            .on_release(AppMessage::PersistConfig),
                    ].spacing(8).width(iced::Length::Fill),
                ].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
                vertical_space(section_gap),
                section_header("Microphone"),
                column![
                    mic_meter,
                    text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext),
                    vertical_space(4.0),
                    {
                        let control: Element<'_, AppMessage> = {
                            #[cfg(target_os = "linux")]
                            {
                                column![
                                    checkbox(state.config.echo_cancellation_enabled)
                                        .label("Echo cancellation")
                                        .on_toggle(AppMessage::ToggleEchoCancellation),
                                    text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
                                ].spacing(8).into()
                            }
                            #[cfg(not(target_os = "linux"))]
                            {
                                column![
                                    checkbox(false)
                                        .label("Echo cancellation"),
                                    text("Echo cancellation is not available on Windows yet.").size(11).color(color_subtext),
                                ].spacing(8).into()
                            }
                        };
                        control
                    },
                ].spacing(8).width(iced::Length::Fill),
                vertical_space(section_gap),
                section_header("Connection quality"),
                column![
                    pick_list(
                        &AudioProfile::ALL[..],
                        Some(state.config.audio_profile),
                        AppMessage::AudioProfileSelected,
                    ).width(iced::Length::Fill),
                    text(audio_profile_hint(state.config.audio_profile)).size(11).color(color_subtext),
                    text("Applies immediately, even mid-call.").size(11).color(color_subtext),
                ].spacing(4).width(iced::Length::Fill),
            ]
            .spacing(10)
            .width(iced::Length::Fill)
            .into(),
            SettingsCategory::Hotkeys => column![
                section_header("Hotkeys"),
                hotkey_section,
            ]
            .spacing(10)
            .width(iced::Length::Fill)
            .into(),
            SettingsCategory::Recording => column![
                section_header("Recording"),
                column![
                    mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
                    mode_radio(RecordingMode::Multitrack, "Multitrack (per-peer stems)"),
                    mode_radio(RecordingMode::Both, "Both (stems + mixed)"),
                    vertical_space(2.0),
                    text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext),
                ].spacing(8).width(iced::Length::Fill),
            ]
            .spacing(10)
            .width(iced::Length::Fill)
            .into(),
            SettingsCategory::Profile => column![
                section_header("Avatar"),
                avatar_section,
                vertical_space(section_gap),
                section_header("Identity"),
                identity_section,
            ]
            .spacing(10)
            .width(iced::Length::Fill)
            .into(),
            SettingsCategory::Appearance => column![
                section_header("Room Layout"),
                column![
                    row![
                        layout_choice(RoomLayout::ThreeColumn, "3-Column"),
                        layout_choice(RoomLayout::BottomDock, "Bottom Dock"),
                        layout_choice(RoomLayout::Drawer, "Drawer"),
                    ].spacing(16),
                    text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext),
                ].spacing(8).width(iced::Length::Fill),
                vertical_space(section_gap),
                section_header("Theme"),
                theme_section,
                vertical_space(section_gap),
                section_header("Background"),
                background_section,
            ]
            .spacing(10)
            .width(iced::Length::Fill)
            .into(),
            SettingsCategory::Network => column![
                section_header("Network & Privacy"),
                column![
                    pick_list(
                        &NetworkMode::ALL[..],
                        Some(state.config.network_mode),
                        AppMessage::NetworkModeSelected,
                    ).width(iced::Length::Fill),
                    text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
                    text("Takes effect on your next room join.").size(11).color(color_subtext),
                ].spacing(4).width(iced::Length::Fill),
            ]
            .spacing(10)
            .width(iced::Length::Fill)
            .into(),
            SettingsCategory::Advanced => column![
                section_header("Screen sharing"),
                screen_share_section,
            ]
            .spacing(10)
            .width(iced::Length::Fill)
            .into(),
            SettingsCategory::Notifications => column![
                section_header("Notifications & Sounds"),
                column![
                    checkbox(state.config.notifications_enabled)
                        .label("Enable sound notifications")
                        .on_toggle(AppMessage::ToggleNotifications),
                    vertical_space(6.0),
                    text("Tick a sound to enable its chime; untick to silence just that one. Optional WAV path overrides the built-in sound (blank = built-in).").size(12).color(color_subtext),
                    row![
                        path_field("Self Join", Sound::SelfJoin),
                        path_field("Peer Join", Sound::PeerJoin),
                    ].spacing(20).width(iced::Length::Fill),
                    row![
                        path_field("Self Leave", Sound::SelfLeave),
                        path_field("Peer Leave", Sound::PeerLeave),
                    ].spacing(20).width(iced::Length::Fill),
                    row![
                        path_field("Reconnect Attempt", Sound::ReconnectAttempt),
                        path_field("Reconnected", Sound::Reconnected),
                    ].spacing(20).width(iced::Length::Fill),
                    row![
                        path_field("Mic Toggle", Sound::MicToggle),
                        path_field("Reconnect Failed", Sound::ReconnectFailed),
                    ].spacing(20).width(iced::Length::Fill),
                    row![
                        path_field("Chat Sent", Sound::ChatSent),
                        path_field("Chat Received", Sound::ChatReceived),
                    ].spacing(20).width(iced::Length::Fill),
                    row![
                        path_field("Contact Online", Sound::ContactOnline),
                        path_field("Contact Offline", Sound::ContactOffline),
                    ].spacing(20).width(iced::Length::Fill),
                ].spacing(8).width(iced::Length::Fill),
            ]
            .spacing(10)
            .width(iced::Length::Fill)
            .into(),
            SettingsCategory::Games => {
                // What's running right now (local detection), for context.
                let detected_label = match &state.current_game {
                    Some(g) => match &g.name {
                        Some(name) => format!("Currently detected: {name}"),
                        None => "Currently detected: (a game, no name available)".to_string(),
                    },
                    None => "Currently detected: nothing".to_string(),
                };

                // Manual override picker: Auto / None, plus "Pin current" when a
                // game is detected.
                let mut override_opts = vec![GameOverrideChoice::Auto, GameOverrideChoice::None];
                if state.current_game.is_some() {
                    override_opts.push(GameOverrideChoice::Current);
                }

                // Per-game background row for the running game (if any).
                let game_bg_section: Element<'_, AppMessage> = match &state.current_game {
                    Some(g) => {
                        let has_bg = state.config.game_backgrounds.contains_key(&g.id);
                        let id_for_pick = g.id.clone();
                        let mut controls = row![
                            button(text("Set background for this game").size(13))
                                .on_press(AppMessage::PickGameBackground(id_for_pick)),
                        ]
                        .spacing(8);
                        if has_bg {
                            let id_for_remove = g.id.clone();
                            controls = controls.push(
                                button(text("Remove").size(13))
                                    .on_press(AppMessage::RemoveGameBackground(id_for_remove)),
                            );
                        }
                        controls.into()
                    }
                    None => text("Start a game to set its background.")
                        .size(12)
                        .color(color_subtext)
                        .into(),
                };

                // The list of configured per-game backgrounds (by stable id).
                let mut bg_list = column![].spacing(4).width(iced::Length::Fill);
                for id in state.config.game_backgrounds.keys() {
                    let id_owned = id.clone();
                    bg_list = bg_list.push(
                        row![
                            text(id.clone()).size(12).width(iced::Length::Fill),
                            button(text("Remove").size(12))
                                .on_press(AppMessage::RemoveGameBackground(id_owned)),
                        ]
                        .spacing(8)
                        .width(iced::Length::Fill),
                    );
                }

                // The process→name mappings (non-Steam games), with an add form.
                let mut map_list = column![].spacing(4).width(iced::Length::Fill);
                for (exe, name) in &state.config.game_process_map {
                    let exe_owned = exe.clone();
                    map_list = map_list.push(
                        row![
                            text(format!("{exe}  →  {name}")).size(12).width(iced::Length::Fill),
                            button(text("Remove").size(12))
                                .on_press(AppMessage::RemoveGameMapping(exe_owned)),
                        ]
                        .spacing(8)
                        .width(iced::Length::Fill),
                    );
                }

                column![
                    section_header("Game presence"),
                    column![
                        checkbox(state.config.game_presence_enabled)
                            .label("Show the game I'm playing to people in the call")
                            .on_toggle(AppMessage::ToggleGamePresence),
                        text("Off by default. When on, your detected game appears next to your avatar for everyone in the room.")
                            .size(11).color(color_subtext),
                        vertical_space(6.0),
                        text(detected_label).size(12).color(color_subtext),
                        row![
                            text("Override:").size(13),
                            pick_list(
                                override_opts,
                                Some(state.game_override),
                                AppMessage::GameOverrideSelected,
                            ),
                        ].spacing(8),
                    ].spacing(6).width(iced::Length::Fill),
                    vertical_space(section_gap),
                    section_header("Per-game background"),
                    column![
                        text("Give a game its own UI background; it switches automatically while you play. Falls back to your custom background (Appearance) otherwise.")
                            .size(11).color(color_subtext),
                        game_bg_section,
                        bg_list,
                    ].spacing(8).width(iced::Length::Fill),
                    vertical_space(section_gap),
                    section_header("Non-Steam games"),
                    column![
                        text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
                            .size(11).color(color_subtext),
                        row![
                            context_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
                                .on_input(AppMessage::GameMapExeChanged)
                                .width(iced::Length::Fill),
                            context_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
                                .on_input(AppMessage::GameMapNameChanged)
                                .width(iced::Length::Fill),
                            button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
                        ].spacing(8).width(iced::Length::Fill),
                        map_list,
                    ].spacing(8).width(iced::Length::Fill),
                ]
                .spacing(10)
                .width(iced::Length::Fill)
                .into()
            }
        };

        let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
            let selected = state.settings_category == category;
            let label_color = if selected { color_blue } else { color_text };
            let border_color = if selected {
                color_blue
            } else {
                Color::TRANSPARENT
            };
            let bg = if selected {
                color_surface
            } else {
                Color::TRANSPARENT
            };
            button(
                container(
                    column![
                        text(category.label()).size(14).color(label_color),
                        text(category.hint()).size(11).color(color_subtext),
                    ]
                    .spacing(2)
                    .width(iced::Length::Fill),
                )
                .width(iced::Length::Fill),
            )
            .on_press(AppMessage::SelectSettingsCategory(category))
            .style(move |_theme: &Theme, status: button::Status| {
                let active_bg = match status {
                    button::Status::Hovered if selected => color_surface,
                    button::Status::Hovered => color_crust,
                    _ => bg,
                };
                button::Style {
                    background: Some(Background::Color(active_bg)),
                    text_color: label_color,
                    border: Border {
                        color: border_color,
                        width: if selected { 1.0 } else { 0.0 },
                        radius: 8.0.into(),
                    },
                    ..Default::default()
                }
            })
            .padding(10)
            .width(iced::Length::Fill)
            .into()
        };

        let mut settings_nav = column![text("SETTINGS").size(11).color(color_subtext),]
            .spacing(8)
            .width(iced::Length::Fill);
        for category in SettingsCategory::ALL {
            settings_nav = settings_nav.push(category_button(category));
        }
        settings_nav = settings_nav
            .push(iced::widget::Space::new().height(iced::Length::Fill))
            .push(text(app_build_label()).size(11).color(color_subtext));
        let settings_nav = container(settings_nav)
            .padding(12)
            .width(iced::Length::Fixed(220.0))
            .height(iced::Length::Fill)
            .style(c_style(color_crust, color_surface, 8.0));

        let settings_content: Element<'_, AppMessage> = if state.window_size.width < 820.0 {
            scrollable(
                column![
                    text("Category").size(12).color(color_subtext),
                    pick_list(
                        &SettingsCategory::ALL[..],
                        Some(state.settings_category),
                        AppMessage::SelectSettingsCategory,
                    )
                    .width(iced::Length::Fill),
                    vertical_space(10.0),
                    settings_body,
                    vertical_space(10.0),
                    text(app_build_label()).size(11).color(color_subtext),
                ]
                .spacing(8)
                .width(iced::Length::Fill),
            )
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .into()
        } else {
            row![
                settings_nav,
                scrollable(settings_body)
                    .width(iced::Length::Fill)
                    .height(iced::Length::Fill),
            ]
            .spacing(16)
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .into()
        };

        // Sticky header bar: stays fixed above the scrollable content so the Back
        // button is always reachable. The "Settings" title is centered by flanking
        // it with two equal-width Fill segments — the Back button lives in the left
        // one (left-aligned) and the right one is an empty balance, so the title is
        // mathematically centered regardless of the Back button's rendered width.
        let settings_header = container(
            row![
                container(
                    button(
                        row![text("←").size(16), text("Back").size(14),]
                            .spacing(6)
                            .align_y(iced::alignment::Vertical::Center)
                    )
                    .on_press(AppMessage::NavigateBack)
                    .style(b_style(color_surface, color_blue, color_text, 8.0))
                    .padding([8, 14])
                )
                .width(iced::Length::Fill),
                text("Settings").size(20).color(color_blue),
                // Equal-width balance spacer keeps the title centered.
                container(text("")).width(iced::Length::Fill),
            ]
            .align_y(iced::alignment::Vertical::Center)
            .width(iced::Length::Fill),
        )
        .padding([12, 16])
        .width(iced::Length::Fill)
        .style(c_style(color_crust, color_surface, 8.0));

        let settings_box = container(
            column![settings_header, vertical_space(12.0), settings_content,]
                .width(iced::Length::Fill)
                .height(iced::Length::Fill),
        )
        .style(c_style(color_mantle, color_surface, 12.0))
        .padding(24)
        .width(iced::Length::Fill)
        .height(iced::Length::Fill);

        let settings_screen = container(settings_box)
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .padding(24)
            .center_x(iced::Length::Fill)
            .style(c_style(root_bg, Color::TRANSPARENT, 0.0));

        return with_regenerate_confirm(settings_screen.into(), state);
    }

    if state.current_screen == Screen::Home {
        // --- HOME SCREEN ---
        // Keep Create/Join dominant on a fresh install. Once Recents or Friends
        // has real content, the wider three-card layout returns.
        let has_recents = !state.config.recents.is_empty();
        let has_friends = !state.friends.list().is_empty();
        let body = responsive(move |size| {
            let cards: Element<AppMessage> =
                match home_layout_mode(size.width, has_recents, has_friends) {
                    HomeLayoutMode::FocusedEmpty => row![
                        connect_card(state),
                        column![friends_panel(state), recents_card(state)]
                            .spacing(16)
                            .width(iced::Length::Fixed(360.0)),
                    ]
                    .spacing(22)
                    .align_y(iced::alignment::Vertical::Top)
                    .into(),
                    HomeLayoutMode::ThreeColumn => row![
                        recents_card(state),
                        connect_card(state),
                        friends_panel(state),
                    ]
                    .spacing(20)
                    .align_y(iced::alignment::Vertical::Top)
                    .into(),
                    HomeLayoutMode::Stacked => {
                        let mut stack = column![connect_card(state)]
                            .spacing(20)
                            .align_x(iced::alignment::Horizontal::Center);
                        if has_recents {
                            stack = stack.push(recents_card(state));
                        }
                        stack = stack.push(friends_panel(state));
                        if !has_recents {
                            stack = stack.push(recents_card(state));
                        }
                        stack.into()
                    }
                };
            scrollable(container(cards).center_x(iced::Length::Fill))
                .width(iced::Length::Fill)
                .into()
        });

        let home = container(
            column![top_bar, vertical_space(20.0), body]
                .align_x(iced::alignment::Horizontal::Center),
        )
        .width(iced::Length::Fill)
        .height(iced::Length::Fill)
        .style(c_style(root_bg, Color::TRANSPARENT, 0.0));

        with_hotkey_info(with_layout_picker(home.into(), state), state)
    } else {
        // --- ROOM SCREEN ---
        let participant_count = state.peers.len() + 1; // peers + you
        let call_secs = state
            .call_started
            .map(|t| t.elapsed().as_secs())
            .unwrap_or(0);
        // The room's cosmetic label (W7) rides in our share ticket; show it under
        // the wordmark when the room was named. Sanitized since a joined ticket is
        // peer-supplied.
        let room_label = crate::sanitize::sanitize_name(
            &crate::network::PeerSpeakTicket::label_of(&state.ticket),
        );
        let title: Element<'_, AppMessage> = if room_label.is_empty() {
            text("PEERSPEAK").size(20).color(color_blue).into()
        } else {
            column![
                text("PEERSPEAK").size(20).color(color_blue),
                text(room_label).size(13).color(color_subtext),
            ]
            .into()
        };
        let header = row![
            title,
            horizontal_space(),
            row![
                icon(IconKind::People, 15.0, color_subtext),
                text(format!("{participant_count} in room"))
                    .size(14)
                    .color(color_subtext),
            ]
            .spacing(5)
            .align_y(iced::alignment::Vertical::Center),
            row![
                icon(IconKind::Clock, 15.0, color_subtext),
                text(format_duration(call_secs))
                    .size(14)
                    .color(color_subtext),
            ]
            .spacing(5)
            .align_y(iced::alignment::Vertical::Center),
            if state.recording {
                let rec_secs = state
                    .recording_started
                    .map(|t| t.elapsed().as_secs())
                    .unwrap_or(0);
                container(
                    row![
                        icon(IconKind::Record, 12.0, color_red),
                        text(format!("REC {}", format_duration(rec_secs)))
                            .size(13)
                            .color(color_red),
                    ]
                    .spacing(5)
                    .align_y(iced::alignment::Vertical::Center),
                )
                .style(c_style(color_crust, color_red, 6.0))
                .padding(6)
            } else {
                container(text("")).padding(0)
            },
            horizontal_space(),
            text(format!("My ID: {}", short_id(&state.self_id)))
                .size(14)
                .color(color_subtext),
            row![
                text("Ticket:").size(12).color(color_subtext),
                locked_value(&state.ticket, AppMessage::Noop)
                    .width(iced::Length::Fixed(260.0))
                    .size(12)
                    .padding(4),
            ]
            .spacing(6)
            .align_y(iced::alignment::Vertical::Center),
            button(
                row![
                    icon(IconKind::Copy, 14.0, color_text),
                    text("Copy Ticket").size(12),
                ]
                .spacing(5)
                .align_y(iced::alignment::Vertical::Center)
            )
            .on_press(AppMessage::CopyToClipboard)
            .style(b_style(color_surface, color_blue, color_text, 6.0))
            .padding(6),
            // Drawer layout: a chat toggle (the drawer is collapsed by default).
            {
                let el: Element<'_, AppMessage> = if state.config.room_layout == RoomLayout::Drawer
                {
                    let (lbl, bg, fg) = if state.drawer_chat_open {
                        ("Hide chat", color_blue, color_crust)
                    } else {
                        ("Chat", color_surface, color_text)
                    };
                    button(
                        row![icon(IconKind::Chat, 14.0, fg), text(lbl).size(12),]
                            .spacing(5)
                            .align_y(iced::alignment::Vertical::Center),
                    )
                    .on_press(AppMessage::ToggleDrawerChat)
                    .style(b_style(bg, color_blue, fg, 6.0))
                    .padding(6)
                    .into()
                } else {
                    iced::widget::Space::new().width(0.0).height(0.0).into()
                };
                el
            }
        ]
        .spacing(16)
        .align_y(iced::alignment::Vertical::Center);

        let header_container = container(header)
            .style(c_style(color_mantle, color_surface, 8.0))
            .padding(15)
            .width(iced::Length::Fill);

        // Peers Column
        let mut peers_list = Column::new().spacing(10);

        // Add ourselves — name/status row plus a live mic meter so you can
        // confirm you're being picked up (and see mute / PTT / gate at work).
        let transmitting = !state.is_muted && (!state.ptt_enabled || state.ptt_active);
        let self_mic_color = if transmitting {
            color_green
        } else {
            color_subtext
        };
        let self_card = container(
            column![
                row![
                    avatar_view(&state.config.avatar, &state.name, &state.self_id, 34.0),
                    text(format!("{} (You)", &state.name)).size(16).color(color_text),
                    horizontal_space(),
                    if state.is_muted {
                        text("[Muted]").size(14).color(color_red)
                    } else {
                        text("[Active]").size(14).color(color_green)
                    }
                ]
                .spacing(10)
                .align_y(iced::alignment::Vertical::Center),
                // Our own detected game (W17/W18). When we're broadcasting it, it
                // matches what peers see; otherwise it's marked "not shared".
                {
                    let el: Element<'_, AppMessage> =
                        match state.current_game.as_ref().and_then(|g| g.name.as_deref()) {
                            Some(name) if state.config.game_presence_enabled => {
                                text(format!("Playing {name}")).size(11).color(color_blue).into()
                            }
                            Some(name) => text(format!("Playing {name} (not shared)"))
                                .size(11)
                                .color(color_subtext)
                                .into(),
                            None => text("").into(),
                        };
                    el
                },
                // Live "you're sharing" badge — only present while sharing. When
                // sharing a specific app whose audio has dropped (A23 strict
                // mode), a warning line is added: viewers hear silence, not the
                // call, until that app plays again.
                {
                    let el: Element<'_, AppMessage> = if state.self_sharing {
                        let badge = row![
                            icon(IconKind::Live, 14.0, color_red),
                            text("Sharing your screen").size(13).color(color_red),
                        ]
                        .spacing(6)
                        .align_y(iced::alignment::Vertical::Center);
                        if state.share_audio_dropped {
                            column![
                                badge,
                                text(
                                    "⚠ Shared app isn't sending audio — viewers hear silence until it plays"
                                )
                                .size(11)
                                .color(color_yellow),
                            ]
                            .spacing(3)
                            .into()
                        } else {
                            badge.into()
                        }
                    } else {
                        iced::widget::Space::new().width(0.0).height(0.0).into()
                    };
                    el
                },
                progress_bar(0.0..=0.3, state.mic_level)
                    .girth(8.0)
                    .style(move |_t: &Theme| iced::widget::progress_bar::Style {
                        background: Background::Color(color_crust),
                        bar: Background::Color(self_mic_color),
                        border: Border { color: color_surface, width: 1.0, radius: 4.0.into() },
                    }),
            ].spacing(8)
        )
        .style(c_style(color_base, color_surface, 6.0))
        .padding(12);
        peers_list = peers_list.push(self_card);

        for (peer_id, peer) in &state.peers {
            let level = state.audio_levels.get(peer_id).copied().unwrap_or(0.0);
            let is_connecting = state.connecting.contains(peer_id);
            let is_speaking = !is_connecting && level > 0.01;

            let (ind_label, ind_color): (&str, Color) = if is_connecting {
                let label = if state.ever_connected.contains(peer_id) {
                    "[Reconnecting…]"
                } else {
                    "[Connecting…]"
                };
                (label, color_yellow)
            } else if peer.is_muted {
                ("[Muted]", color_red)
            } else if is_speaking {
                ("[Speaking]", color_green)
            } else {
                ("[Idle]", color_subtext)
            };
            // Fixed-width, right-aligned slot so the label changing (e.g. Idle→
            // Speaking) doesn't reflow the row and shift the mute button (A10).
            // Width covers the longest label, "[Reconnecting…]".
            let indicator = container(text(ind_label).size(14).color(ind_color))
                .width(iced::Length::Fixed(124.0))
                .align_x(iced::alignment::Horizontal::Right);

            let peer_id_clone = *peer_id;
            let is_locally_muted = state.locally_muted.contains(peer_id);

            // Local-mute toggle (silences this peer for us only).
            let (mute_kind, mute_bg, mute_fg) = if is_locally_muted {
                (IconKind::SpeakerOff, color_red, color_crust)
            } else {
                (IconKind::Speaker, color_surface, color_text)
            };
            let mute_btn = button(icon(mute_kind, 16.0, mute_fg))
                .on_press(AppMessage::TogglePeerMute(peer_id_clone))
                .style(b_style(mute_bg, color_blue, mute_fg, 6.0))
                .padding(6);

            // Screen-share "Live" badge + Watch button when this peer is sharing.
            // Watch is enabled only if pixelpass is installed locally.
            let share_el: Element<'_, AppMessage> = if let Some(ticket) = peer.sharing.clone() {
                let mut watch_btn = button(
                    row![
                        icon(IconKind::Eye, 14.0, color_crust),
                        text("Watch").size(13),
                    ]
                    .spacing(5)
                    .align_y(iced::alignment::Vertical::Center),
                )
                .style(b_style(color_blue, color_lavender, color_crust, 6.0))
                .padding(6);
                // Always actionable: opens the pixelpass explainer if it's missing,
                // otherwise launches the viewer (A11 — consistent with Share Screen).
                watch_btn = watch_btn.on_press(if state.pixelpass_available {
                    AppMessage::WatchShare(ticket)
                } else {
                    AppMessage::OpenPixelpassHelp
                });
                row![
                    row![
                        icon(IconKind::Live, 13.0, color_red),
                        text("Live").size(13).color(color_red),
                    ]
                    .spacing(5)
                    .align_y(iced::alignment::Vertical::Center),
                    watch_btn,
                ]
                .spacing(6)
                .align_y(iced::alignment::Vertical::Center)
                .into()
            } else {
                iced::widget::Space::new().width(0.0).height(0.0).into()
            };

            // VU meter colour: dim when locally muted (you don't hear them),
            // green while speaking, faint otherwise.
            let vu_color = if is_locally_muted {
                color_subtext
            } else if is_speaking {
                color_green
            } else {
                color_surface
            };

            // Add-friend affordance (W7): a star you can click to friend this
            // co-participant; a filled gold star (non-interactive) once they're a
            // friend. Hidden while their friend state is read-only-degraded.
            let add_friend_el: Element<'_, AppMessage> = if state.friends_read_only {
                iced::widget::Space::new().width(0.0).height(0.0).into()
            } else if state.friends.contains(peer_id) {
                tooltip(
                    text("★").size(16).color(color_yellow),
                    container(text("In your friends list").size(11).color(color_text))
                        .padding(8)
                        .style(c_style(color_crust, color_surface, 6.0)),
                    iced::widget::tooltip::Position::Bottom,
                )
                .gap(6)
                .into()
            } else {
                tooltip(
                    button(text("☆").size(16).color(color_subtext))
                        .on_press(AppMessage::AddFriendFromRoom(peer_id_clone))
                        .style(b_style(color_surface, color_blue, color_text, 6.0))
                        .padding([2, 6]),
                    container(text("Add to your friends list").size(11).color(color_text))
                        .padding(8)
                        .style(c_style(color_crust, color_surface, 6.0)),
                    iced::widget::tooltip::Position::Bottom,
                )
                .gap(6)
                .into()
            };

            let mut card_content = column![
                row![
                    avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
                    {
                        // Name + id, plus a "Playing <game>" line when the peer is
                        // broadcasting a game (game presence, W17).
                        let mut name_col = column![
                            text(&peer.name).size(16).color(color_text),
                            text(format!("ID: {}", short_id(&peer_id.to_string())))
                                .size(11)
                                .color(color_subtext),
                        ];
                        if let Some(game) = &peer.game {
                            name_col = name_col
                                .push(text(format!("Playing {game}")).size(11).color(color_blue));
                        }
                        // Connection-transparency badge: path type + RTT, with
                        // the full story (address, loss, bitrate) on hover.
                        // Rendered only while the audio link is live — the
                        // Connecting/Reconnecting indicator covers the rest.
                        if let Some(info) = state.conn_stats.get(peer_id) {
                            let dot_color = if info.relay {
                                color_yellow
                            } else {
                                color_green
                            };
                            let badge = row![
                                text("●").size(9).color(dot_color),
                                text(conn_badge_label(info)).size(11).color(color_subtext),
                            ]
                            .spacing(4)
                            .align_y(iced::alignment::Vertical::Center);
                            let detail = column![
                                text(conn_tooltip_path(info)).size(11).color(color_text),
                                text(conn_loss_label(info.loss_pct))
                                    .size(11)
                                    .color(color_subtext),
                                text(format!(
                                    "↑ {}   ↓ {}",
                                    conn_rate_label(info.up_kbps),
                                    conn_rate_label(info.down_kbps)
                                ))
                                .size(11)
                                .color(color_subtext),
                            ]
                            .spacing(2);
                            name_col = name_col.push(
                                tooltip(
                                    badge,
                                    container(detail).padding(8).style(c_style(
                                        color_crust,
                                        color_surface,
                                        6.0,
                                    )),
                                    iced::widget::tooltip::Position::Bottom,
                                )
                                .gap(6),
                            );
                        }
                        name_col
                    },
                    add_friend_el,
                    horizontal_space(),
                    share_el,
                    mute_btn,
                    indicator
                ]
                .spacing(8)
                .align_y(iced::alignment::Vertical::Center),
                progress_bar(0.0..=0.3, level)
                    .girth(8.0)
                    .style(move |_t: &Theme| iced::widget::progress_bar::Style {
                        background: Background::Color(color_crust),
                        bar: Background::Color(vu_color),
                        border: Border {
                            color: color_surface,
                            width: 1.0,
                            radius: 4.0.into()
                        },
                    }),
            ]
            .spacing(8);

            let advanced_audio_open = state.peer_audio_expanded.contains(peer_id);
            let foldout_symbol = if advanced_audio_open { "▾" } else { "▸" };
            card_content = card_content.push(
                button(
                    row![
                        text(foldout_symbol).size(13).color(color_subtext),
                        text("Advanced audio").size(12).color(color_text),
                    ]
                    .spacing(6)
                    .align_y(iced::alignment::Vertical::Center),
                )
                .on_press(AppMessage::TogglePeerAdvancedAudio(peer_id_clone))
                .style(b_style(color_surface, color_blue, color_text, 6.0))
                .padding([6, 8])
                .width(iced::Length::Fill),
            );

            if advanced_audio_open {
                let peer_key = peer_id.to_string();
                let current_vol = state
                    .config
                    .peer_volume
                    .get(&peer_key)
                    .copied()
                    .unwrap_or(1.0);
                let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
                let current_gate = state
                    .config
                    .peer_gate
                    .get(&peer_key)
                    .copied()
                    .unwrap_or(0.0);
                let gate_label = if current_gate <= 0.0 {
                    "Off".to_string()
                } else {
                    format!(
                        "{:.0}%",
                        (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
                    )
                };

                let volume_row = row![
                    text("Vol:").size(12).color(color_subtext),
                    slider(0.0..=2.0, current_vol, move |v| {
                        AppMessage::PeerVolumeChanged(peer_id_clone, v)
                    })
                    .step(0.01)
                    .on_release(AppMessage::PersistConfig)
                ]
                .spacing(8)
                .align_y(iced::alignment::Vertical::Center);

                let pan_row = row![
                    text("Pan:").size(12).color(color_subtext),
                    container(text(pan_label(current_pan)).size(11).color(color_subtext))
                        .width(iced::Length::Fixed(58.0)),
                    slider(-1.0..=1.0, current_pan, move |v| {
                        AppMessage::PeerPanChanged(peer_id_clone, v)
                    })
                    .step(0.05)
                    .on_release(AppMessage::PersistConfig),
                ]
                .spacing(8)
                .align_y(iced::alignment::Vertical::Center);

                // Peer noise gate: suppress this peer's background noise on our
                // end. Threshold is on the mic meter's 0..METER_MAX scale; 0 = off.
                let gate_row = row![
                    text("Gate:").size(12).color(color_subtext),
                    container(text(gate_label).size(11).color(color_subtext))
                        .width(iced::Length::Fixed(58.0)),
                    slider(0.0..=METER_MAX, current_gate, move |v| {
                        AppMessage::PeerGateChanged(peer_id_clone, v)
                    })
                    .step(0.001)
                    .on_release(AppMessage::PersistConfig),
                ]
                .spacing(8)
                .align_y(iced::alignment::Vertical::Center);

                let eq = peer_eq_settings(&state.config, peer_id);
                let eq_row =
                    |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
                        row![
                            container(
                                text(format!("{label} {value:+.1} dB"))
                                    .size(11)
                                    .color(color_subtext)
                            )
                            .width(iced::Length::Fixed(86.0)),
                            slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
                                AppMessage::PeerEqChanged(peer_id_clone, band, v)
                            })
                            .step(0.5)
                            .on_release(AppMessage::PersistConfig),
                        ]
                        .spacing(8)
                        .align_y(iced::alignment::Vertical::Center)
                        .into()
                    };
                let advanced_audio = column![
                    volume_row,
                    pan_row,
                    gate_row,
                    text("EQ").size(11).color(color_subtext),
                    eq_row("Low", EqBand::Low, eq.low_gain_db),
                    eq_row("Mid", EqBand::Mid, eq.mid_gain_db),
                    eq_row("High", EqBand::High, eq.high_gain_db),
                ]
                .spacing(6);
                card_content = card_content.push(
                    container(advanced_audio)
                        .style(c_style(color_crust, color_surface, 6.0))
                        .padding(10)
                        .width(iced::Length::Fill),
                );
            }

            let card = container(card_content)
                .style(c_style(
                    if is_speaking {
                        color_base
                    } else {
                        color_mantle
                    },
                    if is_connecting {
                        color_yellow
                    } else if is_speaking {
                        color_green
                    } else {
                        color_surface
                    },
                    6.0,
                ))
                .padding(12);

            peers_list = peers_list.push(card);
        }

        let scroll_peers = scrollable(peers_list);

        let peers_panel = container(column![
            text("Room Participants").size(18).color(color_blue),
            vertical_space(10.0),
            scroll_peers
        ])
        .style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
        .padding(15)
        .height(iced::Length::Fill);

        // Control Panel Column
        let mute_text = if state.is_muted {
            "Unmute Mic"
        } else {
            "Mute Mic"
        };
        let mute_bg = if state.is_muted {
            color_red
        } else {
            color_surface
        };
        let mute_hover = if state.is_muted {
            color_maroon
        } else {
            color_blue
        };
        let mute_fg = if state.is_muted {
            color_crust
        } else {
            color_text
        };

        let deafen_text = if state.is_deafened {
            "Undeafen Audio"
        } else {
            "Deafen Audio"
        };
        let deafen_bg = if state.is_deafened {
            color_red
        } else {
            color_surface
        };
        let deafen_hover = if state.is_deafened {
            color_maroon
        } else {
            color_blue
        };
        let deafen_fg = if state.is_deafened {
            color_crust
        } else {
            color_text
        };

        let mute_kind = if state.is_muted {
            IconKind::MicOff
        } else {
            IconKind::Mic
        };
        let deafen_kind = if state.is_deafened {
            IconKind::Deafen
        } else {
            IconKind::Headphones
        };
        let music_panel: Element<'_, AppMessage> = {
            let music_status = status_snapshot(&state.music_status);
            let personal_selected = state.music_tab == MusicTab::Personal;
            let public_selected = state.music_tab == MusicTab::Public;
            let tab_row = row![
                button(
                    text("Personal")
                        .size(12)
                        .align_x(iced::alignment::Horizontal::Center)
                )
                .on_press(AppMessage::MusicSelectTab(MusicTab::Personal))
                .style(b_style(
                    if personal_selected {
                        color_blue
                    } else {
                        color_surface
                    },
                    color_blue,
                    if personal_selected {
                        color_crust
                    } else {
                        color_text
                    },
                    6.0,
                ))
                .padding(8)
                .width(iced::Length::Fill),
                button(
                    text("Public")
                        .size(12)
                        .align_x(iced::alignment::Horizontal::Center)
                )
                .on_press(AppMessage::MusicSelectTab(MusicTab::Public))
                .style(b_style(
                    if public_selected {
                        color_blue
                    } else {
                        color_surface
                    },
                    color_blue,
                    if public_selected {
                        color_crust
                    } else {
                        color_text
                    },
                    6.0,
                ))
                .padding(8)
                .width(iced::Length::Fill),
            ]
            .spacing(8);

            let content: Element<'_, AppMessage> = match state.music_tab {
                MusicTab::Personal => {
                    let mut tracks = Column::new().spacing(6);
                    if state.music_playlist.is_empty() {
                        tracks = tracks.push(
                            text("No tracks yet — Browse to add audio.")
                                .size(12)
                                .color(color_subtext),
                        );
                    } else {
                        let last = state.music_playlist.len().saturating_sub(1);
                        for (i, track) in state.music_playlist.iter().enumerate() {
                            let selected = state.music_current == Some(i);
                            let up = {
                                let btn = button(text("▲").size(13))
                                    .style(b_style(color_surface, color_overlay, color_text, 6.0))
                                    .padding(6);
                                if i > 0 {
                                    btn.on_press(AppMessage::MusicMoveUp(i))
                                } else {
                                    btn
                                }
                            };
                            let down = {
                                let btn = button(text("▼").size(13))
                                    .style(b_style(color_surface, color_overlay, color_text, 6.0))
                                    .padding(6);
                                if i < last {
                                    btn.on_press(AppMessage::MusicMoveDown(i))
                                } else {
                                    btn
                                }
                            };
                            tracks = tracks.push(
                                row![
                                    button(text(&track.name).size(13))
                                        .on_press(AppMessage::MusicPlayIndex(i))
                                        .style(b_style(
                                            if selected { color_blue } else { color_surface },
                                            color_blue,
                                            if selected { color_crust } else { color_text },
                                            6.0,
                                        ))
                                        .padding(6)
                                        .width(iced::Length::Fill),
                                    up,
                                    down,
                                    button(text("×").size(13))
                                        .on_press(AppMessage::MusicRemove(i))
                                        .style(b_style(
                                            color_surface,
                                            color_overlay,
                                            color_text,
                                            6.0
                                        ))
                                        .padding(6),
                                ]
                                .spacing(6)
                                .align_y(iced::alignment::Vertical::Center),
                            );
                        }
                    }
                    let elapsed = format_clip_time(music_status.position);
                    let duration = music_status
                        .total
                        .map(format_clip_time)
                        .unwrap_or_else(|| "--:--".to_string());
                    column![
                        slider(
                            0.0..=1.0,
                            clip_progress(music_status.position, music_status.total),
                            AppMessage::MusicSeek,
                        )
                        .step(0.001),
                        text(format!("{elapsed} / {duration}"))
                            .size(11)
                            .color(color_subtext),
                        text("Music volume").size(11).color(color_subtext),
                        slider(
                            0.0..=2.0,
                            state.config.music_volume,
                            AppMessage::MusicSetVolume
                        )
                        .step(0.01),
                        button(
                            text("Browse")
                                .size(12)
                                .align_x(iced::alignment::Horizontal::Center)
                        )
                        .on_press(AppMessage::MusicBrowse)
                        .style(b_style(color_surface, color_blue, color_text, 6.0))
                        .padding(8)
                        .width(iced::Length::Fill),
                        checkbox(state.music_broadcasting)
                            .label("Let others tune in")
                            .text_size(12)
                            .on_toggle(AppMessage::MusicToggleBroadcast),
                        scrollable(tracks)
                            .height(iced::Length::Fill)
                            .width(iced::Length::Fill),
                    ]
                    .spacing(8)
                    .height(iced::Length::Fill)
                    .into()
                }
                MusicTab::Public => {
                    let mut broadcast_rows = Column::new().spacing(6);
                    let self_id = state.self_id.parse::<EndpointId>().ok();
                    let mut count = 0usize;
                    for (id, peer) in &state.peers {
                        if Some(*id) == self_id {
                            continue;
                        }
                        let Some(music) = &peer.music else {
                            continue;
                        };
                        count += 1;
                        broadcast_rows = broadcast_rows.push(
                            row![
                                column![
                                    text(&peer.name).size(12).color(color_text),
                                    text(&music.name).size(11).color(color_subtext),
                                ]
                                .spacing(2)
                                .width(iced::Length::Fill),
                                button(text("Listen").size(12))
                                    .on_press(AppMessage::MusicListen(*id))
                                    .style(b_style(color_surface, color_blue, color_text, 6.0))
                                    .padding(6),
                            ]
                            .spacing(8)
                            .align_y(iced::alignment::Vertical::Center),
                        );
                    }
                    if count == 0 {
                        broadcast_rows = broadcast_rows.push(
                            text("No one is broadcasting.")
                                .size(12)
                                .color(color_subtext),
                        );
                    }

                    let listen_block: Element<'_, AppMessage> = if let Some(peer) =
                        state.music_listening_to
                    {
                        let name = state
                            .peers
                            .get(&peer)
                            .map(|p| p.name.clone())
                            .unwrap_or_else(|| "source".to_string());
                        let elapsed = format_clip_time(music_status.position);
                        let duration = music_status
                            .total
                            .map(format_clip_time)
                            .unwrap_or_else(|| "--:--".to_string());
                        column![
                            row![
                                text(format!("▶ Listening to {name}"))
                                    .size(12)
                                    .color(color_blue)
                                    .width(iced::Length::Fill),
                                button(text("Stop").size(12))
                                    .on_press(AppMessage::MusicStopListen)
                                    .style(b_style(color_surface, color_maroon, color_text, 6.0))
                                    .padding(6),
                            ]
                            .spacing(8)
                            .align_y(iced::alignment::Vertical::Center),
                            text(format!("{elapsed} / {duration}"))
                                .size(11)
                                .color(color_subtext),
                            text("Listen volume").size(11).color(color_subtext),
                            slider(
                                0.0..=2.0,
                                effective_music_volume(state),
                                AppMessage::MusicSetSourceVolume
                            )
                            .step(0.01),
                        ]
                        .spacing(8)
                        .into()
                    } else {
                        column![].into()
                    };

                    column![
                        listen_block,
                        text("Broadcasting now").size(12).color(color_blue),
                        scrollable(broadcast_rows)
                            .height(iced::Length::Fill)
                            .width(iced::Length::Fill),
                    ]
                    .spacing(8)
                    .height(iced::Length::Fill)
                    .into()
                }
            };

            container(
                column![tab_row, content,]
                    .spacing(10)
                    .height(iced::Length::Fill),
            )
            .style(c_style(color_mantle, color_surface, 6.0))
            .padding(10)
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .into()
        };
        let music_bar_status = status_snapshot(&state.music_status);
        let music_bar_playing = music_bar_status.playing_id.is_some();
        let music_bar_play_label = if music_bar_playing && music_bar_status.paused {
            "▶"
        } else if music_bar_playing {
            "⏸"
        } else {
            "▶"
        };
        let listening_peer_name = state
            .music_listening_to
            .and_then(|peer| state.peers.get(&peer).map(|p| p.name.as_str()));
        let current_track_name = state
            .music_current
            .and_then(|i| state.music_playlist.get(i).map(|track| track.name.as_str()));
        let elapsed = format_clip_time(music_bar_status.position);
        let duration = music_bar_status
            .total
            .map(format_clip_time)
            .unwrap_or_else(|| "--:--".to_string());
        let player_bar = container(
            row![
                container(
                    row![
                        text("♪").size(18).color(color_blue),
                        text(now_playing_label(listening_peer_name, current_track_name))
                            .size(13)
                            .color(color_text),
                    ]
                    .spacing(8)
                    .align_y(iced::alignment::Vertical::Center)
                )
                .width(iced::Length::Fill),
                row![
                    button(text("|◀").size(13))
                        .on_press(AppMessage::MusicPrev)
                        .style(b_style(color_surface, color_blue, color_text, 6.0))
                        .padding(7),
                    button(text(music_bar_play_label).size(13))
                        .on_press(AppMessage::MusicPlayPause)
                        .style(b_style(color_blue, color_lavender, color_crust, 6.0))
                        .padding(7),
                    button(text("▶|").size(13))
                        .on_press(AppMessage::MusicNext)
                        .style(b_style(color_surface, color_blue, color_text, 6.0))
                        .padding(7),
                ]
                .spacing(8)
                .align_y(iced::alignment::Vertical::Center),
                container(
                    row![
                        text(format!("{elapsed} / {duration}"))
                            .size(11)
                            .color(color_subtext),
                        button(
                            text(if state.playlist_drawer_open {
                                "⤡"
                            } else {
                                "⤢"
                            })
                            .size(13)
                        )
                        .on_press(AppMessage::TogglePlaylistDrawer)
                        .style(b_style(
                            if state.playlist_drawer_open {
                                color_blue
                            } else {
                                color_surface
                            },
                            color_blue,
                            if state.playlist_drawer_open {
                                color_crust
                            } else {
                                color_text
                            },
                            6.0,
                        ))
                        .padding(7),
                    ]
                    .spacing(8)
                    .align_y(iced::alignment::Vertical::Center)
                )
                .width(iced::Length::Fill)
                .align_x(iced::alignment::Horizontal::Right),
            ]
            .spacing(8)
            .align_y(iced::alignment::Vertical::Center),
        )
        .style(c_style(color_mantle, color_surface, 8.0))
        .padding(10)
        .width(iced::Length::Fill)
        .height(iced::Length::Fixed(56.0));
        let ctrl_buttons = column![
            button(btn_content(mute_kind, mute_text, mute_fg))
                .on_press(AppMessage::ToggleMutePressed)
                .style(b_style(mute_bg, mute_hover, mute_fg, 8.0))
                .padding(14)
                .width(iced::Length::Fill),
            vertical_space(10.0),
            button(btn_content(deafen_kind, deafen_text, deafen_fg))
                .on_press(AppMessage::ToggleDeafenPressed)
                .style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0))
                .padding(14)
                .width(iced::Length::Fill),
            vertical_space(20.0),
            checkbox(state.ptt_enabled)
                .label("Push-to-Talk")
                .on_toggle(AppMessage::TogglePtt),
            vertical_space(10.0),
            if state.ptt_enabled {
                let ptt_binding = if state.hotkey_capture == Some(HotkeyAction::PushToTalk) {
                    "Press a key...".to_string()
                } else {
                    format_binding(state.config.hotkeys.binding(HotkeyAction::PushToTalk))
                };
                column![
                    text(format!("PTT key: {ptt_binding}"))
                        .size(14)
                        .color(color_subtext),
                    button(
                        text("Set PTT Key")
                            .size(12)
                            .align_x(iced::alignment::Horizontal::Center)
                    )
                    .on_press(AppMessage::StartHotkeyCapture(HotkeyAction::PushToTalk))
                    .style(b_style(color_surface, color_blue, color_text, 6.0))
                    .padding(8)
                    .width(iced::Length::Fill)
                ]
                .spacing(8)
            } else {
                column![]
            },
            vertical_space(20.0),
            {
                // Echo cancellation is wired at join time on Linux; other
                // targets show an inert status row instead of a dead toggle.
                let control: Element<'_, AppMessage> = {
                    #[cfg(target_os = "linux")]
                    {
                        tooltip(
                            checkbox(state.config.echo_cancellation_enabled)
                                .label("Echo cancellation")
                                .on_toggle(AppMessage::ToggleEchoCancellation),
                            container(
                                text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
                                    .size(11)
                                    .color(color_text),
                            )
                            .padding(8)
                            .max_width(260.0)
                            .style(c_style(color_crust, color_surface, 6.0)),
                            iced::widget::tooltip::Position::Top,
                        )
                        .gap(8)
                        .into()
                    }
                    #[cfg(not(target_os = "linux"))]
                    {
                        column![
                            checkbox(false).label("Echo cancellation"),
                            text("Not available on Windows yet.")
                                .size(11)
                                .color(color_subtext),
                        ]
                        .spacing(4)
                        .into()
                    }
                };
                control
            },
            vertical_space(20.0),
            {
                let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
                    (
                        IconKind::Stop,
                        "Stop Recording",
                        color_red,
                        color_maroon,
                        color_crust,
                    )
                } else {
                    (
                        IconKind::Record,
                        "Record Call",
                        color_surface,
                        color_blue,
                        color_text,
                    )
                };
                button(btn_content(rec_kind, rec_label, rec_fg))
                    .on_press(AppMessage::ToggleRecording)
                    .style(b_style(rec_bg, rec_hover, rec_fg, 8.0))
                    .padding(14)
                    .width(iced::Length::Fill)
            },
            vertical_space(20.0),
            {
                // Screen share. Always enabled and labelled "Share Screen": if the
                // optional pixelpass companion isn't installed, clicking opens a
                // short how-to-install explainer instead of being a dead disabled
                // button (A11) — so it never reads as a broken in-app feature.
                let (share_kind, share_label, share_bg, share_hover, share_fg) =
                    if state.self_sharing {
                        (
                            IconKind::Stop,
                            "Stop Sharing",
                            color_red,
                            color_maroon,
                            color_crust,
                        )
                    } else {
                        (
                            IconKind::Monitor,
                            "Share Screen",
                            color_surface,
                            color_blue,
                            color_text,
                        )
                    };
                let share_press = if state.pixelpass_available {
                    AppMessage::ToggleScreenShare
                } else {
                    AppMessage::OpenPixelpassHelp
                };
                let share_button = button(btn_content(share_kind, share_label, share_fg))
                    .on_press(share_press)
                    .style(b_style(share_bg, share_hover, share_fg, 8.0))
                    .padding(14)
                    .width(iced::Length::Fill);
                let share_control: Element<'_, AppMessage> = if state.self_sharing {
                    share_button.into()
                } else {
                    row![
                        share_button,
                        pick_list(
                            &ShareQuality::ALL[..],
                            Some(state.share_quality_selection),
                            AppMessage::SelectShareQualityOverride,
                        )
                        .width(iced::Length::Fixed(112.0)),
                    ]
                    .spacing(8)
                    .align_y(iced::alignment::Vertical::Center)
                    .width(iced::Length::Fill)
                    .into()
                };
                share_control
            },
        ];

        // Leave is the exit control, so it's pinned below the scrolling controls
        // (built separately, outside `ctrl_buttons`) — see the panel assembly (A12).
        let leave_btn = button(btn_content(IconKind::Leave, "Leave Room", color_crust))
            .on_press(AppMessage::LeavePressed)
            .style(b_style(color_red, color_maroon, color_crust, 8.0))
            .padding(14)
            .width(iced::Length::Fill);

        // Controls panel — width is set per layout below. The controls SCROLL when
        // the window is too short, and Leave stays pinned at the bottom so the exit
        // control is always reachable instead of being clipped off-screen (A12).
        let control_panel = container(column![
            text("Controls").size(18).color(color_blue),
            vertical_space(15.0),
            scrollable(ctrl_buttons)
                .width(iced::Length::Fill)
                .height(iced::Length::Fill),
            vertical_space(12.0),
            leave_btn,
            vertical_space(8.0),
            text(&state.status_message).size(12).color(color_subtext)
        ])
        .style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
        .padding(15)
        .height(iced::Length::Fill);

        // Reusable chat body (title + bottom-anchored scrollback + input row),
        // wrapped differently by each layout.
        let mut chat_col = Column::new().spacing(4).width(iced::Length::Fill);
        if state.chat_messages.is_empty() {
            chat_col = chat_col.push(
                text("No messages yet — say hi to the room.")
                    .size(12)
                    .color(color_subtext),
            );
        } else {
            let clip_status = status_snapshot(&state.clip_status);
            for m in &state.chat_messages {
                let name_color = if m.mine { color_green } else { color_lavender };
                // Split the (already-sanitized) message into text + URL spans so
                // links render clickable and open in the system browser (A13).
                // The link ranges were validated + cached at push time, so this
                // only slices borrowed text — no rescanning per redraw.
                let spans: Vec<_> = crate::sanitize::segments(&m.text, &m.links)
                    .into_iter()
                    .map(|(piece, is_link)| {
                        if is_link {
                            span(piece)
                                .size(13)
                                .color(color_blue)
                                .link(piece.to_string())
                        } else {
                            span(piece).size(13).color(color_text)
                        }
                    })
                    .collect();
                let body = selectable_rich_text(spans)
                    .on_link_click(AppMessage::OpenUrl)
                    .selection_color(color_blue)
                    .width(iced::Length::Fill);
                // Small avatar keyed on the sender's id (falls back to name); the
                // " (You)" suffix on our own echoes is stripped for clean initials.
                // Resolve the sender's chosen avatar: our own from config, a peer's
                // from their current presence (looked up by id), else monogram.
                let av_key = m.from.as_deref().unwrap_or(m.name.as_str());
                let av_name = m.name.split(" (").next().unwrap_or(m.name.as_str());
                let av = if m.mine {
                    state.config.avatar.clone()
                } else {
                    m.from
                        .as_deref()
                        .and_then(|f| {
                            state
                                .peers
                                .iter()
                                .find(|(k, _)| k.to_string() == f)
                                .map(|(_, v)| v.avatar.clone())
                        })
                        .unwrap_or_default()
                };
                chat_col = chat_col.push(
                    row![
                        avatar_view(&av, av_name, av_key, 22.0),
                        text(format!("{}:", m.name)).size(12).color(name_color),
                        body,
                    ]
                    .spacing(8)
                    .align_y(iced::alignment::Vertical::Top),
                );
                // Attachment row (indented under the message), if any.
                if let Some(att) = &m.attachment {
                    // This line's cache key is (its author, the attachment id).
                    // `None` only for a system line or an unparseable author.
                    let key: Option<AttachmentKey> = m
                        .from
                        .as_ref()
                        .and_then(|f| f.parse::<EndpointId>().ok())
                        .map(|eid| (eid, att.id));
                    let data = key.as_ref().and_then(|k| state.attachments.get(k));
                    let elem: Element<'_, AppMessage> = if let Some(AttachmentState::Failed(e)) =
                        data
                    {
                        text(format!("⚠ {} — {e}", att.name))
                            .size(12)
                            .color(color_red)
                            .into()
                    } else if att.kind == crate::files::AttachmentKind::Image {
                        match key.as_ref().and_then(|k| state.attachments.handle(k)) {
                            Some(handle) => {
                                // Click an inline image to open it enlarged in the
                                // lightbox overlay (pointer cursor signals it's
                                // interactive). Only wired when we have a valid
                                // (author, id) key to look the handle back up.
                                let img = iced::widget::image(handle.clone())
                                    .width(iced::Length::Fixed(260.0));
                                match key {
                                    Some(k) => mouse_area(img)
                                        .interaction(mouse::Interaction::Pointer)
                                        .on_press(AppMessage::OpenImageLightbox(k))
                                        .into(),
                                    None => img.into(),
                                }
                            }
                            // "loading…" ONLY while a fetch task actually runs
                            // (Phase 3B). Absent/evicted images — over the
                            // auto-fetch cap, budget-skipped, or dropped by the
                            // cache — get an explicit Load button instead of an
                            // indefinite label.
                            None if matches!(data, Some(AttachmentState::Loading)) => {
                                text(format!("🖼 {} — loading…", att.name))
                                    .size(12)
                                    .color(color_subtext)
                                    .into()
                            }
                            None => row![
                                text(format!(
                                    "🖼 {} ({})",
                                    att.name,
                                    crate::files::human_size(att.size)
                                ))
                                .size(12)
                                .color(color_text),
                                button(text("Load image").size(12))
                                    .on_press_maybe(key.map(AppMessage::LoadImage))
                                    .style(b_style(color_blue, color_lavender, color_crust, 6.0,))
                                    .padding(6),
                            ]
                            .spacing(8)
                            .align_y(iced::alignment::Vertical::Center)
                            .into(),
                        }
                    } else if crate::files::looks_like_audio_name(&att.name)
                        && !state.invalid_audio.contains(&att.id)
                    {
                        let active = clip_status.playing_id == Some(att.id);
                        let loading = state.pending_plays.contains(&att.id)
                            && !matches!(data, Some(AttachmentState::Ready(_)));
                        let position = if active {
                            clip_status.position
                        } else {
                            std::time::Duration::ZERO
                        };
                        let total = active.then_some(clip_status.total).flatten();
                        let play_button = if loading {
                            button(text("Loading…").size(12))
                        } else if active && clip_status.paused {
                            button(text("Play").size(12)).on_press(AppMessage::ResumeAudio)
                        } else if active {
                            button(text("Pause").size(12)).on_press(AppMessage::PauseAudio)
                        } else {
                            button(text("Play").size(12))
                                .on_press_maybe(key.map(AppMessage::PlayAudio))
                        }
                        .style(b_style(color_blue, color_lavender, color_crust, 6.0))
                        .padding(6);
                        let elapsed = format_clip_time(position);
                        let duration = total
                            .map(format_clip_time)
                            .unwrap_or_else(|| "--:--".to_string());
                        column![
                            row![
                                text(format!(
                                    "{} ({})",
                                    att.name,
                                    crate::files::human_size(att.size)
                                ))
                                .size(12)
                                .color(color_text),
                                button(
                                    text(match data {
                                        Some(AttachmentState::Ready(_)) => "Save",
                                        Some(AttachmentState::Loading) => "Downloading…",
                                        _ => "Download",
                                    })
                                    .size(12)
                                )
                                .on_press_maybe(key.map(AppMessage::SaveAttachment))
                                .style(b_style(color_surface, color_overlay, color_text, 6.0,))
                                .padding(6),
                            ]
                            .spacing(8)
                            .align_y(iced::alignment::Vertical::Center),
                            row![
                                play_button,
                                slider(
                                    0.0..=1.0,
                                    if active {
                                        clip_progress(position, total)
                                    } else {
                                        0.0
                                    },
                                    move |fraction| AppMessage::SeekAudio(att.id, fraction),
                                )
                                .step(0.001)
                                .width(iced::Length::Fixed(180.0)),
                                text(format!("{elapsed} / {duration}"))
                                    .size(11)
                                    .color(color_subtext),
                                // Per-clip volume. In universal mode this shows
                                // and drives the shared level; otherwise it is
                                // this clip's own remembered level.
                                text("🔊").size(12).color(color_subtext),
                                slider(0.0..=2.0, effective_clip_volume(state, att.id), move |v| {
                                    AppMessage::SetClipVolumeFor(att.id, v)
                                },)
                                .step(0.01)
                                .width(iced::Length::Fixed(80.0)),
                            ]
                            .spacing(8)
                            .align_y(iced::alignment::Vertical::Center),
                        ]
                        .spacing(4)
                        .into()
                    } else {
                        // A repeated click during Loading only re-records the
                        // save intent (the update arm dedups the fetch), so the
                        // button stays enabled with an honest label.
                        let btn_label = match data {
                            Some(AttachmentState::Ready(_)) => "Save",
                            Some(AttachmentState::Loading) => "Downloading…",
                            _ => "Download",
                        };
                        row![
                            text(format!(
                                "📎 {} ({})",
                                att.name,
                                crate::files::human_size(att.size)
                            ))
                            .size(12)
                            .color(color_text),
                            button(text(btn_label).size(12))
                                .on_press_maybe(key.map(AppMessage::SaveAttachment))
                                .style(b_style(color_blue, color_lavender, color_crust, 6.0,))
                                .padding(6),
                        ]
                        .spacing(8)
                        .align_y(iced::alignment::Vertical::Center)
                        .into()
                    };
                    chat_col = chat_col.push(
                        row![
                            iced::widget::Space::new().width(iced::Length::Fixed(30.0)),
                            elem
                        ]
                        .spacing(0),
                    );
                }
                // Honest send status under our own messages (Phase 5). Success
                // (Broadcast) and the transient Pending deliberately show
                // nothing — there are no delivery receipts, so silence is the
                // honest "it went out" state. Only a waiting queue slot or an
                // outright failure is surfaced.
                if let Some(ls) = &m.local_send {
                    let status_elem: Option<Element<'_, AppMessage>> = match &ls.status {
                        SendStatus::Queued => {
                            Some(text("queued…").size(11).color(color_subtext).into())
                        }
                        SendStatus::Failed(reason) => Some(
                            row![
                                text(format!("⚠ Not sent — {reason}"))
                                    .size(11)
                                    .color(color_red),
                                button(text("Retry").size(11))
                                    .on_press(AppMessage::RetryChatSend(ls.id))
                                    .style(b_style(color_surface, color_red, color_text, 6.0))
                                    .padding(4),
                            ]
                            .spacing(6)
                            .align_y(iced::alignment::Vertical::Center)
                            .into(),
                        ),
                        SendStatus::Pending | SendStatus::Broadcast => None,
                    };
                    if let Some(status_elem) = status_elem {
                        chat_col = chat_col.push(
                            row![
                                iced::widget::Space::new().width(iced::Length::Fixed(30.0)),
                                status_elem,
                            ]
                            .spacing(0),
                        );
                    }
                }
            }
        }
        let chat_scroll = scrollable(chat_col)
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .anchor_bottom();
        let chat_input_row = row![
            button(text("📎").size(15))
                .on_press(AppMessage::PickAttachmentFile)
                .style(b_style(color_surface, color_overlay, color_text, 6.0))
                .padding(8),
            context_input("Message the room…", &state.chat_input)
                .on_input(AppMessage::ChatInputChanged)
                .on_submit(AppMessage::ChatSubmit)
                .style(t_style)
                .padding(8),
            button(text("Send").size(13))
                .on_press(AppMessage::ChatSubmit)
                .style(b_style(color_blue, color_lavender, color_crust, 6.0))
                .padding(8),
        ]
        .spacing(8)
        .align_y(iced::alignment::Vertical::Center);
        // Chat header: title on the left, the universal-volume control on the
        // right. The master slider drives every clip when "Universal" is checked;
        // when unchecked each clip keeps its own level and this slider is inert.
        let universal = state.config.clip_volume_universal;
        let chat_header = row![
            text("Chat").size(16).color(color_blue),
            horizontal_space(),
            checkbox(universal)
                .label("Universal volume")
                .text_size(12)
                .on_toggle(AppMessage::ToggleUniversalClipVolume),
            text("🔊").size(13).color(color_subtext),
            slider(
                0.0..=2.0,
                state.config.clip_volume,
                AppMessage::SetClipVolume
            )
            .step(0.01)
            .width(iced::Length::Fixed(110.0)),
        ]
        .spacing(10)
        .align_y(iced::alignment::Vertical::Center);
        let chat_inner = column![chat_header, chat_scroll, chat_input_row,].spacing(8);

        // Divider constructors (fresh widget per call).
        let vdiv = |kind| {
            Canvas::new(Divider {
                kind,
                vertical: true,
                line: color_surface,
                grip: color_lavender,
            })
            .width(iced::Length::Fixed(DIVIDER_THICKNESS))
            .height(iced::Length::Fill)
        };
        let hdiv = |kind| {
            Canvas::new(Divider {
                kind,
                vertical: false,
                line: color_surface,
                grip: color_lavender,
            })
            .width(iced::Length::Fill)
            .height(iced::Length::Fixed(DIVIDER_THICKNESS))
        };

        let playlist_drawer = container(
            column![
                text("Playlist").size(18).color(color_blue),
                vertical_space(8.0),
                music_panel,
            ]
            .spacing(0),
        )
        .style(c_style(color_mantle, color_surface, 8.0))
        .padding(12)
        .width(iced::Length::Fixed(state.config.playlist_drawer_width))
        .height(iced::Length::Fill);

        let drawer_open = state.playlist_drawer_open && state.config.show_player_bar;
        let body_w = if drawer_open {
            state.window_size.width - state.config.playlist_drawer_width - DIVIDER_THICKNESS
        } else {
            state.window_size.width
        };

        // Assemble the body per the chosen room layout. `chat_inner` is moved into
        // exactly one arm (allowed across mutually-exclusive match arms).
        let pw = state.config.participants_width;
        let body: Element<'_, AppMessage> = match state.config.room_layout {
            RoomLayout::BottomDock => {
                // Cap Participants so the Fill Controls panel keeps its minimum.
                let avail = body_w - 30.0;
                let pwb =
                    pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
                let main = row![
                    peers_panel.width(iced::Length::Fixed(pwb)),
                    vdiv(DividerKind::Panels),
                    control_panel.width(iced::Length::Fill),
                ]
                .spacing(0)
                .height(iced::Length::Fill);
                let chat = container(chat_inner)
                    .style(c_style(color_mantle, color_surface, 8.0))
                    .padding(12)
                    .width(iced::Length::Fill)
                    .height(iced::Length::Fixed(state.config.chat_height));
                column![main, hdiv(DividerKind::Chat), chat].into()
            }
            RoomLayout::ThreeColumn => {
                let avail = body_w - 30.0; // outer padding
                let pw3 = pw.min(
                    (avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS)
                        .max(PARTICIPANTS_MIN_W),
                );
                let chat = container(chat_inner)
                    .style(c_style(color_mantle, color_surface, 8.0))
                    .padding(12)
                    .width(iced::Length::Fill)
                    .height(iced::Length::Fill);
                row![
                    peers_panel.width(iced::Length::Fixed(pw3)),
                    vdiv(DividerKind::Panels),
                    chat,
                    vdiv(DividerKind::Controls),
                    control_panel.width(iced::Length::Fixed(state.config.controls_width)),
                ]
                .spacing(0)
                .height(iced::Length::Fill)
                .into()
            }
            RoomLayout::Drawer => {
                let avail = body_w - 30.0;
                if state.drawer_chat_open {
                    // Participants + Chat drawer are both fixed; cap Participants so
                    // the Fill Controls panel between them keeps its minimum.
                    let pwd = pw.min(
                        (avail
                            - state.config.chat_drawer_width
                            - CONTROLS_MIN_W
                            - 2.0 * DIVIDER_THICKNESS)
                            .max(PARTICIPANTS_MIN_W),
                    );
                    let chat = container(chat_inner)
                        .style(c_style(color_mantle, color_surface, 8.0))
                        .padding(12)
                        .width(iced::Length::Fixed(state.config.chat_drawer_width))
                        .height(iced::Length::Fill);
                    row![
                        peers_panel.width(iced::Length::Fixed(pwd)),
                        vdiv(DividerKind::Panels),
                        control_panel.width(iced::Length::Fill),
                        vdiv(DividerKind::ChatDrawer),
                        chat,
                    ]
                    .spacing(0)
                    .height(iced::Length::Fill)
                    .into()
                } else {
                    let pwd = pw
                        .min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
                    row![
                        peers_panel.width(iced::Length::Fixed(pwd)),
                        vdiv(DividerKind::Panels),
                        control_panel.width(iced::Length::Fill),
                    ]
                    .spacing(0)
                    .height(iced::Length::Fill)
                    .into()
                }
            }
        };

        let clock_skew_banner: Element<'_, AppMessage> = if let Some(warning) =
            state.clock_skew_warning
        {
            let direction = if warning.peer_ahead {
                "ahead"
            } else {
                "behind"
            };
            let skew = format_clock_skew_duration(warning.skew_secs);
            let copy = format!(
                "A peer couldn't be seen - clocks are out of sync by ~{skew} (peer clock looks {direction}). Check your system clock (turn on automatic time sync)."
            );
            column![
                vertical_space(10.0),
                container(
                    row![
                        icon(IconKind::Clock, 16.0, color_yellow),
                        text(copy)
                            .size(12)
                            .color(color_text)
                            .width(iced::Length::Fill),
                        button(text("Dismiss").size(12))
                            .on_press(AppMessage::DismissClockSkewWarning)
                            .style(b_style(color_surface, color_blue, color_text, 6.0))
                            .padding(6),
                    ]
                    .spacing(10)
                    .align_y(iced::alignment::Vertical::Center)
                )
                .padding(10)
                .width(iced::Length::Fill)
                .style(move |_theme: &Theme| container::Style {
                    text_color: Some(color_text),
                    background: Some(Background::Color(Color {
                        a: 0.14,
                        ..color_yellow
                    })),
                    border: Border {
                        color: color_yellow,
                        width: 1.0,
                        radius: 8.0.into()
                    },
                    ..Default::default()
                })
            ]
            .into()
        } else {
            iced::widget::Space::new().width(0.0).height(0.0).into()
        };

        let main_area = column![
            header_container,
            clock_skew_banner,
            vertical_space(12.0),
            body,
        ];
        let main_with_bar: Element<'_, AppMessage> = if state.config.show_player_bar {
            column![main_area.height(iced::Length::Fill), player_bar].into()
        } else {
            main_area.into()
        };
        let inner: Element<'_, AppMessage> = if drawer_open {
            row![
                column![main_with_bar].width(iced::Length::Fill),
                vdiv(DividerKind::PlaylistDrawer),
                playlist_drawer,
            ]
            .height(iced::Length::Fill)
            .into()
        } else {
            main_with_bar
        };

        let room = container(column![top_bar, inner])
            .padding(15)
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .style(c_style(root_bg, Color::TRANSPARENT, 0.0));

        with_hotkey_info(
            with_share_picker(
                with_pixelpass_help(with_layout_picker(room.into(), state), state),
                state,
            ),
            state,
        )
    }
}

/// Full-scale of the meter's RMS axis. Speech RMS runs to ~0.3 normalized, so
/// this keeps a normal voice off the ceiling while leaving the gate threshold
/// (usually a few percent) draggable across the lower part of the bar.
const METER_MAX: f32 = 0.3;

/// A unified mic-level meter with a draggable noise-gate handle (Discord/OBS
/// style). The bar fills to the live mic level; the yellow handle marks the gate
/// threshold on the same axis and can be dragged to set it. The fill turns green
/// when the level is above the gate (transmitting), dim when below it (muted).
struct GateMeter {
    level: f32,
    threshold: f32,
    track: Color,
    border: Color,
    fill_on: Color,
    fill_off: Color,
    /// Bright core of the gate handle.
    handle: Color,
    /// Dark outline behind the handle, so it stays visible over the green fill.
    handle_edge: Color,
}

impl GateMeter {
    /// Maps a cursor x (relative to the bar) to a gate threshold on the meter axis.
    fn x_to_threshold(x: f32, width: f32) -> f32 {
        (x / width.max(1.0)).clamp(0.0, 1.0) * METER_MAX
    }
}

#[derive(Default)]
struct GateMeterState {
    dragging: bool,
}

impl Program<AppMessage> for GateMeter {
    type State = GateMeterState;

    fn update(
        &self,
        state: &mut Self::State,
        event: &Event,
        bounds: Rectangle,
        cursor: mouse::Cursor,
    ) -> Option<Action<AppMessage>> {
        match event {
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                if let Some(p) = cursor.position_in(bounds) {
                    state.dragging = true;
                    let t = Self::x_to_threshold(p.x, bounds.width);
                    return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture());
                }
            }
            // Track moves anywhere on screen so the drag survives leaving the bar.
            Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
                if let Some(p) = cursor.position() {
                    let t = Self::x_to_threshold(p.x - bounds.x, bounds.width);
                    return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture());
                }
            }
            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => {
                state.dragging = false;
                let x = cursor.position().map(|p| p.x - bounds.x).unwrap_or(0.0);
                let t = Self::x_to_threshold(x, bounds.width);
                // Persist the final value on release.
                return Some(Action::publish(AppMessage::NoiseGateChanged(t)).and_capture());
            }
            _ => {}
        }
        None
    }

    fn draw(
        &self,
        _state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let w = bounds.width;
        let h = bounds.height;

        // Track.
        frame.fill_rectangle(Point::ORIGIN, Size::new(w, h), self.track);

        // Level fill, coloured by whether we're above the gate.
        let level_frac = (self.level / METER_MAX).clamp(0.0, 1.0);
        let fill = if self.level >= self.threshold {
            self.fill_on
        } else {
            self.fill_off
        };
        if level_frac > 0.0 {
            frame.fill_rectangle(Point::ORIGIN, Size::new(w * level_frac, h), fill);
        }

        // Gate handle: a bright vertical line + grip caps, each backed by a dark
        // edge so the handle stays legible even when the green level sweeps past it.
        let thr_frac = (self.threshold / METER_MAX).clamp(0.0, 1.0);
        let x = (w * thr_frac).clamp(3.0, (w - 3.0).max(3.0));
        // Dark edge (slightly larger), then bright core.
        frame.fill(
            &Path::rectangle(Point::new(x - 3.0, 0.0), Size::new(6.0, h)),
            self.handle_edge,
        );
        frame.fill(
            &Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)),
            self.handle,
        );
        // Grip caps top and bottom.
        frame.fill(
            &Path::rectangle(Point::new(x - 5.5, 0.0), Size::new(11.0, 6.0)),
            self.handle_edge,
        );
        frame.fill(
            &Path::rectangle(Point::new(x - 4.0, 1.0), Size::new(8.0, 4.0)),
            self.handle,
        );
        frame.fill(
            &Path::rectangle(Point::new(x - 5.5, h - 6.0), Size::new(11.0, 6.0)),
            self.handle_edge,
        );
        frame.fill(
            &Path::rectangle(Point::new(x - 4.0, h - 5.0), Size::new(8.0, 4.0)),
            self.handle,
        );

        // Border.
        frame.stroke(
            &Path::rectangle(Point::ORIGIN, Size::new(w, h)),
            canvas::Stroke::default()
                .with_color(self.border)
                .with_width(1.0),
        );

        vec![frame.into_geometry()]
    }

    fn mouse_interaction(
        &self,
        state: &Self::State,
        bounds: Rectangle,
        cursor: mouse::Cursor,
    ) -> mouse::Interaction {
        if state.dragging || cursor.is_over(bounds) {
            mouse::Interaction::ResizingHorizontally
        } else {
            mouse::Interaction::default()
        }
    }
}

/// A draggable splitter between two panels. Reports drag motion along its axis as
/// `AppMessage::DividerDragged(kind, delta_px)`; the parent applies + clamps it.
/// `vertical` = a vertical bar dragged horizontally (resizes width); otherwise a
/// horizontal bar dragged vertically (resizes height). Modeled on [`GateMeter`]'s
/// drag handling: the drag is tracked off the global cursor so it survives the
/// pointer leaving the thin divider strip.
struct Divider {
    kind: DividerKind,
    /// True = vertical bar (horizontal drag); false = horizontal bar (vertical drag).
    vertical: bool,
    /// Centre line colour.
    line: Color,
    /// Grip-dot colour.
    grip: Color,
}

/// Drag state: the last cursor coordinate along the drag axis while dragging.
#[derive(Default)]
struct DividerState {
    last: Option<f32>,
}

impl Program<AppMessage> for Divider {
    type State = DividerState;

    fn update(
        &self,
        state: &mut Self::State,
        event: &Event,
        bounds: Rectangle,
        cursor: mouse::Cursor,
    ) -> Option<Action<AppMessage>> {
        // Cursor coordinate along the drag axis (x for a vertical bar, else y).
        let axis = |p: Point| if self.vertical { p.x } else { p.y };
        match event {
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                if cursor.is_over(bounds)
                    && let Some(p) = cursor.position()
                {
                    // Record the absolute start coordinate; subsequent moves yield
                    // deltas. Capture with a zero-delta no-op.
                    state.last = Some(axis(p));
                    return Some(
                        Action::publish(AppMessage::DividerDragged(self.kind, 0.0)).and_capture(),
                    );
                }
            }
            // Track globally so the drag continues past the thin strip's bounds.
            Event::Mouse(mouse::Event::CursorMoved { .. }) if state.last.is_some() => {
                if let Some(p) = cursor.position() {
                    let cur = axis(p);
                    let last = state.last.unwrap();
                    state.last = Some(cur);
                    return Some(
                        Action::publish(AppMessage::DividerDragged(self.kind, cur - last))
                            .and_capture(),
                    );
                }
            }
            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
                if state.last.is_some() =>
            {
                state.last = None;
                // Persist the final position once, on release (not per pixel).
                return Some(Action::publish(AppMessage::PersistConfig).and_capture());
            }
            _ => {}
        }
        None
    }

    fn draw(
        &self,
        _state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let w = bounds.width;
        let h = bounds.height;
        if self.vertical {
            let x = w / 2.0;
            frame.fill(
                &Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)),
                self.line,
            );
            let cy = h / 2.0;
            for i in -1..=1 {
                let dy = cy + i as f32 * 6.0;
                frame.fill(
                    &Path::rectangle(Point::new(x - 1.5, dy - 1.5), Size::new(3.0, 3.0)),
                    self.grip,
                );
            }
        } else {
            let y = h / 2.0;
            frame.fill(
                &Path::rectangle(Point::new(0.0, y - 1.0), Size::new(w, 2.0)),
                self.line,
            );
            let cx = w / 2.0;
            for i in -1..=1 {
                let dx = cx + i as f32 * 6.0;
                frame.fill(
                    &Path::rectangle(Point::new(dx - 1.5, y - 1.5), Size::new(3.0, 3.0)),
                    self.grip,
                );
            }
        }
        vec![frame.into_geometry()]
    }

    fn mouse_interaction(
        &self,
        state: &Self::State,
        bounds: Rectangle,
        cursor: mouse::Cursor,
    ) -> mouse::Interaction {
        if state.last.is_some() || cursor.is_over(bounds) {
            if self.vertical {
                mouse::Interaction::ResizingHorizontally
            } else {
                mouse::Interaction::ResizingVertically
            }
        } else {
            mouse::Interaction::default()
        }
    }
}

/// Wrap a base screen with the room-layout picker popup when it's open: a dimmed,
/// click-to-dismiss backdrop plus a centered gallery of clickable layout
/// thumbnails. Returns the base unchanged when the picker is closed. Used by both
/// the launch and in-call screens (the Settings screen shows thumbnails inline).
fn with_layout_picker<'a>(
    base: Element<'a, AppMessage>,
    state: &'a AppState,
) -> Element<'a, AppMessage> {
    if !state.layout_picker_open {
        return base;
    }
    let pal = state.config.theme.palette();
    let crust = pal.crust;
    let mantle = pal.mantle;
    let base_c = pal.base;
    let surface = pal.surface;
    let overlay = pal.overlay;
    let text_c = pal.text;
    let subtext = pal.subtext;
    let blue = pal.blue;
    let green = pal.green;

    let backdrop = mouse_area(
        container(horizontal_space())
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .style(move |_t: &Theme| container::Style {
                background: Some(Background::Color(Color { a: 0.55, ..crust })),
                ..Default::default()
            }),
    )
    .on_press(AppMessage::CloseLayoutPicker);

    let thumb = |layout: RoomLayout, label: &'static str| -> Element<'a, AppMessage> {
        let selected = state.config.room_layout == layout;
        let tile = Canvas::new(LayoutThumb {
            layout,
            selected,
            base: base_c,
            surface,
            overlay,
            border: if selected { blue } else { surface },
        })
        .width(iced::Length::Fixed(168.0))
        .height(iced::Length::Fixed(112.0));
        let btn = button(tile)
            .on_press(AppMessage::SelectRoomLayout(layout))
            .padding(2)
            .style(move |_t: &Theme, status: button::Status| button::Style {
                background: Some(Background::Color(match status {
                    button::Status::Hovered => surface,
                    _ => Color::TRANSPARENT,
                })),
                text_color: text_c,
                border: Border {
                    color: Color::TRANSPARENT,
                    width: 0.0,
                    radius: 8.0.into(),
                },
                ..Default::default()
            });
        let (lbl_color, marker): (Color, Element<'a, AppMessage>) = if selected {
            (blue, text("● current").size(10).color(green).into())
        } else {
            (text_c, vertical_space(0.0).into())
        };
        column![btn, text(label).size(13).color(lbl_color), marker]
            .spacing(4)
            .align_x(iced::alignment::Horizontal::Center)
            .into()
    };

    let gallery = container(
        column![
            row![
                text("Choose room layout").size(16).color(blue),
                horizontal_space(),
                button(text("✕").size(16).color(subtext))
                    .on_press(AppMessage::CloseLayoutPicker)
                    .style(|_t: &Theme, _s: button::Status| button::Style {
                        background: None,
                        ..Default::default()
                    })
                    .padding(2),
            ]
            .align_y(iced::alignment::Vertical::Center),
            row![
                thumb(RoomLayout::ThreeColumn, "3-Column"),
                thumb(RoomLayout::BottomDock, "Bottom Dock"),
                thumb(RoomLayout::Drawer, "Drawer"),
            ]
            .spacing(20),
            text("Click a layout to apply it instantly.")
                .size(11)
                .color(subtext),
        ]
        .spacing(16),
    )
    .style(move |_t: &Theme| container::Style {
        text_color: Some(text_c),
        background: Some(Background::Color(mantle)),
        border: Border {
            color: surface,
            width: 1.0,
            radius: 12.0.into(),
        },
        ..Default::default()
    })
    .padding(20)
    .width(iced::Length::Fixed(600.0));

    stack![
        base,
        backdrop,
        container(gallery)
            .center_x(iced::Length::Fill)
            .center_y(iced::Length::Fill),
    ]
    .into()
}

/// Overlay the live hotkey reference from the top-right info button. It reads
/// directly from config, so Settings edits are reflected immediately.
fn with_hotkey_info<'a>(
    base: Element<'a, AppMessage>,
    state: &'a AppState,
) -> Element<'a, AppMessage> {
    if !state.hotkey_info_open {
        return base;
    }
    let pal = state.config.theme.palette();
    let crust = pal.crust;
    let mantle = pal.mantle;
    let surface = pal.surface;
    let text_c = pal.text;
    let subtext = pal.subtext;
    let blue = pal.blue;

    let backdrop = mouse_area(
        container(horizontal_space())
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .style(move |_t: &Theme| container::Style {
                background: Some(Background::Color(Color { a: 0.25, ..crust })),
                ..Default::default()
            }),
    )
    .on_press(AppMessage::CloseHotkeyInfo);

    let mut rows = Column::new().spacing(8).width(iced::Length::Fill);
    for action in HotkeyAction::ALL {
        rows = rows.push(
            row![
                text(action.label()).size(12).color(text_c),
                horizontal_space(),
                text(format_binding(state.config.hotkeys.binding(action)))
                    .size(12)
                    .color(subtext),
            ]
            .spacing(12)
            .align_y(iced::alignment::Vertical::Center),
        );
    }

    let dialog = container(
        column![
            row![
                text("Hotkeys").size(16).color(blue),
                horizontal_space(),
                button(text("✕").size(16).color(subtext))
                    .on_press(AppMessage::CloseHotkeyInfo)
                    .style(|_t: &Theme, _s: button::Status| button::Style {
                        background: None,
                        ..Default::default()
                    })
                    .padding(2),
            ]
            .align_y(iced::alignment::Vertical::Center),
            rows,
        ]
        .spacing(14),
    )
    .style(move |_t: &Theme| container::Style {
        text_color: Some(text_c),
        background: Some(Background::Color(mantle)),
        border: Border {
            color: surface,
            width: 1.0,
            radius: 8.0.into(),
        },
        ..Default::default()
    })
    .padding(16)
    .width(iced::Length::Fixed(320.0));

    stack![
        base,
        backdrop,
        container(column![
            vertical_space(48.0),
            row![horizontal_space(), dialog].width(iced::Length::Fill),
        ])
        .width(iced::Length::Fill)
        .height(iced::Length::Fill)
        .padding(12),
    ]
    .into()
}

/// Overlays the "screen sharing needs pixelpass" explainer popup over `base`
/// when open (A11). Triggered by the Share Screen / Watch controls when the
/// optional `pixelpass` companion isn't installed, so those controls open a
/// short how-to instead of being dead/disabled. Returns the base unchanged when
/// the popup is closed.
fn with_pixelpass_help<'a>(
    base: Element<'a, AppMessage>,
    state: &'a AppState,
) -> Element<'a, AppMessage> {
    if !state.pixelpass_help_open {
        return base;
    }
    let pal = state.config.theme.palette();
    let crust = pal.crust;
    let mantle = pal.mantle;
    let surface = pal.surface;
    let text_c = pal.text;
    let subtext = pal.subtext;
    let blue = pal.blue;

    let backdrop = mouse_area(
        container(horizontal_space())
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .style(move |_t: &Theme| container::Style {
                background: Some(Background::Color(Color { a: 0.55, ..crust })),
                ..Default::default()
            }),
    )
    .on_press(AppMessage::ClosePixelpassHelp);

    let dialog = container(
        column![
            row![
                text("Screen sharing").size(16).color(blue),
                horizontal_space(),
                button(text("✕").size(16).color(subtext))
                    .on_press(AppMessage::ClosePixelpassHelp)
                    .style(|_t: &Theme, _s: button::Status| button::Style {
                        background: None,
                        ..Default::default()
                    })
                    .padding(2),
            ]
            .align_y(iced::alignment::Vertical::Center),
            text(
                "Screen sharing uses pixelpass, a small companion tool that \
                 streams video peer-to-peer alongside your call. It's optional \
                 and ships separately, so peerspeak works fully without it."
            )
            .size(13)
            .color(text_c),
            text("To enable sharing and watching:").size(13).color(text_c),
            text("• Install pixelpass and make sure the `pixelpass` command is on your PATH.").size(12).color(subtext),
            text("• Install `mpv` too — it's what opens a peer's shared screen.").size(12).color(subtext),
            text("Once both are present, restart peerspeak and the Share Screen button will start a share.").size(12).color(subtext),
            row![
                horizontal_space(),
                button(text("Got it").size(13))
                    .on_press(AppMessage::ClosePixelpassHelp)
                    .style(move |_t: &Theme, status: button::Status| button::Style {
                        background: Some(Background::Color(match status {
                            button::Status::Hovered => blue,
                            _ => surface,
                        })),
                        text_color: text_c,
                        border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() },
                        ..Default::default()
                    })
                    .padding(8),
            ],
        ]
        .spacing(14),
    )
    .style(move |_t: &Theme| container::Style {
        text_color: Some(text_c),
        background: Some(Background::Color(mantle)),
        border: Border { color: surface, width: 1.0, radius: 12.0.into() },
        ..Default::default()
    })
    .padding(20)
    .width(iced::Length::Fixed(460.0));

    stack![
        base,
        backdrop,
        container(dialog)
            .center_x(iced::Length::Fill)
            .center_y(iced::Length::Fill),
    ]
    .into()
}

/// Overlay the screen-share **audio picker** when open (A23). Lets the user
/// capture a single app's audio instead of the whole desktop sink — the default
/// whole-desktop capture contains our own call playout, so a viewer would
/// otherwise hear themselves echoed back. "All system audio" keeps the legacy
/// behavior (with a warning); picking an app passes `--app=<name>` to pixelpass.
fn with_share_picker<'a>(
    base: Element<'a, AppMessage>,
    state: &'a AppState,
) -> Element<'a, AppMessage> {
    if !state.share_picker_open {
        return base;
    }
    let pal = state.config.theme.palette();
    let crust = pal.crust;
    let mantle = pal.mantle;
    let surface = pal.surface;
    let text_c = pal.text;
    let subtext = pal.subtext;
    let blue = pal.blue;
    let yellow = pal.yellow;

    let backdrop = mouse_area(
        container(horizontal_space())
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .style(move |_t: &Theme| container::Style {
                background: Some(Background::Color(Color { a: 0.55, ..crust })),
                ..Default::default()
            }),
    )
    .on_press(AppMessage::CloseSharePicker);

    // One selectable row: a radio-style dot + label. Highlighted when selected.
    let opt_row =
        |selected: bool, label: String, sublabel: Option<&'static str>, msg: AppMessage| {
            let dot = text(if selected { "●" } else { "○" })
                .size(14)
                .color(if selected { blue } else { subtext });
            let mut labels = column![text(label).size(13).color(text_c)].spacing(2);
            if let Some(s) = sublabel {
                labels = labels.push(text(s).size(11).color(yellow));
            }
            button(
                row![dot, labels]
                    .spacing(10)
                    .align_y(iced::alignment::Vertical::Center),
            )
            .on_press(msg)
            .width(iced::Length::Fill)
            .padding(8)
            .style(move |_t: &Theme, status: button::Status| {
                let bg = if selected {
                    Some(Background::Color(surface))
                } else if matches!(status, button::Status::Hovered) {
                    Some(Background::Color(Color { a: 0.5, ..surface }))
                } else {
                    None
                };
                button::Style {
                    background: bg,
                    text_color: text_c,
                    border: Border {
                        color: Color::TRANSPARENT,
                        width: 0.0,
                        radius: 6.0.into(),
                    },
                    ..Default::default()
                }
            })
        };

    // "All system audio" first (the whole-desktop default — carries the echo
    // warning), then each currently-playing app.
    let mut options = column![opt_row(
        state.share_audio_selection.is_none(),
        "All system audio".to_string(),
        Some("⚠ may echo the call back to viewers"),
        AppMessage::SelectShareAudioApp(None),
    )]
    .spacing(4);
    for app in &state.share_audio_apps {
        let selected = state.share_audio_selection.as_deref() == Some(app.as_str());
        options = options.push(opt_row(
            selected,
            app.clone(),
            None,
            AppMessage::SelectShareAudioApp(Some(app.clone())),
        ));
    }

    let list: Element<'_, AppMessage> = if !state.share_app_audio_supported {
        // Older pixelpass without --strict-audio: per-app capture is unavailable
        // (it would hard-fail), so only whole-desktop is offered. Nudge to upgrade.
        column![
            options,
            text("Update pixelpass to capture a single app's audio (avoids echoing the call to viewers).")
                .size(11)
                .color(yellow),
        ]
        .spacing(8)
        .into()
    } else if state.share_audio_apps.is_empty() {
        column![
            options,
            text("No other apps are playing audio right now.")
                .size(11)
                .color(subtext),
        ]
        .spacing(8)
        .into()
    } else {
        scrollable(options).height(iced::Length::Shrink).into()
    };

    let cancel_btn = button(text("Cancel").size(13).color(text_c))
        .on_press(AppMessage::CloseSharePicker)
        .style(move |_t: &Theme, status: button::Status| button::Style {
            background: Some(Background::Color(match status {
                button::Status::Hovered => surface,
                _ => mantle,
            })),
            text_color: text_c,
            border: Border {
                color: surface,
                width: 1.0,
                radius: 6.0.into(),
            },
            ..Default::default()
        })
        .padding(8);
    let share_btn = button(text("Share").size(13).color(crust))
        .on_press(AppMessage::ConfirmShareScreen)
        .style(move |_t: &Theme, status: button::Status| button::Style {
            background: Some(Background::Color(match status {
                button::Status::Hovered => pal.lavender,
                _ => blue,
            })),
            text_color: crust,
            border: Border {
                color: Color::TRANSPARENT,
                width: 0.0,
                radius: 6.0.into(),
            },
            ..Default::default()
        })
        .padding(8);

    let dialog = container(
        column![
            row![
                text("Share screen audio").size(16).color(blue),
                horizontal_space(),
                button(text("✕").size(16).color(subtext))
                    .on_press(AppMessage::CloseSharePicker)
                    .style(|_t: &Theme, _s: button::Status| button::Style {
                        background: None,
                        ..Default::default()
                    })
                    .padding(2),
            ]
            .align_y(iced::alignment::Vertical::Center),
            text("Capture audio from:").size(13).color(text_c),
            list,
            row![horizontal_space(), cancel_btn, share_btn,].spacing(8),
        ]
        .spacing(14),
    )
    .style(move |_t: &Theme| container::Style {
        text_color: Some(text_c),
        background: Some(Background::Color(mantle)),
        border: Border {
            color: surface,
            width: 1.0,
            radius: 12.0.into(),
        },
        ..Default::default()
    })
    .padding(20)
    .width(iced::Length::Fixed(420.0))
    .max_height(460.0);

    stack![
        base,
        backdrop,
        container(dialog)
            .center_x(iced::Length::Fill)
            .center_y(iced::Length::Fill),
    ]
    .into()
}

/// Overlay the "Regenerate identity?" confirm dialog when open (W7). A
/// destructive action — minting a new id discards the old one — so it's gated
/// behind an explicit confirm with a clear warning.
fn with_regenerate_confirm<'a>(
    base: Element<'a, AppMessage>,
    state: &'a AppState,
) -> Element<'a, AppMessage> {
    if !state.regenerate_identity_confirm_open {
        return base;
    }
    let pal = state.config.theme.palette();
    let crust = pal.crust;
    let mantle = pal.mantle;
    let surface = pal.surface;
    let text_c = pal.text;
    let subtext = pal.subtext;
    let maroon = pal.maroon;

    let backdrop = mouse_area(
        container(horizontal_space())
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .style(move |_t: &Theme| container::Style {
                background: Some(Background::Color(Color { a: 0.55, ..crust })),
                ..Default::default()
            }),
    )
    .on_press(AppMessage::CloseRegenerateIdentityConfirm);

    let dialog = container(
        column![
            text("Regenerate identity?").size(16).color(maroon),
            text(
                "This mints a brand-new identity and permanently discards your \
                 current one. Friends who saved your old ID will no longer \
                 recognise or reach you until you reconnect and they re-add you. \
                 This can't be undone."
            )
            .size(13)
            .color(text_c),
            text("Takes effect on your next room join.")
                .size(12)
                .color(subtext),
            row![
                horizontal_space(),
                button(text("Cancel").size(13))
                    .on_press(AppMessage::CloseRegenerateIdentityConfirm)
                    .style(move |_t: &Theme, status: button::Status| button::Style {
                        background: Some(Background::Color(match status {
                            button::Status::Hovered => surface,
                            _ => mantle,
                        })),
                        text_color: text_c,
                        border: Border {
                            color: surface,
                            width: 1.0,
                            radius: 6.0.into()
                        },
                        ..Default::default()
                    })
                    .padding(8),
                button(text("Regenerate").size(13))
                    .on_press(AppMessage::ConfirmRegenerateIdentity)
                    .style(move |_t: &Theme, status: button::Status| button::Style {
                        background: Some(Background::Color(match status {
                            button::Status::Hovered => Color { a: 0.85, ..maroon },
                            _ => maroon,
                        })),
                        text_color: text_c,
                        border: Border {
                            color: Color::TRANSPARENT,
                            width: 0.0,
                            radius: 6.0.into()
                        },
                        ..Default::default()
                    })
                    .padding(8),
            ]
            .spacing(10),
        ]
        .spacing(14),
    )
    .style(move |_t: &Theme| container::Style {
        text_color: Some(text_c),
        background: Some(Background::Color(mantle)),
        border: Border {
            color: surface,
            width: 1.0,
            radius: 12.0.into(),
        },
        ..Default::default()
    })
    .padding(20)
    .width(iced::Length::Fixed(440.0));

    stack![
        base,
        backdrop,
        container(dialog)
            .center_x(iced::Length::Fill)
            .center_y(iced::Length::Fill),
    ]
    .into()
}

/// Overlay the click-to-enlarge image lightbox when `state.image_lightbox` is set
/// (W22). Clicking the dimmed backdrop, the enlarged image, or the top-right ✕
/// button closes it (`CloseImageLightbox`). Modeled on `with_regenerate_confirm`.
fn with_image_lightbox<'a>(
    base: Element<'a, AppMessage>,
    state: &'a AppState,
) -> Element<'a, AppMessage> {
    let Some(key) = state.image_lightbox.as_ref() else {
        return base;
    };
    // If the cached handle was evicted (Tier C F-02 bound), there's nothing to
    // show — fall back to the base UI rather than an empty overlay.
    let Some(handle) = state.attachments.handle(key) else {
        return base;
    };
    let pal = state.config.theme.palette();
    let crust = pal.crust;
    let mantle = pal.mantle;
    let surface = pal.surface;
    let text_c = pal.text;

    // Full-window dim that closes on click (click-outside-to-close).
    let backdrop = mouse_area(
        container(horizontal_space())
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .style(move |_t: &Theme| container::Style {
                background: Some(Background::Color(Color { a: 0.80, ..crust })),
                ..Default::default()
            }),
    )
    .on_press(AppMessage::CloseImageLightbox);

    // Contain-fit so the image scales down to the window without cropping or
    // overflowing, bounded to most of the viewport by the surrounding padding.
    let big = iced::widget::image(handle.clone())
        .content_fit(iced::ContentFit::Contain)
        .width(iced::Length::Fill)
        .height(iced::Length::Fill);

    // ✕ close button, pinned top-right (subtle styling matching the dialog's
    // Cancel button).
    let close_btn = button(text("✕").size(18))
        .on_press(AppMessage::CloseImageLightbox)
        .style(move |_t: &Theme, status: button::Status| button::Style {
            background: Some(Background::Color(match status {
                button::Status::Hovered => surface,
                _ => mantle,
            })),
            text_color: text_c,
            border: Border {
                color: surface,
                width: 1.0,
                radius: 6.0.into(),
            },
            ..Default::default()
        })
        .padding(8);

    // The image sits in its own layer above the backdrop, so clicking the picture
    // wouldn't reach the backdrop — wrap it so the image itself also closes.
    let image_area = mouse_area(
        container(big)
            .center_x(iced::Length::Fill)
            .center_y(iced::Length::Fill)
            .padding(40),
    )
    .on_press(AppMessage::CloseImageLightbox);

    stack![
        base,
        backdrop,
        image_area,
        container(close_btn)
            .align_x(iced::alignment::Horizontal::Right)
            .align_y(iced::alignment::Vertical::Top)
            .width(iced::Length::Fill)
            .height(iced::Length::Fill)
            .padding(16),
    ]
    .into()
}

/// A small two-pane glyph for the square layout-picker button in the top bar.
struct LayoutIcon {
    fg: Color,
}

impl Program<AppMessage> for LayoutIcon {
    type State = ();
    fn draw(
        &self,
        _state: &(),
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut f = Frame::new(renderer, bounds.size());
        let (w, h) = (bounds.width, bounds.height);
        f.stroke(
            &Path::rectangle(Point::new(1.0, 1.5), Size::new(w - 2.0, h - 3.0)),
            canvas::Stroke::default()
                .with_color(self.fg)
                .with_width(1.5),
        );
        // Vertical split into two panes.
        f.fill(
            &Path::rectangle(Point::new(w * 0.5 - 0.75, 1.5), Size::new(1.5, h - 3.0)),
            self.fg,
        );
        vec![f.into_geometry()]
    }
}

/// A schematic thumbnail of a [`RoomLayout`] (colored boxes for each panel),
/// drawn so the picker stays in sync with the theme and needs no image assets.
struct LayoutThumb {
    layout: RoomLayout,
    selected: bool,
    base: Color,
    surface: Color,
    /// Accent shade for the chat panel, to distinguish it from the others.
    overlay: Color,
    /// Border colour (blue when selected, surface otherwise).
    border: Color,
}

impl LayoutThumb {
    fn pane(f: &mut Frame, x: f32, y: f32, w: f32, h: f32, color: Color) {
        f.fill(&Path::rectangle(Point::new(x, y), Size::new(w, h)), color);
    }
}

impl Program<AppMessage> for LayoutThumb {
    type State = ();
    fn draw(
        &self,
        _state: &(),
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut f = Frame::new(renderer, bounds.size());
        let (w, h) = (bounds.width, bounds.height);
        f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), self.base);

        let pad = 9.0;
        let (ix, iy) = (pad, pad);
        let (iw, ih) = (w - 2.0 * pad, h - 2.0 * pad);
        let g = 4.0;
        match self.layout {
            RoomLayout::ThreeColumn => {
                let c1 = iw * 0.34;
                let c3 = iw * 0.24;
                let c2 = iw - c1 - c3 - 2.0 * g;
                Self::pane(&mut f, ix, iy, c1, ih, self.surface);
                Self::pane(&mut f, ix + c1 + g, iy, c2, ih, self.overlay);
                Self::pane(&mut f, ix + c1 + g + c2 + g, iy, c3, ih, self.surface);
            }
            RoomLayout::BottomDock => {
                let toph = ih * 0.6;
                let both = ih - toph - g;
                let lw = iw * 0.66;
                Self::pane(&mut f, ix, iy, lw, toph, self.surface);
                Self::pane(&mut f, ix + lw + g, iy, iw - lw - g, toph, self.surface);
                Self::pane(&mut f, ix, iy + toph + g, iw, both, self.overlay);
            }
            RoomLayout::Drawer => {
                let dw = iw * 0.18;
                let main = iw - dw - g;
                let lw = main * 0.62;
                Self::pane(&mut f, ix, iy, lw, ih, self.surface);
                Self::pane(&mut f, ix + lw + g, iy, main - lw - g, ih, self.surface);
                Self::pane(&mut f, ix + main + g, iy, dw, ih, self.overlay);
            }
        }

        // Border on top (thicker + blue when selected).
        let bw: f32 = if self.selected { 2.0 } else { 1.0 };
        f.stroke(
            &Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
            canvas::Stroke::default()
                .with_color(self.border)
                .with_width(bw),
        );
        vec![f.into_geometry()]
    }
}

/// A small palette-preview tile for the theme picker (modeled on `LayoutThumb`):
/// the theme's background, a surface panel with two "text" lines to preview
/// legibility, and a stack of accent colours. Border highlights the selection.
struct ThemeSwatch {
    palette: Palette,
    selected: bool,
    border: Color,
}

impl Program<AppMessage> for ThemeSwatch {
    type State = ();
    fn draw(
        &self,
        _state: &(),
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut f = Frame::new(renderer, bounds.size());
        let (w, h) = (bounds.width, bounds.height);
        let p = &self.palette;
        let pad = 7.0;

        // Background fill.
        f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), p.base);

        // Surface panel on the left half, with two text-colour lines on it.
        let panel_w = (w - 2.0 * pad) * 0.52;
        f.fill(
            &Path::rectangle(Point::new(pad, pad), Size::new(panel_w, h - 2.0 * pad)),
            p.surface,
        );
        let mut line = |y: f32, ww: f32, col: Color| {
            f.fill(
                &Path::rectangle(Point::new(pad + 6.0, y), Size::new(ww, 3.0)),
                col,
            );
        };
        line(pad + 9.0, panel_w * 0.66, p.text);
        line(pad + 17.0, panel_w * 0.48, p.subtext);

        // Accent swatches stacked on the right.
        let ax = pad + panel_w + 6.0;
        let aw = (w - pad - ax).max(2.0);
        let accents = [p.blue, p.green, p.yellow, p.red, p.lavender];
        let gap = 3.0;
        let ah = ((h - 2.0 * pad) - gap * (accents.len() as f32 - 1.0)) / accents.len() as f32;
        for (i, c) in accents.iter().enumerate() {
            let ay = pad + i as f32 * (ah + gap);
            f.fill(&Path::rectangle(Point::new(ax, ay), Size::new(aw, ah)), *c);
        }

        // Border (thicker + accent when selected).
        let bw: f32 = if self.selected { 2.0 } else { 1.0 };
        f.stroke(
            &Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
            canvas::Stroke::default()
                .with_color(self.border)
                .with_width(bw),
        );
        vec![f.into_geometry()]
    }
}

/// The PeerSpeak icon set, drawn on a `canvas` so it needs no image/font asset
/// and recolors with the theme (consistent with `LayoutThumb`/`GateMeter`).
/// Each icon is authored in a 24×24 space and scaled to the widget size.
#[derive(Clone, Copy, PartialEq)]
enum IconKind {
    Mic,
    MicOff,
    Headphones,
    Deafen,
    Speaker,
    SpeakerOff,
    Monitor,
    Eye,
    Record,
    Stop,
    Chat,
    People,
    Clock,
    Info,
    Settings,
    Copy,
    Leave,
    Create,
    Live,
}

struct Icon {
    kind: IconKind,
    color: Color,
    /// Stroke weight in the 24-unit authoring space (scaled with the widget).
    weight: f32,
}

/// Build an icon element at `size` px in `color`. Used throughout the room/
/// settings UI in place of emoji.
fn icon<'a>(kind: IconKind, size: f32, color: Color) -> Element<'a, AppMessage> {
    Canvas::new(Icon {
        kind,
        color,
        weight: 2.0,
    })
    .width(size)
    .height(size)
    .into()
}

/// A circular monogram avatar (W4): the participant's initial(s) on a colour
/// deterministically derived from `key` (their node id, or display name where no
/// id is available). This is the fallback shown until presets / custom uploads
/// (W4 Phases 2–3) override it. `size` is the diameter in px.
fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage> {
    let (r, g, b) = crate::avatar::color_for_key(key);
    let bg = Color::from_rgb8(r, g, b);
    let fg = if crate::avatar::use_dark_text_on((r, g, b)) {
        Color::from_rgb8(0x1e, 0x1e, 0x2e)
    } else {
        Color::WHITE
    };
    container(
        text(crate::avatar::initials(name))
            .size(size * 0.42)
            .color(fg),
    )
    .center_x(iced::Length::Fixed(size))
    .center_y(iced::Length::Fixed(size))
    .style(move |_t: &Theme| container::Style {
        background: Some(Background::Color(bg)),
        border: Border {
            color: Color::TRANSPARENT,
            width: 0.0,
            radius: (size / 2.0).into(),
        },
        ..Default::default()
    })
    .into()
}

/// Maximum distinct avatar images we keep handles for. Each avatar is bounded to
/// 48 KiB / 256×256 at ingest, so a 64-entry LRU caps this cache at a few MB
/// regardless of how many distinct avatars peers publish over time (Tier C F-03).
const AVATAR_CACHE_CAP: usize = 64;

thread_local! {
    /// Bounded cache of avatar image handles, keyed by PNG content, so the SAME
    /// `image::Handle` (and thus the same GPU texture id) is reused across
    /// redraws. `image::Handle::from_bytes` mints a fresh *unique* id on every
    /// call, so building handles inline in `view()` made iced re-upload the
    /// texture on every repaint — including the redraws fired on each mouse move —
    /// which showed up as constant flicker. A peer can publish an unbounded stream
    /// of distinct valid avatars over a session, so the cache is an LRU (bounded +
    /// byte-equality keyed) rather than a plain map (Tier C F-03). Lives on the
    /// (single) UI thread.
    static AVATAR_HANDLE_CACHE:
        std::cell::RefCell<crate::avatar::ByteLru<iced::widget::image::Handle>> =
        std::cell::RefCell::new(crate::avatar::ByteLru::new(AVATAR_CACHE_CAP));
}

/// A stable image handle for these exact PNG bytes (cached by content), so it
/// keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`].
fn cached_image_handle(bytes: bytes::Bytes) -> iced::widget::image::Handle {
    AVATAR_HANDLE_CACHE.with(|cache| {
        cache.borrow_mut().get_or_insert(bytes.as_ref(), || {
            iced::widget::image::Handle::from_bytes(bytes.clone())
        })
    })
}

/// Render a participant's avatar (W4): the chosen preset image if any, else the
/// monogram fallback. `name`/`key` feed the monogram; `size` is the diameter.
fn avatar_view<'a>(
    avatar: &crate::avatar::Avatar,
    name: &str,
    key: &str,
    size: f32,
) -> Element<'a, AppMessage> {
    let png_bytes: Option<bytes::Bytes> = avatar
        .preset_png()
        .map(bytes::Bytes::from_static)
        .or_else(|| avatar.custom_png().map(bytes::Bytes::from));
    match png_bytes {
        Some(b) => iced::widget::image(cached_image_handle(b))
            .width(iced::Length::Fixed(size))
            .height(iced::Length::Fixed(size))
            .into(),
        None => avatar_badge(name, key, size),
    }
}

/// Centered "icon + label" content for a full-width control-panel button. The
/// icon takes the button's foreground colour so it matches the label.
fn btn_content<'a>(kind: IconKind, label: &'a str, color: Color) -> Element<'a, AppMessage> {
    container(
        row![icon(kind, 18.0, color), text(label).size(16)]
            .spacing(8)
            .align_y(iced::alignment::Vertical::Center),
    )
    .center_x(iced::Length::Fill)
    .into()
}

impl Program<AppMessage> for Icon {
    type State = ();
    fn draw(
        &self,
        _state: &(),
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        use std::f32::consts::PI;
        let mut f = Frame::new(renderer, bounds.size());
        let s = bounds.width.min(bounds.height) / 24.0;
        let col = self.color;
        let sw = (self.weight * s).max(1.0);
        let p = |x: f32, y: f32| Point::new(x * s, y * s);
        let stk = || {
            canvas::Stroke::default()
                .with_color(col)
                .with_width(sw)
                .with_line_cap(canvas::LineCap::Round)
                .with_line_join(canvas::LineJoin::Round)
        };
        // A stroked polyline (or polygon when `closed`).
        let poly = |pts: &[(f32, f32)], closed: bool| {
            Path::new(|b| {
                for (i, q) in pts.iter().enumerate() {
                    let pt = p(q.0, q.1);
                    if i == 0 {
                        b.move_to(pt);
                    } else {
                        b.line_to(pt);
                    }
                }
                if closed {
                    b.close();
                }
            })
        };
        // A stroked circular arc from a0..a1 radians (0 = +x, sweeps toward +y).
        let arcp = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| {
            Path::new(|b| {
                b.arc(iced::widget::canvas::path::Arc {
                    center: p(cx, cy),
                    radius: r * s,
                    start_angle: iced::Radians(a0),
                    end_angle: iced::Radians(a1),
                });
            })
        };
        let rrect = |x: f32, y: f32, w: f32, h: f32, r: f32| {
            Path::rounded_rectangle(p(x, y), Size::new(w * s, h * s), (r * s).into())
        };

        match self.kind {
            IconKind::Mic => {
                f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk());
                f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk());
                f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk());
                f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk());
            }
            IconKind::MicOff => {
                f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk());
                f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk());
                f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk());
                f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk());
                f.stroke(&poly(&[(3.5, 3.5), (20.5, 20.5)], false), stk());
            }
            IconKind::Headphones => {
                f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk());
                f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk());
                f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk());
            }
            IconKind::Deafen => {
                f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk());
                f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk());
                f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk());
                f.stroke(&poly(&[(3.0, 3.0), (21.0, 21.0)], false), stk());
            }
            IconKind::Speaker => {
                f.stroke(
                    &poly(
                        &[
                            (4.0, 9.5),
                            (7.5, 9.5),
                            (13.0, 5.0),
                            (13.0, 19.0),
                            (7.5, 14.5),
                            (4.0, 14.5),
                        ],
                        true,
                    ),
                    stk(),
                );
                f.stroke(&arcp(14.0, 12.0, 4.0, -0.5, 0.5), stk());
                f.stroke(&arcp(14.0, 12.0, 7.5, -0.65, 0.65), stk());
            }
            IconKind::SpeakerOff => {
                f.stroke(
                    &poly(
                        &[
                            (4.0, 9.5),
                            (7.5, 9.5),
                            (13.0, 5.0),
                            (13.0, 19.0),
                            (7.5, 14.5),
                            (4.0, 14.5),
                        ],
                        true,
                    ),
                    stk(),
                );
                f.stroke(&poly(&[(16.5, 9.5), (21.5, 14.5)], false), stk());
                f.stroke(&poly(&[(21.5, 9.5), (16.5, 14.5)], false), stk());
            }
            IconKind::Monitor => {
                f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 2.0), stk());
                f.stroke(&poly(&[(9.0, 20.0), (15.0, 20.0)], false), stk());
                f.stroke(&poly(&[(12.0, 16.0), (12.0, 20.0)], false), stk());
                // up-arrow inside (the "share" cue)
                f.stroke(&poly(&[(12.0, 13.0), (12.0, 7.5)], false), stk());
                f.stroke(
                    &poly(&[(9.5, 10.0), (12.0, 7.5), (14.5, 10.0)], false),
                    stk(),
                );
            }
            IconKind::Eye => {
                f.stroke(
                    &poly(
                        &[
                            (2.5, 12.0),
                            (6.0, 8.5),
                            (12.0, 7.0),
                            (18.0, 8.5),
                            (21.5, 12.0),
                            (18.0, 15.5),
                            (12.0, 17.0),
                            (6.0, 15.5),
                        ],
                        true,
                    ),
                    stk(),
                );
                f.stroke(&Path::circle(p(12.0, 12.0), 3.0 * s), stk());
            }
            IconKind::Record => {
                f.fill(&Path::circle(p(12.0, 12.0), 6.0 * s), col);
            }
            IconKind::Stop => {
                f.fill(&rrect(6.0, 6.0, 12.0, 12.0, 2.5), col);
            }
            IconKind::Chat => {
                f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 3.0), stk());
                f.stroke(
                    &poly(&[(8.0, 16.0), (8.0, 20.5), (12.5, 16.0)], false),
                    stk(),
                );
            }
            IconKind::People => {
                f.stroke(&Path::circle(p(9.0, 9.0), 3.2 * s), stk());
                f.stroke(&arcp(9.0, 20.0, 5.3, PI, 2.0 * PI), stk());
                f.stroke(&Path::circle(p(16.5, 8.0), 2.7 * s), stk());
                f.stroke(&arcp(16.5, 20.0, 4.6, PI, 2.0 * PI), stk());
            }
            IconKind::Clock => {
                f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
                f.stroke(&poly(&[(12.0, 7.0), (12.0, 12.0)], false), stk());
                f.stroke(&poly(&[(12.0, 12.0), (15.5, 14.0)], false), stk());
            }
            IconKind::Info => {
                f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
                f.stroke(&poly(&[(12.0, 10.5), (12.0, 17.0)], false), stk());
                f.fill(&Path::circle(p(12.0, 7.0), 1.1 * s), col);
            }
            IconKind::Settings => {
                f.stroke(&poly(&[(4.0, 7.0), (20.0, 7.0)], false), stk());
                f.stroke(&poly(&[(4.0, 12.0), (20.0, 12.0)], false), stk());
                f.stroke(&poly(&[(4.0, 17.0), (20.0, 17.0)], false), stk());
                f.fill(&Path::circle(p(15.0, 7.0), 2.4 * s), col);
                f.fill(&Path::circle(p(9.0, 12.0), 2.4 * s), col);
                f.fill(&Path::circle(p(16.0, 17.0), 2.4 * s), col);
            }
            IconKind::Copy => {
                f.stroke(&rrect(8.0, 8.0, 12.0, 12.0, 2.0), stk());
                f.stroke(&rrect(4.0, 4.0, 12.0, 12.0, 2.0), stk());
            }
            IconKind::Leave => {
                f.stroke(
                    &poly(&[(9.0, 4.0), (6.0, 4.0), (6.0, 20.0), (9.0, 20.0)], false),
                    stk(),
                );
                f.stroke(&poly(&[(9.5, 12.0), (20.0, 12.0)], false), stk());
                f.stroke(
                    &poly(&[(15.5, 8.0), (20.0, 12.0), (15.5, 16.0)], false),
                    stk(),
                );
            }
            IconKind::Create => {
                f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
                f.stroke(&poly(&[(12.0, 8.0), (12.0, 16.0)], false), stk());
                f.stroke(&poly(&[(8.0, 12.0), (16.0, 12.0)], false), stk());
            }
            IconKind::Live => {
                f.fill(&Path::circle(p(12.0, 12.0), 3.0 * s), col);
                f.stroke(&arcp(12.0, 12.0, 5.0, -0.4 * PI, 0.4 * PI), stk());
                f.stroke(&arcp(12.0, 12.0, 5.0, 0.6 * PI, 1.4 * PI), stk());
                f.stroke(&arcp(12.0, 12.0, 8.0, -0.32 * PI, 0.32 * PI), stk());
                f.stroke(&arcp(12.0, 12.0, 8.0, 0.68 * PI, 1.32 * PI), stk());
            }
        }
        vec![f.into_geometry()]
    }
}

#[cfg(test)]
mod tests {
    use super::PendingSend;
    use super::sendqueue::{self, LocalSend, SendStatus};
    use super::{
        AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
        CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen,
        ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
        clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
        format_relative_ago, friend_presence_notification, initial_window_position,
        now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
        set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update,
    };
    use iroh::SecretKey;
    use std::collections::VecDeque;

    /// Two distinct (peer, id) keys for cache tests; `id` may be shared to model
    /// a malicious peer reusing a victim's attachment id (Tier C F-12).
    fn peer_key(id: [u8; 32]) -> super::AttachmentKey {
        (SecretKey::generate().public(), id)
    }

    /// A `Ready` state around bytes (tests only).
    fn ready(bytes: Vec<u8>) -> AttachmentState {
        AttachmentState::Ready(std::sync::Arc::new(bytes))
    }

    /// A cache with byte budgets too large to bind, isolating count-cap tests.
    fn count_only_cache(cap: usize) -> AttachmentCache {
        AttachmentCache::new(cap, usize::MAX, usize::MAX)
    }

    /// A dummy preview handle carrying an explicit decoded weight (tests only).
    fn preview(decoded_cost: usize) -> super::PreviewHandle {
        super::PreviewHandle {
            handle: iced::widget::image::Handle::from_rgba(1, 1, vec![0u8; 4]),
            decoded_cost,
        }
    }

    #[test]
    fn attachment_cache_evicts_oldest_when_full() {
        let mut cache = count_only_cache(2);
        let k1 = peer_key([1u8; 32]);
        let k2 = peer_key([2u8; 32]);
        let k3 = peer_key([3u8; 32]);
        cache.insert(k1, ready(vec![1]), None);
        cache.insert(k2, ready(vec![2]), None);
        assert_eq!(cache.len(), 2);
        // Inserting a third NEW key evicts the oldest (k1), not the newest.
        cache.insert(k3, ready(vec![3]), None);
        assert_eq!(cache.len(), 2);
        assert!(cache.get(&k1).is_none(), "oldest should be evicted");
        assert!(cache.get(&k2).is_some());
        assert!(cache.get(&k3).is_some());
    }

    #[test]
    fn attachment_cache_replace_keeps_position_and_count() {
        let mut cache = count_only_cache(2);
        let k1 = peer_key([1u8; 32]);
        let k2 = peer_key([2u8; 32]);
        cache.insert(k1, AttachmentState::Failed("pending".into()), None);
        cache.insert(k2, ready(vec![2]), None);
        // Replacing k1 (Failed -> Ready) must NOT bump it to newest; it stays the
        // eviction candidate, and the count is unchanged (no order leak).
        cache.insert(k1, ready(vec![1]), None);
        assert_eq!(cache.len(), 2);
        let k3 = peer_key([3u8; 32]);
        cache.insert(k3, ready(vec![3]), None);
        assert!(cache.get(&k1).is_none(), "replaced entry kept its old age");
        assert!(cache.get(&k2).is_some());
        assert!(cache.get(&k3).is_some());
    }

    #[test]
    fn attachment_cache_same_id_distinct_authors_do_not_alias() {
        // The F-12 core property: two peers sending the SAME attachment id keep
        // separate bytes — one never overwrites or aliases the other.
        let mut cache = count_only_cache(8);
        let shared_id = [7u8; 32];
        let victim = peer_key(shared_id);
        let attacker = peer_key(shared_id);
        cache.insert(victim, ready(vec![1, 1, 1]), None);
        cache.insert(attacker, ready(vec![9, 9, 9]), None);
        assert_eq!(cache.len(), 2);
        assert!(
            matches!(cache.get(&victim), Some(AttachmentState::Ready(b)) if b.as_slice() == [1, 1, 1])
        );
        assert!(
            matches!(cache.get(&attacker), Some(AttachmentState::Ready(b)) if b.as_slice() == [9, 9, 9])
        );
    }

    #[test]
    fn attachment_cache_is_ready_and_handle_and_clear() {
        let mut cache = count_only_cache(4);
        let k = peer_key([5u8; 32]);
        cache.insert(k, ready(vec![1]), Some(preview(4)));
        assert!(cache.is_ready(&k));
        assert!(cache.handle(&k).is_some());
        let failed = peer_key([6u8; 32]);
        cache.insert(failed, AttachmentState::Failed("nope".into()), None);
        assert!(!cache.is_ready(&failed));
        assert!(cache.handle(&failed).is_none());
        cache.clear();
        assert_eq!(cache.len(), 0);
        assert!(cache.get(&k).is_none());
        assert_eq!(cache.encoded_total, 0);
        assert_eq!(cache.decoded_total, 0);
    }

    #[test]
    fn attachment_cache_cap_zero_is_clamped_to_one() {
        let mut cache = count_only_cache(0);
        let k = peer_key([1u8; 32]);
        cache.insert(k, ready(vec![1]), None);
        assert_eq!(cache.len(), 1);
        assert!(cache.get(&k).is_some());
    }

    #[test]
    fn attachment_cache_encoded_budget_evicts_oldest() {
        // Budget of 100 encoded bytes; entries of 60 + 30 fit, adding 40 must
        // evict the oldest (60) — not fail, not exceed the budget.
        let mut cache = AttachmentCache::new(64, 100, usize::MAX);
        let k1 = peer_key([1u8; 32]);
        let k2 = peer_key([2u8; 32]);
        let k3 = peer_key([3u8; 32]);
        assert!(cache.insert(k1, ready(vec![0; 60]), None));
        assert!(cache.insert(k2, ready(vec![0; 30]), None));
        assert_eq!(cache.encoded_total, 90);
        assert!(cache.insert(k3, ready(vec![0; 40]), None));
        assert!(cache.get(&k1).is_none(), "oldest evicted for byte budget");
        assert!(cache.get(&k2).is_some());
        assert!(cache.get(&k3).is_some());
        assert_eq!(cache.encoded_total, 70);
    }

    #[test]
    fn attachment_cache_decoded_budget_evicts_oldest() {
        let mut cache = AttachmentCache::new(64, usize::MAX, 100);
        let k1 = peer_key([1u8; 32]);
        let k2 = peer_key([2u8; 32]);
        assert!(cache.insert(k1, ready(vec![1]), Some(preview(80))));
        assert!(cache.insert(k2, ready(vec![2]), Some(preview(40))));
        assert!(
            cache.get(&k1).is_none(),
            "oldest evicted for decoded budget"
        );
        assert!(cache.get(&k2).is_some());
        assert_eq!(cache.decoded_total, 40);
    }

    #[test]
    fn attachment_cache_replacement_subtracts_old_weight_first() {
        // k1 holds 80 of a 100-byte budget. Replacing it with 90 must fit
        // WITHOUT evicting k2 (10): the old 80 is subtracted before the check.
        let mut cache = AttachmentCache::new(64, 100, usize::MAX);
        let k1 = peer_key([1u8; 32]);
        let k2 = peer_key([2u8; 32]);
        assert!(cache.insert(k1, ready(vec![0; 80]), None));
        assert!(cache.insert(k2, ready(vec![0; 10]), None));
        assert!(cache.insert(k1, ready(vec![0; 90]), None));
        assert!(cache.get(&k2).is_some(), "replacement must not over-evict");
        assert_eq!(cache.encoded_total, 100);
        // A non-Ready replacement releases the weight entirely.
        cache.insert(k1, AttachmentState::Evicted, None);
        assert_eq!(cache.encoded_total, 10);
    }

    #[test]
    fn attachment_cache_overweight_entry_is_not_retained() {
        let mut cache = AttachmentCache::new(64, 100, usize::MAX);
        let k1 = peer_key([1u8; 32]);
        let k2 = peer_key([2u8; 32]);
        assert!(cache.insert(k2, ready(vec![0; 50]), None));
        // Individually larger than the whole budget: rejected, nothing evicted.
        assert!(!cache.insert(k1, ready(vec![0; 101]), None));
        assert!(cache.get(&k1).is_none());
        assert!(cache.get(&k2).is_some(), "others untouched by the reject");
        assert_eq!(cache.encoded_total, 50);
        // Rejecting a REPLACEMENT drops the stale entry too (its old bytes
        // must not linger under a key the caller will mark Evicted).
        assert!(!cache.insert(k2, ready(vec![0; 200]), None));
        assert!(cache.get(&k2).is_none());
        assert_eq!(cache.encoded_total, 0);
        // The caller's follow-up Evicted marker is retained (zero weight).
        assert!(cache.insert(k1, AttachmentState::Evicted, None));
        assert!(matches!(cache.get(&k1), Some(AttachmentState::Evicted)));
    }

    #[test]
    fn attachment_cache_loading_states_are_weightless_and_tracked() {
        let mut cache = AttachmentCache::new(64, 100, 100);
        let k = peer_key([1u8; 32]);
        assert!(cache.insert(k, AttachmentState::Loading, None));
        assert!(cache.is_loading(&k));
        assert!(!cache.is_ready(&k));
        assert_eq!(cache.encoded_total, 0);
        // Ready replaces Loading, weights counted; not loading any more.
        assert!(cache.insert(k, ready(vec![0; 10]), None));
        assert!(!cache.is_loading(&k));
        assert!(cache.is_ready(&k));
        assert_eq!(cache.encoded_total, 10);
    }

    #[test]
    fn attachment_default_name_matches_full_key_not_bare_id() {
        // Two chat lines carry the SAME attachment id but come from different
        // senders. The save-dialog default filename must be the one belonging to
        // the clicked (author, id) — not whichever line happens to match the bare
        // id first (Tier C F-12 metadata residual).
        let victim = SecretKey::generate().public();
        let attacker = SecretKey::generate().public();
        let shared_id = [7u8; 32];
        let mk = |from: iroh::EndpointId, fname: &str| ChatEntry {
            name: "Peer".to_string(),
            text: String::new(),
            mine: false,
            from: Some(from.to_string()),
            attachment: Some(crate::files::ChatAttachment {
                name: fname.to_string(),
                size: 3,
                kind: crate::files::AttachmentKind::File,
                id: shared_id,
            }),
            links: Vec::new(),
            local_send: None,
        };
        // Attacker's line is FIRST in history, so a bare-id scan would pick it.
        let messages = vec![mk(attacker, "evil.sh"), mk(victim, "report.pdf")];
        assert_eq!(
            attachment_default_name(&messages, (victim, shared_id)),
            "report.pdf"
        );
        assert_eq!(
            attachment_default_name(&messages, (attacker, shared_id)),
            "evil.sh"
        );
        // Unknown line → safe fallback.
        let unknown = SecretKey::generate().public();
        assert_eq!(
            attachment_default_name(&messages, (unknown, shared_id)),
            "download"
        );
    }

    #[test]
    fn reset_room_state_clears_all_room_scoped_state() {
        let mut state = AppState::default();
        let peer = SecretKey::generate().public();
        let attachment_id = [9u8; 32];
        let now = std::time::Instant::now();

        state.peers.insert(
            peer,
            crate::network::PeerState {
                name: "Peer".to_string(),
                is_muted: false,
                addr: iroh::EndpointAddr::from(peer),
                sharing: None,
                avatar: crate::avatar::Avatar::default(),
                game: None,
                music: None,
            },
        );
        state.audio_levels.insert(peer, 0.5);
        state.locally_muted.insert(peer);
        state.peer_audio_expanded.insert(peer);
        state.chat_messages.push(ChatEntry {
            name: "Peer".to_string(),
            text: "old room".to_string(),
            mine: false,
            from: Some(peer.to_string()),
            attachment: None,
            links: Vec::new(),
            local_send: None,
        });
        state.chat_input = "draft".to_string();
        let att_key = (peer, attachment_id);
        state
            .attachments
            .insert(att_key, ready(vec![1]), Some(preview(4)));
        state.pending_saves.insert(att_key);
        state.pending_plays.insert(attachment_id);
        state.invalid_audio.insert(attachment_id);
        state.connecting.insert(peer);
        state.ever_connected.insert(peer);
        state.conn_stats.insert(
            peer,
            crate::core::connstats::PeerConnInfo {
                relay: false,
                remote_addr: "1.2.3.4:5".to_string(),
                rtt_ms: 12,
                loss_pct: None,
                up_kbps: None,
                down_kbps: None,
            },
        );
        state.recording = true;
        state.recording_started = Some(now);
        state.call_started = Some(now);
        state.mic_level = 0.75;
        state.self_sharing = true;
        state.share_picker_open = true;
        state.share_audio_apps = vec!["Firefox".to_string()];
        state.share_audio_selection = Some("Firefox".to_string());
        state.share_starting = true;
        state.share_audio_dropped = true;
        state.share_audio_app_active = true;
        state.share_app_audio_supported = false;
        state.clock_skew_warning = Some(ClockSkewBanner {
            skew_secs: 180,
            peer_ahead: true,
            expires_at: now,
        });
        state.music_broadcast_id = Some(attachment_id);
        state.music_broadcast_next = Some((0, attachment_id, 128));
        state.music_listening_to = Some(peer);
        state.music_listen_loaded = Some(attachment_id);
        state.music_listen_inflight = Some(attachment_id);
        state.music_prefetch = Some((attachment_id, vec![1, 2, 3]));
        state.music_prefetch_inflight = Some(attachment_id);
        state.clip_status.lock().unwrap().playing_id = Some(attachment_id);

        state.reset_room_state();

        assert!(state.peers.is_empty());
        assert!(state.audio_levels.is_empty());
        assert!(state.locally_muted.is_empty());
        assert!(state.peer_audio_expanded.is_empty());
        assert!(state.chat_messages.is_empty());
        assert!(state.chat_input.is_empty());
        assert!(state.attachments.len() == 0);
        assert!(state.pending_saves.is_empty());
        assert!(state.pending_plays.is_empty());
        assert!(state.invalid_audio.is_empty());
        assert!(state.connecting.is_empty());
        assert!(state.ever_connected.is_empty());
        assert!(state.conn_stats.is_empty());
        assert!(!state.recording);
        assert!(state.recording_started.is_none());
        assert!(state.call_started.is_none());
        assert_eq!(state.mic_level, 0.0);
        assert!(!state.self_sharing);
        assert!(!state.share_picker_open);
        assert!(state.share_audio_apps.is_empty());
        assert!(state.share_audio_selection.is_none());
        assert_eq!(
            state.share_quality_selection,
            state.config.screen_share.quality
        );
        assert!(!state.share_starting);
        assert!(!state.share_audio_dropped);
        assert!(!state.share_audio_app_active);
        assert!(
            state.share_app_audio_supported,
            "reset is optimistic by default"
        );
        assert!(state.clock_skew_warning.is_none());
        assert!(state.music_broadcast_id.is_none());
        assert!(state.music_broadcast_next.is_none());
        assert!(state.music_listening_to.is_none());
        assert!(state.music_listen_loaded.is_none());
        assert!(state.music_listen_inflight.is_none());
        assert!(state.music_prefetch.is_none());
        assert!(state.music_prefetch_inflight.is_none());

        for _ in 0..50 {
            if crate::audio::clip_player::status_snapshot(&state.clip_status)
                .playing_id
                .is_none()
            {
                return;
            }
            std::thread::sleep(std::time::Duration::from_millis(2));
        }
        panic!("clip player did not stop during room reset");
    }

    #[test]
    fn peer_advanced_audio_toggle_is_per_peer_and_rejects_stale_ids() {
        let mut state = AppState::default();
        let peer = SecretKey::generate().public();
        state.peers.insert(
            peer,
            crate::network::PeerState {
                name: "Peer".to_string(),
                is_muted: false,
                addr: iroh::EndpointAddr::from(peer),
                sharing: None,
                avatar: crate::avatar::Avatar::default(),
                game: None,
                music: None,
            },
        );

        let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
        assert!(state.peer_audio_expanded.contains(&peer));

        let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
        assert!(!state.peer_audio_expanded.contains(&peer));

        let stale = SecretKey::generate().public();
        let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(stale));
        assert!(!state.peer_audio_expanded.contains(&stale));
    }

    #[test]
    fn clock_skew_warning_shows_dismisses_and_expires() {
        let mut state = AppState::default();
        let now = std::time::Instant::now();

        show_clock_skew_warning(&mut state, 181, true, now);
        let warning = state.clock_skew_warning.expect("warning should be visible");
        assert_eq!(warning.skew_secs, 181);
        assert!(warning.peer_ahead);
        assert_eq!(
            warning.expires_at,
            now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS)
        );

        let _ = update(&mut state, AppMessage::DismissClockSkewWarning);
        assert!(state.clock_skew_warning.is_none());

        show_clock_skew_warning(&mut state, 240, false, now);
        clear_expired_clock_skew_warning(
            &mut state,
            now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS - 1),
        );
        assert!(state.clock_skew_warning.is_some());
        clear_expired_clock_skew_warning(
            &mut state,
            now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
        );
        assert!(state.clock_skew_warning.is_none());
    }

    #[test]
    fn clock_skew_ui_event_populates_banner() {
        let mut state = AppState::default();
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ClockSkewWarning {
                skew_secs: 121,
                peer_ahead: false,
            }),
        );

        let warning = state.clock_skew_warning.expect("event should show banner");
        assert_eq!(warning.skew_secs, 121);
        assert!(!warning.peer_ahead);
    }

    #[test]
    fn clock_skew_duration_rounds_up_to_minutes() {
        assert_eq!(format_clock_skew_duration(0), "1 minute");
        assert_eq!(format_clock_skew_duration(1), "1 minute");
        assert_eq!(format_clock_skew_duration(60), "1 minute");
        assert_eq!(format_clock_skew_duration(61), "2 minutes");
        assert_eq!(format_clock_skew_duration(181), "4 minutes");
    }

    #[test]
    fn share_picker_startup_window_is_guarded() {
        // P3-1: between confirming the picker and the core's ScreenShareStarted,
        // self_sharing is still false. The picker must not be reopenable in that
        // window, and a stray confirm must not re-fire StartScreenShare.
        // Picker open, user confirms a selection.
        let mut state = AppState {
            share_picker_open: true,
            share_audio_selection: Some("mpv".to_string()),
            ..Default::default()
        };
        let _ = update(&mut state, AppMessage::ConfirmShareScreen);
        assert!(
            state.share_starting,
            "confirm should mark a start in flight"
        );
        assert!(!state.share_picker_open, "confirm should close the picker");
        assert!(!state.self_sharing, "core hasn't acked the start yet");

        // Clicking Share again during startup must NOT reopen the picker.
        let _ = update(&mut state, AppMessage::ToggleScreenShare);
        assert!(
            !state.share_picker_open,
            "picker must stay closed while starting"
        );
        assert!(state.share_starting);

        // A stray confirm during startup is ignored (no double-start).
        let _ = update(&mut state, AppMessage::ConfirmShareScreen);
        assert!(state.share_starting);

        // Core acks: flag clears, sharing begins, no picker lingers.
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
        );
        assert!(!state.share_starting);
        assert!(state.self_sharing);
        assert!(!state.share_picker_open);
    }

    #[test]
    fn inline_quality_override_survives_opening_the_picker() {
        // The inline quality dropdown (next to the Share button) sets a
        // per-call `share_quality_selection`. Opening the audio picker via
        // ToggleScreenShare must NOT reset it back to the saved config default,
        // or the override the user just made is silently discarded before
        // ConfirmShareScreen reads it into StartScreenShare.
        use crate::config::ShareQuality;
        let mut state = AppState::default();
        // Saved default is Auto; the user picks a different per-call quality.
        assert_eq!(state.config.screen_share.quality, ShareQuality::Auto);
        let _ = update(
            &mut state,
            AppMessage::SelectShareQualityOverride(ShareQuality::High),
        );
        assert_eq!(state.share_quality_selection, ShareQuality::High);

        // Clicking Share opens the picker — the override must be preserved.
        let _ = update(&mut state, AppMessage::ToggleScreenShare);
        assert!(state.share_picker_open);
        assert_eq!(
            state.share_quality_selection,
            ShareQuality::High,
            "opening the picker must not clobber the inline per-call override"
        );

        // Confirming reads that same override into the share start.
        let _ = update(&mut state, AppMessage::ConfirmShareScreen);
        assert!(state.share_starting);
        assert_eq!(
            state.share_quality_selection,
            ShareQuality::High,
            "the override the picker preserved must still be what ConfirmShareScreen sends"
        );
    }

    #[test]
    fn share_start_failure_clears_in_flight_flag() {
        // A failed spawn surfaces as UiEvent::Error (not ScreenShareStopped); the
        // in-flight flag must still clear so the user can retry.
        let mut state = AppState {
            share_picker_open: true,
            ..Default::default()
        };
        let _ = update(&mut state, AppMessage::ConfirmShareScreen);
        assert!(state.share_starting);

        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::Error("boom".into())),
        );
        assert!(!state.share_starting, "error must un-wedge the start flag");
        assert!(!state.self_sharing);

        // And now the picker can be opened again.
        let _ = update(&mut state, AppMessage::ToggleScreenShare);
        assert!(state.share_picker_open);
    }

    #[test]
    fn share_audio_dropped_tracks_app_audio_events() {
        // While sharing a specific app, app_audio lost/routed toggles the warning
        // flag; start and stop both reset it so it can't linger across sessions.
        // A specific app was chosen in the picker, so the share is app-specific.
        let mut state = AppState {
            share_audio_selection: Some("mpv".to_string()),
            ..Default::default()
        };

        // Start sharing — flag is clear, and the share is marked app-specific.
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
        );
        assert!(!state.share_audio_dropped);
        assert!(state.share_audio_app_active);

        // The chosen app's audio stops → warning on.
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
        );
        assert!(state.share_audio_dropped);

        // It plays again → warning off.
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ShareAudioActive(true)),
        );
        assert!(!state.share_audio_dropped);

        // Drop again, then stop sharing → flag reset regardless.
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
        );
        assert!(state.share_audio_dropped);
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ScreenShareStopped),
        );
        assert!(!state.share_audio_dropped);
        assert!(!state.share_audio_app_active);
    }

    #[test]
    fn share_audio_active_ignored_unless_app_sharing() {
        // P3 (unscoped events): a late app_audio event from a just-killed host
        // must not flip the warning when we're not sharing a specific app —
        // neither after stop nor on a whole-desktop share.

        // (a) Whole-desktop share (no app selected): events are ignored.
        let mut state = AppState::default();
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
        );
        assert!(
            !state.share_audio_app_active,
            "no app selected ⇒ not app-specific"
        );
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
        );
        assert!(
            !state.share_audio_dropped,
            "whole-desktop share ignores app_audio"
        );

        // (b) After stop: a straggling event can't resurrect the warning.
        let mut state = AppState {
            share_audio_selection: Some("mpv".to_string()),
            ..Default::default()
        };
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
        );
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ScreenShareStopped),
        );
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
        );
        assert!(!state.share_audio_dropped, "post-stop event is ignored");
    }

    #[test]
    fn old_pixelpass_picker_offers_whole_desktop_only() {
        // P2 (version skew): when the resolved pixelpass lacks --strict-audio, the
        // picker must drop all per-app options and force the whole-desktop choice,
        // so a per-app share (which would pass the unknown flag) can't be started.
        let mut state = AppState {
            share_picker_open: true,
            share_audio_selection: Some("Firefox".to_string()),
            share_audio_apps: vec!["Firefox".to_string(), "mpv".to_string()],
            ..Default::default()
        };
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
                apps: vec!["Firefox".to_string(), "mpv".to_string()],
                app_audio_supported: false,
            }),
        );
        assert!(!state.share_app_audio_supported);
        assert!(state.share_audio_apps.is_empty(), "no per-app rows offered");
        assert!(
            state.share_audio_selection.is_none(),
            "forced to whole-desktop"
        );

        // A supported pixelpass keeps the app list and a valid selection.
        let _ = update(
            &mut state,
            AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
                apps: vec!["Firefox".to_string(), "mpv".to_string()],
                app_audio_supported: true,
            }),
        );
        assert!(state.share_app_audio_supported);
        assert_eq!(state.share_audio_apps.len(), 2);
    }

    #[test]
    fn peer_gate_persists_when_on_and_clears_when_off() {
        let mut config = AppConfig::default();
        let id = SecretKey::generate().public();

        // A positive threshold is stored, clamped to the slider's METER_MAX ceiling.
        let stored = set_peer_gate_config(&mut config, id, 0.05);
        assert_eq!(stored, 0.05);
        assert_eq!(config.peer_gate.get(&id.to_string()).copied(), Some(0.05));
        assert_eq!(set_peer_gate_config(&mut config, id, 99.0), METER_MAX);

        // Zero (or negative) means "gate off" — the entry is removed so the
        // config doesn't carry a disabled gate.
        let stored = set_peer_gate_config(&mut config, id, 0.0);
        assert_eq!(stored, 0.0);
        assert!(!config.peer_gate.contains_key(&id.to_string()));
    }

    #[test]
    fn peer_volume_persists_non_unity_and_clears_at_unity() {
        let mut config = AppConfig::default();
        let id = SecretKey::generate().public();

        // A non-unity value is clamped into range and stored.
        let stored = set_peer_volume_config(&mut config, id, 1.5);
        assert_eq!(stored, 1.5);
        assert_eq!(config.peer_volume.get(&id.to_string()).copied(), Some(1.5));

        // Out-of-range values clamp to the slider bounds.
        assert_eq!(set_peer_volume_config(&mut config, id, 5.0), 2.0);
        assert_eq!(set_peer_volume_config(&mut config, id, -1.0), 0.0);

        // Returning to unity removes the entry (unity is the implicit default),
        // so the config doesn't accumulate no-op entries.
        let stored = set_peer_volume_config(&mut config, id, 1.0);
        assert_eq!(stored, 1.0);
        assert!(!config.peer_volume.contains_key(&id.to_string()));
    }

    fn assert_specific_position(position: iced::window::Position, x: i32, y: i32) {
        match position {
            iced::window::Position::Specific(p) => {
                assert_eq!(p.x, x as f32);
                assert_eq!(p.y, y as f32);
            }
            other => panic!("expected Specific, got {other:?}"),
        }
    }

    #[test]
    fn clamp_window_position_preserves_fully_inside_bounds() {
        let bounds = ScreenBounds {
            x: 0,
            y: 0,
            width: 1200,
            height: 900,
        };

        assert_specific_position(
            clamp_window_position(120, 80, 900, 760, Some(bounds)),
            120,
            80,
        );
    }

    #[test]
    fn clamp_window_position_pulls_visible_edge_back_inside_bounds() {
        let bounds = ScreenBounds {
            x: 0,
            y: 0,
            width: 1000,
            height: 800,
        };

        assert_specific_position(
            clamp_window_position(760, 650, 300, 200, Some(bounds)),
            700,
            600,
        );
    }

    #[test]
    fn clamp_window_position_centers_disconnected_monitor_position() {
        let bounds = ScreenBounds {
            x: 0,
            y: 0,
            width: 1000,
            height: 800,
        };

        assert!(matches!(
            clamp_window_position(5000, 5000, 900, 760, Some(bounds)),
            iced::window::Position::Centered
        ));
    }

    #[test]
    fn clamp_window_position_preserves_negative_origin_monitor_position() {
        let bounds = ScreenBounds {
            x: -1920,
            y: -200,
            width: 1920,
            height: 1080,
        };

        assert_specific_position(
            clamp_window_position(-1800, -120, 900, 760, Some(bounds)),
            -1800,
            -120,
        );
    }

    #[test]
    fn clamp_window_position_without_bounds_preserves_sane_and_rejects_absurd() {
        assert_specific_position(clamp_window_position(120, -40, 900, 760, None), 120, -40);
        assert!(matches!(
            clamp_window_position(32_001, 0, 900, 760, None),
            iced::window::Position::Centered
        ));
        assert!(matches!(
            clamp_window_position(0, -32_001, 900, 760, None),
            iced::window::Position::Centered
        ));
    }

    #[test]
    fn x11_restores_saved_window_position() {
        // On X11 (is_wayland = false) a saved position becomes Specific(x, y).
        match initial_window_position(Some(120), Some(-40), 900, 760, false) {
            iced::window::Position::Specific(p) => {
                assert_eq!(p.x, 120.0);
                assert_eq!(p.y, -40.0); // negative coords (left/above primary) preserved
            }
            other => panic!("expected Specific, got {other:?}"),
        }
    }

    #[test]
    fn wayland_always_centers_even_with_saved_position() {
        assert!(matches!(
            initial_window_position(Some(120), Some(40), 900, 760, true),
            iced::window::Position::Centered
        ));
    }

    #[test]
    fn missing_or_partial_saved_position_centers() {
        assert!(matches!(
            initial_window_position(None, None, 900, 760, false),
            iced::window::Position::Centered
        ));
        // A half-saved position (one axis missing) is not enough to restore.
        assert!(matches!(
            initial_window_position(Some(10), None, 900, 760, false),
            iced::window::Position::Centered
        ));
        assert!(matches!(
            initial_window_position(None, Some(10), 900, 760, false),
            iced::window::Position::Centered
        ));
    }

    #[test]
    fn format_duration_renders_mss_and_hmmss() {
        assert_eq!(format_duration(0), "0:00");
        assert_eq!(format_duration(5), "0:05");
        assert_eq!(format_duration(59), "0:59");
        assert_eq!(format_duration(65), "1:05");
        assert_eq!(format_duration(600), "10:00");
        assert_eq!(format_duration(3599), "59:59");
        // Past an hour switches to h:mm:ss with zero-padded minutes/seconds.
        assert_eq!(format_duration(3600), "1:00:00");
        assert_eq!(format_duration(3661), "1:01:01");
        assert_eq!(format_duration(3725), "1:02:05");
    }

    #[test]
    fn format_relative_ago_buckets_by_minute() {
        use std::time::Duration;
        // Under a minute reads "just now" (no seconds-level churn).
        assert_eq!(format_relative_ago(Duration::from_secs(0)), "just now");
        assert_eq!(format_relative_ago(Duration::from_secs(59)), "just now");
        // Minutes.
        assert_eq!(format_relative_ago(Duration::from_secs(60)), "1m ago");
        assert_eq!(format_relative_ago(Duration::from_secs(125)), "2m ago");
        assert_eq!(format_relative_ago(Duration::from_secs(59 * 60)), "59m ago");
        // Hours, with minutes only when non-zero.
        assert_eq!(format_relative_ago(Duration::from_secs(3600)), "1h ago");
        assert_eq!(
            format_relative_ago(Duration::from_secs(3600 + 120)),
            "1h 2m ago"
        );
        assert_eq!(
            format_relative_ago(Duration::from_secs(23 * 3600 + 59 * 60)),
            "23h 59m ago"
        );
        // Days, with hours only when non-zero.
        assert_eq!(
            format_relative_ago(Duration::from_secs(24 * 3600)),
            "1d ago"
        );
        assert_eq!(
            format_relative_ago(Duration::from_secs(50 * 3600)),
            "2d 2h ago"
        );
    }

    #[test]
    fn settings_categories_are_stable_and_grouped_for_navigation() {
        use super::SettingsCategory;
        let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
        assert_eq!(
            labels,
            vec![
                "Audio",
                "Hotkeys",
                "Recording",
                "Profile",
                "Appearance",
                "Network",
                "Advanced",
                "Notifications",
                "Games"
            ]
        );
        assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
        assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
        assert_eq!(SettingsCategory::Advanced.hint(), "Screen sharing");
        assert_eq!(
            SettingsCategory::Games.hint(),
            "Detection, presence, backgrounds"
        );
    }

    #[test]
    fn home_layout_prioritizes_connect_on_empty_home() {
        use super::{HomeLayoutMode, home_layout_mode};
        assert_eq!(
            home_layout_mode(1280.0, false, false),
            HomeLayoutMode::FocusedEmpty
        );
        assert_eq!(
            home_layout_mode(760.0, false, false),
            HomeLayoutMode::Stacked
        );
        assert_eq!(
            home_layout_mode(1280.0, true, false),
            HomeLayoutMode::ThreeColumn
        );
        assert_eq!(
            home_layout_mode(1100.0, true, true),
            HomeLayoutMode::Stacked
        );
    }

    use super::{CHAT_MIN_H, PARTICIPANTS_MIN_W, clamp_chat_height, clamp_participants_width};

    #[test]
    fn participants_width_clamps_to_min_and_leaves_room_for_controls() {
        let window_w = 900.0;
        // Mid-range value passes through unchanged.
        assert_eq!(clamp_participants_width(500.0, window_w), 500.0);
        // Below the minimum snaps up to it.
        assert_eq!(clamp_participants_width(50.0, window_w), PARTICIPANTS_MIN_W);
        // Too wide leaves at least CONTROLS_MIN_W (220) for the controls panel.
        assert_eq!(
            clamp_participants_width(window_w, window_w),
            window_w - 220.0
        );
    }

    #[test]
    fn chat_height_clamps_to_min_and_leaves_room_above() {
        let window_h = 760.0;
        assert_eq!(clamp_chat_height(200.0, window_h), 200.0);
        assert_eq!(clamp_chat_height(10.0, window_h), CHAT_MIN_H);
        // Too tall leaves at least ABOVE_CHAT_MIN_H (300) above the dock.
        assert_eq!(clamp_chat_height(window_h, window_h), window_h - 300.0);
    }

    // (sanitize_chat's own behavior is tested in `crate::sanitize`, where the
    // chat text policy now lives.)

    #[test]
    fn oversized_paste_never_exceeds_chat_ceilings_in_state() {
        use crate::sanitize::{CHAT_MSG_MAX_BYTES, CHAT_MSG_MAX_CHARS};
        let mut state = AppState::default();
        // A paste far past both ceilings, through the real update handler
        // (keyboard, clipboard, primary-selection, and context-menu paste all
        // land on ChatInputChanged via the controlled input widget).
        let huge = "🎮x ".repeat(CHAT_MSG_MAX_CHARS);
        let _ = update(&mut state, AppMessage::ChatInputChanged(huge));
        assert!(state.chat_input.chars().count() <= CHAT_MSG_MAX_CHARS);
        assert!(state.chat_input.len() <= CHAT_MSG_MAX_BYTES);
        // An in-bounds draft is stored untouched — no normalization mid-edit.
        let draft = "  spaced   draft \t".to_string();
        let _ = update(&mut state, AppMessage::ChatInputChanged(draft.clone()));
        assert_eq!(state.chat_input, draft);
    }

    #[test]
    fn short_id_is_panic_free_on_short_and_unicode_ids() {
        use super::short_id;
        // Normal long hex id → first 8 chars.
        assert_eq!(short_id("abcdef0123456789"), "abcdef01");
        // Shorter-than-8 id must not panic (the old `[..8]` slice would).
        assert_eq!(short_id("abc"), "abc");
        assert_eq!(short_id(""), "");
        // Multi-byte chars: take 8 *chars*, never split a byte boundary.
        assert_eq!(short_id("héllo 世界 more"), "héllo 世界");
    }

    #[test]
    fn divider_clamps_are_finite_on_a_tiny_window() {
        // A window smaller than the reserves must not produce NaN/inverted ranges.
        let pw = clamp_participants_width(300.0, 100.0);
        assert!(pw.is_finite() && pw >= PARTICIPANTS_MIN_W);
        let ch = clamp_chat_height(300.0, 100.0);
        assert!(ch.is_finite() && ch >= CHAT_MIN_H);
    }

    #[test]
    fn conn_badge_and_tooltip_labels() {
        use super::{conn_badge_label, conn_loss_label, conn_rate_label, conn_tooltip_path};
        let direct = crate::core::connstats::PeerConnInfo {
            relay: false,
            remote_addr: "192.168.1.7:53340".to_string(),
            rtt_ms: 12,
            loss_pct: Some(0.44),
            up_kbps: Some(32.4),
            down_kbps: None,
        };
        assert_eq!(conn_badge_label(&direct), "Direct · 12 ms");
        assert_eq!(conn_tooltip_path(&direct), "Direct (192.168.1.7:53340)");

        let relay = crate::core::connstats::PeerConnInfo {
            relay: true,
            remote_addr: "https://relay.example./".to_string(),
            ..direct.clone()
        };
        assert_eq!(conn_badge_label(&relay), "Relay · 12 ms");
        assert_eq!(conn_tooltip_path(&relay), "Relay (https://relay.example./)");

        assert_eq!(conn_loss_label(Some(0.44)), "Loss 0.4% (last second)");
        // Out-of-range inputs clamp instead of reading nonsense.
        assert_eq!(conn_loss_label(Some(250.0)), "Loss 100.0% (last second)");
        assert_eq!(conn_loss_label(None), "Loss — (last second)");

        assert_eq!(conn_rate_label(Some(32.4)), "32 kbps");
        assert_eq!(conn_rate_label(Some(1500.0)), "1.5 Mbps");
        assert_eq!(conn_rate_label(None), "—");
    }

    #[test]
    fn controls_and_drawer_width_clamps() {
        use super::{CHAT_MIN_W, CONTROLS_MIN_W, clamp_chat_drawer_width, clamp_controls_width};
        let window_w = 1000.0;
        // Mid-range passes through.
        assert_eq!(clamp_controls_width(300.0, window_w), 300.0);
        assert_eq!(clamp_chat_drawer_width(320.0, window_w), 320.0);
        // Below minimum snaps up.
        assert_eq!(clamp_controls_width(10.0, window_w), CONTROLS_MIN_W);
        assert_eq!(clamp_chat_drawer_width(10.0, window_w), CHAT_MIN_W);
        // Tiny window stays finite and at/above the minimum (no inverted range).
        let c = clamp_controls_width(400.0, 100.0);
        assert!(c.is_finite() && c >= CONTROLS_MIN_W);
        let d = clamp_chat_drawer_width(400.0, 100.0);
        assert!(d.is_finite() && d >= CHAT_MIN_W);
    }

    #[test]
    fn playlist_drawer_width_clamps() {
        use super::{CHAT_MIN_W, CONTROLS_MIN_W, PARTICIPANTS_MIN_W, clamp_playlist_drawer_width};
        let window_w = 1000.0;
        let max = window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W;
        // Mid-range passes through.
        assert_eq!(clamp_playlist_drawer_width(320.0, window_w), 320.0);
        // Below minimum snaps up.
        assert_eq!(clamp_playlist_drawer_width(10.0, window_w), CHAT_MIN_W);
        // Above maximum leaves the rest of the room at its reserved width.
        assert_eq!(clamp_playlist_drawer_width(800.0, window_w), max);
        // Tiny window stays finite and at/above the minimum (no inverted range).
        let d = clamp_playlist_drawer_width(400.0, 100.0);
        assert!(d.is_finite() && d >= CHAT_MIN_W);
    }

    #[test]
    fn now_playing_label_prefers_tuned_peer() {
        assert_eq!(
            now_playing_label(Some("Alice"), Some("local.flac")),
            "Tuned in to Alice"
        );
    }

    #[test]
    fn now_playing_label_uses_current_track_when_local() {
        assert_eq!(now_playing_label(None, Some("local.flac")), "local.flac");
    }

    #[test]
    fn now_playing_label_handles_empty_player() {
        assert_eq!(now_playing_label(None, None), "Nothing playing");
    }

    use crate::notify::Sound;
    use iroh::EndpointId;
    use std::collections::HashSet;

    const W: f32 = 200.0;

    #[test]
    fn initial_contact_presence_announces_only_online() {
        use crate::presence::FriendPresence;

        assert_eq!(
            friend_presence_notification(Screen::Home, None, &FriendPresence::Online),
            Some(Sound::ContactOnline)
        );
        assert_eq!(
            friend_presence_notification(Screen::Home, None, &FriendPresence::Offline),
            None
        );
    }

    #[test]
    fn contact_presence_chimes_only_on_online_boundary() {
        use crate::presence::FriendPresence;

        let in_room = FriendPresence::InRoom {
            name: "Game night".to_string(),
            ticket: "ticket".to_string(),
        };
        assert_eq!(
            friend_presence_notification(Screen::Home, Some(&FriendPresence::Offline), &in_room,),
            Some(Sound::ContactOnline)
        );
        assert_eq!(
            friend_presence_notification(
                Screen::Home,
                Some(&FriendPresence::Online),
                &FriendPresence::Offline,
            ),
            Some(Sound::ContactOffline)
        );
        assert_eq!(
            friend_presence_notification(Screen::Home, Some(&FriendPresence::Online), &in_room,),
            None
        );
        assert_eq!(
            friend_presence_notification(
                Screen::Home,
                Some(&FriendPresence::Offline),
                &FriendPresence::Offline,
            ),
            None
        );
    }

    #[test]
    fn contact_presence_is_silent_away_from_landing_page() {
        use crate::presence::FriendPresence;

        assert_eq!(
            friend_presence_notification(Screen::Room, None, &FriendPresence::Online),
            None
        );
        assert_eq!(
            friend_presence_notification(
                Screen::Settings,
                Some(&FriendPresence::Online),
                &FriendPresence::Offline,
            ),
            None
        );
    }

    #[test]
    fn selected_notification_sound_accepts_wav_and_preserves_cancel() {
        use std::path::PathBuf;

        assert_eq!(selected_wav_path(None), None);
        assert_eq!(
            selected_wav_path(Some(PathBuf::from("/tmp/notify.mp3"))),
            None
        );
        assert_eq!(
            selected_wav_path(Some(PathBuf::from("/tmp/notify.wav"))),
            Some("/tmp/notify.wav".to_string())
        );
        assert_eq!(
            selected_wav_path(Some(PathBuf::from("/tmp/notify.WAV"))),
            Some("/tmp/notify.WAV".to_string())
        );
    }

    #[test]
    fn gate_drag_maps_left_edge_to_zero() {
        assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
    }

    #[test]
    fn gate_drag_maps_right_edge_to_full_scale() {
        assert!((GateMeter::x_to_threshold(W, W) - METER_MAX).abs() < 1e-6);
    }

    #[test]
    fn gate_drag_maps_midpoint_to_half_scale() {
        assert!((GateMeter::x_to_threshold(W / 2.0, W) - METER_MAX / 2.0).abs() < 1e-6);
    }

    #[test]
    fn gate_drag_clamps_out_of_bounds() {
        // Dragging past either edge clamps to the axis ends (no overshoot).
        assert_eq!(GateMeter::x_to_threshold(-50.0, W), 0.0);
        assert!((GateMeter::x_to_threshold(W + 80.0, W) - METER_MAX).abs() < 1e-6);
    }

    #[test]
    fn gate_drag_zero_width_is_finite() {
        // A degenerate bound (pre-layout) must not divide by zero / produce NaN.
        let t = GateMeter::x_to_threshold(10.0, 0.0);
        assert!(t.is_finite());
        assert!((0.0..=METER_MAX).contains(&t));
    }

    /// A distinct, real `EndpointId` (via the same path the network tests use).
    fn id() -> EndpointId {
        iroh::EndpointAddr::from(iroh::SecretKey::generate().public()).id
    }

    #[test]
    fn first_dial_does_not_chime() {
        let mut connecting = HashSet::new();
        let ever = HashSet::new(); // never connected
        let peer = id();
        // A peer we've never linked with is just an initial connect, not a reconnect.
        assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None);
        assert!(connecting.contains(&peer)); // but it is now marked connecting
    }

    #[test]
    fn first_connected_does_not_chime() {
        let mut connecting = HashSet::from([id()]);
        let mut ever = HashSet::new();
        let peer = id();
        connecting.insert(peer);
        // First successful link: record it, but no "reconnected" chime.
        assert_eq!(reconnected_chime(&mut connecting, &mut ever, peer), None);
        assert!(ever.contains(&peer));
        assert!(!connecting.contains(&peer)); // connecting state cleared
    }

    #[test]
    fn reconnect_attempt_chimes_once_then_stays_silent_on_redials() {
        let peer = id();
        let mut connecting = HashSet::new();
        let ever = HashSet::from([peer]); // previously connected

        // First drop → one ReconnectAttempt chime.
        assert_eq!(
            reconnect_attempt_chime(&mut connecting, &ever, peer),
            Some(Sound::ReconnectAttempt)
        );
        // The supervisor redials repeatedly while still down — must NOT re-chime.
        assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None);
        assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None);
    }

    #[test]
    fn full_outage_cycle_chimes_attempt_then_reconnected_each_time() {
        let peer = id();
        let mut connecting = HashSet::new();
        let mut ever = HashSet::new();

        // Initial connect: silent, records the peer.
        assert_eq!(reconnected_chime(&mut connecting, &mut ever, peer), None);

        // Outage 1: attempt chimes once, recovery chimes "reconnected".
        assert_eq!(
            reconnect_attempt_chime(&mut connecting, &ever, peer),
            Some(Sound::ReconnectAttempt)
        );
        assert_eq!(
            reconnected_chime(&mut connecting, &mut ever, peer),
            Some(Sound::Reconnected)
        );

        // Outage 2: a fresh disconnect chimes again (per-outage, not once-ever).
        assert_eq!(
            reconnect_attempt_chime(&mut connecting, &ever, peer),
            Some(Sound::ReconnectAttempt)
        );
        assert_eq!(
            reconnected_chime(&mut connecting, &mut ever, peer),
            Some(Sound::Reconnected)
        );
    }

    #[test]
    fn test_push_chat_single() {
        use super::{ChatEntry, push_chat};
        let mut messages = Vec::new();
        let entry = ChatEntry {
            name: "Alice".to_string(),
            text: "Hello".to_string(),
            mine: true,
            from: None,
            attachment: None,
            links: Vec::new(),
            local_send: None,
        };
        push_chat(&mut messages, entry);
        assert_eq!(messages.len(), 1);
        assert_eq!(messages[0].name, "Alice");
        assert_eq!(messages[0].text, "Hello");
        assert!(messages[0].mine);
    }

    #[test]
    fn test_push_chat_below_cap() {
        use super::{CHAT_HISTORY_MAX, ChatEntry, push_chat};
        let mut messages = Vec::new();
        for i in 0..CHAT_HISTORY_MAX - 10 {
            push_chat(
                &mut messages,
                ChatEntry {
                    name: format!("User{}", i),
                    text: format!("Msg{}", i),
                    mine: i % 2 == 0,
                    from: None,
                    attachment: None,
                    links: Vec::new(),
                    local_send: None,
                },
            );
        }
        assert_eq!(messages.len(), CHAT_HISTORY_MAX - 10);
        assert_eq!(messages[0].name, "User0");
        assert_eq!(messages[0].text, "Msg0");
        assert_eq!(
            messages[messages.len() - 1].name,
            format!("User{}", CHAT_HISTORY_MAX - 11)
        );
        assert_eq!(
            messages[messages.len() - 1].text,
            format!("Msg{}", CHAT_HISTORY_MAX - 11)
        );
    }

    #[test]
    fn test_push_chat_above_cap() {
        use super::{CHAT_HISTORY_MAX, ChatEntry, push_chat};
        let mut messages = Vec::new();
        let total_pushes = CHAT_HISTORY_MAX + 5;
        for i in 0..total_pushes {
            push_chat(
                &mut messages,
                ChatEntry {
                    name: format!("User{}", i),
                    text: format!("Msg{}", i),
                    mine: i % 2 == 0,
                    from: None,
                    attachment: None,
                    links: Vec::new(),
                    local_send: None,
                },
            );
        }
        assert_eq!(messages.len(), CHAT_HISTORY_MAX);
        // The first 5 should be dropped. First remaining should be index 5.
        assert_eq!(messages[0].name, "User5");
        assert_eq!(messages[0].text, "Msg5");
        // The last remaining should be index total_pushes - 1.
        assert_eq!(
            messages[messages.len() - 1].name,
            format!("User{}", total_pushes - 1)
        );
        assert_eq!(
            messages[messages.len() - 1].text,
            format!("Msg{}", total_pushes - 1)
        );
    }

    #[test]
    fn test_push_chat_evicts_on_total_text_bytes() {
        use super::{CHAT_HISTORY_MAX_TEXT_BYTES, ChatEntry, push_chat};
        // Ceiling-length messages (8 KiB each) hit the 512 KiB byte budget at 64
        // entries — far below the 300-entry cap.
        let big = "x".repeat(crate::sanitize::CHAT_MSG_MAX_BYTES);
        let mut messages = Vec::new();
        for i in 0..80 {
            push_chat(
                &mut messages,
                ChatEntry {
                    name: format!("User{}", i),
                    text: big.clone(),
                    mine: false,
                    from: None,
                    attachment: None,
                    links: Vec::new(),
                    local_send: None,
                },
            );
            let total: usize = messages.iter().map(|m| m.text.len()).sum();
            assert!(total <= CHAT_HISTORY_MAX_TEXT_BYTES, "over budget at {i}");
        }
        assert_eq!(
            messages.len(),
            CHAT_HISTORY_MAX_TEXT_BYTES / crate::sanitize::CHAT_MSG_MAX_BYTES
        );
        // Oldest evicted, newest kept.
        assert_eq!(messages[0].name, "User16");
        assert_eq!(messages[messages.len() - 1].name, "User79");
    }

    #[test]
    fn test_push_chat_caches_validated_link_ranges() {
        use super::{ChatEntry, push_chat};
        let mut messages = Vec::new();
        push_chat(
            &mut messages,
            ChatEntry {
                name: "Alice".to_string(),
                text: "see https://ok.org and javascript:alert(1) too".to_string(),
                mine: false,
                from: None,
                attachment: None,
                links: Vec::new(),
                local_send: None,
            },
        );
        // Ranges are filled at push time (the construction-site value is
        // ignored) and slicing them yields exactly the validated link.
        let m = &messages[0];
        assert_eq!(m.links.len(), 1);
        assert_eq!(&m.text[m.links[0].clone()], "https://ok.org");
        let pieces = crate::sanitize::segments(&m.text, &m.links);
        let rebuilt: String = pieces.iter().map(|(s, _)| *s).collect();
        assert_eq!(rebuilt, m.text);
    }

    // ---- Phase 5: honest send status (chat-hardening plan) ----

    /// Push a locally authored echo the way `ChatSubmit` does and return its id.
    fn push_own(state: &mut AppState, text: &str) -> u64 {
        let id = state.next_send_id;
        state.next_send_id += 1;
        state.chat_messages.push(ChatEntry {
            name: "Me (You)".to_string(),
            text: text.to_string(),
            mine: true,
            from: Some(state.self_id.clone()),
            attachment: None,
            links: Vec::new(),
            local_send: Some(LocalSend {
                id,
                status: SendStatus::Pending,
            }),
        });
        id
    }

    fn status_of(state: &AppState, id: u64) -> Option<SendStatus> {
        state
            .chat_messages
            .iter()
            .find_map(|m| m.local_send.as_ref().filter(|s| s.id == id))
            .map(|s| s.status.clone())
    }

    #[test]
    fn send_status_pending_then_broadcast_on_success() {
        let mut state = AppState::default();
        let id = push_own(&mut state, "hi");
        state.submit_send(id, PendingSend::Text("hi".to_string()));
        // Empty queue + a fresh full pacer → dispatched immediately.
        assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
        assert!(state.send_payloads.contains_key(&id));
        assert!(state.apply_send_result(id, None));
        assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
        // A completed send releases its retry payload.
        assert!(!state.send_payloads.contains_key(&id));
    }

    #[test]
    fn send_status_failed_keeps_payload_for_retry() {
        let mut state = AppState::default();
        let id = push_own(&mut state, "yo");
        state.submit_send(id, PendingSend::Text("yo".to_string()));
        assert!(!state.apply_send_result(id, Some("not in a room".to_string())));
        assert_eq!(
            status_of(&state, id),
            Some(SendStatus::Failed("not in a room".to_string()))
        );
        // Retained so Retry can re-dispatch the exact payload.
        assert!(state.send_payloads.contains_key(&id));
    }

    #[test]
    fn send_result_updates_only_the_matching_entry() {
        let mut state = AppState::default();
        let a = push_own(&mut state, "a");
        state.submit_send(a, PendingSend::Text("a".to_string()));
        let b = push_own(&mut state, "b");
        state.submit_send(b, PendingSend::Text("b".to_string()));
        assert!(state.apply_send_result(a, None));
        assert_eq!(status_of(&state, a), Some(SendStatus::Broadcast));
        assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
        // A result for an id with no matching entry is a harmless no-op.
        assert!(!state.apply_send_result(9999, None));
        assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
    }

    #[test]
    fn send_result_after_eviction_drops_orphan_payload() {
        let mut state = AppState::default();
        let id = push_own(&mut state, "gone");
        state.submit_send(id, PendingSend::Text("gone".to_string()));
        // Simulate the entry leaving history (byte-budget eviction) with its
        // send still in flight.
        state
            .chat_messages
            .retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id));
        assert!(!state.apply_send_result(id, Some("dead".to_string())));
        // No entry to mark → the payload must not leak.
        assert!(!state.send_payloads.contains_key(&id));
    }

    #[test]
    fn send_result_after_room_reset_is_a_noop() {
        let mut state = AppState::default();
        let id = push_own(&mut state, "before");
        state.submit_send(id, PendingSend::Text("before".to_string()));
        state.reset_room_state();
        assert!(state.chat_messages.is_empty());
        assert!(state.send_queue.is_empty());
        assert!(state.send_payloads.is_empty());
        // A late result for the pre-reset send touches nothing and adds no entry.
        assert!(!state.apply_send_result(id, None));
        assert!(state.chat_messages.is_empty());
        assert!(state.send_payloads.is_empty());
    }

    #[test]
    fn fast_burst_dispatches_the_budget_then_queues() {
        let mut state = AppState::default();
        let mut ids = Vec::new();
        // Nine near-instant sends: the pacer grants exactly the 8-message burst
        // (sub-millisecond elapsed refills nothing), so the 9th must queue.
        for _ in 0..9 {
            let id = push_own(&mut state, "m");
            state.submit_send(id, PendingSend::Text("m".to_string()));
            ids.push(id);
        }
        for &id in &ids[..8] {
            assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
        }
        assert_eq!(status_of(&state, ids[8]), Some(SendStatus::Queued));
        assert_eq!(state.send_queue, VecDeque::from([ids[8]]));

        // Draining once the pacer has refilled (the ChatSendTick path) releases
        // the queued message and dispatches it.
        let now = state.send_now_ms() + 2000;
        let ready = sendqueue::release_ready(&mut state.send_queue, &mut state.send_pacer, now);
        for id in ready {
            let payload = state.send_payloads.get(&id).cloned().unwrap();
            state.dispatch_send(id, &payload);
        }
        assert!(state.send_queue.is_empty());
        assert_eq!(status_of(&state, ids[8]), Some(SendStatus::Pending));
    }

    #[test]
    fn retry_redispatches_only_the_targeted_send() {
        let mut state = AppState::default();
        let a = push_own(&mut state, "a");
        state.submit_send(a, PendingSend::Text("a".to_string()));
        let b = push_own(&mut state, "b");
        state.submit_send(b, PendingSend::Text("b".to_string()));
        // Both fail.
        state.apply_send_result(a, Some("x".to_string()));
        state.apply_send_result(b, Some("x".to_string()));
        // Retry a: dispatched or requeued; b is untouched and still Failed.
        if !state.send_queue.contains(&a)
            && let Some(payload) = state.send_payloads.get(&a).cloned()
        {
            state.submit_send(a, payload);
        }
        assert!(matches!(
            status_of(&state, a),
            Some(SendStatus::Pending) | Some(SendStatus::Queued)
        ));
        assert_eq!(
            status_of(&state, b),
            Some(SendStatus::Failed("x".to_string()))
        );
        assert!(state.send_payloads.contains_key(&b));
    }
}
