//! Node configuration subsections.
//!
//! All the `node.*` configuration parameters: resource limits, rate limiting,
//! retry/backoff, cache sizing, discovery, spanning tree, bloom filters,
//! session management, and internal buffers.

use serde::{Deserialize, Serialize};

use super::IdentityConfig;
use crate::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpConfig, MmpMode};

// ============================================================================
// Node Configuration Subsections
// ============================================================================

/// Resource limits (`node.limits.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LimitsConfig {
    /// Max handshake-phase connections (`node.limits.max_connections`).
    #[serde(default = "LimitsConfig::default_max_connections")]
    pub max_connections: usize,
    /// Max authenticated peers (`node.limits.max_peers`).
    #[serde(default = "LimitsConfig::default_max_peers")]
    pub max_peers: usize,
    /// Max active links (`node.limits.max_links`).
    #[serde(default = "LimitsConfig::default_max_links")]
    pub max_links: usize,
    /// Max pending inbound handshakes (`node.limits.max_pending_inbound`).
    #[serde(default = "LimitsConfig::default_max_pending_inbound")]
    pub max_pending_inbound: usize,
}

impl Default for LimitsConfig {
    fn default() -> Self {
        Self {
            max_connections: 256,
            max_peers: 128,
            max_links: 256,
            max_pending_inbound: 1000,
        }
    }
}

impl LimitsConfig {
    fn default_max_connections() -> usize {
        256
    }
    fn default_max_peers() -> usize {
        128
    }
    fn default_max_links() -> usize {
        256
    }
    fn default_max_pending_inbound() -> usize {
        1000
    }
}

/// Rate limiting (`node.rate_limit.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
    /// Token bucket burst capacity (`node.rate_limit.handshake_burst`).
    #[serde(default = "RateLimitConfig::default_handshake_burst")]
    pub handshake_burst: u32,
    /// Tokens/sec refill rate (`node.rate_limit.handshake_rate`).
    #[serde(default = "RateLimitConfig::default_handshake_rate")]
    pub handshake_rate: f64,
    /// Stale handshake cleanup timeout in seconds (`node.rate_limit.handshake_timeout_secs`).
    #[serde(default = "RateLimitConfig::default_handshake_timeout_secs")]
    pub handshake_timeout_secs: u64,
    /// Initial handshake resend interval in ms (`node.rate_limit.handshake_resend_interval_ms`).
    /// Handshake messages are resent with exponential backoff within the timeout window.
    #[serde(default = "RateLimitConfig::default_handshake_resend_interval_ms")]
    pub handshake_resend_interval_ms: u64,
    /// Handshake resend backoff multiplier (`node.rate_limit.handshake_resend_backoff`).
    #[serde(default = "RateLimitConfig::default_handshake_resend_backoff")]
    pub handshake_resend_backoff: f64,
    /// Max handshake resends per attempt (`node.rate_limit.handshake_max_resends`).
    #[serde(default = "RateLimitConfig::default_handshake_max_resends")]
    pub handshake_max_resends: u32,
}

impl Default for RateLimitConfig {
    fn default() -> Self {
        Self {
            handshake_burst: 100,
            handshake_rate: 10.0,
            handshake_timeout_secs: 30,
            handshake_resend_interval_ms: 1000,
            handshake_resend_backoff: 2.0,
            handshake_max_resends: 5,
        }
    }
}

impl RateLimitConfig {
    fn default_handshake_burst() -> u32 {
        100
    }
    fn default_handshake_rate() -> f64 {
        10.0
    }
    fn default_handshake_timeout_secs() -> u64 {
        30
    }
    fn default_handshake_resend_interval_ms() -> u64 {
        1000
    }
    fn default_handshake_resend_backoff() -> f64 {
        2.0
    }
    fn default_handshake_max_resends() -> u32 {
        5
    }
}

/// Retry/backoff configuration (`node.retry.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryConfig {
    /// Max connection retry attempts (`node.retry.max_retries`).
    #[serde(default = "RetryConfig::default_max_retries")]
    pub max_retries: u32,
    /// Base backoff interval in seconds (`node.retry.base_interval_secs`).
    #[serde(default = "RetryConfig::default_base_interval_secs")]
    pub base_interval_secs: u64,
    /// Cap on exponential backoff in seconds (`node.retry.max_backoff_secs`).
    #[serde(default = "RetryConfig::default_max_backoff_secs")]
    pub max_backoff_secs: u64,
}

impl Default for RetryConfig {
    fn default() -> Self {
        Self {
            max_retries: 5,
            base_interval_secs: 5,
            max_backoff_secs: 300,
        }
    }
}

impl RetryConfig {
    fn default_max_retries() -> u32 {
        5
    }
    fn default_base_interval_secs() -> u64 {
        5
    }
    fn default_max_backoff_secs() -> u64 {
        300
    }
}

/// Cache parameters (`node.cache.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
    /// Max entries in coord cache (`node.cache.coord_size`).
    #[serde(default = "CacheConfig::default_coord_size")]
    pub coord_size: usize,
    /// Coord cache entry TTL in seconds (`node.cache.coord_ttl_secs`).
    #[serde(default = "CacheConfig::default_coord_ttl_secs")]
    pub coord_ttl_secs: u64,
    /// Max entries in identity cache (`node.cache.identity_size`).
    #[serde(default = "CacheConfig::default_identity_size")]
    pub identity_size: usize,
}

impl Default for CacheConfig {
    fn default() -> Self {
        Self {
            coord_size: 50_000,
            coord_ttl_secs: 300,
            identity_size: 10_000,
        }
    }
}

impl CacheConfig {
    fn default_coord_size() -> usize {
        50_000
    }
    fn default_coord_ttl_secs() -> u64 {
        300
    }
    fn default_identity_size() -> usize {
        10_000
    }
}

/// Discovery protocol (`node.discovery.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryConfig {
    /// Hop limit for LookupRequest flood (`node.discovery.ttl`).
    #[serde(default = "DiscoveryConfig::default_ttl")]
    pub ttl: u8,
    /// Per-attempt timeouts in seconds (`node.discovery.attempt_timeouts_secs`).
    /// Each entry is the time to wait for a response before sending the next
    /// LookupRequest (with a fresh request_id). Sequence length determines the
    /// total number of attempts before declaring the destination unreachable.
    /// Default `[1, 2, 4, 8]` gives 4 attempts and a 15s total budget.
    #[serde(default = "DiscoveryConfig::default_attempt_timeouts_secs")]
    pub attempt_timeouts_secs: Vec<u64>,
    /// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`).
    #[serde(default = "DiscoveryConfig::default_recent_expiry_secs")]
    pub recent_expiry_secs: u64,
    /// Base backoff after lookup failure in seconds (`node.discovery.backoff_base_secs`).
    /// Doubles per consecutive failure up to `backoff_max_secs`. Defaults to 0
    /// (no post-failure suppression); the per-attempt sequence in
    /// `attempt_timeouts_secs` provides the only retry pacing.
    #[serde(default = "DiscoveryConfig::default_backoff_base_secs")]
    pub backoff_base_secs: u64,
    /// Maximum backoff cap in seconds (`node.discovery.backoff_max_secs`).
    #[serde(default = "DiscoveryConfig::default_backoff_max_secs")]
    pub backoff_max_secs: u64,
    /// Minimum interval between forwarded lookups for the same target in seconds
    /// (`node.discovery.forward_min_interval_secs`).
    /// Defense-in-depth against misbehaving nodes.
    #[serde(default = "DiscoveryConfig::default_forward_min_interval_secs")]
    pub forward_min_interval_secs: u64,
    /// Nostr-mediated overlay endpoint discovery.
    #[serde(default = "DiscoveryConfig::default_nostr")]
    pub nostr: NostrDiscoveryConfig,
    /// mDNS / DNS-SD peer discovery on the local link. Identity surface
    /// is a strict subset of what `nostr.advertise` already publishes
    /// publicly, so there's no marginal privacy cost; the latency win
    /// for same-LAN peers is large (sub-second pairing, no relay).
    #[serde(default = "DiscoveryConfig::default_lan")]
    pub lan: crate::discovery::lan::LanDiscoveryConfig,
}

impl Default for DiscoveryConfig {
    fn default() -> Self {
        Self {
            ttl: 64,
            attempt_timeouts_secs: vec![1, 2, 4, 8],
            recent_expiry_secs: 10,
            backoff_base_secs: 0,
            backoff_max_secs: 0,
            forward_min_interval_secs: 2,
            nostr: NostrDiscoveryConfig::default(),
            lan: crate::discovery::lan::LanDiscoveryConfig::default(),
        }
    }
}

impl DiscoveryConfig {
    fn default_ttl() -> u8 {
        64
    }
    fn default_attempt_timeouts_secs() -> Vec<u64> {
        vec![1, 2, 4, 8]
    }
    fn default_recent_expiry_secs() -> u64 {
        10
    }
    fn default_backoff_base_secs() -> u64 {
        0
    }
    fn default_backoff_max_secs() -> u64 {
        0
    }
    fn default_forward_min_interval_secs() -> u64 {
        2
    }
    fn default_nostr() -> NostrDiscoveryConfig {
        NostrDiscoveryConfig::default()
    }
    fn default_lan() -> crate::discovery::lan::LanDiscoveryConfig {
        crate::discovery::lan::LanDiscoveryConfig::default()
    }
}

/// Nostr advert discovery policy.
///
/// Controls how overlay endpoint adverts are consumed:
/// - `disabled`: ignore advert-derived endpoints for all peers
/// - `configured_only`: allow advert fallback for configured peers
/// - `open`: also consider adverts for non-configured peers
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NostrDiscoveryPolicy {
    Disabled,
    #[default]
    ConfiguredOnly,
    Open,
}

/// Nostr-mediated overlay endpoint discovery (`node.discovery.nostr.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NostrDiscoveryConfig {
    /// Enable Nostr-signaled traversal bootstrap.
    #[serde(default)]
    pub enabled: bool,
    /// Publish service advertisements so remote peers can bootstrap inbound.
    #[serde(default = "NostrDiscoveryConfig::default_advertise")]
    pub advertise: bool,
    /// Relay URLs used for service advertisements.
    #[serde(default = "NostrDiscoveryConfig::default_advert_relays")]
    pub advert_relays: Vec<String>,
    /// Relay URLs used for encrypted signaling events.
    #[serde(default = "NostrDiscoveryConfig::default_dm_relays")]
    pub dm_relays: Vec<String>,
    /// STUN servers used for local reflexive address discovery.
    /// Outbound observation uses only this local list; peer-advertised STUN
    /// values are informational and are not treated as egress targets.
    #[serde(default = "NostrDiscoveryConfig::default_stun_servers")]
    pub stun_servers: Vec<String>,
    /// Whether to advertise local (RFC 1918 / ULA) interface addresses as
    /// host candidates in the traversal offer.
    ///
    /// Off by default: in most deployments the relevant peers are not on the
    /// same broadcast domain, and sharing private host candidates causes
    /// misleading punch successes when an asymmetric L3 path (corporate VPN,
    /// Tailscale subnet route, overlapping address space, etc.) makes a
    /// peer's private IP one-way reachable from this node. Enable only when
    /// peers are on the same physical LAN and same-LAN punching is wanted.
    #[serde(default)]
    pub share_local_candidates: bool,
    /// Traversal application namespace and advert identifier suffix.
    #[serde(default = "NostrDiscoveryConfig::default_app")]
    pub app: String,
    /// Signaling TTL in seconds.
    #[serde(default = "NostrDiscoveryConfig::default_signal_ttl_secs")]
    pub signal_ttl_secs: u64,
    /// Policy for advert-derived endpoint discovery.
    #[serde(default)]
    pub policy: NostrDiscoveryPolicy,
    /// Max number of open-discovery peers queued for outbound retry/connection
    /// at once. Prevents unbounded queue growth from ambient advert traffic.
    #[serde(default = "NostrDiscoveryConfig::default_open_discovery_max_pending")]
    pub open_discovery_max_pending: usize,
    /// Max concurrent inbound traversal offers processed at once.
    /// Acts as a rate limit against offer spam from relays.
    #[serde(default = "NostrDiscoveryConfig::default_max_concurrent_incoming_offers")]
    pub max_concurrent_incoming_offers: usize,
    /// Max cached overlay adverts retained from relay traffic.
    /// Bounds memory under ambient advert volume.
    #[serde(default = "NostrDiscoveryConfig::default_advert_cache_max_entries")]
    pub advert_cache_max_entries: usize,
    /// Max seen-session IDs retained for replay detection.
    /// Oldest entries are evicted when the cap is exceeded.
    #[serde(default = "NostrDiscoveryConfig::default_seen_sessions_max_entries")]
    pub seen_sessions_max_entries: usize,
    /// Overall punch attempt timeout in seconds.
    #[serde(default = "NostrDiscoveryConfig::default_attempt_timeout_secs")]
    pub attempt_timeout_secs: u64,
    /// Replay tracking retention window in seconds.
    #[serde(default = "NostrDiscoveryConfig::default_replay_window_secs")]
    pub replay_window_secs: u64,
    /// Delay before punch traffic starts.
    #[serde(default = "NostrDiscoveryConfig::default_punch_start_delay_ms")]
    pub punch_start_delay_ms: u64,
    /// Interval between punch packets.
    #[serde(default = "NostrDiscoveryConfig::default_punch_interval_ms")]
    pub punch_interval_ms: u64,
    /// How long to keep punching before failure.
    #[serde(default = "NostrDiscoveryConfig::default_punch_duration_ms")]
    pub punch_duration_ms: u64,
    /// Advert TTL in seconds.
    #[serde(default = "NostrDiscoveryConfig::default_advert_ttl_secs")]
    pub advert_ttl_secs: u64,
    /// How often adverts are refreshed in seconds.
    #[serde(default = "NostrDiscoveryConfig::default_advert_refresh_secs")]
    pub advert_refresh_secs: u64,
    /// Settle delay in seconds after Nostr discovery starts before the
    /// one-shot startup sweep of cached adverts runs. Allows the relay
    /// subscription backlog to populate the in-memory advert cache.
    /// Only used under `policy: open`. Default: 5.
    #[serde(default = "NostrDiscoveryConfig::default_startup_sweep_delay_secs")]
    pub startup_sweep_delay_secs: u64,
    /// Maximum age in seconds for cached adverts considered by the
    /// one-shot startup sweep. Adverts whose `created_at` is older than
    /// `now - startup_sweep_max_age_secs` are skipped. Only used under
    /// `policy: open`. Default: 3600 (1 hour).
    #[serde(default = "NostrDiscoveryConfig::default_startup_sweep_max_age_secs")]
    pub startup_sweep_max_age_secs: u64,
    /// Number of consecutive NAT-traversal failures against a peer before
    /// an extended cooldown is applied to throttle further offer publishes.
    /// At this threshold the daemon also actively re-fetches the peer's
    /// advert from `advert_relays` to evict cache entries for peers that
    /// have gone away. Default: 5.
    #[serde(default = "NostrDiscoveryConfig::default_failure_streak_threshold")]
    pub failure_streak_threshold: u32,
    /// Cooldown applied to a peer once `failure_streak_threshold` is hit.
    /// Suppresses both open-discovery sweep enqueues and per-attempt
    /// retry firings until elapsed. Default: 1800 (30 minutes).
    #[serde(default = "NostrDiscoveryConfig::default_extended_cooldown_secs")]
    pub extended_cooldown_secs: u64,
    /// Minimum interval between `NAT traversal failed` WARN log lines for
    /// the same peer. Subsequent failures inside the window log at DEBUG.
    /// Reduces log spam on public-test nodes with many cache-learned
    /// peers. Default: 300 (5 minutes).
    #[serde(default = "NostrDiscoveryConfig::default_warn_log_interval_secs")]
    pub warn_log_interval_secs: u64,
    /// Maximum entries retained in the per-npub failure-state map.
    /// Bounds memory under high cache turnover. Oldest entries (by last
    /// failure time) evicted when the cap is exceeded. Default: 4096.
    #[serde(default = "NostrDiscoveryConfig::default_failure_state_max_entries")]
    pub failure_state_max_entries: usize,
    /// Cooldown applied after observing a fatal protocol mismatch on a
    /// Nostr-adopted bootstrap transport (e.g. `Unknown FMP version`
    /// from a peer running a different FMP-protocol version). Independent
    /// of `extended_cooldown_secs` and much longer because the mismatch
    /// is structural — re-traversing the peer is wasted effort until one
    /// side upgrades. Default: 86400 (24 hours).
    #[serde(default = "NostrDiscoveryConfig::default_protocol_mismatch_cooldown_secs")]
    pub protocol_mismatch_cooldown_secs: u64,
}

impl Default for NostrDiscoveryConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            advertise: Self::default_advertise(),
            advert_relays: Self::default_advert_relays(),
            dm_relays: Self::default_dm_relays(),
            stun_servers: Self::default_stun_servers(),
            share_local_candidates: false,
            app: Self::default_app(),
            signal_ttl_secs: Self::default_signal_ttl_secs(),
            policy: NostrDiscoveryPolicy::default(),
            open_discovery_max_pending: Self::default_open_discovery_max_pending(),
            max_concurrent_incoming_offers: Self::default_max_concurrent_incoming_offers(),
            advert_cache_max_entries: Self::default_advert_cache_max_entries(),
            seen_sessions_max_entries: Self::default_seen_sessions_max_entries(),
            attempt_timeout_secs: Self::default_attempt_timeout_secs(),
            replay_window_secs: Self::default_replay_window_secs(),
            punch_start_delay_ms: Self::default_punch_start_delay_ms(),
            punch_interval_ms: Self::default_punch_interval_ms(),
            punch_duration_ms: Self::default_punch_duration_ms(),
            advert_ttl_secs: Self::default_advert_ttl_secs(),
            advert_refresh_secs: Self::default_advert_refresh_secs(),
            startup_sweep_delay_secs: Self::default_startup_sweep_delay_secs(),
            startup_sweep_max_age_secs: Self::default_startup_sweep_max_age_secs(),
            failure_streak_threshold: Self::default_failure_streak_threshold(),
            extended_cooldown_secs: Self::default_extended_cooldown_secs(),
            warn_log_interval_secs: Self::default_warn_log_interval_secs(),
            failure_state_max_entries: Self::default_failure_state_max_entries(),
            protocol_mismatch_cooldown_secs: Self::default_protocol_mismatch_cooldown_secs(),
        }
    }
}

impl NostrDiscoveryConfig {
    fn default_advertise() -> bool {
        true
    }

    fn default_advert_relays() -> Vec<String> {
        vec![
            "wss://relay.damus.io".to_string(),
            "wss://nos.lol".to_string(),
            "wss://offchain.pub".to_string(),
        ]
    }

    fn default_dm_relays() -> Vec<String> {
        vec![
            "wss://relay.damus.io".to_string(),
            "wss://nos.lol".to_string(),
            "wss://offchain.pub".to_string(),
        ]
    }

    fn default_stun_servers() -> Vec<String> {
        vec![
            "stun:stun.l.google.com:19302".to_string(),
            "stun:stun.cloudflare.com:3478".to_string(),
            "stun:global.stun.twilio.com:3478".to_string(),
        ]
    }

    fn default_app() -> String {
        "fips-overlay-v1".to_string()
    }

    fn default_signal_ttl_secs() -> u64 {
        120
    }

    fn default_open_discovery_max_pending() -> usize {
        64
    }

    fn default_max_concurrent_incoming_offers() -> usize {
        16
    }

    fn default_advert_cache_max_entries() -> usize {
        2048
    }

    fn default_seen_sessions_max_entries() -> usize {
        2048
    }

    fn default_attempt_timeout_secs() -> u64 {
        10
    }

    fn default_replay_window_secs() -> u64 {
        300
    }

    fn default_punch_start_delay_ms() -> u64 {
        2_000
    }

    fn default_punch_interval_ms() -> u64 {
        200
    }

    fn default_punch_duration_ms() -> u64 {
        10_000
    }

    fn default_advert_ttl_secs() -> u64 {
        3_600
    }

    fn default_advert_refresh_secs() -> u64 {
        1_800
    }

    fn default_startup_sweep_delay_secs() -> u64 {
        5
    }

    fn default_startup_sweep_max_age_secs() -> u64 {
        3_600
    }

    fn default_failure_streak_threshold() -> u32 {
        5
    }

    fn default_extended_cooldown_secs() -> u64 {
        1_800
    }

    fn default_warn_log_interval_secs() -> u64 {
        300
    }

    fn default_failure_state_max_entries() -> usize {
        4_096
    }

    fn default_protocol_mismatch_cooldown_secs() -> u64 {
        86_400
    }
}

/// Spanning tree (`node.tree.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeConfig {
    /// Per-peer TreeAnnounce rate limit in ms (`node.tree.announce_min_interval_ms`).
    #[serde(default = "TreeConfig::default_announce_min_interval_ms")]
    pub announce_min_interval_ms: u64,
    /// Hysteresis factor for cost-based parent re-selection (`node.tree.parent_hysteresis`).
    ///
    /// Only switch parents when the candidate's effective_depth is better than
    /// `current_effective_depth * (1.0 - parent_hysteresis)`. Range: 0.0-1.0.
    /// Set to 0.0 to disable hysteresis (switch on any improvement).
    #[serde(default = "TreeConfig::default_parent_hysteresis")]
    pub parent_hysteresis: f64,
    /// Hold-down period after parent switch in seconds (`node.tree.hold_down_secs`).
    ///
    /// After switching parents, suppress re-evaluation for this duration to allow
    /// MMP metrics to stabilize on the new link. Set to 0 to disable.
    #[serde(default = "TreeConfig::default_hold_down_secs")]
    pub hold_down_secs: u64,
    /// Periodic parent re-evaluation interval in seconds (`node.tree.reeval_interval_secs`).
    ///
    /// How often to re-evaluate parent selection based on current MMP link costs,
    /// independent of TreeAnnounce traffic. Catches link degradation after the
    /// tree has stabilized. Set to 0 to disable.
    #[serde(default = "TreeConfig::default_reeval_interval_secs")]
    pub reeval_interval_secs: u64,
    /// Flap dampening: max parent switches before extended hold-down (`node.tree.flap_threshold`).
    #[serde(default = "TreeConfig::default_flap_threshold")]
    pub flap_threshold: u32,
    /// Flap dampening: window in seconds for counting switches (`node.tree.flap_window_secs`).
    #[serde(default = "TreeConfig::default_flap_window_secs")]
    pub flap_window_secs: u64,
    /// Flap dampening: extended hold-down duration in seconds (`node.tree.flap_dampening_secs`).
    #[serde(default = "TreeConfig::default_flap_dampening_secs")]
    pub flap_dampening_secs: u64,
}

impl Default for TreeConfig {
    fn default() -> Self {
        Self {
            announce_min_interval_ms: 500,
            parent_hysteresis: 0.2,
            hold_down_secs: 30,
            reeval_interval_secs: 60,
            flap_threshold: 4,
            flap_window_secs: 60,
            flap_dampening_secs: 120,
        }
    }
}

impl TreeConfig {
    fn default_announce_min_interval_ms() -> u64 {
        500
    }
    fn default_parent_hysteresis() -> f64 {
        0.2
    }
    fn default_hold_down_secs() -> u64 {
        30
    }
    fn default_reeval_interval_secs() -> u64 {
        60
    }
    fn default_flap_threshold() -> u32 {
        4
    }
    fn default_flap_window_secs() -> u64 {
        60
    }
    fn default_flap_dampening_secs() -> u64 {
        120
    }
}

/// Routing strategy selection (`node.routing.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutingConfig {
    /// Next-hop selection mode (`node.routing.mode`).
    #[serde(default)]
    pub mode: RoutingMode,
    /// TTL for learned reverse-path routes in seconds (`node.routing.learned_ttl_secs`).
    #[serde(default = "RoutingConfig::default_learned_ttl_secs")]
    pub learned_ttl_secs: u64,
    /// Maximum locally observed next-hop candidates kept per destination for
    /// reply-learned multipath/exploration
    /// (`node.routing.max_learned_routes_per_dest`).
    #[serde(default = "RoutingConfig::default_max_learned_routes_per_dest")]
    pub max_learned_routes_per_dest: usize,
    /// Every N learned-route selections, try the coordinate/bloom/tree route
    /// instead so new paths can be discovered (`0` disables fallback exploration).
    #[serde(default = "RoutingConfig::default_learned_fallback_explore_interval")]
    pub learned_fallback_explore_interval: u64,
}

impl Default for RoutingConfig {
    fn default() -> Self {
        Self {
            mode: RoutingMode::default(),
            learned_ttl_secs: 300,
            max_learned_routes_per_dest: 4,
            learned_fallback_explore_interval: 16,
        }
    }
}

impl RoutingConfig {
    fn default_learned_ttl_secs() -> u64 {
        300
    }

    fn default_max_learned_routes_per_dest() -> usize {
        4
    }

    fn default_learned_fallback_explore_interval() -> u64 {
        16
    }
}

/// Daemon routing mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RoutingMode {
    /// Current FIPS behavior: bloom-assisted greedy tree routing.
    #[default]
    Tree,
    /// Prefer locally learned reverse paths before falling back to tree routing.
    ///
    /// Learned routes are populated only from local evidence: inbound
    /// SessionDatagrams and verified LookupResponses.
    ReplyLearned,
}

impl std::fmt::Display for RoutingMode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RoutingMode::Tree => write!(f, "tree"),
            RoutingMode::ReplyLearned => write!(f, "reply_learned"),
        }
    }
}

/// Bloom filter (`node.bloom.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BloomConfig {
    /// Debounce interval for filter updates in ms (`node.bloom.update_debounce_ms`).
    #[serde(default = "BloomConfig::default_update_debounce_ms")]
    pub update_debounce_ms: u64,
    /// Antipoison cap: reject inbound FilterAnnounce whose FPR exceeds
    /// this value (`node.bloom.max_inbound_fpr`). Valid range `(0.0, 1.0)`.
    /// Default `0.05` ≈ fill 0.549 at k=5 ≈ ~3,200 entries on the 1KB
    /// filter. Conceptually distinct from future autoscaling hysteresis
    /// setpoints — same unit, different knobs.
    #[serde(default = "BloomConfig::default_max_inbound_fpr")]
    pub max_inbound_fpr: f64,
}

impl Default for BloomConfig {
    fn default() -> Self {
        Self {
            update_debounce_ms: 500,
            max_inbound_fpr: 0.05,
        }
    }
}

impl BloomConfig {
    fn default_update_debounce_ms() -> u64 {
        500
    }
    fn default_max_inbound_fpr() -> f64 {
        0.05
    }
}

/// Session/data plane (`node.session.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfig {
    /// Default SessionDatagram TTL (`node.session.default_ttl`).
    #[serde(default = "SessionConfig::default_ttl")]
    pub default_ttl: u8,
    /// Queue depth per dest during session establishment (`node.session.pending_packets_per_dest`).
    #[serde(default = "SessionConfig::default_pending_packets_per_dest")]
    pub pending_packets_per_dest: usize,
    /// Max destinations with pending packets (`node.session.pending_max_destinations`).
    #[serde(default = "SessionConfig::default_pending_max_destinations")]
    pub pending_max_destinations: usize,
    /// Idle session timeout in seconds (`node.session.idle_timeout_secs`).
    /// Established sessions with no application data for this duration are
    /// removed. MMP reports do not count as activity for this timer.
    #[serde(default = "SessionConfig::default_idle_timeout_secs")]
    pub idle_timeout_secs: u64,
    /// Number of initial data packets per session that include COORDS_PRESENT
    /// for transit cache warmup (`node.session.coords_warmup_packets`).
    /// Also used as the reset count on CoordsRequired receipt.
    #[serde(default = "SessionConfig::default_coords_warmup_packets")]
    pub coords_warmup_packets: u8,
    /// Minimum interval (ms) between standalone CoordsWarmup responses to
    /// CoordsRequired/PathBroken signals, per destination
    /// (`node.session.coords_response_interval_ms`).
    #[serde(default = "SessionConfig::default_coords_response_interval_ms")]
    pub coords_response_interval_ms: u64,
}

impl Default for SessionConfig {
    fn default() -> Self {
        Self {
            default_ttl: 64,
            pending_packets_per_dest: 16,
            pending_max_destinations: 256,
            idle_timeout_secs: 90,
            coords_warmup_packets: 5,
            coords_response_interval_ms: 2000,
        }
    }
}

impl SessionConfig {
    fn default_ttl() -> u8 {
        64
    }
    fn default_pending_packets_per_dest() -> usize {
        16
    }
    fn default_pending_max_destinations() -> usize {
        256
    }
    fn default_idle_timeout_secs() -> u64 {
        90
    }
    fn default_coords_warmup_packets() -> u8 {
        5
    }
    fn default_coords_response_interval_ms() -> u64 {
        2000
    }
}

/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
///
/// Separate from link-layer `node.mmp.*` to allow independent mode/interval
/// configuration per layer. Session reports consume bandwidth on every transit
/// link, so operators may want a lighter mode (e.g., Lightweight) for sessions
/// while running Full mode on links.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMmpConfig {
    /// Operating mode (`node.session_mmp.mode`).
    #[serde(default)]
    pub mode: MmpMode,

    /// Periodic operator log interval in seconds (`node.session_mmp.log_interval_secs`).
    #[serde(default = "SessionMmpConfig::default_log_interval_secs")]
    pub log_interval_secs: u64,

    /// OWD trend ring buffer size (`node.session_mmp.owd_window_size`).
    #[serde(default = "SessionMmpConfig::default_owd_window_size")]
    pub owd_window_size: usize,
}

impl Default for SessionMmpConfig {
    fn default() -> Self {
        Self {
            mode: MmpMode::default(),
            log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
            owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
        }
    }
}

impl SessionMmpConfig {
    fn default_log_interval_secs() -> u64 {
        DEFAULT_LOG_INTERVAL_SECS
    }
    fn default_owd_window_size() -> usize {
        DEFAULT_OWD_WINDOW_SIZE
    }
}

/// Control socket configuration (`node.control.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlConfig {
    /// Enable the control socket (`node.control.enabled`).
    #[serde(default = "ControlConfig::default_enabled")]
    pub enabled: bool,
    /// Unix socket path (`node.control.socket_path`).
    #[serde(default = "ControlConfig::default_socket_path")]
    pub socket_path: String,
}

impl Default for ControlConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            socket_path: Self::default_socket_path(),
        }
    }
}

impl ControlConfig {
    fn default_enabled() -> bool {
        true
    }

    /// Default control socket path.
    ///
    /// On Unix, returns the shared `/run/fips`, `XDG_RUNTIME_DIR`, then `/tmp`
    /// fallback used by fipsctl and fipstop. On Windows, returns a TCP port
    /// number as a string since Windows does not support Unix domain sockets;
    /// the control socket listens on localhost at this port.
    fn default_socket_path() -> String {
        #[cfg(unix)]
        {
            super::resolve_default_socket("control.sock")
        }
        #[cfg(windows)]
        {
            "21210".to_string()
        }
    }
}

/// Internal buffers (`node.buffers.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffersConfig {
    /// Transport→Node packet channel capacity (`node.buffers.packet_channel`).
    #[serde(default = "BuffersConfig::default_packet_channel")]
    pub packet_channel: usize,
    /// TUN→Node outbound channel capacity (`node.buffers.tun_channel`).
    #[serde(default = "BuffersConfig::default_tun_channel")]
    pub tun_channel: usize,
    /// DNS→Node identity channel capacity (`node.buffers.dns_channel`).
    #[serde(default = "BuffersConfig::default_dns_channel")]
    pub dns_channel: usize,
}

impl Default for BuffersConfig {
    fn default() -> Self {
        Self {
            packet_channel: 1024,
            tun_channel: 1024,
            dns_channel: 64,
        }
    }
}

impl BuffersConfig {
    fn default_packet_channel() -> usize {
        1024
    }
    fn default_tun_channel() -> usize {
        1024
    }
    fn default_dns_channel() -> usize {
        64
    }
}

// ============================================================================
// ECN Congestion Signaling
// ============================================================================

/// Rekey / session rekeying configuration (`node.rekey.*`).
///
/// Controls periodic full rekey for both FMP (link layer) and FSP
/// (session layer) Noise sessions. Rekeying provides true forward secrecy
/// with fresh DH randomness, nonce reset, and session index rotation.
///
/// Keep the packet-count default high for packet-tunnel workloads. A low value
/// such as 65k packets can force multi-hundred-Mbit tunnels to rekey every few
/// seconds, which creates avoidable cutover churn and can dominate throughput.
/// Operators can still lower `node.rekey.after_messages` for CI stress tests or
/// very conservative deployments; the time-based `after_secs` default remains
/// the normal production rekey cadence.
const DEFAULT_REKEY_AFTER_MESSAGES: u64 = 1 << 48;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RekeyConfig {
    /// Enable periodic rekey (`node.rekey.enabled`).
    #[serde(default = "RekeyConfig::default_enabled")]
    pub enabled: bool,

    /// Initiate rekey after this many seconds (`node.rekey.after_secs`).
    #[serde(default = "RekeyConfig::default_after_secs")]
    pub after_secs: u64,

    /// Initiate rekey after this many messages sent (`node.rekey.after_messages`).
    #[serde(default = "RekeyConfig::default_after_messages")]
    pub after_messages: u64,
}

impl Default for RekeyConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            after_secs: 120,
            after_messages: DEFAULT_REKEY_AFTER_MESSAGES,
        }
    }
}

impl RekeyConfig {
    fn default_enabled() -> bool {
        true
    }
    fn default_after_secs() -> u64 {
        120
    }
    fn default_after_messages() -> u64 {
        DEFAULT_REKEY_AFTER_MESSAGES
    }
}

/// ECN congestion signaling configuration (`node.ecn.*`).
///
/// Controls the FMP CE relay chain: transit nodes detect congestion on outgoing
/// links and set the CE flag in forwarded datagrams. The destination marks
/// IPv6 ECN-CE on ECN-capable packets before TUN delivery.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EcnConfig {
    /// Enable ECN congestion signaling (`node.ecn.enabled`).
    #[serde(default = "EcnConfig::default_enabled")]
    pub enabled: bool,

    /// Loss rate threshold for marking CE (`node.ecn.loss_threshold`).
    /// When the outgoing link's loss rate meets or exceeds this value,
    /// the transit node sets CE on forwarded datagrams.
    #[serde(default = "EcnConfig::default_loss_threshold")]
    pub loss_threshold: f64,

    /// ETX threshold for marking CE (`node.ecn.etx_threshold`).
    /// When the outgoing link's ETX meets or exceeds this value,
    /// the transit node sets CE on forwarded datagrams.
    #[serde(default = "EcnConfig::default_etx_threshold")]
    pub etx_threshold: f64,
}

impl Default for EcnConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            loss_threshold: 0.05,
            etx_threshold: 3.0,
        }
    }
}

impl EcnConfig {
    fn default_enabled() -> bool {
        true
    }
    fn default_loss_threshold() -> f64 {
        0.05
    }
    fn default_etx_threshold() -> f64 {
        3.0
    }
}

// ============================================================================
// Node Configuration (Root)
// ============================================================================

/// Node configuration (`node.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeConfig {
    /// Identity configuration (`node.identity.*`).
    #[serde(default)]
    pub identity: IdentityConfig,

    /// Leaf-only mode (`node.leaf_only`).
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub leaf_only: bool,

    /// RX loop maintenance tick period in seconds (`node.tick_interval_secs`).
    #[serde(default = "NodeConfig::default_tick_interval_secs")]
    pub tick_interval_secs: u64,

    /// Initial RTT estimate for new links in ms (`node.base_rtt_ms`).
    #[serde(default = "NodeConfig::default_base_rtt_ms")]
    pub base_rtt_ms: u64,

    /// Link heartbeat send interval in seconds (`node.heartbeat_interval_secs`).
    #[serde(default = "NodeConfig::default_heartbeat_interval_secs")]
    pub heartbeat_interval_secs: u64,

    /// Link dead timeout in seconds (`node.link_dead_timeout_secs`).
    /// Peers silent for this duration are removed.
    #[serde(default = "NodeConfig::default_link_dead_timeout_secs")]
    pub link_dead_timeout_secs: u64,

    /// Accelerated link dead timeout in seconds, used in place of
    /// `link_dead_timeout_secs` while a recent `transport.send` returned
    /// a local-side errno (`NetworkUnreachable` / `HostUnreachable` /
    /// `AddrNotAvailable`) — direct evidence our outbound path is broken
    /// right now (interface vanished, default route flapped, etc.). No
    /// reason to wait the full receive-silence window when the kernel
    /// already told us we can't send. Steady-state behavior is unchanged
    /// because the signal is cleared on the next successful send.
    /// (`node.fast_link_dead_timeout_secs`)
    #[serde(default = "NodeConfig::default_fast_link_dead_timeout_secs")]
    pub fast_link_dead_timeout_secs: u64,

    /// Resource limits (`node.limits.*`).
    #[serde(default)]
    pub limits: LimitsConfig,

    /// Rate limiting (`node.rate_limit.*`).
    #[serde(default)]
    pub rate_limit: RateLimitConfig,

    /// Retry/backoff (`node.retry.*`).
    #[serde(default)]
    pub retry: RetryConfig,

    /// Cache parameters (`node.cache.*`).
    #[serde(default)]
    pub cache: CacheConfig,

    /// Discovery protocol (`node.discovery.*`).
    #[serde(default)]
    pub discovery: DiscoveryConfig,

    /// Spanning tree (`node.tree.*`).
    #[serde(default)]
    pub tree: TreeConfig,

    /// Routing strategy (`node.routing.*`).
    #[serde(default)]
    pub routing: RoutingConfig,

    /// Bloom filter (`node.bloom.*`).
    #[serde(default)]
    pub bloom: BloomConfig,

    /// Session/data plane (`node.session.*`).
    #[serde(default)]
    pub session: SessionConfig,

    /// Internal buffers (`node.buffers.*`).
    #[serde(default)]
    pub buffers: BuffersConfig,

    /// Control socket (`node.control.*`).
    #[serde(default)]
    pub control: ControlConfig,

    /// Metrics Measurement Protocol — link layer (`node.mmp.*`).
    #[serde(default)]
    pub mmp: MmpConfig,

    /// Metrics Measurement Protocol — session layer (`node.session_mmp.*`).
    #[serde(default)]
    pub session_mmp: SessionMmpConfig,

    /// ECN congestion signaling (`node.ecn.*`).
    #[serde(default)]
    pub ecn: EcnConfig,

    /// Rekey / session rekeying (`node.rekey.*`).
    #[serde(default)]
    pub rekey: RekeyConfig,

    /// Enable daemon-oriented system files such as `/etc/fips/hosts` and
    /// `/etc/fips/peers.{allow,deny}`. Embedded endpoints disable this.
    #[serde(default = "NodeConfig::default_system_files_enabled")]
    pub system_files_enabled: bool,

    /// Enable off-task Unix encrypt/decrypt worker pools (`node.worker_pools_enabled`).
    /// Embedded/mobile endpoints can disable this to keep crypto/send work inline
    /// with the rx loop when platform extension sandboxes make OS-thread pools
    /// unsuitable.
    #[serde(default = "NodeConfig::default_worker_pools_enabled")]
    pub worker_pools_enabled: bool,

    /// Log level (`node.log_level`). Case-insensitive.
    /// Valid values: trace, debug, info, warn, error. Default: info.
    #[serde(default)]
    pub log_level: Option<String>,
}

impl Default for NodeConfig {
    fn default() -> Self {
        Self {
            identity: IdentityConfig::default(),
            leaf_only: false,
            tick_interval_secs: 1,
            base_rtt_ms: 100,
            heartbeat_interval_secs: 10,
            link_dead_timeout_secs: 30,
            fast_link_dead_timeout_secs: 5,
            limits: LimitsConfig::default(),
            rate_limit: RateLimitConfig::default(),
            retry: RetryConfig::default(),
            cache: CacheConfig::default(),
            discovery: DiscoveryConfig::default(),
            tree: TreeConfig::default(),
            routing: RoutingConfig::default(),
            bloom: BloomConfig::default(),
            session: SessionConfig::default(),
            buffers: BuffersConfig::default(),
            control: ControlConfig::default(),
            mmp: MmpConfig::default(),
            session_mmp: SessionMmpConfig::default(),
            ecn: EcnConfig::default(),
            rekey: RekeyConfig::default(),
            system_files_enabled: true,
            worker_pools_enabled: true,
            log_level: None,
        }
    }
}

impl NodeConfig {
    /// Get the log level as a tracing Level. Default: INFO.
    pub fn log_level(&self) -> tracing::Level {
        match self
            .log_level
            .as_deref()
            .map(|s| s.to_lowercase())
            .as_deref()
        {
            Some("trace") => tracing::Level::TRACE,
            Some("debug") => tracing::Level::DEBUG,
            Some("warn") | Some("warning") => tracing::Level::WARN,
            Some("error") => tracing::Level::ERROR,
            _ => tracing::Level::INFO,
        }
    }

    fn default_tick_interval_secs() -> u64 {
        1
    }
    fn default_base_rtt_ms() -> u64 {
        100
    }
    fn default_heartbeat_interval_secs() -> u64 {
        10
    }
    fn default_link_dead_timeout_secs() -> u64 {
        30
    }
    fn default_fast_link_dead_timeout_secs() -> u64 {
        5
    }
    fn default_system_files_enabled() -> bool {
        true
    }
    fn default_worker_pools_enabled() -> bool {
        true
    }
}

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

    #[test]
    fn test_ecn_config_defaults() {
        let c = EcnConfig::default();
        assert!(c.enabled);
        assert!((c.loss_threshold - 0.05).abs() < 1e-9);
        assert!((c.etx_threshold - 3.0).abs() < 1e-9);
    }

    #[test]
    fn test_rekey_config_defaults() {
        let c = RekeyConfig::default();
        assert!(c.enabled);
        assert_eq!(c.after_secs, 120);
        assert_eq!(c.after_messages, 1 << 48);
    }

    #[test]
    fn test_rekey_config_partial_yaml_uses_defaults() {
        let yaml = "after_secs: 30\n";
        let c: RekeyConfig = serde_yaml::from_str(yaml).unwrap();
        assert!(c.enabled);
        assert_eq!(c.after_secs, 30);
        assert_eq!(c.after_messages, 1 << 48);
    }

    #[test]
    fn test_routing_config_defaults() {
        let c = RoutingConfig::default();
        assert_eq!(c.mode, RoutingMode::Tree);
        assert_eq!(c.learned_ttl_secs, 300);
        assert_eq!(c.max_learned_routes_per_dest, 4);
        assert_eq!(c.learned_fallback_explore_interval, 16);
    }

    #[test]
    fn test_routing_config_yaml() {
        let yaml = "mode: reply_learned\nlearned_ttl_secs: 120\nmax_learned_routes_per_dest: 2\nlearned_fallback_explore_interval: 8\n";
        let c: RoutingConfig = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(c.mode, RoutingMode::ReplyLearned);
        assert_eq!(c.learned_ttl_secs, 120);
        assert_eq!(c.max_learned_routes_per_dest, 2);
        assert_eq!(c.learned_fallback_explore_interval, 8);
    }

    #[test]
    fn test_ecn_config_yaml_roundtrip() {
        let yaml = "loss_threshold: 0.10\netx_threshold: 2.5\nenabled: false\n";
        let c: EcnConfig = serde_yaml::from_str(yaml).unwrap();
        assert!(!c.enabled);
        assert!((c.loss_threshold - 0.10).abs() < 1e-9);
        assert!((c.etx_threshold - 2.5).abs() < 1e-9);
    }

    #[test]
    fn test_ecn_config_partial_yaml() {
        // Only specify loss_threshold — others should get defaults
        let yaml = "loss_threshold: 0.02\n";
        let c: EcnConfig = serde_yaml::from_str(yaml).unwrap();
        assert!(c.enabled); // default
        assert!((c.loss_threshold - 0.02).abs() < 1e-9);
        assert!((c.etx_threshold - 3.0).abs() < 1e-9); // default
    }

    #[test]
    fn test_nostr_discovery_startup_sweep_defaults() {
        let c = NostrDiscoveryConfig::default();
        assert_eq!(c.startup_sweep_delay_secs, 5);
        assert_eq!(c.startup_sweep_max_age_secs, 3_600);
    }

    #[test]
    fn test_nostr_discovery_startup_sweep_yaml_override() {
        let yaml = "enabled: true\npolicy: open\nstartup_sweep_delay_secs: 10\nstartup_sweep_max_age_secs: 1800\n";
        let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
        assert!(c.enabled);
        assert_eq!(c.policy, NostrDiscoveryPolicy::Open);
        assert_eq!(c.startup_sweep_delay_secs, 10);
        assert_eq!(c.startup_sweep_max_age_secs, 1_800);
    }

    #[test]
    fn test_nostr_discovery_startup_sweep_partial_yaml_uses_defaults() {
        // Only override delay; max_age should fall back to default.
        let yaml = "enabled: true\nstartup_sweep_delay_secs: 30\n";
        let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(c.startup_sweep_delay_secs, 30);
        assert_eq!(c.startup_sweep_max_age_secs, 3_600);
    }

    #[test]
    fn test_log_level_parser() {
        // Pin the observed behavior of NodeConfig::log_level():
        // - 5 explicit lowercased match arms (trace/debug/warn|warning/error)
        // - INFO is the default (no explicit "info" arm; falls through default)
        // - Case-insensitive via .to_lowercase()
        // - Unknown strings and None both fall through to INFO
        let cases: &[(Option<&str>, tracing::Level)] = &[
            // Explicit arms (lowercase canonical form)
            (Some("trace"), tracing::Level::TRACE),
            (Some("debug"), tracing::Level::DEBUG),
            (Some("warn"), tracing::Level::WARN),
            (Some("warning"), tracing::Level::WARN),
            (Some("error"), tracing::Level::ERROR),
            // "info" has no explicit arm — falls through default
            (Some("info"), tracing::Level::INFO),
            // None → default INFO
            (None, tracing::Level::INFO),
            // Case-insensitivity (parser lowercases via .to_lowercase())
            (Some("TRACE"), tracing::Level::TRACE),
            (Some("Debug"), tracing::Level::DEBUG),
            (Some("Warning"), tracing::Level::WARN),
            (Some("WARN"), tracing::Level::WARN),
            (Some("ERROR"), tracing::Level::ERROR),
            (Some("INFO"), tracing::Level::INFO),
            // Unknown strings → INFO default (no error path)
            (Some("verbose"), tracing::Level::INFO),
            (Some("nonsense"), tracing::Level::INFO),
            (Some(""), tracing::Level::INFO),
        ];

        for (input, expected) in cases {
            let cfg = NodeConfig {
                log_level: input.map(|s| s.to_string()),
                ..NodeConfig::default()
            };
            assert_eq!(
                cfg.log_level(),
                *expected,
                "input {:?} should map to {:?}",
                input,
                expected
            );
        }
    }

    #[cfg(windows)]
    #[test]
    fn test_default_socket_path_windows() {
        let config = ControlConfig::default();
        // On Windows, socket_path is a TCP port number
        let port: u16 = config
            .socket_path
            .parse()
            .expect("should be a valid port number");
        assert_eq!(port, 21210);
    }
}
