//! TOML configuration structures for the server and client, with sane defaults.

use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use thiserror::Error;
use zeroize::Zeroize;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("failed to read config file {path}: {source}")]
    Read {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("failed to parse config file {path}: {source}")]
    Parse {
        path: PathBuf,
        source: toml::de::Error,
    },
    #[error("invalid config: {0}")]
    Invalid(String),
}

// ----------------------------- Server config -----------------------------

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
    pub server: ServerSection,
    pub command: CommandSection,
    #[serde(default)]
    pub clients: Vec<ClientEntry>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerSection {
    /// UDP socket address to bind, e.g. "0.0.0.0:7370".
    pub bind: String,
    /// This server's identity, signed into the canonical payload.
    pub server_id: String,
    #[serde(default = "default_nonce_ttl")]
    pub nonce_ttl_seconds: u64,
    #[serde(default = "default_command_timeout")]
    pub command_timeout_seconds: u64,
    /// Minimum seconds between successful command executions for one client_id
    /// (and for one source IP). **Default 0 (disabled)**; set a positive value
    /// (e.g. 60) to rate-limit how often the hook can fire per client.
    #[serde(default)]
    pub client_cooldown_seconds: u64,
    #[serde(default = "default_max_packet_size")]
    pub max_packet_size: usize,
    /// Max HELLO packets accepted per source IP within `hello_rate_window_seconds`.
    #[serde(default = "default_hello_rate_max")]
    pub hello_rate_max: u32,
    #[serde(default = "default_hello_rate_window")]
    pub hello_rate_window_seconds: u64,
    /// Maximum accepted difference (seconds) between a HELLO's signed timestamp
    /// and the server clock. HELLOs outside this window are rejected as stale.
    /// Requires loose clock sync (NTP) between client and server.
    #[serde(default = "default_hello_max_skew")]
    pub hello_max_skew_seconds: u64,
    /// Optional override for the on-demand status state file. Defaults to a path
    /// under `$XDG_RUNTIME_DIR`/`/run` (see `status::default_state_path`).
    #[serde(default)]
    pub state_file: Option<String>,
    /// Base64 X25519 secret key. Required when `wire_encryption = "required"`
    /// and to decrypt the RESPONSE `param`. Generated by `signedpulse-server init`.
    #[serde(default)]
    pub encryption_private_key: Option<String>,
    /// Maximum decrypted parameter length in bytes (rejects larger).
    #[serde(default = "default_max_param_len")]
    pub max_param_len: usize,
    /// Whether datagrams must be sealed (`required`) or sent in the clear (`off`).
    #[serde(default = "default_wire_encryption")]
    pub wire_encryption: WireEncryption,
    /// After this many faulty packets from one source IP, the IP is blacklisted
    /// (dropped before any decryption work). 0 disables blacklisting.
    #[serde(default = "default_max_faulty_packets")]
    pub max_faulty_packets: u32,
    /// How long a blacklisted source IP stays blocked (seconds).
    #[serde(default = "default_blacklist_seconds")]
    pub blacklist_seconds: u64,
    /// If more than this many distinct IPs are blacklisted within
    /// `attack_window_seconds`, the server enters lockdown and only serves IPs
    /// that recently completed a handshake. 0 disables lockdown.
    #[serde(default = "default_attack_blacklist_threshold")]
    pub attack_blacklist_threshold: u32,
    /// Rolling window for the lockdown trigger (seconds).
    #[serde(default = "default_attack_window")]
    pub attack_window_seconds: u64,
    /// Also enter lockdown if more than this many packets are rejected within
    /// `attack_window_seconds` (catches floods of well-formed-but-unauthenticated
    /// packets that burn CPU without blacklisting any single IP). 0 disables.
    #[serde(default = "default_attack_rejection_threshold")]
    pub attack_rejection_threshold: u32,
    /// How long a source IP stays "active" after a successful exchange, for the
    /// purpose of lockdown allow-listing (seconds).
    #[serde(default = "default_active_ip_ttl")]
    pub active_ip_ttl_seconds: u64,
    /// Maximum datagrams processed concurrently. Excess datagrams are dropped
    /// while at capacity, bounding CPU/memory under a flood. 0 = unbounded.
    #[serde(default = "default_max_inflight_packets")]
    pub max_inflight_packets: usize,
    /// Hard cap on distinct source IPs tracked in the bounded maps: the
    /// blacklist, the per-IP HELLO rate limiter, and the lockdown active-IP
    /// allow-list. When full, new IPs are refused tracking rather than evicting.
    /// (The nonce store and HELLO replay cache are not capped by this value;
    /// they are bounded by their TTL and grow only via signature-authenticated
    /// HELLOs, so a pure source-spoofer cannot inflate them.)
    #[serde(default = "default_max_tracked_ips")]
    pub max_tracked_ips: usize,
}

/// Whether the server requires sealed datagrams or speaks cleartext binary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WireEncryption {
    /// Every datagram must be an opaque sealed blob (default).
    Required,
    /// Cleartext compact-binary packets (still signed; param still sealed).
    Off,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandSection {
    /// Argv array. Placeholders `{ip}`, `{client_id}`, `{source_port}` are
    /// substituted safely before execution — never via a shell.
    pub argv: Vec<String>,
    #[serde(default)]
    pub working_dir: Option<String>,
    #[serde(default = "default_max_concurrent")]
    pub max_concurrent: usize,
    /// Danger flag: when true, the argv is run through `sh -c`. Defaults to
    /// false and should stay false outside of trusted, controlled setups.
    #[serde(default)]
    pub allow_shell: bool,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientEntry {
    /// 256-bit client id as 64 hex chars.
    pub client_id: String,
    /// Base64-encoded Ed25519 public key.
    pub public_key: String,
    /// Optional human-friendly name for logs/status.
    #[serde(default)]
    pub label: Option<String>,
}

fn default_nonce_ttl() -> u64 {
    30
}
fn default_command_timeout() -> u64 {
    10
}
fn default_max_packet_size() -> usize {
    2048
}
fn default_max_concurrent() -> usize {
    4
}
fn default_hello_rate_max() -> u32 {
    30
}
fn default_hello_rate_window() -> u64 {
    60
}
fn default_hello_max_skew() -> u64 {
    30
}
fn default_max_param_len() -> usize {
    256
}
fn default_param_command_timeout() -> u64 {
    5
}
fn default_wire_encryption() -> WireEncryption {
    WireEncryption::Required
}
fn default_max_faulty_packets() -> u32 {
    10
}
fn default_blacklist_seconds() -> u64 {
    300
}
fn default_attack_blacklist_threshold() -> u32 {
    10
}
fn default_attack_window() -> u64 {
    10
}
fn default_attack_rejection_threshold() -> u32 {
    256
}
fn default_active_ip_ttl() -> u64 {
    900
}
fn default_max_inflight_packets() -> usize {
    1024
}
fn default_max_tracked_ips() -> usize {
    100_000
}

impl ServerConfig {
    pub fn load(path: &Path) -> Result<Self, ConfigError> {
        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
            path: path.to_path_buf(),
            source,
        })?;
        let cfg: ServerConfig = toml::from_str(&text).map_err(|source| ConfigError::Parse {
            path: path.to_path_buf(),
            source,
        })?;
        cfg.validate()?;
        Ok(cfg)
    }

    fn validate(&self) -> Result<(), ConfigError> {
        if self.command.argv.is_empty() {
            return Err(ConfigError::Invalid(
                "command.argv must contain at least the program path".into(),
            ));
        }
        if self.command.max_concurrent == 0 {
            return Err(ConfigError::Invalid(
                "command.max_concurrent must be >= 1".into(),
            ));
        }
        // A UDP payload can't exceed ~64 KiB; bound it so the receive buffer
        // (`max_packet_size + 1`) can't overflow or trigger a huge allocation.
        if self.server.max_packet_size == 0 || self.server.max_packet_size > 65_535 {
            return Err(ConfigError::Invalid(
                "server.max_packet_size must be between 1 and 65535".into(),
            ));
        }
        // 0 would make every hook time out instantly (it is NOT a "disable").
        if self.server.command_timeout_seconds == 0 {
            return Err(ConfigError::Invalid(
                "server.command_timeout_seconds must be >= 1".into(),
            ));
        }
        // These time windows have no "0 = disable" meaning; 0 silently breaks the
        // handshake (nonce_ttl) or a protection (skew/replay window, rate window).
        if self.server.nonce_ttl_seconds == 0 {
            return Err(ConfigError::Invalid(
                "server.nonce_ttl_seconds must be >= 1".into(),
            ));
        }
        if self.server.hello_max_skew_seconds == 0 {
            return Err(ConfigError::Invalid(
                "server.hello_max_skew_seconds must be >= 1".into(),
            ));
        }
        if self.server.hello_rate_window_seconds == 0 {
            return Err(ConfigError::Invalid(
                "server.hello_rate_window_seconds must be >= 1".into(),
            ));
        }
        // Keep the sealed param within one datagram and the u16 length prefix.
        // Reject 0 too: it would silently make every param-bearing pulse fail.
        if self.server.max_param_len == 0 || self.server.max_param_len > 60_000 {
            return Err(ConfigError::Invalid(
                "server.max_param_len must be between 1 and 60000".into(),
            ));
        }
        // client_ids must be valid 256-bit hex and unambiguous.
        let mut seen = std::collections::HashSet::new();
        for c in &self.clients {
            crate::protocol::ClientId::from_hex(&c.client_id).map_err(|_| {
                ConfigError::Invalid(format!(
                    "client_id {:?} must be 64 hex characters (256 bits)",
                    c.client_id
                ))
            })?;
            if !seen.insert(c.client_id.to_lowercase()) {
                return Err(ConfigError::Invalid(format!(
                    "duplicate client_id {:?} in config",
                    c.client_id
                )));
            }
        }
        // Encrypted wire requires the server's decryption key.
        if self.server.wire_encryption == WireEncryption::Required
            && self.server.encryption_private_key.is_none()
        {
            return Err(ConfigError::Invalid(
                "server.wire_encryption = \"required\" needs server.encryption_private_key \
                 (run `signedpulse-server init`)"
                    .into(),
            ));
        }
        Ok(())
    }
}

// ----------------------------- Client config -----------------------------

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientConfig {
    pub client: ClientSection,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientSection {
    pub client_id: String,
    /// Server UDP address, e.g. "203.0.113.10:7370".
    pub server_addr: String,
    /// Must match the server's configured `server_id` (signed into payload).
    pub server_id: String,
    #[serde(default = "default_interval")]
    pub interval_seconds: u64,
    /// Base64-encoded Ed25519 private key seed. Sensitive.
    pub private_key: String,
    #[serde(default = "default_challenge_timeout")]
    pub challenge_timeout_seconds: u64,
    #[serde(default = "default_retries")]
    pub retries: u32,
    /// Optional override for the on-demand status state file. Defaults to a path
    /// under `$XDG_RUNTIME_DIR`/`/run` (see `status::default_state_path`).
    #[serde(default)]
    pub state_file: Option<String>,
    /// Optional argv whose stdout becomes the (encrypted) parameter sent to the
    /// server each pulse. No shell unless you put one in the argv yourself.
    #[serde(default)]
    pub param_command: Option<Vec<String>>,
    /// Timeout for `param_command` in seconds.
    #[serde(default = "default_param_command_timeout")]
    pub param_command_timeout_seconds: u64,
    /// Maximum parameter length in bytes (the generated value is rejected if larger).
    #[serde(default = "default_max_param_len")]
    pub param_max_len: usize,
    /// Base64 X25519 *public* key of the server, used to encrypt the parameter
    /// and (when `wire_encryption`) the whole packet. Required when
    /// `wire_encryption` is true or `param_command` is set. Printed by
    /// `signedpulse-server init`.
    #[serde(default)]
    pub server_encryption_key: Option<String>,
    /// Seal every datagram to the server (default true). Must match the server's
    /// `wire_encryption` setting.
    #[serde(default = "default_true")]
    pub wire_encryption: bool,
    /// Additional servers to pulse, configured as `[client.servers.<name>]`
    /// tables. The table key is a *local label* used for status and uniqueness,
    /// not necessarily the wire `server_id` (see [`ServerOverride::server_id`]).
    /// The `[client]` block above is the primary server; each entry here adds
    /// another independent pulse loop that reuses the shared identity
    /// (`client_id`/`private_key`) but targets its own address and key. Unset
    /// fields inherit from `[client]`.
    #[serde(default)]
    pub servers: BTreeMap<String, ServerOverride>,
}

/// A secondary server target (`[client.servers.<name>]`). The table key is a
/// local label; only `server_addr` is required, and every other field falls back
/// to the corresponding `[client]` value when omitted. Each server has its own
/// X25519 key, so `server_encryption_key` is never inherited — an encrypted
/// entry must supply its own.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerOverride {
    /// Server UDP address, e.g. "203.0.113.11:7370".
    pub server_addr: String,
    /// The `server_id` signed into this server's payloads — it **must match the
    /// remote server's** configured `server_id`, or every HELLO is rejected as an
    /// invalid signature. Defaults to the table key (the local label) when unset,
    /// so set it explicitly only when the label differs from the remote id (e.g.
    /// two servers that both use the default `signedpulse-main`).
    #[serde(default)]
    pub server_id: Option<String>,
    /// This server's base64 X25519 public key (not inherited from `[client]`).
    #[serde(default)]
    pub server_encryption_key: Option<String>,
    /// Override `[client].wire_encryption` for this server.
    #[serde(default)]
    pub wire_encryption: Option<bool>,
    /// Override `[client].interval_seconds` for this server.
    #[serde(default)]
    pub interval_seconds: Option<u64>,
    /// Override `[client].challenge_timeout_seconds` for this server.
    #[serde(default)]
    pub challenge_timeout_seconds: Option<u64>,
    /// Override `[client].retries` for this server.
    #[serde(default)]
    pub retries: Option<u32>,
    /// Override `[client].param_command` for this server.
    #[serde(default)]
    pub param_command: Option<Vec<String>>,
    /// Override `[client].param_command_timeout_seconds` for this server.
    #[serde(default)]
    pub param_command_timeout_seconds: Option<u64>,
    /// Override `[client].param_max_len` for this server.
    #[serde(default)]
    pub param_max_len: Option<usize>,
}

fn default_true() -> bool {
    true
}

fn default_interval() -> u64 {
    300
}
fn default_challenge_timeout() -> u64 {
    5
}
fn default_retries() -> u32 {
    3
}

impl ClientConfig {
    pub fn load(path: &Path) -> Result<Self, ConfigError> {
        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
            path: path.to_path_buf(),
            source,
        })?;
        let cfg: ClientConfig = toml::from_str(&text).map_err(|source| ConfigError::Parse {
            path: path.to_path_buf(),
            source,
        })?;
        if cfg.client.interval_seconds == 0 {
            return Err(ConfigError::Invalid(
                "client.interval_seconds must be >= 1".into(),
            ));
        }
        // client_id must be a valid 256-bit hex value.
        crate::protocol::ClientId::from_hex(&cfg.client.client_id).map_err(|_| {
            ConfigError::Invalid("client.client_id must be 64 hex characters (256 bits)".into())
        })?;
        // Encryption (wire or param) needs the server's public key.
        let needs_key = cfg.client.wire_encryption || cfg.client.param_command.is_some();
        if needs_key && cfg.client.server_encryption_key.is_none() {
            return Err(ConfigError::Invalid(
                "client.server_encryption_key is required when wire_encryption is true or \
                 param_command is set (run `signedpulse-server init` to print it)"
                    .into(),
            ));
        }
        if let Some(argv) = &cfg.client.param_command {
            if argv.is_empty() {
                return Err(ConfigError::Invalid(
                    "client.param_command must not be an empty array".into(),
                ));
            }
        }
        // 0-second timeouts would make the operation fail instantly.
        if cfg.client.challenge_timeout_seconds == 0 {
            return Err(ConfigError::Invalid(
                "client.challenge_timeout_seconds must be >= 1".into(),
            ));
        }
        if cfg.client.param_command.is_some() && cfg.client.param_command_timeout_seconds == 0 {
            return Err(ConfigError::Invalid(
                "client.param_command_timeout_seconds must be >= 1".into(),
            ));
        }
        // Keep the sealed param within one datagram / the u16 length prefix.
        // Reject 0 too: it would silently make every param-bearing pulse fail.
        if cfg.client.param_max_len == 0 || cfg.client.param_max_len > 60_000 {
            return Err(ConfigError::Invalid(
                "client.param_max_len must be between 1 and 60000".into(),
            ));
        }
        // Validate each additional server target. The map key is its server_id.
        for (server_id, ov) in &cfg.client.servers {
            if server_id.is_empty() {
                return Err(ConfigError::Invalid(
                    "client.servers entries must have a non-empty server_id (the table key)".into(),
                ));
            }
            if *server_id == cfg.client.server_id {
                return Err(ConfigError::Invalid(format!(
                    "client.servers.{server_id} duplicates the primary [client] server_id"
                )));
            }
            if ov.server_addr.is_empty() {
                return Err(ConfigError::Invalid(format!(
                    "client.servers.{server_id}.server_addr must not be empty"
                )));
            }
            // Effective settings inherit from [client] when not overridden.
            let wire = ov.wire_encryption.unwrap_or(cfg.client.wire_encryption);
            let has_param = ov
                .param_command
                .as_ref()
                .or(cfg.client.param_command.as_ref())
                .is_some();
            // Each server has its own key; it is never inherited from [client].
            if (wire || has_param) && ov.server_encryption_key.is_none() {
                return Err(ConfigError::Invalid(format!(
                    "client.servers.{server_id}.server_encryption_key is required when its \
                     wire_encryption is true or a param_command applies"
                )));
            }
            if let Some(argv) = &ov.param_command {
                if argv.is_empty() {
                    return Err(ConfigError::Invalid(format!(
                        "client.servers.{server_id}.param_command must not be an empty array"
                    )));
                }
            }
            if ov.interval_seconds == Some(0) {
                return Err(ConfigError::Invalid(format!(
                    "client.servers.{server_id}.interval_seconds must be >= 1"
                )));
            }
            if ov.challenge_timeout_seconds == Some(0) {
                return Err(ConfigError::Invalid(format!(
                    "client.servers.{server_id}.challenge_timeout_seconds must be >= 1"
                )));
            }
            if let Some(n) = ov.param_max_len {
                if n == 0 || n > 60_000 {
                    return Err(ConfigError::Invalid(format!(
                        "client.servers.{server_id}.param_max_len must be between 1 and 60000"
                    )));
                }
            }
        }
        Ok(cfg)
    }
}

impl Drop for ClientSection {
    fn drop(&mut self) {
        // Best-effort scrub of the in-memory private key string.
        self.private_key.zeroize();
    }
}

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

    fn write_temp(contents: &str) -> tempfileless::Temp {
        tempfileless::Temp::new(contents)
    }

    /// The shipped example configs must parse against the current schema,
    /// including `deny_unknown_fields` (catches a stale/renamed example key).
    #[test]
    fn example_configs_match_schema() {
        let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/");
        let server: ServerConfig = toml::from_str(
            &std::fs::read_to_string(format!("{root}server.toml")).expect("read server.toml"),
        )
        .expect("examples/server.toml must parse against the schema");
        assert_eq!(server.server.wire_encryption, WireEncryption::Required);
        let client: ClientConfig = toml::from_str(
            &std::fs::read_to_string(format!("{root}client.toml")).expect("read client.toml"),
        )
        .expect("examples/client.toml must parse against the schema");
        assert!(client.client.wire_encryption);
    }

    // Minimal temp-file helper to avoid an extra dev-dependency.
    mod tempfileless {
        use std::path::{Path, PathBuf};
        pub struct Temp(pub PathBuf);
        impl Temp {
            pub fn new(contents: &str) -> Self {
                let mut p = std::env::temp_dir();
                let unique = format!(
                    "signedpulse-test-{}-{}.toml",
                    std::process::id(),
                    std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)
                        .unwrap()
                        .as_nanos()
                );
                p.push(unique);
                std::fs::write(&p, contents).unwrap();
                Temp(p)
            }
            pub fn path(&self) -> &Path {
                &self.0
            }
        }
        impl Drop for Temp {
            fn drop(&mut self) {
                let _ = std::fs::remove_file(&self.0);
            }
        }
    }

    const HEX_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    const HEX_ID2: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

    #[test]
    fn server_config_parses_with_defaults() {
        let toml = format!(
            r#"
            [server]
            bind = "0.0.0.0:7370"
            server_id = "signedpulse-main"
            wire_encryption = "off"

            [command]
            argv = ["/bin/echo", "{{ip}}", "{{client_id}}"]

            [[clients]]
            client_id = "{HEX_ID}"
            public_key = "AAAA"
            label = "laptop"
        "#
        );
        let _ = std::io::stdout().flush();
        let t = write_temp(&toml);
        let cfg = ServerConfig::load(t.path()).unwrap();
        assert_eq!(cfg.server.nonce_ttl_seconds, 30);
        assert_eq!(cfg.server.hello_max_skew_seconds, 30);
        assert_eq!(cfg.server.max_faulty_packets, 10);
        assert_eq!(cfg.command.max_concurrent, 4);
        assert!(!cfg.command.allow_shell);
        assert_eq!(cfg.clients.len(), 1);
    }

    #[test]
    fn server_config_requires_encryption_key_when_required() {
        // wire_encryption defaults to "required", so the key is mandatory.
        let toml = format!(
            r#"
            [server]
            bind = "0.0.0.0:7370"
            server_id = "s"
            [command]
            argv = ["/bin/echo"]
            [[clients]]
            client_id = "{HEX_ID}"
            public_key = "AAAA"
        "#
        );
        let t = write_temp(&toml);
        assert!(matches!(
            ServerConfig::load(t.path()),
            Err(ConfigError::Invalid(_))
        ));
    }

    #[test]
    fn server_config_rejects_non_hex_client_id() {
        let toml = r#"
            [server]
            bind = "0.0.0.0:7370"
            server_id = "s"
            wire_encryption = "off"
            [command]
            argv = ["/bin/echo"]
            [[clients]]
            client_id = "not-hex"
            public_key = "AAAA"
        "#;
        let t = write_temp(toml);
        assert!(matches!(
            ServerConfig::load(t.path()),
            Err(ConfigError::Invalid(_))
        ));
    }

    #[test]
    fn server_config_rejects_bad_bounds() {
        // max_packet_size out of range, and a zero command timeout, are rejected.
        for line in [
            "max_packet_size = 70000",
            "command_timeout_seconds = 0",
            "max_param_len = 100000",
        ] {
            let toml = format!(
                r#"
                [server]
                bind = "0.0.0.0:7370"
                server_id = "s"
                wire_encryption = "off"
                {line}
                [command]
                argv = ["/bin/echo"]
            "#
            );
            let t = write_temp(&toml);
            assert!(
                matches!(ServerConfig::load(t.path()), Err(ConfigError::Invalid(_))),
                "expected {line:?} to be rejected"
            );
        }
    }

    #[test]
    fn server_config_rejects_duplicate_clients() {
        let toml = format!(
            r#"
            [server]
            bind = "0.0.0.0:7370"
            server_id = "s"
            wire_encryption = "off"
            [command]
            argv = ["/bin/echo"]
            [[clients]]
            client_id = "{HEX_ID}"
            public_key = "AAAA"
            [[clients]]
            client_id = "{HEX_ID}"
            public_key = "BBBB"
        "#
        );
        let t = write_temp(&toml);
        assert!(matches!(
            ServerConfig::load(t.path()),
            Err(ConfigError::Invalid(_))
        ));
    }

    #[test]
    fn client_config_parses() {
        let toml = format!(
            r#"
            [client]
            client_id = "{HEX_ID2}"
            server_addr = "127.0.0.1:7370"
            server_id = "signedpulse-main"
            private_key = "AAAA"
            wire_encryption = false
        "#
        );
        let t = write_temp(&toml);
        let cfg = ClientConfig::load(t.path()).unwrap();
        assert_eq!(cfg.client.interval_seconds, 300);
        assert_eq!(cfg.client.retries, 3);
        assert!(!cfg.client.wire_encryption);
    }

    #[test]
    fn client_config_requires_server_key_when_encrypted() {
        let toml = format!(
            r#"
            [client]
            client_id = "{HEX_ID2}"
            server_addr = "127.0.0.1:7370"
            server_id = "signedpulse-main"
            private_key = "AAAA"
        "#
        );
        let t = write_temp(&toml);
        // wire_encryption defaults true → server_encryption_key required.
        assert!(matches!(
            ClientConfig::load(t.path()),
            Err(ConfigError::Invalid(_))
        ));
    }

    #[test]
    fn client_config_parses_multiple_servers() {
        let toml = format!(
            r#"
            [client]
            client_id = "{HEX_ID2}"
            server_addr = "127.0.0.1:7370"
            server_id = "sp-A"
            private_key = "AAAA"
            wire_encryption = false

            [client.servers.sp-B]
            server_addr = "10.0.0.2:7370"
            wire_encryption = false

            [client.servers.sp-C]
            server_addr = "10.0.0.3:9999"
            interval_seconds = 60
            wire_encryption = false
        "#
        );
        let t = write_temp(&toml);
        let cfg = ClientConfig::load(t.path()).unwrap();
        assert_eq!(cfg.client.servers.len(), 2);
        assert_eq!(cfg.client.servers["sp-B"].server_addr, "10.0.0.2:7370");
        assert_eq!(cfg.client.servers["sp-C"].interval_seconds, Some(60));
    }

    #[test]
    fn client_config_allows_shared_wire_server_id_via_distinct_labels() {
        // Two servers that both use server_id "sp-A": the primary, and a labeled
        // secondary that sets server_id explicitly. Distinct labels keep them
        // unambiguous even though the wire server_id is the same.
        let toml = format!(
            r#"
            [client]
            client_id = "{HEX_ID2}"
            server_addr = "127.0.0.1:7370"
            server_id = "sp-A"
            private_key = "AAAA"
            wire_encryption = false

            [client.servers.velizy1]
            server_addr = "10.0.0.2:7370"
            server_id = "sp-A"
            wire_encryption = false
        "#
        );
        let t = write_temp(&toml);
        let cfg = ClientConfig::load(t.path()).unwrap();
        assert_eq!(
            cfg.client.servers["velizy1"].server_id.as_deref(),
            Some("sp-A")
        );
    }

    #[test]
    fn client_config_rejects_secondary_server_id_colliding_with_primary() {
        let toml = format!(
            r#"
            [client]
            client_id = "{HEX_ID2}"
            server_addr = "127.0.0.1:7370"
            server_id = "sp-A"
            private_key = "AAAA"
            wire_encryption = false

            [client.servers.sp-A]
            server_addr = "10.0.0.2:7370"
            wire_encryption = false
        "#
        );
        let t = write_temp(&toml);
        assert!(matches!(
            ClientConfig::load(t.path()),
            Err(ConfigError::Invalid(_))
        ));
    }

    #[test]
    fn client_config_secondary_server_requires_its_own_key_when_encrypted() {
        // Primary is cleartext, but the secondary defaults wire_encryption=true
        // (inherited) and provides no key of its own → rejected.
        let toml = format!(
            r#"
            [client]
            client_id = "{HEX_ID2}"
            server_addr = "127.0.0.1:7370"
            server_id = "sp-A"
            private_key = "AAAA"
            wire_encryption = false

            [client.servers.sp-B]
            server_addr = "10.0.0.2:7370"
            wire_encryption = true
        "#
        );
        let t = write_temp(&toml);
        assert!(matches!(
            ClientConfig::load(t.path()),
            Err(ConfigError::Invalid(_))
        ));
    }
}
