use crate::notify::Sound;
use crate::theme::AppTheme;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

/// Relay/discovery posture, trading connectivity against how much the n0
/// infrastructure learns about you. See the network module for details.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum NetworkMode {
    /// n0 relay for NAT traversal, but no DNS presence beacon. Peer addresses
    /// come from the join ticket and gossip, so n0 only sees relayed-call
    /// metadata, never a standing "I'm online" record. Default.
    #[default]
    RelayNoDiscovery,
    /// Full n0 defaults: relay plus DNS publish/resolve (most convenient,
    /// most phone-home).
    N0Full,
    /// No relay, no discovery: direct hole-punching only. Fully serverless,
    /// but fails behind symmetric/CGNAT NATs with no fallback.
    DirectOnly,
}

impl NetworkMode {
    /// All variants, for presentation in a picker.
    pub const ALL: [NetworkMode; 3] = [
        NetworkMode::RelayNoDiscovery,
        NetworkMode::N0Full,
        NetworkMode::DirectOnly,
    ];
}

/// Arrangement of the in-call room screen, chosen via the layout picker.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum RoomLayout {
    /// Participants | Chat | Controls, all columns visible at once.
    ThreeColumn,
    /// Participants + Controls on top, full-width Chat docked along the bottom.
    #[default]
    BottomDock,
    /// Participants | Controls, with a collapsible Chat drawer on the right edge.
    Drawer,
}

impl RoomLayout {
    /// All variants, in picker display order.
    pub const ALL: [RoomLayout; 3] = [
        RoomLayout::ThreeColumn,
        RoomLayout::BottomDock,
        RoomLayout::Drawer,
    ];
}

/// What a call recording captures. `Mixed` is the original single-file behaviour;
/// `Multitrack`/`Both` write per-peer stems for post-production (see
/// `docs/multitrack-recording-plan.md`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum RecordingMode {
    /// One mixed WAV (your mic + the incoming mix). Smallest; the default.
    #[default]
    Mixed,
    /// One WAV per peer + your mic, all sample-aligned. Rebuild the mix yourself.
    Multitrack,
    /// Per-peer stems + your mic + a convenience mixed track.
    Both,
}

impl RecordingMode {
    /// All variants, in picker display order.
    pub const ALL: [RecordingMode; 3] = [
        RecordingMode::Mixed,
        RecordingMode::Multitrack,
        RecordingMode::Both,
    ];

    /// True when this mode writes per-peer stem tracks (Multitrack or Both).
    pub fn is_multitrack(self) -> bool {
        matches!(self, RecordingMode::Multitrack | RecordingMode::Both)
    }
}

impl std::fmt::Display for RecordingMode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            RecordingMode::Mixed => "Mixed (single file)",
            RecordingMode::Multitrack => "Multitrack (per-peer stems)",
            RecordingMode::Both => "Both (stems + mixed)",
        })
    }
}

/// Named Opus encoder / network-resilience policy (W12). The user picks a
/// profile instead of raw codec knobs; the concrete libopus parameters live in
/// `codec::opus_impl::opus_params`. Applies live to the running encoder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AudioProfile {
    /// Lowest mouth-to-ear delay: modest bitrate, no FEC redundancy. Best on a
    /// clean LAN / low-loss link where added latency matters more than loss.
    LowLatency,
    /// Sensible default: voice bitrate with in-band FEC for light packet loss.
    #[default]
    Balanced,
    /// Maximum resilience on a lossy/congested link: in-band FEC tuned for heavy
    /// loss, at a lower bitrate to leave headroom for the redundancy.
    BadNetwork,
}

impl AudioProfile {
    /// All variants, in picker display order.
    pub const ALL: [AudioProfile; 3] = [
        AudioProfile::LowLatency,
        AudioProfile::Balanced,
        AudioProfile::BadNetwork,
    ];

    /// Compact discriminant for handing the profile to the capture thread via an
    /// atomic. Pairs with [`AudioProfile::from_u8`].
    pub fn as_u8(self) -> u8 {
        match self {
            AudioProfile::LowLatency => 0,
            AudioProfile::Balanced => 1,
            AudioProfile::BadNetwork => 2,
        }
    }

    /// Inverse of [`AudioProfile::as_u8`]; unknown values fall back to the default.
    pub fn from_u8(v: u8) -> AudioProfile {
        match v {
            0 => AudioProfile::LowLatency,
            2 => AudioProfile::BadNetwork,
            _ => AudioProfile::Balanced,
        }
    }
}

impl std::fmt::Display for AudioProfile {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            AudioProfile::LowLatency => "Low latency",
            AudioProfile::Balanced => "Balanced",
            AudioProfile::BadNetwork => "Bad network",
        })
    }
}

impl std::fmt::Display for RoomLayout {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            RoomLayout::ThreeColumn => "3-Column",
            RoomLayout::BottomDock => "Bottom Dock",
            RoomLayout::Drawer => "Drawer",
        })
    }
}

impl std::fmt::Display for NetworkMode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let label = match self {
            NetworkMode::RelayNoDiscovery => "Relay, no presence beacon",
            NetworkMode::N0Full => "n0 defaults (relay + DNS)",
            NetworkMode::DirectOnly => "Direct only (no relay)",
        };
        f.write_str(label)
    }
}

/// Pixelpass host quality preset for screen shares. `Auto` leaves pixelpass free
/// to choose from its bandwidth pre-flight; fixed presets are passed as CLI flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareQuality {
    #[default]
    Auto,
    Low,
    Medium,
    High,
    Source,
}

impl ShareQuality {
    pub const ALL: [ShareQuality; 5] = [
        ShareQuality::Auto,
        ShareQuality::Low,
        ShareQuality::Medium,
        ShareQuality::High,
        ShareQuality::Source,
    ];
}

impl std::fmt::Display for ShareQuality {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            ShareQuality::Auto => "Auto",
            ShareQuality::Low => "Low",
            ShareQuality::Medium => "Medium",
            ShareQuality::High => "High",
            ShareQuality::Source => "Source",
        })
    }
}

/// Preferred local player for watching a peer's screen share.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SharePlayer {
    #[default]
    Mpv,
    Vlc,
}

impl SharePlayer {
    pub const ALL: [SharePlayer; 2] = [SharePlayer::Mpv, SharePlayer::Vlc];
}

impl std::fmt::Display for SharePlayer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            SharePlayer::Mpv => "mpv",
            SharePlayer::Vlc => "VLC",
        })
    }
}

/// Local player buffering posture for screen-share playback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareBuffering {
    #[default]
    LowLatency,
    Smooth,
}

impl ShareBuffering {
    pub const ALL: [ShareBuffering; 2] = [ShareBuffering::LowLatency, ShareBuffering::Smooth];
}

impl std::fmt::Display for ShareBuffering {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            ShareBuffering::LowLatency => "Low latency",
            ShareBuffering::Smooth => "Smooth",
        })
    }
}

fn default_screen_share_cache_mb() -> u32 {
    2
}

/// Local-only screen-share preferences. Host fields become pixelpass host CLI
/// flags; viewer fields shape local mpv/VLC launch. None/empty/default values
/// deliberately let pixelpass/player defaults stand.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScreenShareSettings {
    #[serde(default)]
    pub quality: ShareQuality,
    #[serde(default)]
    pub bitrate_mbps: Option<u32>,
    #[serde(default)]
    pub framerate: Option<u32>,
    #[serde(default)]
    pub max_height: Option<u32>,
    #[serde(default)]
    pub max_viewers: Option<u32>,
    #[serde(default)]
    pub force_software_encode: bool,
    #[serde(default)]
    pub extra_host_args: String,
    #[serde(default)]
    pub player: SharePlayer,
    #[serde(default)]
    pub hardware_decode: bool,
    #[serde(default)]
    pub buffering: ShareBuffering,
    #[serde(default = "default_screen_share_cache_mb")]
    pub cache_mb: u32,
    #[serde(default)]
    pub extra_mpv_args: String,
}

impl Default for ScreenShareSettings {
    fn default() -> Self {
        Self {
            quality: ShareQuality::default(),
            bitrate_mbps: None,
            framerate: None,
            max_height: None,
            max_viewers: None,
            force_software_encode: false,
            extra_host_args: String::new(),
            player: SharePlayer::default(),
            hardware_decode: false,
            buffering: ShareBuffering::default(),
            cache_mb: default_screen_share_cache_mb(),
            extra_mpv_args: String::new(),
        }
    }
}

fn default_true() -> bool {
    true
}

fn default_background_dim() -> f32 {
    crate::background::DEFAULT_DIM
}

fn default_volume() -> f32 {
    1.0
}

fn default_username() -> String {
    "Peer".to_string()
}

fn default_participants_width() -> f32 {
    540.0
}

fn default_chat_height() -> f32 {
    180.0
}

fn default_threecol_playlist_height() -> f32 {
    220.0
}

fn default_controls_width() -> f32 {
    280.0
}

fn default_chat_drawer_width() -> f32 {
    320.0
}

fn default_playlist_drawer_width() -> f32 {
    320.0
}

fn default_window_width() -> f32 {
    900.0
}

fn default_window_height() -> f32 {
    760.0
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AppConfig {
    /// Last nickname used to join/create a room; pre-filled on the launch screen.
    #[serde(default = "default_username")]
    pub username: String,
    pub input_device: String,
    pub output_device: String,
    pub noise_gate_threshold: f32,
    /// App-internal capture gain applied to the mic before encode (1.0 = unity).
    #[serde(default = "default_volume")]
    pub input_volume: f32,
    /// App-internal playback gain applied to the mixed output (1.0 = unity).
    #[serde(default = "default_volume")]
    pub output_volume: f32,
    /// Universal playback gain for inline chat audio clips (1.0 = unity). One
    /// level shared by every uploaded clip so the slider sticks across plays.
    #[serde(default = "default_volume")]
    pub clip_volume: f32,
    /// W22 music: the user's personal playlist as local file PATHS (not bytes).
    /// Loaded into memory at startup; missing files are skipped/marked on play.
    #[serde(default)]
    pub music_playlist: Vec<String>,
    /// W22 music: local playback gain for the dedicated music player (1.0 = unity).
    #[serde(default = "default_volume")]
    pub music_volume: f32,
    /// W22 music: opt-in shared listening broadcast toggle. Local preference.
    #[serde(default)]
    pub music_broadcast: bool,
    /// Show the slim now-playing player bar in the room screen.
    #[serde(default = "default_true")]
    pub show_player_bar: bool,
    /// When true, `clip_volume` governs every clip. When false, each clip keeps
    /// its own (in-memory) level and the universal slider is inactive.
    #[serde(default = "default_true")]
    pub clip_volume_universal: bool,
    #[serde(default)]
    pub network_mode: NetworkMode,
    /// Opus encoder / network-resilience profile (W12). Applies live to the
    /// running encoder; default `Balanced`.
    #[serde(default)]
    pub audio_profile: AudioProfile,
    /// Presence posture for the friends idle listener (W7): invisible / normal /
    /// discoverable. Default `Normal` = answer friends only, no DNS beacon.
    #[serde(default)]
    pub presence_mode: crate::presence::PresenceMode,
    /// Route audio through PipeWire's echo-cancel module (AEC + noise suppression).
    /// Takes effect on the next room join. Off by default.
    #[serde(default)]
    pub echo_cancellation_enabled: bool,
    #[serde(default = "default_true")]
    pub notifications_enabled: bool,
    /// Persisted room-screen divider positions (px): Participants panel width and
    /// Chat dock height. Re-clamped to the window on load and on resize.
    #[serde(default = "default_participants_width")]
    pub participants_width: f32,
    #[serde(default = "default_chat_height")]
    pub chat_height: f32,
    /// Height (px) of the standalone Playlist card stacked under Chat in the
    /// 3-column layout. Resized via its own horizontal divider; re-clamped to the
    /// window on load/resize. Only used by `RoomLayout::ThreeColumn`.
    #[serde(default = "default_threecol_playlist_height")]
    pub threecol_playlist_height: f32,
    /// Controls panel width for the 3-column layout (px).
    #[serde(default = "default_controls_width")]
    pub controls_width: f32,
    /// Chat drawer width for the drawer layout (px).
    #[serde(default = "default_chat_drawer_width")]
    pub chat_drawer_width: f32,
    /// Playlist drawer width for the room-screen right-edge music panel (px).
    #[serde(default = "default_playlist_drawer_width")]
    pub playlist_drawer_width: f32,
    /// Chosen arrangement of the in-call room screen.
    #[serde(default)]
    pub room_layout: RoomLayout,
    /// Chosen UI colour theme.
    #[serde(default)]
    pub theme: AppTheme,
    /// Our chosen avatar (W4): monogram fallback or a bundled preset.
    #[serde(default)]
    pub avatar: crate::avatar::Avatar,
    /// Custom UI background image (W16): path to the downscaled PNG we wrote into
    /// the config dir (see `background_path`). `None` = use the theme background.
    /// Local-only; never sent to peers.
    #[serde(default)]
    pub background: Option<String>,
    /// Scrim strength drawn over the custom background for legibility (0.0 = image
    /// at full strength, 1.0 = fully hidden behind the theme base). See
    /// `crate::background::scrim_color`.
    #[serde(default = "default_background_dim")]
    pub background_dim: f32,
    /// Broadcast the detected game as presence next to our avatar (game-detection
    /// feature). **Opt-in, default OFF.** Enabling immediately publishes the
    /// current game; disabling immediately publishes `game: None`. Toggling this
    /// is the only thing that puts our game on the wire — detection itself (for the
    /// local background) runs regardless.
    #[serde(default)]
    pub game_presence_enabled: bool,
    /// Per-game UI background overrides (W18), keyed by stable game id
    /// (`steam:730`, `exe:hl2_linux`) → path to the processed PNG we wrote in the
    /// config dir (see `game_background_path`). The running game's entry wins; with
    /// no entry we fall back to the single custom `background`. Local-only; never
    /// sent to peers. `BTreeMap` for deterministic serialization.
    #[serde(default)]
    pub game_backgrounds: BTreeMap<String, String>,
    /// User process→display-name mappings for non-Steam game detection, keyed by
    /// normalized executable basename (`hl2_linux`) → the name to show/broadcast
    /// (`Half-Life 2`). Only exact mappings here are ever matched (we never guess a
    /// game from an arbitrary process). Local-only.
    #[serde(default)]
    pub game_process_map: BTreeMap<String, String>,
    /// What a call recording captures (mixed / per-peer stems / both).
    #[serde(default)]
    pub recording_mode: RecordingMode,
    #[serde(default)]
    pub custom_sound_self_join: Option<String>,
    #[serde(default)]
    pub custom_sound_peer_join: Option<String>,
    #[serde(default)]
    pub custom_sound_peer_leave: Option<String>,
    #[serde(default)]
    pub custom_sound_reconnect_attempt: Option<String>,
    #[serde(default)]
    pub custom_sound_reconnected: Option<String>,
    #[serde(default)]
    pub custom_sound_self_leave: Option<String>,
    #[serde(default)]
    pub custom_sound_mic_toggle: Option<String>,
    #[serde(default)]
    pub custom_sound_reconnect_failed: Option<String>,
    #[serde(default)]
    pub custom_sound_chat_sent: Option<String>,
    #[serde(default)]
    pub custom_sound_chat_received: Option<String>,
    #[serde(default)]
    pub custom_sound_contact_online: Option<String>,
    #[serde(default)]
    pub custom_sound_contact_offline: Option<String>,
    /// Per-sound enable flags (W6). The master `notifications_enabled` toggle
    /// gates ALL chimes; these let the user silence individual events while the
    /// master stays on. A chime plays only if the master AND its flag are true.
    /// Default true so behaviour is unchanged until a user opts a sound out.
    #[serde(default = "default_true")]
    pub sound_self_join_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_peer_join_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_peer_leave_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_reconnect_attempt_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_reconnected_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_self_leave_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_mic_toggle_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_reconnect_failed_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_chat_sent_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_chat_received_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_contact_online_enabled: bool,
    #[serde(default = "default_true")]
    pub sound_contact_offline_enabled: bool,
    /// Optional override for the `pixelpass` binary location (screen share).
    /// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
    #[serde(default)]
    pub pixelpass_path: Option<String>,
    /// Local-only host/player controls for screen sharing.
    #[serde(default)]
    pub screen_share: ScreenShareSettings,
    /// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
    /// one-click rejoin; never sent over the wire. De-duped by room topic and
    /// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
    #[serde(default)]
    pub recents: Vec<crate::recents::Recent>,
    /// Per-peer listener-side EQ settings, keyed by peer node id string. Local
    /// preference only; never sent to peers.
    #[serde(default)]
    pub peer_eq: HashMap<String, crate::audio::eq::EqSettings>,
    /// Per-peer listener-side pan (`-1.0` left, `0.0` center, `1.0` right),
    /// keyed by peer node id string. Local preference only.
    #[serde(default)]
    pub peer_pan: HashMap<String, f32>,
    /// Per-peer listener-side volume/gain (`1.0` = unity), keyed by peer node id
    /// string. Local preference only; never sent to peers. Absent entry = unity.
    #[serde(default)]
    pub peer_volume: HashMap<String, f32>,
    /// Per-source music listen volume/gain (`1.0` = unity), keyed by peer node id
    /// string. Local preference only; never sent to peers. Absent entry falls
    /// back to `music_volume`.
    #[serde(default)]
    pub music_source_volume: HashMap<String, f32>,
    /// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off),
    /// keyed by peer node id string. Local preference only; never sent to peers.
    /// Absent entry = gate disabled (pass-through).
    #[serde(default)]
    pub peer_gate: HashMap<String, f32>,
    /// Focused app-local keyboard shortcuts.
    #[serde(default)]
    pub hotkeys: crate::hotkeys::HotkeyMap,
    /// Last window size (px), restored as the initial size on next launch.
    /// Saved on close.
    #[serde(default = "default_window_width")]
    pub window_width: f32,
    #[serde(default = "default_window_height")]
    pub window_height: f32,
    /// Last window position (px). Saved on close, restored on next launch — but
    /// only on **X11**: Wayland's xdg-shell gives clients no way to place their
    /// own window, so we center there and let the compositor decide. `None` =
    /// never saved a position yet (e.g. always run under Wayland) → center.
    #[serde(default)]
    pub window_x: Option<i32>,
    #[serde(default)]
    pub window_y: Option<i32>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadOutcome {
    Missing,
    Loaded,
    Recovered,
}

impl Default for AppConfig {
    fn default() -> Self {
        Self {
            username: default_username(),
            input_device: "".to_string(),
            output_device: "".to_string(),
            noise_gate_threshold: 0.01,
            input_volume: 1.0,
            output_volume: 1.0,
            clip_volume: 1.0,
            music_playlist: Vec::new(),
            music_volume: 1.0,
            music_broadcast: false,
            show_player_bar: true,
            clip_volume_universal: true,
            network_mode: NetworkMode::default(),
            audio_profile: AudioProfile::default(),
            presence_mode: crate::presence::PresenceMode::default(),
            echo_cancellation_enabled: false,
            notifications_enabled: true,
            participants_width: default_participants_width(),
            chat_height: default_chat_height(),
            threecol_playlist_height: default_threecol_playlist_height(),
            controls_width: default_controls_width(),
            chat_drawer_width: default_chat_drawer_width(),
            playlist_drawer_width: default_playlist_drawer_width(),
            room_layout: RoomLayout::default(),
            theme: AppTheme::default(),
            avatar: crate::avatar::Avatar::default(),
            background: None,
            background_dim: default_background_dim(),
            game_presence_enabled: false,
            game_backgrounds: BTreeMap::new(),
            game_process_map: BTreeMap::new(),
            recording_mode: RecordingMode::default(),
            custom_sound_self_join: None,
            custom_sound_peer_join: None,
            custom_sound_peer_leave: None,
            custom_sound_reconnect_attempt: None,
            custom_sound_reconnected: None,
            custom_sound_self_leave: None,
            custom_sound_mic_toggle: None,
            custom_sound_reconnect_failed: None,
            custom_sound_chat_sent: None,
            custom_sound_chat_received: None,
            custom_sound_contact_online: None,
            custom_sound_contact_offline: None,
            sound_self_join_enabled: true,
            sound_peer_join_enabled: true,
            sound_peer_leave_enabled: true,
            sound_reconnect_attempt_enabled: true,
            sound_reconnected_enabled: true,
            sound_self_leave_enabled: true,
            sound_mic_toggle_enabled: true,
            sound_reconnect_failed_enabled: true,
            sound_chat_sent_enabled: true,
            sound_chat_received_enabled: true,
            sound_contact_online_enabled: true,
            sound_contact_offline_enabled: true,
            pixelpass_path: None,
            screen_share: ScreenShareSettings::default(),
            recents: Vec::new(),
            peer_eq: HashMap::new(),
            peer_pan: HashMap::new(),
            peer_volume: HashMap::new(),
            music_source_volume: HashMap::new(),
            peer_gate: HashMap::new(),
            hotkeys: crate::hotkeys::HotkeyMap::default(),
            window_width: default_window_width(),
            window_height: default_window_height(),
            window_x: None,
            window_y: None,
        }
    }
}

impl AppConfig {
    /// Whether the chime for `sound` is enabled (its own per-sound flag; does
    /// NOT factor in the master `notifications_enabled` toggle — see `notify`).
    pub fn sound_enabled(&self, sound: Sound) -> bool {
        match sound {
            Sound::SelfJoin => self.sound_self_join_enabled,
            Sound::PeerJoin => self.sound_peer_join_enabled,
            Sound::PeerLeave => self.sound_peer_leave_enabled,
            Sound::ReconnectAttempt => self.sound_reconnect_attempt_enabled,
            Sound::Reconnected => self.sound_reconnected_enabled,
            Sound::SelfLeave => self.sound_self_leave_enabled,
            Sound::MicToggle => self.sound_mic_toggle_enabled,
            Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
            Sound::ChatSent => self.sound_chat_sent_enabled,
            Sound::ChatReceived => self.sound_chat_received_enabled,
            Sound::ContactOnline => self.sound_contact_online_enabled,
            Sound::ContactOffline => self.sound_contact_offline_enabled,
        }
    }

    /// Set the per-sound enable flag for `sound`.
    pub fn set_sound_enabled(&mut self, sound: Sound, enabled: bool) {
        match sound {
            Sound::SelfJoin => self.sound_self_join_enabled = enabled,
            Sound::PeerJoin => self.sound_peer_join_enabled = enabled,
            Sound::PeerLeave => self.sound_peer_leave_enabled = enabled,
            Sound::ReconnectAttempt => self.sound_reconnect_attempt_enabled = enabled,
            Sound::Reconnected => self.sound_reconnected_enabled = enabled,
            Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
            Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
            Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
            Sound::ChatSent => self.sound_chat_sent_enabled = enabled,
            Sound::ChatReceived => self.sound_chat_received_enabled = enabled,
            Sound::ContactOnline => self.sound_contact_online_enabled = enabled,
            Sound::ContactOffline => self.sound_contact_offline_enabled = enabled,
        }
    }

    fn config_path() -> Option<PathBuf> {
        dirs::config_dir().map(|mut p| {
            p.push("peerspeak");
            p.push("config.json");
            p
        })
    }

    /// Path to a processed-background PNG of the given filename, alongside
    /// `config.json` in the app config dir. We store our own downscaled copies here
    /// (rather than base64 in the config) so the JSON stays small. Used for both
    /// the single custom background and the per-game backgrounds.
    fn background_dir_path(filename: &str) -> Option<PathBuf> {
        dirs::config_dir().map(|mut p| {
            p.push("peerspeak");
            p.push(filename);
            p
        })
    }

    /// Path the single custom-background PNG (W16) is written to.
    pub fn background_path() -> Option<PathBuf> {
        Self::background_dir_path("background.png")
    }

    /// Path the processed per-game background PNG (W18) for `game_id` is written
    /// to. The filename is an app-owned hash of the id (see
    /// `crate::background::game_background_filename`), so raw game ids never appear
    /// on disk and the name is always filesystem-safe.
    pub fn game_background_path(game_id: &str) -> Option<PathBuf> {
        Self::background_dir_path(&crate::background::game_background_filename(game_id))
    }

    pub fn load() -> Self {
        let Some(path) = Self::config_path() else {
            return Self::default();
        };
        let (config, _) = Self::load_from(&path);
        config
    }

    pub fn save(&self) {
        if let Some(path) = Self::config_path() {
            if let Err(e) = self.save_to(&path) {
                crate::log_msg(&format!("config: save failed: {e:#}"));
            }
        } else {
            crate::log_msg("config: save failed: could not determine a config directory");
        }
    }

    pub fn load_from(path: &Path) -> (Self, LoadOutcome) {
        match fs::read_to_string(path) {
            Ok(contents) => match serde_json::from_str(&contents) {
                Ok(config) => (config, LoadOutcome::Loaded),
                Err(e) => {
                    let backup = recover_corrupt_config(path, &format!("failed to parse: {e}"));
                    (Self::default(), backup)
                }
            },
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                (Self::default(), LoadOutcome::Missing)
            }
            Err(e) => {
                let backup = recover_corrupt_config(path, &format!("failed to read: {e}"));
                (Self::default(), backup)
            }
        }
    }

    pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
        let parent = path
            .parent()
            .context("config path has no parent directory")?;
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;

        let json = serde_json::to_string_pretty(self).context("failed to encode config")?;
        let tmp = config_tmp_path(path)?;
        let result = (|| -> anyhow::Result<()> {
            {
                let mut f = fs::File::create(&tmp)
                    .with_context(|| format!("failed to create {}", tmp.display()))?;
                f.write_all(json.as_bytes())
                    .with_context(|| format!("failed to write {}", tmp.display()))?;
                f.sync_all().ok();
            }
            fs::rename(&tmp, path).with_context(|| {
                format!("failed to rename {} -> {}", tmp.display(), path.display())
            })?;
            Ok(())
        })();

        if result.is_err() {
            let _ = fs::remove_file(&tmp);
        }
        result
    }
}

fn config_tmp_path(path: &Path) -> anyhow::Result<PathBuf> {
    let parent = path
        .parent()
        .context("config path has no parent directory")?;
    let mut name = path
        .file_name()
        .context("config path has no file name")?
        .to_os_string();
    name.push(format!(".tmp.{}", std::process::id()));
    Ok(parent.join(name))
}

fn corrupt_backup_path(path: &Path) -> PathBuf {
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    let mut name = path
        .file_name()
        .map(|n| n.to_os_string())
        .unwrap_or_else(|| "config.json".into());
    name.push(format!(".corrupt.{secs}"));
    parent.join(name)
}

fn recover_corrupt_config(path: &Path, reason: &str) -> LoadOutcome {
    let backup = corrupt_backup_path(path);
    match fs::rename(path, &backup) {
        Ok(()) => {
            crate::log_msg(&format!(
                "config: {reason}; moved damaged config to {}",
                backup.display()
            ));
        }
        Err(e) => {
            crate::log_msg(&format!(
                "config: {reason}; failed to move damaged config to {}: {e}",
                backup.display()
            ));
        }
    }
    LoadOutcome::Recovered
}

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

    #[test]
    fn test_round_trip() {
        let original = AppConfig::default();
        let serialized = serde_json::to_string(&original).unwrap();
        let deserialized: AppConfig = serde_json::from_str(&serialized).unwrap();
        assert_eq!(original, deserialized);
    }

    fn temp_config_path(tag: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        p.push(format!(
            "peerspeak-configtest-{}-{}",
            std::process::id(),
            tag
        ));
        p.push("config.json");
        p
    }

    #[test]
    fn save_to_then_load_from_round_trips() {
        let path = temp_config_path("roundtrip");
        let _ = fs::remove_dir_all(path.parent().unwrap());
        let cfg = AppConfig {
            username: "Ada".into(),
            input_device: "mic".into(),
            output_device: "speaker".into(),
            noise_gate_threshold: 0.42,
            ..AppConfig::default()
        };

        cfg.save_to(&path).unwrap();
        let (loaded, outcome) = AppConfig::load_from(&path);

        assert_eq!(outcome, LoadOutcome::Loaded);
        assert_eq!(loaded, cfg);
        let _ = fs::remove_dir_all(path.parent().unwrap());
    }

    #[test]
    fn load_from_missing_returns_default_without_corrupt_backup() {
        let path = temp_config_path("missing");
        let _ = fs::remove_dir_all(path.parent().unwrap());

        let (loaded, outcome) = AppConfig::load_from(&path);

        assert_eq!(outcome, LoadOutcome::Missing);
        assert_eq!(loaded, AppConfig::default());
        assert!(!path.parent().unwrap().exists());
    }

    #[test]
    fn load_from_corrupt_file_preserves_original_bytes() {
        let path = temp_config_path("corrupt");
        let _ = fs::remove_dir_all(path.parent().unwrap());
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        let corrupt = b"{ this is not json";
        fs::write(&path, corrupt).unwrap();

        let (loaded, outcome) = AppConfig::load_from(&path);

        assert_eq!(outcome, LoadOutcome::Recovered);
        assert_eq!(loaded, AppConfig::default());
        assert_ne!(fs::read(&path).ok().as_deref(), Some(corrupt.as_slice()));
        let backups: Vec<_> = fs::read_dir(path.parent().unwrap())
            .unwrap()
            .map(|entry| entry.unwrap().path())
            .filter(|entry| {
                entry
                    .file_name()
                    .and_then(|name| name.to_str())
                    .is_some_and(|name| name.starts_with("config.json.corrupt."))
            })
            .collect();
        assert_eq!(backups.len(), 1, "expected one corrupt backup");
        assert_eq!(fs::read(&backups[0]).unwrap(), corrupt);
        let _ = fs::remove_dir_all(path.parent().unwrap());
    }

    #[test]
    fn save_to_leaves_no_tmp_file_after_success() {
        let path = temp_config_path("atomic");
        let _ = fs::remove_dir_all(path.parent().unwrap());

        AppConfig::default().save_to(&path).unwrap();

        let tmp_files: Vec<_> = fs::read_dir(path.parent().unwrap())
            .unwrap()
            .map(|entry| entry.unwrap().path())
            .filter(|entry| {
                entry
                    .file_name()
                    .and_then(|name| name.to_str())
                    .is_some_and(|name| name.contains(".tmp."))
            })
            .collect();
        assert!(tmp_files.is_empty(), "leftover temp files: {tmp_files:?}");
        let _ = fs::remove_dir_all(path.parent().unwrap());
    }

    #[test]
    fn test_backward_compat_default_fill() {
        let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
        let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap();

        assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
        // Configs predating the presence posture load as friends-only (no beacon).
        assert_eq!(
            deserialized.presence_mode,
            crate::presence::PresenceMode::Normal
        );
        assert!(!deserialized.echo_cancellation_enabled);
        assert!(deserialized.notifications_enabled);
        // Configs predating the volume sliders must load at unity gain.
        assert_eq!(deserialized.input_volume, 1.0);
        assert_eq!(deserialized.output_volume, 1.0);
        // Configs predating the draggable dividers must load the default sizes.
        assert_eq!(deserialized.participants_width, 540.0);
        assert_eq!(deserialized.chat_height, 180.0);
        // Configs predating the remembered username load the default nickname.
        assert_eq!(deserialized.username, "Peer");
        // Configs predating the layout picker load the default layout + sizes.
        assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
        assert_eq!(deserialized.controls_width, 280.0);
        assert_eq!(deserialized.chat_drawer_width, 320.0);
        assert_eq!(deserialized.playlist_drawer_width, 320.0);
        assert!(deserialized.show_player_bar);
        assert!(deserialized.custom_sound_self_join.is_none());
        assert!(deserialized.custom_sound_peer_join.is_none());
        assert!(deserialized.custom_sound_peer_leave.is_none());
        assert!(deserialized.custom_sound_reconnect_attempt.is_none());
        assert!(deserialized.custom_sound_reconnected.is_none());
        assert!(deserialized.custom_sound_self_leave.is_none());
        assert!(deserialized.custom_sound_mic_toggle.is_none());
        assert!(deserialized.custom_sound_reconnect_failed.is_none());
        assert!(deserialized.custom_sound_chat_sent.is_none());
        assert!(deserialized.custom_sound_chat_received.is_none());
        assert!(deserialized.custom_sound_contact_online.is_none());
        assert!(deserialized.custom_sound_contact_offline.is_none());
        assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
        assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
        assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
        assert_eq!(
            deserialized.screen_share.buffering,
            ShareBuffering::LowLatency
        );
        assert_eq!(deserialized.screen_share.cache_mb, 2);
        // Configs predating the per-sound flags (W6) enable every chime, so an
        // upgrade is silent-change-free.
        for sound in Sound::ALL {
            assert!(
                deserialized.sound_enabled(sound),
                "{sound:?} should default on"
            );
        }
        // The accessor and mutator agree round-trip.
        let mut cfg = AppConfig::default();
        cfg.set_sound_enabled(Sound::PeerJoin, false);
        assert!(!cfg.sound_enabled(Sound::PeerJoin));
        assert!(cfg.sound_enabled(Sound::SelfJoin));
        // Configs predating the remembered window size load the default size.
        assert_eq!(deserialized.window_width, 900.0);
        assert_eq!(deserialized.window_height, 760.0);
        // Configs predating the recents list load an empty list.
        assert!(deserialized.recents.is_empty());
        // Configs predating per-peer listener shaping load flat/center/default
        // shortcut settings.
        assert!(deserialized.peer_eq.is_empty());
        assert!(deserialized.peer_pan.is_empty());
        assert!(deserialized.peer_volume.is_empty());
        assert!(deserialized.music_source_volume.is_empty());
        assert!(deserialized.peer_gate.is_empty());
        assert_eq!(
            crate::hotkeys::format_binding(
                deserialized
                    .hotkeys
                    .binding(crate::hotkeys::HotkeyAction::PushToTalk)
            ),
            "Space"
        );
    }

    #[test]
    fn test_backward_compat_game_detection_fields() {
        // A config that predates the game-detection feature (W18) — and crucially
        // still carries the W16 single `background` as a plain string — must
        // deserialize without error. `AppConfig::load()` silently replaces ANY
        // deserialize failure with full defaults, so a broken migration here would
        // wipe everyone's settings; this guards that the additive fields kept the
        // old shape loadable and that `background` was NOT retyped.
        let legacy_json = r#"{
            "input_device": "",
            "output_device": "",
            "noise_gate_threshold": 0.01,
            "username": "Eric",
            "background": "/home/eric/.config/peerspeak/background.png",
            "background_dim": 0.4
        }"#;
        let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
        // The pre-existing single background survives untouched (still Option<String>).
        assert_eq!(
            cfg.background.as_deref(),
            Some("/home/eric/.config/peerspeak/background.png")
        );
        assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
        // The new game-detection fields default to off/empty → silent, opt-in upgrade.
        assert!(!cfg.game_presence_enabled);
        assert!(cfg.game_backgrounds.is_empty());
        assert!(cfg.game_process_map.is_empty());
    }

    #[test]
    fn test_game_maps_serialize_deterministically() {
        // BTreeMap ordering makes the serialized config stable across runs.
        let mut cfg = AppConfig::default();
        cfg.game_backgrounds
            .insert("steam:730".into(), "/a.png".into());
        cfg.game_backgrounds
            .insert("exe:hl2_linux".into(), "/b.png".into());
        cfg.game_process_map
            .insert("hl2_linux".into(), "Half-Life 2".into());
        let json = serde_json::to_string(&cfg).unwrap();
        // Keys appear in sorted order (exe: before steam:).
        let bg = json.find("game_backgrounds").unwrap();
        let exe_at = json[bg..].find("exe:hl2_linux").unwrap();
        let steam_at = json[bg..].find("steam:730").unwrap();
        assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted");
        // Full round-trip preserves the maps.
        let back: AppConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(back.game_backgrounds, cfg.game_backgrounds);
        assert_eq!(back.game_process_map, cfg.game_process_map);
    }

    #[test]
    fn test_window_size_fields() {
        // Default impl is the standard launch size.
        let def = AppConfig::default();
        assert_eq!(def.window_width, 900.0);
        assert_eq!(def.window_height, 760.0);
        // A saved size round-trips.
        let cfg = AppConfig {
            window_width: 1280.0,
            window_height: 720.0,
            ..AppConfig::default()
        };
        let json = serde_json::to_string(&cfg).unwrap();
        let back: AppConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(back.window_width, 1280.0);
        assert_eq!(back.window_height, 720.0);
    }

    #[test]
    fn test_window_position_fields() {
        // Position is unset by default (only ever saved on X11).
        let def = AppConfig::default();
        assert_eq!(def.window_x, None);
        assert_eq!(def.window_y, None);
        // A saved position (incl. negative coords) round-trips.
        let cfg = AppConfig {
            window_x: Some(200),
            window_y: Some(-50),
            ..AppConfig::default()
        };
        let json = serde_json::to_string(&cfg).unwrap();
        let back: AppConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(back.window_x, Some(200));
        assert_eq!(back.window_y, Some(-50));
    }

    #[test]
    fn test_recording_mode_field() {
        // Default is Mixed (back-compat with pre-feature configs).
        assert_eq!(AppConfig::default().recording_mode, RecordingMode::Mixed);
        // A chosen mode round-trips through JSON.
        let cfg = AppConfig {
            recording_mode: RecordingMode::Both,
            ..AppConfig::default()
        };
        let back: AppConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
        assert_eq!(back.recording_mode, RecordingMode::Both);
        // is_multitrack() classifies correctly.
        assert!(!RecordingMode::Mixed.is_multitrack());
        assert!(RecordingMode::Multitrack.is_multitrack());
        assert!(RecordingMode::Both.is_multitrack());
    }

    #[test]
    fn test_theme_field() {
        // Default theme is Mocha (the original look).
        assert_eq!(AppConfig::default().theme, AppTheme::Mocha);
        // A chosen theme round-trips.
        let cfg = AppConfig {
            theme: AppTheme::Nord,
            ..AppConfig::default()
        };
        let json = serde_json::to_string(&cfg).unwrap();
        let back: AppConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(back.theme, AppTheme::Nord);
    }

    #[test]
    fn old_config_without_theme_loads() {
        // Configs predating the theme picker load with the default (Mocha).
        let json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
        let cfg: AppConfig = serde_json::from_str(json).unwrap();
        assert_eq!(cfg.theme, AppTheme::Mocha);
    }

    #[test]
    fn old_config_without_position_loads() {
        // A config written before window_x/window_y existed still deserializes
        // (serde default → None), so upgrades don't wipe a user's settings.
        // (The three device/gate fields have no serde default, so any valid
        // config must carry them — mirrors test_backward_compat_default_fill.)
        let json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"window_width":1024.0,"window_height":768.0}"#;
        let cfg: AppConfig = serde_json::from_str(json).unwrap();
        assert_eq!(cfg.window_x, None);
        assert_eq!(cfg.window_y, None);
        assert_eq!(cfg.window_width, 1024.0);
    }

    #[test]
    fn test_input_output_volume_fields() {
        // Default impl is unity gain.
        let def = AppConfig::default();
        assert_eq!(def.input_volume, 1.0);
        assert_eq!(def.output_volume, 1.0);
        assert_eq!(def.clip_volume, 1.0);
        assert!(def.music_playlist.is_empty());
        assert_eq!(def.music_volume, 1.0);
        assert!(!def.music_broadcast);
        assert!(def.show_player_bar);
        assert!(def.clip_volume_universal);

        // Missing in JSON → unity (serde default).
        let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
        let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
        assert_eq!(cfg_missing.input_volume, 1.0);
        assert_eq!(cfg_missing.output_volume, 1.0);
        assert_eq!(cfg_missing.clip_volume, 1.0);
        assert!(cfg_missing.music_playlist.is_empty());
        assert_eq!(cfg_missing.music_volume, 1.0);
        assert!(!cfg_missing.music_broadcast);
        assert!(cfg_missing.show_player_bar);
        // Configs predating the toggle default to universal mode.
        assert!(cfg_missing.clip_volume_universal);

        // Explicit non-unity values are preserved across a round-trip.
        let cfg = AppConfig {
            input_volume: 1.5,
            output_volume: 0.25,
            clip_volume: 0.7,
            music_playlist: vec!["/tmp/song.ogg".to_string()],
            music_volume: 0.6,
            music_broadcast: true,
            show_player_bar: false,
            clip_volume_universal: false,
            ..AppConfig::default()
        };
        let round_tripped: AppConfig =
            serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
        assert_eq!(round_tripped.input_volume, 1.5);
        assert_eq!(round_tripped.output_volume, 0.25);
        assert_eq!(round_tripped.clip_volume, 0.7);
        assert_eq!(
            round_tripped.music_playlist,
            vec!["/tmp/song.ogg".to_string()]
        );
        assert_eq!(round_tripped.music_volume, 0.6);
        assert!(round_tripped.music_broadcast);
        assert!(!round_tripped.show_player_bar);
        assert!(!round_tripped.clip_volume_universal);
    }

    #[test]
    fn test_notifications_enabled_specifically() {
        let missing_notifications =
            r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
        let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap();
        assert!(config_missing.notifications_enabled);

        let explicit_false = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"notifications_enabled":false}"#;
        let config_false: AppConfig = serde_json::from_str(explicit_false).unwrap();
        assert!(!config_false.notifications_enabled);

        let explicit_true = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01,"notifications_enabled":true}"#;
        let config_true: AppConfig = serde_json::from_str(explicit_true).unwrap();
        assert!(config_true.notifications_enabled);
    }

    #[test]
    fn test_network_mode() {
        // NetworkMode::default() == RelayNoDiscovery
        assert_eq!(NetworkMode::default(), NetworkMode::RelayNoDiscovery);

        // ALL.len() == 3 and contains all three variants
        assert_eq!(NetworkMode::ALL.len(), 3);
        assert!(NetworkMode::ALL.contains(&NetworkMode::RelayNoDiscovery));
        assert!(NetworkMode::ALL.contains(&NetworkMode::N0Full));
        assert!(NetworkMode::ALL.contains(&NetworkMode::DirectOnly));

        // each variant serde round-trips to itself
        for mode in NetworkMode::ALL {
            let serialized = serde_json::to_string(&mode).unwrap();
            let deserialized: NetworkMode = serde_json::from_str(&serialized).unwrap();
            assert_eq!(mode, deserialized);
        }

        // the three Display strings are non-empty and distinct
        let display_0 = NetworkMode::RelayNoDiscovery.to_string();
        let display_1 = NetworkMode::N0Full.to_string();
        let display_2 = NetworkMode::DirectOnly.to_string();

        assert!(!display_0.is_empty());
        assert!(!display_1.is_empty());
        assert!(!display_2.is_empty());

        assert_ne!(display_0, display_1);
        assert_ne!(display_1, display_2);
        assert_ne!(display_0, display_2);
    }

    #[test]
    fn test_audio_profile() {
        // Default is Balanced.
        assert_eq!(AudioProfile::default(), AudioProfile::Balanced);

        // ALL holds the three variants.
        assert_eq!(AudioProfile::ALL.len(), 3);
        assert!(AudioProfile::ALL.contains(&AudioProfile::LowLatency));
        assert!(AudioProfile::ALL.contains(&AudioProfile::Balanced));
        assert!(AudioProfile::ALL.contains(&AudioProfile::BadNetwork));

        // as_u8 / from_u8 round-trip every variant, and unknown bytes fall back
        // to the default rather than panicking.
        for p in AudioProfile::ALL {
            assert_eq!(AudioProfile::from_u8(p.as_u8()), p);
        }
        assert_eq!(AudioProfile::from_u8(99), AudioProfile::Balanced);

        // serde round-trips, and Display strings are non-empty + distinct.
        let mut labels = Vec::new();
        for p in AudioProfile::ALL {
            let s = serde_json::to_string(&p).unwrap();
            assert_eq!(serde_json::from_str::<AudioProfile>(&s).unwrap(), p);
            let label = p.to_string();
            assert!(!label.is_empty());
            labels.push(label);
        }
        labels.sort();
        labels.dedup();
        assert_eq!(labels.len(), 3);
    }

    #[test]
    fn test_unknown_field_tolerance() {
        // Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
        // Assert whatever actually happens and leave a comment; do not add deny_unknown_fields.
        let json_with_extra = r#"{
            "input_device": "",
            "output_device": "",
            "noise_gate_threshold": 0.01,
            "unrecognized_field_xyz_123": "some_value"
        }"#;
        let deserialized_res: Result<AppConfig, _> = serde_json::from_str(json_with_extra);

        // Assert that deserialization succeeds even with unrecognized/unknown fields.
        // This confirms that serde does not reject unknown fields (i.e. default behavior).
        assert!(
            deserialized_res.is_ok(),
            "Config deserialization failed when an unknown field was present"
        );

        let config = deserialized_res.unwrap();
        assert_eq!(config.input_device, "");
        assert_eq!(config.output_device, "");
        assert_eq!(config.noise_gate_threshold, 0.01);
    }
}
