use crate::capture_test::TestCaptureArgs;
use crate::emulation_test::TestEmulationArgs;
use clap::{Parser, Subcommand, ValueEnum};
use notify::{EventKind, RecommendedWatcher, Watcher};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env::{self, VarError};
use std::fmt::Display;
use std::fs::{self, File};
use std::io::Write;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::{collections::HashSet, io};
use thiserror::Error;
use toml;
use toml_edit::{self, DocumentMut};

use mousehop_cli::CliArgs;
use mousehop_ipc::{
    ClipboardSuppression, ConnectionMode, DEFAULT_PORT, IncomingPeerConfig, Position,
};

use input_event::scancode::{
    self,
    Linux::{KeyLeftAlt, KeyLeftCtrl, KeyLeftMeta, KeyLeftShift},
};

use shadow_rs::shadow;

shadow!(build);

/// Local build's 8-byte ASCII short commit hash, suitable for use
/// in [`mousehop_proto::ProtoEvent::Hello`]. Pads with `'?'` if
/// shadow_rs returns an unexpected length so the field is always
/// well-formed on the wire.
pub fn local_commit() -> [u8; 8] {
    let bytes = build::SHORT_COMMIT.as_bytes();
    let mut out = [b'?'; 8];
    let n = bytes.len().min(8);
    out[..n].copy_from_slice(&bytes[..n]);
    out
}

const CONFIG_FILE_NAME: &str = "config.toml";
const CERT_FILE_NAME: &str = "mousehop.pem";

fn default_path() -> Result<PathBuf, VarError> {
    #[cfg(unix)]
    let default_path = {
        let xdg_config_home =
            env::var("XDG_CONFIG_HOME").unwrap_or(format!("{}/.config", env::var("HOME")?));
        format!("{xdg_config_home}/mousehop/")
    };

    #[cfg(not(unix))]
    let default_path = {
        let app_data =
            env::var("LOCALAPPDATA").unwrap_or(format!("{}/.config", env::var("USERPROFILE")?));
        format!("{app_data}\\mousehop\\")
    };
    Ok(PathBuf::from(default_path))
}

#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
struct ConfigToml {
    capture_backend: Option<CaptureBackend>,
    emulation_backend: Option<EmulationBackend>,
    port: Option<u16>,
    release_bind: Option<Vec<scancode::Linux>>,
    /// pixel threshold for the wall-press auto-release fallback.
    /// 0 (or absent) disables it; the cursor only releases on the
    /// release-bind chord or a peer-side `Leave`.
    release_threshold_px: Option<u32>,
    /// Advertise (and consume) `_mousehop._udp.local.` Bonjour
    /// service records. The TXT record's `primary=` field tells the
    /// dialer which interface IP the OS prefers (macOS service order
    /// / Linux default route), so when a peer is multi-homed the
    /// dialer biases toward the right interface instead of racing
    /// blindly. Default true; turn off on networks where mDNS
    /// multicast (224.0.0.251) is firewalled.
    mdns_discovery: Option<bool>,
    cert_path: Option<PathBuf>,
    clients: Option<Vec<TomlClient>>,
    authorized_fingerprints: Option<HashMap<String, IncomingPeerConfig>>,
    /// Per-OS clipboard suppression lists. Each host machine reads
    /// and writes only its own OS's slot; the rest round-trip
    /// untouched so a single config can be shared across machines.
    /// `None` / empty means no suppression — the user list is
    /// purely additive on top of platform-specific automatic
    /// detection (e.g. macOS `org.nspasteboard.ConcealedType`).
    clipboard_suppress_apps: Option<ClipboardSuppression>,
}

#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
struct TomlClient {
    hostname: Option<String>,
    host_name: Option<String>,
    ips: Option<Vec<IpAddr>>,
    port: Option<u16>,
    position: Option<Position>,
    activate_on_startup: Option<bool>,
    enter_hook: Option<String>,
    /// Base connection policy (`auto` | `fastest`). Absent in legacy
    /// configs → `Auto`. See [`mousehop_ipc::ConnectionMode`].
    #[serde(default)]
    mode: Option<ConnectionMode>,
    /// Per-network address locks, keyed by network fingerprint. Absent
    /// in legacy configs. See [`mousehop_ipc::ClientConfig::network_locks`].
    #[serde(default)]
    network_locks: Option<HashMap<String, IpAddr>>,
    /// Per-pair clipboard send gate. Absent in legacy configs;
    /// defaults to `false` so existing pairs don't start
    /// transmitting clipboard text on upgrade.
    clipboard_send: Option<bool>,
}

impl ConfigToml {
    fn new(path: &Path) -> Result<ConfigToml, ConfigError> {
        let config = fs::read_to_string(path)?;
        Ok(toml::from_str::<_>(&config)?)
    }
}

#[derive(Parser, Debug)]
#[command(author, version=build::CLAP_LONG_VERSION, about, long_about = None)]
struct Args {
    /// the listen port for mousehop
    #[arg(short, long)]
    port: Option<u16>,

    /// non-default config file location
    #[arg(short, long)]
    config: Option<PathBuf>,

    /// capture backend override
    #[arg(long)]
    capture_backend: Option<CaptureBackend>,

    /// emulation backend override
    #[arg(long)]
    emulation_backend: Option<EmulationBackend>,

    /// path to non-default certificate location
    #[arg(long)]
    cert_path: Option<PathBuf>,

    /// subcommands
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand, Clone, Debug, Eq, PartialEq)]
pub enum Command {
    /// test input emulation
    TestEmulation(TestEmulationArgs),
    /// test input capture
    TestCapture(TestCaptureArgs),
    /// Mousehop commandline interface
    Cli(CliArgs),
    /// run in daemon mode
    Daemon,
    /// Add (or, with --remove, delete) a host-firewall rule allowing
    /// mousehop's UDP port inbound — for the detected firewall
    /// (ufw/firewalld/nftables/iptables on Linux, Windows Firewall).
    /// Run with sudo / as administrator.
    Firewall(FirewallArgs),
    /// macOS-only: probe Accessibility permission in a fresh process
    /// (which bypasses the cached TCC trust state in already-running
    /// processes). Exits with status 0 if granted, 1 if not. Used by
    /// the daemon's TCC.db watcher to detect "remove from list" cases
    /// where AXIsProcessTrusted in the parent process keeps reporting
    /// cached-true even after the entry is removed.
    #[cfg(target_os = "macos")]
    AxProbe,
}

#[derive(clap::Args, Clone, Debug, Eq, PartialEq)]
pub struct FirewallArgs {
    /// Remove the rule instead of adding it.
    #[arg(long)]
    pub remove: bool,
    /// Print the commands that would run, without executing them.
    #[arg(long)]
    pub dry_run: bool,
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
pub enum CaptureBackend {
    #[cfg(all(unix, feature = "libei_capture", not(target_os = "macos")))]
    #[serde(rename = "input-capture-portal")]
    InputCapturePortal,
    #[cfg(all(unix, feature = "layer_shell_capture", not(target_os = "macos")))]
    #[serde(rename = "layer-shell")]
    LayerShell,
    #[cfg(all(unix, feature = "x11_capture", not(target_os = "macos")))]
    #[serde(rename = "x11")]
    X11,
    #[cfg(windows)]
    #[serde(rename = "windows")]
    Windows,
    #[cfg(target_os = "macos")]
    #[serde(rename = "macos")]
    MacOs,
    #[serde(rename = "dummy")]
    Dummy,
}

impl Display for CaptureBackend {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            #[cfg(all(unix, feature = "libei_capture", not(target_os = "macos")))]
            CaptureBackend::InputCapturePortal => write!(f, "input-capture-portal"),
            #[cfg(all(unix, feature = "layer_shell_capture", not(target_os = "macos")))]
            CaptureBackend::LayerShell => write!(f, "layer-shell"),
            #[cfg(all(unix, feature = "x11_capture", not(target_os = "macos")))]
            CaptureBackend::X11 => write!(f, "X11"),
            #[cfg(windows)]
            CaptureBackend::Windows => write!(f, "windows"),
            #[cfg(target_os = "macos")]
            CaptureBackend::MacOs => write!(f, "MacOS"),
            CaptureBackend::Dummy => write!(f, "dummy"),
        }
    }
}

impl From<CaptureBackend> for input_capture::Backend {
    fn from(backend: CaptureBackend) -> Self {
        match backend {
            #[cfg(all(unix, feature = "libei_capture", not(target_os = "macos")))]
            CaptureBackend::InputCapturePortal => Self::InputCapturePortal,
            #[cfg(all(unix, feature = "layer_shell_capture", not(target_os = "macos")))]
            CaptureBackend::LayerShell => Self::LayerShell,
            #[cfg(all(unix, feature = "x11_capture", not(target_os = "macos")))]
            CaptureBackend::X11 => Self::X11,
            #[cfg(windows)]
            CaptureBackend::Windows => Self::Windows,
            #[cfg(target_os = "macos")]
            CaptureBackend::MacOs => Self::MacOs,
            CaptureBackend::Dummy => Self::Dummy,
        }
    }
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
pub enum EmulationBackend {
    #[cfg(all(unix, feature = "wlroots_emulation", not(target_os = "macos")))]
    #[serde(rename = "wlroots")]
    Wlroots,
    #[cfg(all(unix, feature = "libei_emulation", not(target_os = "macos")))]
    #[serde(rename = "libei")]
    Libei,
    #[cfg(all(unix, feature = "rdp_emulation", not(target_os = "macos")))]
    #[serde(rename = "xdp")]
    Xdp,
    #[cfg(all(unix, feature = "x11_emulation", not(target_os = "macos")))]
    #[serde(rename = "x11")]
    X11,
    #[cfg(windows)]
    #[serde(rename = "windows")]
    Windows,
    #[cfg(target_os = "macos")]
    #[serde(rename = "macos")]
    MacOs,
    #[serde(rename = "dummy")]
    Dummy,
}

impl From<EmulationBackend> for input_emulation::Backend {
    fn from(backend: EmulationBackend) -> Self {
        match backend {
            #[cfg(all(unix, feature = "wlroots_emulation", not(target_os = "macos")))]
            EmulationBackend::Wlroots => Self::Wlroots,
            #[cfg(all(unix, feature = "libei_emulation", not(target_os = "macos")))]
            EmulationBackend::Libei => Self::Libei,
            #[cfg(all(unix, feature = "rdp_emulation", not(target_os = "macos")))]
            EmulationBackend::Xdp => Self::Xdp,
            #[cfg(all(unix, feature = "x11_emulation", not(target_os = "macos")))]
            EmulationBackend::X11 => Self::X11,
            #[cfg(windows)]
            EmulationBackend::Windows => Self::Windows,
            #[cfg(target_os = "macos")]
            EmulationBackend::MacOs => Self::MacOs,
            EmulationBackend::Dummy => Self::Dummy,
        }
    }
}

impl Display for EmulationBackend {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            #[cfg(all(unix, feature = "wlroots_emulation", not(target_os = "macos")))]
            EmulationBackend::Wlroots => write!(f, "wlroots"),
            #[cfg(all(unix, feature = "libei_emulation", not(target_os = "macos")))]
            EmulationBackend::Libei => write!(f, "libei"),
            #[cfg(all(unix, feature = "rdp_emulation", not(target_os = "macos")))]
            EmulationBackend::Xdp => write!(f, "xdg-desktop-portal"),
            #[cfg(all(unix, feature = "x11_emulation", not(target_os = "macos")))]
            EmulationBackend::X11 => write!(f, "X11"),
            #[cfg(windows)]
            EmulationBackend::Windows => write!(f, "windows"),
            #[cfg(target_os = "macos")]
            EmulationBackend::MacOs => write!(f, "macos"),
            EmulationBackend::Dummy => write!(f, "dummy"),
        }
    }
}

#[derive(Debug)]
pub struct Config {
    /// command line arguments
    args: Args,
    /// path to the certificate file used
    cert_path: PathBuf,
    /// path to the config file used
    config_path: PathBuf,
    /// path to config directory (parent of above)
    config_dir: PathBuf,
    /// the (optional) toml config and it's path
    config_toml: Option<ConfigToml>,
    // filesystem watcher
    watcher: notify::RecommendedWatcher,
    // channel for filesystem events
    watch_rx: tokio::sync::mpsc::Receiver<Result<notify::Event, notify::Error>>,
}

pub struct ConfigClient {
    pub ips: HashSet<IpAddr>,
    pub hostname: Option<String>,
    pub port: u16,
    pub pos: Position,
    pub active: bool,
    pub enter_hook: Option<String>,
    pub mode: ConnectionMode,
    pub network_locks: HashMap<String, IpAddr>,
    pub clipboard_send: bool,
}

impl From<TomlClient> for ConfigClient {
    fn from(toml: TomlClient) -> Self {
        let active = toml.activate_on_startup.unwrap_or(false);
        let enter_hook = toml.enter_hook;
        let hostname = toml.hostname;
        let ips = HashSet::from_iter(toml.ips.into_iter().flatten());
        let port = toml.port.unwrap_or(DEFAULT_PORT);
        let pos = toml.position.unwrap_or_default();
        let mode = toml.mode.unwrap_or_default();
        let network_locks = toml.network_locks.unwrap_or_default();
        let clipboard_send = toml.clipboard_send.unwrap_or(false);
        Self {
            ips,
            hostname,
            port,
            pos,
            active,
            enter_hook,
            mode,
            network_locks,
            clipboard_send,
        }
    }
}

impl From<ConfigClient> for TomlClient {
    fn from(client: ConfigClient) -> Self {
        let hostname = client.hostname;
        let host_name = None;
        let mut ips = client.ips.into_iter().collect::<Vec<_>>();
        ips.sort();
        let ips = Some(ips);
        let port = if client.port == DEFAULT_PORT {
            None
        } else {
            Some(client.port)
        };
        let position = Some(client.pos);
        let activate_on_startup = if client.active { Some(true) } else { None };
        let enter_hook = client.enter_hook;
        // Persist `clipboard_send = true` only when the user has
        // opted in. Default-false stays implicit so configs upgraded
        // from older builds don't gain a new field on every save.
        let clipboard_send = if client.clipboard_send {
            Some(true)
        } else {
            None
        };
        // Persist the mode only when it diverges from the default, and
        // the lock map only when non-empty, so untouched configs don't
        // sprout new keys on every save.
        let mode = (client.mode != ConnectionMode::default()).then_some(client.mode);
        let network_locks = (!client.network_locks.is_empty()).then_some(client.network_locks);
        Self {
            hostname,
            host_name,
            ips,
            port,
            position,
            activate_on_startup,
            enter_hook,
            mode,
            network_locks,
            clipboard_send,
        }
    }
}

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error(transparent)]
    Toml(#[from] toml::de::Error),
    #[error(transparent)]
    Io(#[from] io::Error),
    #[error(transparent)]
    Var(#[from] VarError),
    #[error(transparent)]
    Watcher(#[from] notify::Error),
}

const DEFAULT_RELEASE_KEYS: [scancode::Linux; 4] =
    [KeyLeftCtrl, KeyLeftShift, KeyLeftMeta, KeyLeftAlt];

impl Config {
    pub fn new() -> Result<Self, ConfigError> {
        let args = Args::parse();

        // --config <file> overrules default location
        let config_path = args
            .config
            .clone()
            .unwrap_or(default_path()?.join(CONFIG_FILE_NAME));
        let config_dir = config_path
            .parent()
            .expect("config directory")
            .to_path_buf();

        // Ensure the config directory exists and write a default config file
        // if none is present. Runs on every Config::new(), regardless of which
        // entry path (GUI main, spawned daemon, CLI, test commands) we're on,
        // so a fresh Mac never hits "No such file or directory" on config.toml
        // and notify::Watcher (which requires the dir to exist on macOS
        // FSEvents and some Linux backends) has a concrete path to watch.
        fs::create_dir_all(&config_dir)?;
        if !config_path.exists() {
            let default_toml = toml::to_string_pretty(&ConfigToml::default())
                .expect("default ConfigToml serialization cannot fail");
            fs::write(&config_path, default_toml)?;
        }

        let config_toml = match ConfigToml::new(&config_path) {
            Err(e) => {
                log::warn!("{config_path:?}: {e}");
                log::warn!("Continuing without config file ...");
                None
            }
            Ok(c) => Some(c),
        };

        // --cert-path <file> overrules default location
        let cert_path = args
            .cert_path
            .clone()
            .or(config_toml.as_ref().and_then(|c| c.cert_path.clone()))
            .unwrap_or(default_path()?.join(CERT_FILE_NAME));

        let (tx, watch_rx) = tokio::sync::mpsc::channel(16);
        let watcher = RecommendedWatcher::new(
            move |res| {
                let _ = tx.blocking_send(res);
            },
            notify::Config::default(),
        )?;
        let mut config = Config {
            args,
            cert_path,
            config_path,
            config_dir,
            config_toml,
            watcher,
            watch_rx,
        };
        config.watch()?;
        Ok(config)
    }

    fn watch(&mut self) -> Result<(), notify::Error> {
        self.watcher
            .watch(&self.config_dir, notify::RecursiveMode::NonRecursive)?;
        Ok(())
    }

    fn unwatch(&mut self) -> Result<(), notify::Error> {
        self.watcher.unwatch(&self.config_dir)?;
        Ok(())
    }

    pub async fn changed(&mut self) -> Result<(), notify::Error> {
        loop {
            let event = self.watch_rx.recv().await.expect("channel closed");
            let event = event.expect("filesystem event");
            if event.paths.contains(&self.config_path)
                && matches!(
                    event.kind,
                    EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
                )
                && self.read_from_disk()?
            {
                return Ok(());
            }
        }
    }

    /// the command to run
    pub fn command(&self) -> Option<Command> {
        self.args.command.clone()
    }

    pub fn config_path(&self) -> &Path {
        &self.config_path
    }

    /// public key fingerprints authorized for connection, with each
    /// peer's per-pair receive-side post-processing preferences.
    pub fn authorized_fingerprints(&self) -> HashMap<String, IncomingPeerConfig> {
        self.config_toml
            .as_ref()
            .and_then(|c| c.authorized_fingerprints.clone())
            .unwrap_or_default()
    }

    /// path to certificate
    pub fn cert_path(&self) -> &Path {
        &self.cert_path
    }

    /// optional input-capture backend override
    pub fn capture_backend(&self) -> Option<CaptureBackend> {
        self.args
            .capture_backend
            .or(self.config_toml.as_ref().and_then(|c| c.capture_backend))
    }

    /// optional input-emulation backend override
    pub fn emulation_backend(&self) -> Option<EmulationBackend> {
        self.args
            .emulation_backend
            .or(self.config_toml.as_ref().and_then(|c| c.emulation_backend))
    }

    /// the port to use (initially)
    pub fn port(&self) -> u16 {
        self.args
            .port
            .or(self.config_toml.as_ref().and_then(|c| c.port))
            .unwrap_or(DEFAULT_PORT)
    }

    /// list of configured clients
    pub fn clients(&self) -> Vec<ConfigClient> {
        self.config_toml
            .as_ref()
            .map(|c| c.clients.clone())
            .unwrap_or_default()
            .into_iter()
            .flatten()
            .map(From::<TomlClient>::from)
            .collect()
    }

    /// release bind for returning control to the host
    pub fn release_bind(&self) -> Vec<scancode::Linux> {
        self.config_toml
            .as_ref()
            .and_then(|c| c.release_bind.clone())
            .unwrap_or(Vec::from_iter(DEFAULT_RELEASE_KEYS.iter().cloned()))
    }

    /// Persist a new release-bind chord. An empty `bind` clears the
    /// override so the built-in default applies again.
    pub fn set_release_bind(&mut self, bind: Vec<scancode::Linux>) {
        if self.config_toml.is_none() {
            self.config_toml = Some(Default::default());
        }
        let slot = &mut self.config_toml.as_mut().expect("config").release_bind;
        if bind.is_empty() {
            *slot = None;
        } else {
            *slot = Some(bind);
        }
    }

    /// Pixel threshold for the wall-press auto-release fallback.
    /// 0 disables it.
    pub fn release_threshold_px(&self) -> u32 {
        self.config_toml
            .as_ref()
            .and_then(|c| c.release_threshold_px)
            .unwrap_or(0)
    }

    pub fn set_release_threshold_px(&mut self, threshold: u32) {
        if self.config_toml.is_none() {
            self.config_toml = Some(Default::default());
        }
        self.config_toml
            .as_mut()
            .expect("config")
            .release_threshold_px = Some(threshold);
    }

    /// Whether mDNS-SD discovery is enabled. Defaults to true when
    /// the key is absent.
    pub fn mdns_discovery(&self) -> bool {
        self.config_toml
            .as_ref()
            .and_then(|c| c.mdns_discovery)
            .unwrap_or(true)
    }

    pub fn set_mdns_discovery(&mut self, enabled: bool) {
        if self.config_toml.is_none() {
            self.config_toml = Some(Default::default());
        }
        self.config_toml.as_mut().expect("config").mdns_discovery = Some(enabled);
    }

    /// set configured clients
    pub fn set_clients(&mut self, clients: Vec<ConfigClient>) {
        if clients.is_empty() {
            return;
        }
        if self.config_toml.is_none() {
            self.config_toml = Some(Default::default());
        }
        self.config_toml.as_mut().expect("config").clients =
            Some(clients.into_iter().map(|c| c.into()).collect::<Vec<_>>());
    }

    /// set authorized keys
    pub fn set_authorized_keys(&mut self, fingerprints: HashMap<String, IncomingPeerConfig>) {
        if self.config_toml.is_none() {
            self.config_toml = Some(Default::default());
        }
        self.config_toml
            .as_mut()
            .expect("config")
            .authorized_fingerprints = Some(fingerprints);
    }

    /// Snapshot of the persisted per-OS suppression lists.
    pub fn clipboard_suppression(&self) -> ClipboardSuppression {
        self.config_toml
            .as_ref()
            .and_then(|c| c.clipboard_suppress_apps.clone())
            .unwrap_or_default()
    }

    /// Replace the persisted per-OS suppression lists. `None` is
    /// written when every slot is empty so a fresh config doesn't
    /// gain a new top-level key on every save.
    pub fn set_clipboard_suppression(&mut self, suppression: ClipboardSuppression) {
        if self.config_toml.is_none() {
            self.config_toml = Some(Default::default());
        }
        self.config_toml
            .as_mut()
            .expect("config")
            .clipboard_suppress_apps = if suppression.is_empty() {
            None
        } else {
            Some(suppression)
        };
    }

    pub fn read_from_disk(&mut self) -> Result<bool, io::Error> {
        log::info!("reading config from {:?}", &self.config_path);

        let current_config = fs::read_to_string(&self.config_path)?;
        let current_config = match current_config.parse::<DocumentMut>() {
            Ok(c) => c,
            Err(e) => {
                log::warn!("{:?} {e}", self.config_path());
                return Ok(false);
            }
        };
        let mut changed = false;
        match toml_edit::de::from_document::<ConfigToml>(current_config) {
            Ok(current_config) => {
                changed = self
                    .config_toml
                    .as_ref()
                    .is_none_or(|c| c != &current_config);
                self.config_toml.replace(current_config);
            }
            Err(e) => log::warn!("{:?} {e}", self.config_path()),
        };
        Ok(changed)
    }

    pub async fn write_back(&mut self) -> Result<(), io::Error> {
        log::info!("writing config to {:?}", &self.config_path);
        /* the new config */
        let new_config = self.config_toml.clone().unwrap_or_default();
        let new_config = toml_edit::ser::to_string_pretty(&new_config).expect("config");

        /*
         * TODO merge with current config file to preserve comments
         * => eventually we might want to split this up into clients configured
         * via the config file and clients managed through the GUI / frontend.
         * The latter should be saved to $XDG_DATA_HOME instead of $XDG_CONFIG_HOME,
         * and clients configured through .config could be made permanent.
         * For now we just override the config file.
         */

        let _ = self.unwatch();
        /* Offload the write — and especially the fsync — to the
         * blocking pool. `sync_all()` can stall for many ms on a
         * busy disk, and `write_back` runs on the single-threaded
         * runtime that also drains captured input events; a
         * synchronous fsync here would briefly stall forwarding.
         * Serializing the TOML above is cheap and stays inline;
         * only the I/O is offloaded. The watcher is re-armed after
         * the write completes (await), preserving the unwatch/watch
         * bracket that suppresses a self-write reload. */
        let path = self.config_path().to_path_buf();
        let res = tokio::task::spawn_blocking(move || -> Result<(), io::Error> {
            if let Some(p) = path.parent() {
                fs::create_dir_all(p)?;
            }
            let mut f = File::create(&path)?;
            f.write_all(new_config.as_bytes())?;
            f.sync_all()?;
            Ok(())
        })
        .await
        .expect("config write task panicked");

        let _ = self.watch();

        res
    }
}

#[cfg(test)]
mod connection_mode_tests {
    use super::*;
    use std::net::{IpAddr, Ipv4Addr};

    fn client_with(mode: ConnectionMode, locks: HashMap<String, IpAddr>) -> ConfigClient {
        ConfigClient {
            ips: Default::default(),
            hostname: Some("host.local".into()),
            port: DEFAULT_PORT,
            pos: Position::Left,
            active: true,
            enter_hook: None,
            mode,
            network_locks: locks,
            clipboard_send: false,
        }
    }

    #[test]
    fn mode_and_network_locks_roundtrip_through_toml() {
        let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 153));
        let mut locks = HashMap::new();
        locks.insert("gw:aa:bb:cc:dd:ee:ff".to_string(), ip);
        // Use a non-default mode so it's actually persisted.
        let toml: TomlClient = client_with(ConnectionMode::Auto, locks.clone()).into();
        assert_eq!(toml.mode, Some(ConnectionMode::Auto));
        assert_eq!(toml.network_locks.as_ref(), Some(&locks));
        let back: ConfigClient = toml.into();
        assert_eq!(back.mode, ConnectionMode::Auto);
        assert_eq!(back.network_locks.get("gw:aa:bb:cc:dd:ee:ff"), Some(&ip));
    }

    #[test]
    fn defaults_stay_implicit_in_toml() {
        // The default mode + empty locks shouldn't write new keys on save.
        let toml: TomlClient = client_with(ConnectionMode::default(), HashMap::new()).into();
        assert_eq!(toml.mode, None);
        assert_eq!(toml.network_locks, None);
        // A non-default mode IS persisted.
        let toml: TomlClient = client_with(ConnectionMode::Auto, HashMap::new()).into();
        assert_eq!(toml.mode, Some(ConnectionMode::Auto));
    }

    #[test]
    fn legacy_toml_without_mode_uses_default() {
        let parsed: TomlClient = toml::from_str(
            r#"
            hostname = "host.local"
            port = 4252
            "#,
        )
        .expect("parse legacy client");
        assert_eq!(parsed.mode, None);
        let cc: ConfigClient = parsed.into();
        assert_eq!(cc.mode, ConnectionMode::default());
        assert!(cc.network_locks.is_empty());
    }
}
