// Historical schema typed lenses for migration. Each module is frozen after
// its corresponding version ships; only their `migrate(self) -> ...` methods
// are referenced at runtime by `crate::migration`.
pub mod v1;
pub mod v2;

use crate::autonomy::AutonomyLevel;
use crate::autonomy::DelegationPolicy;
use crate::domain_matcher::DomainMatcher;
use crate::traits::{ChannelConfig, HasPropKind, PropKind};
use crate::validation_bail;
use anyhow::{Context, Result};
use directories::UserDirs;
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{OnceLock, RwLock};
#[cfg(unix)]
use tokio::fs::File;
use tokio::fs::{self, OpenOptions};
use tokio::io::AsyncWriteExt;
use zeroclaw_api::runtime_status::RuntimeConfigKind;
use zeroclaw_macros::Configurable;

const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
    "model_provider.anthropic",
    "model_provider.compatible",
    "model_provider.copilot",
    "model_provider.gemini",
    "model_provider.glm",
    "model_provider.ollama",
    "model_provider.openai",
    "model_provider.openrouter",
    "channel.dingtalk",
    "channel.discord",
    "channel.lark",
    "channel.matrix",
    "channel.mattermost",
    "channel.nextcloud_talk",
    "channel.qq",
    "channel.signal",
    "channel.slack",
    "channel.telegram",
    "channel.wati",
    "channel.wechat",
    "channel.whatsapp",
    "tool.browser",
    "tool.composio",
    "tool.http_request",
    "tool.pushover",
    "tool.web_search",
    "memory.embeddings",
    "tunnel.custom",
    "transcription.groq",
];

const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] = &[
    "model_provider.*",
    "channel.*",
    "tool.*",
    "memory.*",
    "tunnel.*",
    "transcription.*",
];

static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
    OnceLock::new();

// ── Top-level config ──────────────────────────────────────────────

/// Top-level ZeroClaw configuration, loaded from `config.toml`.
///
/// Resolution order: `ZEROCLAW_CONFIG_DIR` env → `ZEROCLAW_WORKSPACE` env → `~/.zeroclaw/config.toml`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Config {
    /// Shared instance data directory (databases, hygiene state, cost
    /// records, daemon state files). Computed from `ZEROCLAW_CONFIG_DIR`
    /// / `ZEROCLAW_DATA_DIR` / `ZEROCLAW_WORKSPACE` (deprecated) at
    /// load time, not serialized. Per-agent identity + markdown lives
    /// at `agent_workspace_dir(&alias)`, not here.
    #[serde(skip)]
    pub data_dir: PathBuf,
    /// Path to config.toml - computed from home, not serialized
    #[serde(skip)]
    pub config_path: PathBuf,
    /// Dotted prop-paths overridden by `ZEROCLAW_*` env vars at load time.
    /// Populated by `apply_env_overrides`; consulted by `save()` to mask the
    /// env-injected values back to disk-or-default before encryption, and by
    /// `prop_is_env_overridden` for O(1) display-layer lookup (config list,
    /// dashboard, quickstart).
    #[serde(skip)]
    pub env_overridden_paths: std::collections::HashSet<String>,
    /// Per-path snapshot of pre-override raw values, captured at apply time
    /// from the post-`decrypt_secrets` in-memory state (so secret entries
    /// hold plaintext, not the display mask). `save()` restores from this
    /// map so env-injected values never reach disk and the operator's
    /// original on-disk credentials survive any save cycle.
    #[serde(skip)]
    pub pre_override_snapshots: std::collections::HashMap<String, String>,
    /// Per-path snapshot of `op://` external secret references captured before
    /// `decrypt_secrets()` resolves them for runtime use. `save()` restores
    /// these references unless the same path was intentionally edited.
    #[serde(skip)]
    pub onepassword_reference_snapshots: std::collections::HashMap<String, String>,
    /// Dotted prop-paths mutated since the last persist; drives the
    /// per-path PATCH applied by `save_dirty()`.
    #[serde(skip)]
    pub dirty_paths: std::collections::HashSet<String>,
    /// Security-critical sections the resilient loader reset to `Default`
    /// because the on-disk block was malformed. Non-empty = posture may be
    /// weaker than intended; exposure gating should refuse to trust the
    /// instance until repaired. Never serialized — a load-time signal.
    #[serde(skip)]
    pub degraded_security: Vec<String>,
    /// Non-security sections the resilient loader reset to `Default`
    /// because the on-disk block was malformed (e.g. `[plugins.entries]`
    /// written where `[[plugins.entries]]` was meant). Never serialized;
    /// a load-time signal the CLI surfaces on stderr so a silently-dropped
    /// section is impossible to miss.
    #[serde(skip)]
    pub degraded_sections: Vec<String>,
    /// Config file schema version.
    #[serde(default = "default_schema_version")]
    pub schema_version: u32,

    /// All configured provider profiles, grouped by category under a
    /// single `[providers]` root. Categories today: `models`, `tts`,
    /// `transcription`. Shape: `[providers.<category>.<type>.<alias>]`,
    /// e.g. `[providers.models.anthropic.default]`,
    /// `[providers.tts.openai.default]`,
    /// `[providers.transcription.groq.default]`.
    #[serde(default)]
    #[nested]
    pub providers: crate::providers::Providers,

    /// Model-routing rules — route `hint:<name>` to specific
    /// model_provider + model combos.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[nested]
    #[natural_key = "hint"]
    #[group = "Foundation"]
    pub model_routes: Vec<ModelRouteConfig>,

    /// Embedding-routing rules — route `hint:<name>` to specific
    /// model_provider + model combos for embedding requests.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[nested]
    #[natural_key = "hint"]
    #[group = "Foundation"]
    pub embedding_routes: Vec<EmbeddingRouteConfig>,

    /// Observability backend configuration (`[observability]`).
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub observability: ObservabilityConfig,

    /// Trust scoring and regression detection configuration (`[trust]`).
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub trust: crate::scattered_types::TrustConfig,

    /// Security subsystem configuration (`[security]`).
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub security: SecurityConfig,

    /// Backup tool configuration (`[backup]`).
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub backup: BackupConfig,

    /// Data retention and purge configuration (`[data_retention]`).
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub data_retention: DataRetentionConfig,

    /// Cloud transformation accelerator configuration (`[cloud_ops]`).
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub cloud_ops: CloudOpsConfig,

    /// Conversational AI agent builder configuration (`[conversational_ai]`).
    ///
    /// Experimental / future feature — not yet wired into the agent runtime.
    /// Omitted from generated config files when disabled (the default).
    /// Existing configs that already contain this section will continue to
    /// deserialize correctly thanks to `#[serde(default)]`.
    #[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
    #[nested]
    #[group = "Operations"]
    pub conversational_ai: ConversationalAiConfig,

    /// Managed cybersecurity service configuration (`[security_ops]`).
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub security_ops: SecurityOpsConfig,

    /// Runtime adapter configuration (`[runtime]`). Controls native vs Docker execution.
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub runtime: RuntimeConfig,

    /// Reliability settings: retries, backoff, key rotation (`[reliability]`).
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub reliability: ReliabilityConfig,

    /// Scheduler configuration for periodic task execution (`[scheduler]`).
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub scheduler: SchedulerConfig,

    /// Agent evaluation harness (`[eval]`) — surfaced via `zeroclaw eval`.
    /// Distinct from `[agent.eval]`, which is the in-loop response-quality scorer.
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub eval: crate::scattered_types::EvalHarnessConfig,

    /// Pacing controls for slow/local LLM workloads (`[pacing]`).
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub pacing: PacingConfig,

    /// Skills loading and community repository behavior (`[skills]`).
    #[serde(default)]
    #[nested]
    pub skills: SkillsConfig,

    /// Pipeline tool configuration (`[pipeline]`).
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub pipeline: PipelineConfig,

    /// Automatic query classification — maps user messages to model hints.
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub query_classification: QueryClassificationConfig,

    /// Heartbeat configuration for periodic health pings (`[heartbeat]`).
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub heartbeat: HeartbeatConfig,

    /// ZeroCode live task tracker (`[todotracker]`), the read-only
    /// TodoWrite visual tracker in the Code pane.
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub todotracker: TodoTrackerConfig,

    /// Declarative cron jobs (`[cron.<alias>]`), alias-keyed.
    ///
    /// Each entry is a named scheduled job synced into the database at
    /// scheduler startup. Subsystem runtime knobs (enable/disable, catch-up,
    /// run-history retention) live on `[scheduler]`.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub cron: HashMap<String, CronJobDecl>,

    /// ACP (Agent Client Protocol) server configuration (`[acp]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub acp: AcpConfig,

    /// Channel configurations: Telegram, Discord, Slack, etc. (`[channels]`).
    #[serde(default, alias = "channels_config")]
    #[nested]
    pub channels: ChannelsConfig,

    /// Memory backend configuration: sqlite, markdown, embeddings (`[memory]`).
    #[serde(default)]
    #[nested]
    pub memory: MemoryConfig,

    /// Persistent storage model_provider configuration (`[storage]`).
    #[serde(default)]
    #[nested]
    pub storage: StorageConfig,

    /// Tunnel configuration for exposing the gateway publicly (`[tunnel]`).
    #[serde(default)]
    #[nested]
    pub tunnel: TunnelConfig,

    /// Gateway server configuration: host, port, pairing, rate limits (`[gateway]`).
    #[serde(default)]
    #[nested]
    #[group = "Network"]
    pub gateway: GatewayConfig,

    /// Inbound A2A discovery server (`[a2a.server]`). Default-closed:
    /// serves the well-known catalog card and per-alias agent cards only
    /// when `enabled = true`. See `crate::multi_agent::A2aServerConfig`.
    #[serde(default)]
    #[nested]
    #[group = "Network"]
    pub a2a: crate::multi_agent::A2aServerSection,

    /// WebSocket Secure (WSS) transport for remote TUI connections (`[wss]`).
    #[serde(default)]
    #[nested]
    pub wss: WssConfig,

    /// Composio managed OAuth tools integration (`[composio]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub composio: ComposioConfig,

    /// Microsoft 365 Graph API integration (`[microsoft365]`).
    #[serde(default)]
    #[nested]
    pub microsoft365: Microsoft365Config,

    /// Secrets encryption configuration (`[secrets]`).
    #[serde(default)]
    #[nested]
    #[group = "Storage"]
    pub secrets: SecretsConfig,

    /// Browser automation configuration (`[browser]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub browser: BrowserConfig,

    /// Browser delegation configuration (`[browser_delegate]`).
    ///
    /// Delegates browser-based tasks to a browser-capable CLI subprocess (e.g.
    /// Claude Code with `claude-in-chrome` MCP tools). Useful for interacting
    /// with corporate web apps (Teams, Outlook, Jira, Confluence) that lack
    /// direct API access. A persistent Chrome profile can be configured so SSO
    /// sessions survive across invocations.
    ///
    /// Fields:
    /// - `enabled` (`bool`, default `false`) — enable the browser delegation tool.
    /// - `cli_binary` (`String`, default `"claude"`) — CLI binary to spawn for browser tasks.
    /// - `chrome_profile_dir` (`String`, default `""`) — Chrome user-data directory for
    ///   persistent SSO sessions. When empty, a fresh profile is used each invocation.
    /// - `allowed_domains` (`Vec<String>`, default `[]`) — allowlist of domains the browser
    ///   may navigate to. Empty means all non-blocked domains are permitted.
    /// - `blocked_domains` (`Vec<String>`, default `[]`) — denylist of domains. Blocked
    ///   domains take precedence over allowed domains.
    /// - `task_timeout_secs` (`u64`, default `120`) — per-task timeout in seconds.
    ///
    /// Compatibility: additive and disabled by default; existing configs remain valid when omitted.
    /// Rollback/migration: remove `[browser_delegate]` or keep `enabled = false` to disable.
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub browser_delegate: crate::scattered_types::BrowserDelegateConfig,

    /// HTTP request tool configuration (`[http_request]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub http_request: HttpRequestConfig,

    /// Multimodal (image) handling configuration (`[multimodal]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub multimodal: MultimodalConfig,

    /// Automatic media understanding pipeline (`[media_pipeline]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub media_pipeline: MediaPipelineConfig,

    /// Web fetch tool configuration (`[web_fetch]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub web_fetch: WebFetchConfig,

    /// Link enricher configuration (`[link_enricher]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub link_enricher: LinkEnricherConfig,

    /// Text browser tool configuration (`[text_browser]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub text_browser: TextBrowserConfig,

    /// Web search tool configuration (`[web_search]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub web_search: WebSearchConfig,

    /// Project delivery intelligence configuration (`[project_intel]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub project_intel: ProjectIntelConfig,

    /// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub google_workspace: GoogleWorkspaceConfig,

    /// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]`).
    #[serde(default)]
    #[nested]
    #[group = "Network"]
    pub proxy: ProxyConfig,

    /// Cost tracking and budget enforcement configuration (`[cost]`).
    /// Also hosts the operator-managed rate sheet at
    /// `[cost.rates.<type>.<model>]`.
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub cost: CostConfig,

    /// Peripheral board configuration for hardware integration (`[peripherals]`).
    #[serde(default)]
    #[nested]
    #[group = "Operations"]
    pub peripherals: PeripheralsConfig,

    /// Delegate tool global default configuration (`[delegate]`).
    #[serde(default)]
    #[nested]
    #[group = "Multi-agent"]
    pub delegate: DelegateToolConfig,

    /// Aliased agents in this install. Each entry under `[agents.<alias>]`
    /// is one user-facing agent with its own identity, channels, model
    /// provider, risk profile, workspace, and memory scope.
    /// `DelegateTool` consults this map when one agent delegates a
    /// subtask to another.
    #[serde(default)]
    #[nested]
    pub agents: HashMap<String, AliasedAgentConfig>,

    /// Named risk/autonomy profiles (`[risk_profiles.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub risk_profiles: HashMap<String, RiskProfileConfig>,

    /// Named runtime/LLM execution profiles (`[runtime_profiles.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub runtime_profiles: HashMap<String, RuntimeProfileConfig>,

    /// Named skill bundles (`[skill_bundles.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub skill_bundles: HashMap<String, SkillBundleConfig>,

    /// Named knowledge bundles (`[knowledge_bundles.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub knowledge_bundles: HashMap<String, KnowledgeBundleConfig>,

    /// Named MCP server bundles (`[mcp_bundles.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub mcp_bundles: HashMap<String, McpBundleConfig>,

    /// Named peer groups (`[peer_groups.<name>]`). Each entry binds a
    /// channel, a list of member agents, and optional non-agent
    /// (external) members and a per-group blocklist. Mutual opt-in:
    /// two agents become peers only when both appear in the same
    /// group's `agents`. Empty by default for single-agent installs.
    /// See `crate::multi_agent::PeerGroupConfig`.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub peer_groups: HashMap<String, crate::multi_agent::PeerGroupConfig>,

    /// Hooks configuration (lifecycle hooks and built-in hook toggles).
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub hooks: HooksConfig,

    /// Hardware configuration (wizard-driven physical world setup).
    #[serde(default)]
    #[nested]
    pub hardware: HardwareConfig,

    /// Voice transcription configuration (Whisper API via Groq).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub transcription: TranscriptionConfig,

    /// Text-to-Speech configuration (`[tts]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub tts: TtsConfig,

    /// External MCP server connections (`[mcp]`).
    #[serde(default, alias = "mcpServers")]
    #[nested]
    pub mcp: McpConfig,

    /// Dynamic node discovery configuration (`[nodes]`).
    #[serde(default)]
    #[nested]
    #[group = "Network"]
    pub nodes: NodesConfig,

    /// Meta-state for the Quickstart flow (which sections the user has
    /// already walked through). Not user-facing config (`[onboard_state]`).
    #[serde(default)]
    #[nested]
    pub onboard_state: OnboardStateConfig,

    /// Notion integration configuration (`[notion]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub notion: NotionConfig,

    /// Jira integration configuration (`[jira]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub jira: JiraConfig,

    /// Secure inter-node transport configuration (`[node_transport]`).
    #[serde(default)]
    #[nested]
    #[group = "Network"]
    pub node_transport: NodeTransportConfig,

    /// Knowledge graph configuration (`[knowledge]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub knowledge: KnowledgeConfig,

    /// LinkedIn integration configuration (`[linkedin]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub linkedin: LinkedInConfig,

    /// Standalone image generation tool configuration (`[image_gen]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub image_gen: ImageGenConfig,

    /// Standalone file upload tool configuration (`[file_upload]`).
    #[serde(default)]
    #[nested]
    pub file_upload: FileUploadConfig,

    /// Standalone multi-file bundle upload tool configuration
    /// (`[file_upload_bundle]`).
    #[serde(default)]
    #[nested]
    pub file_upload_bundle: FileUploadBundleConfig,

    /// Standalone file download tool configuration (`[file_download]`).
    #[serde(default)]
    #[nested]
    pub file_download: FileDownloadConfig,

    /// Plugin system configuration (`[plugins]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub plugins: PluginsConfig,

    /// Locale for tool descriptions (e.g. `"en"`, `"zh-CN"`).
    ///
    /// When set, tool descriptions shown in system prompts are loaded from
    /// Fluent `.ftl` locale files. Falls back to embedded English, then to
    /// hardcoded descriptions.
    ///
    /// If omitted or empty, the locale is auto-detected from the host
    /// system's locale (defaulting to `"en"` if that can't be determined).
    #[serde(default)]
    pub locale: Option<String>,

    /// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]`).
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub verifiable_intent: VerifiableIntentConfig,

    /// Claude Code tool configuration (`[claude_code]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub claude_code: ClaudeCodeConfig,

    /// Claude Code task runner with Slack progress and SSH session handoff (`[claude_code_runner]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub claude_code_runner: ClaudeCodeRunnerConfig,

    /// Codex CLI tool configuration (`[codex_cli]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub codex_cli: CodexCliConfig,

    /// Gemini CLI tool configuration (`[gemini_cli]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub gemini_cli: GeminiCliConfig,

    /// OpenCode CLI tool configuration (`[opencode_cli]`).
    #[serde(default)]
    #[nested]
    #[group = "Integrations"]
    pub opencode_cli: OpenCodeCliConfig,

    /// Standard Operating Procedures engine configuration (`[sop]`).
    #[serde(default)]
    #[nested]
    #[group = "Agent"]
    pub sop: SopConfig,

    /// Shell tool configuration (`[shell_tool]`).
    #[serde(default)]
    #[nested]
    #[group = "Tools"]
    pub shell_tool: ShellToolConfig,

    /// Escalation routing configuration (`[escalation]`).
    #[serde(default)]
    #[nested]
    pub escalation: EscalationConfig,
}

/// Multi-client workspace isolation configuration.
///
/// When enabled, each client engagement gets an isolated workspace with
/// separate memory, audit, secrets, and tool restrictions.
#[allow(clippy::struct_excessive_bools)]
/// Opaque state the Quickstart flow writes so it can tell, on a
/// re-run, which sections the user has already walked through at least
/// once — which lets it offer "Reconfigure? [y/N]" skip gates instead of
/// forcing users through every field again.
///
/// This is meta-state about the Quickstart flow, not user-facing config.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "onboard_state"]
pub struct OnboardStateConfig {
    /// Section keys the user has completed at least once.
    /// Values are the lowercased Section variant names
    /// (`"workspace"`, `"model_providers"`, …).
    #[serde(default)]
    pub completed_sections: Vec<String>,
    /// `true` once the Quickstart has applied a `BuilderSubmission`
    /// successfully on this install. Web gateway and TUI auto-launch
    /// the Quickstart on startup iff this is `false` **and** no
    /// `agents.*` entries exist (the implicit-completion rule covers
    /// upgrades). The flag is flipped in the same atomic write that
    /// lands the Quickstart submission; re-entering the Quickstart
    /// later to add another agent does not flip it back to `false`.
    #[serde(default)]
    pub quickstart_completed: bool,
}

/// Used by `#[serde(skip_serializing_if)]` on plain `bool` fields to omit
/// them from TOML output when they carry their struct-level default (`false`).
/// Keeps fresh model_provider entries clean — a default-constructed
/// `ModelProviderConfig` for one model_provider family shouldn't write flag fields
/// that only apply to a different family.
fn is_false(value: &bool) -> bool {
    !*value
}

/// One trait per family-endpoint enum. Returns the URI template for the chosen
/// variant — a literal URL for fixed endpoints (`https://api.openai.com/v1`),
/// or a substitution template for computed endpoints (Azure's
/// `https://{resource}.openai.azure.com/...`). Substitution happens family-side
/// in the runtime constructor; for non-templated families the return value is
/// the final URL.
///
/// Resolution order at runtime is uniform across every model model_provider family:
/// operator's `cfg.uri` first; family endpoint enum's `uri()` second; loud
/// failure when neither is set.
pub trait ModelEndpoint {
    fn uri(&self) -> &'static str;
}

/// Implemented by every `*ModelProviderConfig`. Multi-region families
/// override to return `Some(self.endpoint.uri())`; single-endpoint families
/// inherit the `None` default. Drives `ModelProviders::resolved_endpoint_uri`,
/// which is itself driven by the `for_each_model_provider_slot!` macro — so
/// adding a new family without an impl is a compile error.
pub trait FamilyEndpoint {
    fn endpoint_uri(&self) -> Option<&'static str> {
        None
    }
}

/// Wire protocol flavor for the model_provider client. `responses` routes
/// through OpenAI's Responses API (`POST /v1/responses`);
/// `chat_completions` routes through the legacy `/v1/chat/completions` (or
/// the family's chat-completions-compatible endpoint). New OpenAI provider
/// slots default to `responses`; other families default to chat-completions
/// (or ignore the field) when unset.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum WireApi {
    Responses,
    ChatCompletions,
}

impl WireApi {
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Responses => "responses",
            Self::ChatCompletions => "chat_completions",
        }
    }
}

/// Authentication mode for model model_provider families that support more than one
/// (e.g. Qwen, Minimax can use API key OR OAuth). Families that only support a
/// single auth flow simply omit this field from their config struct.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
    /// Standard API key authentication via the `api_key` field.
    #[default]
    ApiKey,
    /// OAuth flow — credential resolution defers to the family runtime impl
    /// (typically reading a vendor-specific token cache or env var).
    OAuth,
}

/// Named model_provider profile definition.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models"]
pub struct ModelProviderConfig {
    /// Secret API token for this model_provider. Grab it from the model_provider's dashboard (OpenAI platform, Anthropic console, OpenRouter keys page, etc.). Stored via the OS keyring when possible; never commit it to config.toml directly.
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key: Option<String>,
    /// Provider implementation to instantiate for this profile. Use this
    /// when a canonical typed slot should run through a compatible
    /// implementation, e.g. `[providers.models.openai.proxy] kind =
    /// "openai-compatible"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
    /// Endpoint URI the client hits. Override the family's default endpoint when pointing at a self-hosted gateway (LiteLLM, vLLM, Ollama), a custom proxy, or any non-standard URL. Leave unset to use the family's default URI from its `ModelEndpoint` impl. Set this to the FULL endpoint URL; there is no separate path-suffix field.
    #[tab(Connection)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uri: Option<String>,
    /// Model identifier to send with each request: the ID string from the model_provider's catalog (e.g. `gpt-4o`, `claude-sonnet-4-5`, `llama-3.3-70b`). Must match a model the model_provider actually serves on this account.
    #[tab(Model)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Ordered list of other provider aliases to try when every model on this
    /// alias has failed. Each entry is a dotted `<type>.<alias>` reference into
    /// `providers.models` and resolves with its own credentials, endpoint, and
    /// model. A fallback never inherits this alias's key. The walk is
    /// depth-first: this alias's models are exhausted first, then each fallback
    /// alias is descended in turn (applying its own `fallback_models` and
    /// `fallback`). Empty means no provider-level fallback.
    #[tab(Model)]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub fallback: Vec<crate::providers::ModelProviderRef>,
    /// Ordered alternate models to try on THIS provider before falling over to
    /// the `fallback` aliases. Same endpoint, key, and headers as the primary
    /// `model`. Only the model identifier changes. Use this when a provider
    /// serves a backup model (e.g. a smaller or older variant) that should be
    /// tried before leaving the provider entirely. Empty means only `model` is
    /// tried.
    #[tab(Model)]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub fallback_models: Vec<String>,
    /// Sampling temperature passed to the model. Lower values (0.0–0.3) give
    /// deterministic, near-verbatim output, which fits code, routing, summarization.
    /// Higher values (0.7–1.2) give more varied output, which fits open-ended chat.
    #[tab(Model)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub temperature: Option<f64>,
    /// HTTP request timeout in seconds. Bump this for slow local model_providers (Ollama on CPU, big local models) or high-latency networks; leave unset otherwise.
    #[tab(Model)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout_secs: Option<u64>,
    /// Extra HTTP headers sent with every request. Niche: used for auth bridges, corporate proxies, or custom gateways that demand a tracing header. Most users never touch this; edit `config.toml` directly if you need it.
    #[tab(Connection)]
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub extra_headers: HashMap<String, String>,
    /// Wire protocol flavor: `responses` for OpenAI's Responses API (`POST /v1/responses`), `chat_completions` for the legacy chat wire and most OpenAI-compatible gateways. New OpenAI provider slots default to `responses`; other families default to chat-completions (or ignore the field). Only override if you're forcing an unusual combination.
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub wire_api: Option<WireApi>,
    /// When true, the client pulls credentials from ZeroClaw's stored `openai-codex` auth profile instead of the `api_key` field above. Import an existing Codex CLI login with `zeroclaw auth login --model-provider openai-codex --import ~/.codex/auth.json`, or run `zeroclaw auth login --model-provider openai-codex`. Turn on only for the OpenAI Codex model_provider; leave off for standard API-key model_providers.
    #[tab(Connection)]
    #[serde(default, skip_serializing_if = "is_false")]
    #[credential_class = "external_auth_store"]
    pub requires_openai_auth: bool,
    /// Hard cap on response length in tokens. Most models enforce sensible built-in limits already; leave unset unless you specifically need to clip long outputs for cost or latency reasons.
    #[tab(Model)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_tokens: Option<u32>,
    /// ModelProvider-specific quirk: fold the system prompt into the first user message instead of sending a separate system role. Only needed for models that reject (or mishandle) a standalone system role, e.g. certain older Mistral variants.
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "is_false")]
    pub merge_system_into_user: bool,
    /// Extra JSON parameters to include in API requests.
    /// Merged at the top level of the request body, allowing provider-specific
    /// features (routing, transforms, etc.) without code changes.
    /// Example: `provider_extra = { model_provider = { only = ["Anthropic"] } }`
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider_extra: Option<serde_json::Value>,
    /// Per-model pricing for cost tracking, USD per 1M tokens.
    ///
    /// Free-form key/value map. Keys are user-defined model identifiers; an
    /// optional `.input` / `.output` suffix encodes pricing dimension when
    /// the operator wants to split rates. A bare key without a suffix is
    /// used as a flat per-token rate when neither dimension is specified.
    /// Default is empty: cost tracking falls back to "unknown" rates and
    /// only token usage is recorded.
    ///
    /// Example: `pricing = { opus = 15.0, sonnet = 3.0 }`
    /// Or split: `pricing = { "opus.input" = 15.0, "opus.output" = 75.0 }`
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub pricing: HashMap<String, f64>,
    /// Whether stored assistant reasoning should be replayed on outbound
    /// assistant history messages. `Some(false)` strips `reasoning_content`
    /// and `reasoning` before sending. `None` (default) honours the provider's
    /// built-in default (true for most compat providers, false for Groq).
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub replay_assistant_reasoning: Option<bool>,
    /// Pull live token prices for this provider's models from its own
    /// OpenAI-compatible `/models` listing (the gateway is the source of truth
    /// for its prices), filling cost-tracking rates for models the operator
    /// has NOT priced under `[cost.rates]` / `pricing`. Models the gateway does
    /// not price (or providers with no HTTP `/models` listing at all, such as
    /// a subprocess gateway like `kilocli`) fall back to the public models.dev
    /// catalog. Configured rates always win; live prices only fill gaps. A
    /// background task refreshes the price snapshot hourly; the cost-recording
    /// path reads the cached snapshot and never blocks on the network. Default
    /// `false`: off means no fetching and behavior identical to a build
    /// without the feature.
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "is_false")]
    pub live_pricing: bool,
    /// Override the provider's default for native tool calling.
    /// `None` (default) honors the provider's built-in choice. `Some(true)`
    /// forces native tool calls on, `Some(false)` forces text-fallback.
    /// Currently consulted only by the Groq factory, which defaults to
    /// text-fallback because llama-family Groq models reject native tool
    /// calls with HTTP 400. Setting `native_tools = true` re-enables native
    /// tool calling for Groq models that support it.
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub native_tools: Option<bool>,
    /// Enable or disable chain-of-thought thinking for models that support it
    /// (e.g. Qwen3, GLM-4). `true` turns thinking on, `false` turns it off.
    /// `None` (default) lets the model decide. Forwarded as `enable_thinking`
    /// in the request body; mirrors the Ollama provider's `think` field.
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub think: Option<bool>,
    /// Override the provider's vision (image input) capability.
    /// `None` (default) uses the provider family's built-in default. Several
    /// families (llama.cpp, the generic OpenAI-compatible endpoint, etc.)
    /// assume vision-capable because they can serve multimodal models. Set
    /// `vision = false` for a text-only model served by such a family (e.g. a
    /// text LLM behind llama.cpp) so image messages are routed to a configured
    /// `[multimodal] vision_model_provider` instead of being sent to a model
    /// that rejects them. `Some(true)` forces vision on.
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vision: Option<bool>,
    /// Arbitrary key/value pairs forwarded verbatim as `chat_template_kwargs`
    /// in the request body (llama.cpp-specific). Use this to pass model-family
    /// template variables that control behaviour not exposed by other fields.
    /// Example (Qwen3 thinking suppression):
    ///   `chat_template_kwargs = { enable_thinking = false }`
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub chat_template_kwargs: Option<serde_json::Value>,
    /// Context window size (max input tokens) for this model.
    /// Auto-populated on setup from provider's /models endpoint if available.
    /// Override manually for custom endpoints or when auto-detection fails.
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context_window: Option<usize>,
    /// Path to a PEM-encoded CA certificate for TLS connections to this provider.
    /// Must be an absolute path; shell expansion (e.g. `~`) is not performed.
    /// Leave unset to use the system's default trust store.
    #[tab(Connection)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tls_ca_cert_path: Option<String>,
}

// ── Per-family model model_provider configs ────────────────────────────
//
// Each family carries its own typed config (composing `ModelProviderConfig`
// via `#[serde(flatten)]`) plus a per-family `*Endpoint` enum that names the
// known endpoints and resolves them via the `ModelEndpoint` trait. Families
// that support multiple auth flows additionally carry an `auth_mode` field.
//
// Pattern reference for adding a new family:
// - Single-endpoint family with no extras: see `AnthropicModelProviderConfig`
// - Family with extras: see `OpenAIModelProviderConfig`
// - Family with computed-endpoint template: see `AzureModelProviderConfig`
// - Multi-region family with a required `endpoint` field: see `MoonshotModelProviderConfig`
//
// The `ModelProviders` container in `crates/zeroclaw-config/src/model_providers.rs`
// holds a typed slot per family; the runtime impls in zeroclaw-providers
// consume the typed configs directly.

// ── OpenAI ──

/// OpenAI canonical endpoint. Single variant — OpenAI publishes one base URL.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpenAIEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for OpenAIEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.openai.com/v1",
        }
    }
}

/// OpenAI model model_provider config. The OpenAI-family extras (`wire_api`,
/// `requires_openai_auth`) live on the shared `ModelProviderConfig` base
/// because they're consumed by validation and runtime helpers that operate
/// on the base struct without family awareness; this wrapper is a thin
/// typed slot, no extra fields.
///
/// New OpenAI provider entries **persisted via `create_map_key` / `ensure`**
/// (quickstart, gateway/config UI, programmatic slot creation) default to
/// `wire_api = "responses"` because OpenAI has moved recent GPT models onto
/// `POST /v1/responses` as the primary wire. OpenAI-compatible families
/// (`custom`, `llamacpp`, branded vendors, …) keep the shared
/// `ModelProviderConfig` default of unset / chat-completions.
///
/// This default governs **persisted slot creation only**. Two paths keep the
/// legacy chat-completions wire for backward compatibility:
/// - Existing persisted configs that omit `wire_api` deserialize as `None`, and
///   the factory falls back to chat-completions.
/// - Implicit dispatch with no config entry at all — a bare
///   `model_provider = "openai"` reference or a dotted ref to a nonexistent
///   alias — is built from a chat-anchored fallback config (see
///   `openai_missing_entry_fallback_config` in `zeroclaw-providers`), not this
///   `Default`, and stays on the chat wire so existing bare-ref installs don't
///   flip wire + tool-calling mode on upgrade.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.openai"]
pub struct OpenAIModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

impl Default for OpenAIModelProviderConfig {
    fn default() -> Self {
        Self {
            base: ModelProviderConfig {
                // OpenAI's current default wire for new provider slots.
                wire_api: Some(WireApi::Responses),
                ..ModelProviderConfig::default()
            },
        }
    }
}

// ── Azure OpenAI ──

/// Azure OpenAI endpoint template. Single variant; the URL is computed at
/// runtime by substituting `{resource}` and `{deployment}` from the typed
/// config fields.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AzureEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for AzureEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            // Azure's URI is a template — substitution happens in the
            // AzureModelProvider runtime constructor against the typed
            // config's resource / deployment fields.
            Self::Default => "https://{resource}.openai.azure.com/openai/deployments/{deployment}",
        }
    }
}

/// Azure OpenAI model model_provider config. Carries the Azure-specific connection
/// fields (`resource`, `deployment`, `api_version`) — the URI template
/// substitutes `{resource}` and `{deployment}` at runtime. Operators can
/// still override the entire endpoint via `base.uri`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.azure"]
pub struct AzureModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    /// Azure resource name (the `<resource>` part of `<resource>.openai.azure.com`).
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        alias = "azure_openai_resource"
    )]
    pub resource: Option<String>,
    /// Azure deployment name: the deployment created in Azure AI Studio.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        alias = "azure_openai_deployment"
    )]
    pub deployment: Option<String>,
    /// Azure API version string (e.g. `2024-10-21`).
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        alias = "azure_openai_api_version"
    )]
    pub api_version: Option<String>,
}

// ── Anthropic ──

/// Anthropic canonical endpoint.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AnthropicEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for AnthropicEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.anthropic.com",
        }
    }
}

/// Anthropic model model_provider config. No family-specific extras yet — typed
/// slot reserved for future Anthropic-only knobs (cache_control, beta
/// headers) so they land cleanly without another schema rework.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.anthropic"]
pub struct AnthropicModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Moonshot (multi-region exemplar) ──

/// Moonshot endpoint variants. Operators pick the region that matches their
/// account; the runtime resolves the URI from the chosen variant unless
/// overridden by `base.uri`. Code variant is intl-only.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MoonshotEndpoint {
    /// Mainland China endpoint.
    Cn,
    /// International endpoint.
    #[default]
    Intl,
    /// Code-specialist endpoint (intl).
    Code,
}

impl ModelEndpoint for MoonshotEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Cn => "https://api.moonshot.cn/v1",
            Self::Intl => "https://api.moonshot.ai/v1",
            // Kimi Code (Kimi For Coding) moved to api.kimi.com — the old api.moonshot.cn/coder/v1 returns 404.
            Self::Code => "https://api.kimi.com/coding/v1",
        }
    }
}

/// Moonshot model model_provider config. The `endpoint` field is required (no
/// implicit default) — operators must pick a region explicitly. Migration
/// fills it in from collapsed `moonshot-cn` / `moonshot-intl` outer keys.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.moonshot"]
pub struct MoonshotModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    /// Required: pick `cn`, `intl`, or `code`. Defaults to `intl` when omitted
    /// to ease transition; operators on the China endpoint should set
    /// `endpoint = "cn"` explicitly.
    #[serde(default)]
    pub endpoint: MoonshotEndpoint,
}

impl FamilyEndpoint for MoonshotModelProviderConfig {
    fn endpoint_uri(&self) -> Option<&'static str> {
        Some(self.endpoint.uri())
    }
}

// ── Qwen (multi-region + auth_mode exemplar) ──

/// Qwen endpoint variants. Operators pick the region matching their account.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum QwenEndpoint {
    /// Mainland China (DashScope).
    Cn,
    /// International (alicloud international).
    #[default]
    Intl,
    /// United States (DashScope US).
    Us,
    /// Code-specialist endpoint.
    Code,
}

impl ModelEndpoint for QwenEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Cn => "https://dashscope.aliyuncs.com/compatible-mode/v1",
            Self::Intl => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
            Self::Us => "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
            Self::Code => {
                "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
            }
        }
    }
}

/// Qwen model model_provider config. Multi-region (`endpoint` required) and
/// supports both API key and OAuth flows (`auth_mode` chooses which).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.qwen"]
pub struct QwenModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    #[serde(default)]
    pub endpoint: QwenEndpoint,
    /// Auth flow. Defaults to `api_key`; set to `oauth` to use the vendor's
    /// OAuth-cache integration instead of the `api_key` field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_mode: Option<AuthMode>,
    /// Long-lived Qwen OAuth refresh token. When set, the runtime
    /// exchanges it for a short-lived access token at provider
    /// construction time. Operators relying on the upstream `qwen login`
    /// tool (which writes `~/.qwen/oauth_creds.json`) leave this unset;
    /// the file-cache integration takes over.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[secret(category = "model_provider")]
    pub oauth_refresh_token: Option<String>,
    /// Override of Qwen's published OAuth client_id. Most operators
    /// should leave this unset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth_client_id: Option<String>,
    /// Operator override of the resource URL the refreshed access token
    /// is paired with. When unset, the runtime falls back to the
    /// `endpoint`-derived URL (or the cached `resource_url` when reading
    /// from `~/.qwen/oauth_creds.json`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth_resource_url: Option<String>,
}

impl FamilyEndpoint for QwenModelProviderConfig {
    fn endpoint_uri(&self) -> Option<&'static str> {
        Some(self.endpoint.uri())
    }
}

// ── OpenRouter ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpenRouterEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for OpenRouterEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://openrouter.ai/api/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.openrouter"]
pub struct OpenRouterModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Ollama (local-default endpoint) ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OllamaEndpoint {
    #[default]
    LocalDefault,
}

impl ModelEndpoint for OllamaEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalDefault => "http://localhost:11434",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.ollama"]
pub struct OllamaModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    /// Override the Ollama `num_ctx` (context window, in tokens) sent on
    /// every `/api/chat` request. Defaults to the framework constant
    /// (`OLLAMA_DEFAULT_NUM_CTX`) when unset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub num_ctx: Option<u32>,
    /// Override the Ollama `num_predict` (max output tokens) sent on every
    /// `/api/chat` request. Defaults to the framework constant
    /// (`OLLAMA_DEFAULT_NUM_PREDICT`) when unset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub num_predict: Option<i32>,
    /// Force every Ollama `/api/chat` request to use this temperature,
    /// overriding the per-call value passed through
    /// `ModelProvider::chat_with_system(.., temperature)`. When unset
    /// (`None`, the default), the per-call temperature wins: full
    /// backward compatibility.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub temperature_override: Option<f64>,
}

// ── Together ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum TogetherEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for TogetherEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.together.xyz/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.together"]
pub struct TogetherModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Fireworks ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum FireworksEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for FireworksEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.fireworks.ai/inference/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.fireworks"]
pub struct FireworksModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Groq ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum GroqEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for GroqEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.groq.com/openai/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.groq"]
pub struct GroqModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Mistral ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MistralEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for MistralEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.mistral.ai/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.mistral"]
pub struct MistralModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Atomic Chat (local OpenAI-compatible runtime, e.g. Jan) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AtomicChatEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for AtomicChatEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "http://127.0.0.1:1337/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.atomic_chat"]
pub struct AtomicChatModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── DeepSeek ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum DeepseekEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for DeepseekEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.deepseek.com/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.deepseek"]
pub struct DeepseekModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Cohere ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CohereEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for CohereEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.cohere.ai/compatibility/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.cohere"]
pub struct CohereModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Perplexity ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum PerplexityEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for PerplexityEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.perplexity.ai",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.perplexity"]
pub struct PerplexityModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── xAI (Grok) ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum XaiEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for XaiEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.x.ai/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.xai"]
pub struct XaiModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Cerebras ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CerebrasEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for CerebrasEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.cerebras.ai/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.cerebras"]
pub struct CerebrasModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── SambaNova ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SambanovaEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for SambanovaEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.sambanova.ai/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.sambanova"]
pub struct SambanovaModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Hyperbolic ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum HyperbolicEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for HyperbolicEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.hyperbolic.xyz/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.hyperbolic"]
pub struct HyperbolicModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── DeepInfra ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum DeepinfraEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for DeepinfraEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.deepinfra.com/v1/openai",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.deepinfra"]
pub struct DeepinfraModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Hugging Face ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum HuggingfaceEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for HuggingfaceEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://router.huggingface.co/v1",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.huggingface"]
pub struct HuggingfaceModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── AI21 ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum Ai21Endpoint {
    #[default]
    Default,
}
impl ModelEndpoint for Ai21Endpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.ai21.com/studio/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.ai21"]
pub struct Ai21ModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Reka ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RekaEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for RekaEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.reka.ai/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.reka"]
pub struct RekaModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── BaseTen ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum BasetenEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for BasetenEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://inference.baseten.co/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.baseten"]
pub struct BasetenModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── NScale ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum NscaleEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for NscaleEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://inference.api.nscale.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.nscale"]
pub struct NscaleModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── AnyScale ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AnyscaleEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for AnyscaleEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.endpoints.anyscale.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.anyscale"]
pub struct AnyscaleModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Nebius ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum NebiusEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for NebiusEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.tokenfactory.nebius.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.nebius"]
pub struct NebiusModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Friendli ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum FriendliEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for FriendliEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.friendli.ai/serverless/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.friendli"]
pub struct FriendliModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Stepfun ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum StepfunEndpoint {
    /// Mainland China endpoint.
    Cn,
    /// International endpoint.
    #[default]
    Intl,
}
impl ModelEndpoint for StepfunEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Cn => "https://api.stepfun.com/v1",
            Self::Intl => "https://api.stepfun.ai/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.stepfun"]
pub struct StepfunModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    #[serde(default)]
    pub endpoint: StepfunEndpoint,
}

impl FamilyEndpoint for StepfunModelProviderConfig {
    fn endpoint_uri(&self) -> Option<&'static str> {
        Some(self.endpoint.uri())
    }
}

// ── AIHubMix ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AihubmixEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for AihubmixEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://aihubmix.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.aihubmix"]
pub struct AihubmixModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── SiliconFlow ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SiliconflowEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for SiliconflowEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.siliconflow.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.siliconflow"]
pub struct SiliconflowModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Astrai ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AstraiEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for AstraiEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://as-trai.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.astrai"]
pub struct AstraiModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Avian ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AvianEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for AvianEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.avian.io/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.avian"]
pub struct AvianModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── DeepMyst ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum DeepmystEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for DeepmystEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.deepmyst.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.deepmyst"]
pub struct DeepmystModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Venice ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum VeniceEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for VeniceEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.venice.ai",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.venice"]
pub struct VeniceModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── NEAR AI Cloud ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum NearaiEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for NearaiEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://cloud-api.near.ai/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.nearai"]
pub struct NearaiModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Novita ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum NovitaEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for NovitaEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.novita.ai/openai",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.novita"]
pub struct NovitaModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── NVIDIA ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum NvidiaEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for NvidiaEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://integrate.api.nvidia.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.nvidia"]
pub struct NvidiaModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Telnyx ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum TelnyxEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for TelnyxEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.telnyx.com/v2",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.telnyx"]
pub struct TelnyxModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Vercel AI Gateway ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum VercelEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for VercelEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://ai-gateway.vercel.sh/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.vercel"]
pub struct VercelModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Cloudflare AI Gateway ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CloudflareEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for CloudflareEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://gateway.ai.cloudflare.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.cloudflare"]
pub struct CloudflareModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── OVH ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OvhEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for OvhEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.ovh"]
pub struct OvhModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── GitHub Copilot ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CopilotEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for CopilotEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.githubcopilot.com",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.copilot"]
pub struct CopilotModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── GLM (multi-region) ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum GlmEndpoint {
    Cn,
    #[default]
    Global,
}
impl ModelEndpoint for GlmEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Cn => "https://open.bigmodel.cn/api/paas/v4",
            Self::Global => "https://api.z.ai/api/paas/v4",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.glm"]
pub struct GlmModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    #[serde(default)]
    pub endpoint: GlmEndpoint,
}

impl FamilyEndpoint for GlmModelProviderConfig {
    fn endpoint_uri(&self) -> Option<&'static str> {
        Some(self.endpoint.uri())
    }
}

// ── Minimax (multi-region + auth_mode) ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MinimaxEndpoint {
    Cn,
    #[default]
    Intl,
}
impl ModelEndpoint for MinimaxEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Cn => "https://api.minimaxi.com/v1",
            Self::Intl => "https://api.minimax.io/v1",
        }
    }
}

impl MinimaxEndpoint {
    /// OAuth `/oauth/token` endpoint for this region. Used by
    /// `refresh_minimax_oauth_access_token` to mint short-lived access
    /// tokens from the operator-supplied `oauth_refresh_token`.
    pub fn oauth_token_endpoint(self) -> &'static str {
        match self {
            Self::Cn => "https://api.minimaxi.com/oauth/token",
            Self::Intl => "https://api.minimax.io/oauth/token",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.minimax"]
pub struct MinimaxModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    #[serde(default)]
    pub endpoint: MinimaxEndpoint,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_mode: Option<AuthMode>,
    /// Long-lived OAuth refresh token issued by MiniMax. When set, the
    /// runtime exchanges it for a short-lived access token at provider
    /// construction time and uses that as the API credential. Operators
    /// who prefer dashboard-generated long-lived API keys can leave this
    /// unset and populate `api_key` directly.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[secret(category = "model_provider")]
    pub oauth_refresh_token: Option<String>,
    /// Override of MiniMax's published OAuth client_id. Most operators
    /// should leave this unset; the runtime defaults to the
    /// vendor-published client_id (same one MiniMax's own portal uses).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth_client_id: Option<String>,
}

impl FamilyEndpoint for MinimaxModelProviderConfig {
    fn endpoint_uri(&self) -> Option<&'static str> {
        Some(self.endpoint.uri())
    }
}

// ── Z.AI (multi-region) ──

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ZaiEndpoint {
    Cn,
    #[default]
    Global,
}
impl ModelEndpoint for ZaiEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Cn => "https://open.bigmodel.cn/api/coding/paas/v4",
            Self::Global => "https://api.z.ai/api/coding/paas/v4",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.zai"]
pub struct ZaiModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    #[serde(default)]
    pub endpoint: ZaiEndpoint,
}

impl FamilyEndpoint for ZaiModelProviderConfig {
    fn endpoint_uri(&self) -> Option<&'static str> {
        Some(self.endpoint.uri())
    }
}

// ── Doubao (Volcengine; single canonical endpoint) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum DoubaoEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for DoubaoEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://ark.cn-beijing.volces.com/api/v3",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.doubao"]
pub struct DoubaoModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Yi (Lingyiwanwu; single endpoint) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum YiEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for YiEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.lingyiwanwu.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.yi"]
pub struct YiModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Hunyuan (Tencent; single endpoint) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum HunyuanEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for HunyuanEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.hunyuan.cloud.tencent.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.hunyuan"]
pub struct HunyuanModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Qianfan (Baidu) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum QianfanEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for QianfanEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://qianfan.baidubce.com/v2",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.qianfan"]
pub struct QianfanModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Baichuan ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum BaichuanEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for BaichuanEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.baichuan-ai.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.baichuan"]
pub struct BaichuanModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Gemini (OAuth-capable) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum GeminiEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for GeminiEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://generativelanguage.googleapis.com/v1beta",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.gemini"]
pub struct GeminiModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    /// Auth flow. Defaults to `api_key`; `oauth` uses GeminiModelProvider's
    /// OAuth-cache integration instead of the `api_key` field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_mode: Option<AuthMode>,
    /// Google OAuth app `client_id`, used when this alias drives ZeroClaw's
    /// own browser/device-code login flow (`zeroclaw auth login
    /// --model-provider gemini --profile <alias>`). Operators relying on
    /// the upstream `gemini login` tool don't need this; that tool writes
    /// its own client_id / client_secret into `~/.gemini/oauth_creds.json`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[secret(category = "model_provider")]
    pub oauth_client_id: Option<String>,
    /// Google OAuth app `client_secret`. Set alongside `oauth_client_id`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[secret(category = "model_provider")]
    pub oauth_client_secret: Option<String>,
    /// Pin a specific GCP project ID for the OAuth `loadCodeAssist`
    /// discovery call. When unset, the discovery probes for an
    /// already-onboarded project on the credential's account. Replaces
    /// `GOOGLE_CLOUD_PROJECT` / `GOOGLE_CLOUD_PROJECT_ID` env vars.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth_project: Option<String>,
}

// ── Gemini CLI (subprocess wrapper) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum GeminiCliEndpoint {
    #[default]
    LocalSubprocess,
}
impl ModelEndpoint for GeminiCliEndpoint {
    fn uri(&self) -> &'static str {
        // Subprocess — no remote endpoint. Sentinel for trait conformity.
        "subprocess://gemini-cli"
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.gemini_cli"]
pub struct GeminiCliModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    /// Path to the `gemini` CLI binary. Falls back to `gemini` (PATH lookup).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub binary_path: Option<String>,
}

// ── LMStudio (local default) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LmstudioEndpoint {
    #[default]
    LocalDefault,
}
impl ModelEndpoint for LmstudioEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalDefault => "http://localhost:1234/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.lmstudio"]
pub struct LmstudioModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── llama.cpp (local default) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LlamacppEndpoint {
    #[default]
    LocalDefault,
}
impl ModelEndpoint for LlamacppEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalDefault => "http://localhost:8080/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.llamacpp"]
pub struct LlamacppModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── SGLang (local default) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SglangEndpoint {
    #[default]
    LocalDefault,
}
impl ModelEndpoint for SglangEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalDefault => "http://localhost:30000/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.sglang"]
pub struct SglangModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── vLLM (local default) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum VllmEndpoint {
    #[default]
    LocalDefault,
}
impl ModelEndpoint for VllmEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalDefault => "http://localhost:8000/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.vllm"]
pub struct VllmModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Osaurus (local default) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OsaurusEndpoint {
    #[default]
    LocalDefault,
}
impl ModelEndpoint for OsaurusEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalDefault => "http://localhost:1337/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.osaurus"]
pub struct OsaurusModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── LiteLLM (operator-self-hosted gateway) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LitellmEndpoint {
    #[default]
    LocalDefault,
}
impl ModelEndpoint for LitellmEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalDefault => "http://localhost:4000/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.litellm"]
pub struct LitellmModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Lepton ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LeptonEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for LeptonEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://llama3-1-405b.lepton.run/api/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.lepton"]
pub struct LeptonModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Morph ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MorphEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for MorphEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.morphllm.com/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.morph"]
pub struct MorphModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Manifest ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ManifestEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for ManifestEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://app.manifest.build/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.manifest"]
pub struct ManifestModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── GitHub Models ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum GithubModelsEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for GithubModelsEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://models.github.ai/inference",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.github_models"]
pub struct GithubModelsModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Upstage ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum UpstageEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for UpstageEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.upstage.ai/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.upstage"]
pub struct UpstageModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Featherless ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum FeatherlessEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for FeatherlessEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.featherless.ai/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.featherless"]
pub struct FeatherlessModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Arcee ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ArceeEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for ArceeEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            // Arcee publishes its OpenAI-compatible API at the `/api/v1` path
            // (not the conventional `/v1` root). Confirmed against Arcee docs.
            Self::Default => "https://api.arcee.ai/api/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.arcee"]
pub struct ArceeModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Lambda AI ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LambdaAiEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for LambdaAiEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.lambda.ai/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.lambda_ai"]
pub struct LambdaAiModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Inception ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum InceptionEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for InceptionEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.inceptionlabs.ai/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.inception"]
pub struct InceptionModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Synthetic ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SyntheticEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for SyntheticEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.synthetic.new/openai/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.synthetic"]
pub struct SyntheticModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── OpenCode (Zen) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpencodeEndpoint {
    #[default]
    Default,
}
impl ModelEndpoint for OpencodeEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://opencode.ai/zen/v1",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.opencode"]
pub struct OpencodeModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── KiloCli (subprocess wrapper) ──

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum KiloCliEndpoint {
    #[default]
    LocalSubprocess,
}
impl ModelEndpoint for KiloCliEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalSubprocess => "subprocess://kilocli",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.kilocli"]
pub struct KiloCliModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    /// Path to the `kilo` CLI binary. Falls back to `kilo` (PATH lookup).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub binary_path: Option<String>,
}

// ── Kilo (AI Gateway — OpenAI-compatible) ──

/// Kilo AI Gateway endpoint. Single canonical endpoint at kilo.ai.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum KiloEndpoint {
    #[default]
    Gateway,
}
impl ModelEndpoint for KiloEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Gateway => "https://api.kilo.ai/api/gateway",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.kilo"]
pub struct KiloModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    /// Kilo endpoint variant. Defaults to the canonical Kilo AI Gateway.
    #[serde(default, skip_serializing_if = "KiloEndpoint::is_default")]
    pub endpoint: KiloEndpoint,
}

impl KiloEndpoint {
    fn is_default(&self) -> bool {
        matches!(self, Self::Gateway)
    }
}

impl FamilyEndpoint for KiloModelProviderConfig {
    fn endpoint_uri(&self) -> Option<&'static str> {
        Some(self.endpoint.uri())
    }
}

// ── Custom (user-supplied URL, no canonical default) ──

/// Custom catch-all for operator-defined endpoints. The endpoint variant has
/// no canonical URL — operators must always set `base.uri`. The trait return
/// is a sentinel string; the runtime constructor must verify `base.uri` is
/// set for `custom` entries and fail with a clear error if not.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CustomEndpoint {
    #[default]
    OperatorSupplied,
}
impl ModelEndpoint for CustomEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::OperatorSupplied => "operator-supplied:set-cfg-uri",
        }
    }
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.custom"]
pub struct CustomModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
}

// ── Bedrock (computed-endpoint exemplar, AWS region template) ──

/// AWS Bedrock endpoint template. Single variant; the URL is computed at
/// runtime by substituting `{region}` from the typed config field.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum BedrockEndpoint {
    #[default]
    Default,
}

impl ModelEndpoint for BedrockEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            // Bedrock URI is a template — substitution happens in the
            // BedrockModelProvider runtime constructor against cfg.region.
            Self::Default => "https://bedrock-runtime.{region}.amazonaws.com",
        }
    }
}

/// AWS Bedrock model model_provider config. Carries the AWS region (the URI
/// template substitutes `{region}` from this field). Bedrock auth is
/// SigV4 — credentials come from the standard AWS credential chain
/// (env vars, instance metadata, profile), not from `api_key`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.models.bedrock"]
pub struct BedrockModelProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: ModelProviderConfig,
    /// AWS region for the Bedrock endpoint (e.g. `us-east-1`, `eu-west-1`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub region: Option<String>,
}

// ── FamilyEndpoint default impls (single-endpoint families) ─────
//
// Multi-endpoint families (Moonshot, Qwen, Glm, Minimax, Zai, Stepfun) define
// their own `impl FamilyEndpoint` next to the struct. Every other family
// gets the `None` default via this list. The list is exhaustive: a new
// family with no impl here AND no manual impl elsewhere will fail to
// compile against `ModelProviders::resolved_endpoint_uri`, which expands
// `endpoint_uri()` per slot through `for_each_model_provider_slot!`.

macro_rules! impl_default_family_endpoint {
    ($($t:ty),+ $(,)?) => {
        $( impl FamilyEndpoint for $t {} )+
    };
}

impl_default_family_endpoint! {
    OpenAIModelProviderConfig,
    AzureModelProviderConfig,
    AnthropicModelProviderConfig,
    AtomicChatModelProviderConfig,
    OpenRouterModelProviderConfig,
    OllamaModelProviderConfig,
    TogetherModelProviderConfig,
    FireworksModelProviderConfig,
    GroqModelProviderConfig,
    MistralModelProviderConfig,
    DeepseekModelProviderConfig,
    CohereModelProviderConfig,
    PerplexityModelProviderConfig,
    XaiModelProviderConfig,
    CerebrasModelProviderConfig,
    SambanovaModelProviderConfig,
    HyperbolicModelProviderConfig,
    DeepinfraModelProviderConfig,
    HuggingfaceModelProviderConfig,
    Ai21ModelProviderConfig,
    RekaModelProviderConfig,
    BasetenModelProviderConfig,
    NscaleModelProviderConfig,
    AnyscaleModelProviderConfig,
    NebiusModelProviderConfig,
    FriendliModelProviderConfig,
    AihubmixModelProviderConfig,
    SiliconflowModelProviderConfig,
    AstraiModelProviderConfig,
    AvianModelProviderConfig,
    DeepmystModelProviderConfig,
    VeniceModelProviderConfig,
    NearaiModelProviderConfig,
    NovitaModelProviderConfig,
    NvidiaModelProviderConfig,
    TelnyxModelProviderConfig,
    VercelModelProviderConfig,
    CloudflareModelProviderConfig,
    OvhModelProviderConfig,
    CopilotModelProviderConfig,
    DoubaoModelProviderConfig,
    YiModelProviderConfig,
    HunyuanModelProviderConfig,
    QianfanModelProviderConfig,
    BaichuanModelProviderConfig,
    GeminiModelProviderConfig,
    GeminiCliModelProviderConfig,
    LmstudioModelProviderConfig,
    LlamacppModelProviderConfig,
    SglangModelProviderConfig,
    VllmModelProviderConfig,
    OsaurusModelProviderConfig,
    LitellmModelProviderConfig,
    LeptonModelProviderConfig,
    ManifestModelProviderConfig,
    MorphModelProviderConfig,
    GithubModelsModelProviderConfig,
    UpstageModelProviderConfig,
    FeatherlessModelProviderConfig,
    ArceeModelProviderConfig,
    LambdaAiModelProviderConfig,
    InceptionModelProviderConfig,
    SyntheticModelProviderConfig,
    OpencodeModelProviderConfig,
    KiloCliModelProviderConfig,
    CustomModelProviderConfig,
    BedrockModelProviderConfig,
}

// ── Delegate Tool Configuration ─────────────────────────────────

/// Global delegate tool configuration for default timeout values.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "delegate"]
pub struct DelegateToolConfig {
    /// Default timeout in seconds for non-agentic sub-agent model_provider calls.
    /// Can be overridden per-agent in `[agents.<name>]` config.
    /// Default: 120 seconds.
    #[serde(default = "default_delegate_timeout_secs")]
    pub timeout_secs: u64,
    /// Default timeout in seconds for agentic sub-agent runs.
    /// Can be overridden per-agent in `[agents.<name>]` config.
    /// Default: 300 seconds.
    #[serde(default = "default_delegate_agentic_timeout_secs")]
    pub agentic_timeout_secs: u64,
}

impl Default for DelegateToolConfig {
    fn default() -> Self {
        Self {
            timeout_secs: DEFAULT_DELEGATE_TIMEOUT_SECS,
            agentic_timeout_secs: DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS,
        }
    }
}

// ── Aliased Agents ───────────────────────────────────────────────

/// Runtime tunables resolved from the agent's runtime profile. Populated
/// by `Config::resolved_agent_config`; never deserialized from the agent
/// table. The runtime profile is the sole config surface for these.
#[derive(Debug, Clone)]
pub struct ResolvedRuntime {
    pub compact_context: bool,
    pub max_tool_iterations: usize,
    pub max_history_messages: usize,
    /// Token budget for preemptive context/history trimming (from runtime profile).
    /// NOT the provider `max_tokens` output limit.
    pub max_context_tokens: usize,
    /// Model's context window (max input tokens) — from provider config.
    pub model_context_window: usize,
    pub parallel_tools: bool,
    pub tool_dispatcher: String,
    pub strict_tool_parsing: bool,
    pub tool_call_dedup_exempt: Vec<String>,
    pub tool_filter_groups: Vec<ToolFilterGroup>,
    pub max_system_prompt_chars: usize,
    pub thinking: crate::scattered_types::ThinkingConfig,
    pub history_pruning: crate::scattered_types::HistoryPrunerConfig,
    pub eval: crate::scattered_types::EvalConfig,
    pub auto_classify: Option<crate::scattered_types::AutoClassifyConfig>,
    pub context_compression: crate::scattered_types::ContextCompressionConfig,
    pub max_tool_result_chars: usize,
    pub keep_tool_context_turns: usize,
    pub tool_receipts: ToolReceiptsConfig,
    pub prompt_injection_mode: SkillsPromptInjectionMode,
}

impl ResolvedRuntime {
    /// Effective token budget for preemptive whole-turn history trimming.
    /// When `history_pruning.enabled` is set, an explicit `max_tokens` floor
    /// trims earlier than the hard context ceiling; otherwise the ceiling is
    /// the only trigger. Reuses the existing `history_pruning.*` idents.
    pub fn effective_context_budget(&self) -> usize {
        if self.history_pruning.enabled && self.history_pruning.max_tokens > 0 {
            self.max_context_tokens.min(self.history_pruning.max_tokens)
        } else {
            self.max_context_tokens
        }
    }
}

impl Default for ResolvedRuntime {
    fn default() -> Self {
        Self {
            compact_context: true,
            max_tool_iterations: 10,
            max_history_messages: 50,
            max_context_tokens: 32_000,
            model_context_window: 32_000,
            parallel_tools: false,
            tool_dispatcher: default_agent_tool_dispatcher(),
            strict_tool_parsing: false,
            tool_call_dedup_exempt: Vec::new(),
            tool_filter_groups: Vec::new(),
            max_system_prompt_chars: default_max_system_prompt_chars(),
            thinking: crate::scattered_types::ThinkingConfig::default(),
            history_pruning: crate::scattered_types::HistoryPrunerConfig::default(),
            eval: crate::scattered_types::EvalConfig::default(),
            auto_classify: None,
            context_compression: crate::scattered_types::ContextCompressionConfig::default(),
            max_tool_result_chars: default_max_tool_result_chars(),
            keep_tool_context_turns: default_keep_tool_context_turns(),
            tool_receipts: ToolReceiptsConfig::default(),
            prompt_injection_mode: SkillsPromptInjectionMode::default(),
        }
    }
}

/// Execution semantics for one explicit delegate target.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum DelegateExecutionMode {
    /// Parent-bounded delegation: the target is reachable through the caller,
    /// shares caller budgets, and agentic tools are capped by the caller's
    /// tool envelope.
    #[default]
    Bounded,
    /// Independent delegation: the target runs under its own configured
    /// policy/tool envelope, like opening a new chat with that agent.
    Independent,
}

/// One explicit delegate target listed under `[agents.<alias>].delegates`.
///
/// String entries deserialize as `{ agent = "<string>", mode = "bounded" }`
/// for concise manual editing. Serialization always emits the object form so
/// the config surface is a pure object array.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Configurable, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "delegate_target"]
pub struct DelegateTargetConfig {
    /// Target agent alias.
    pub agent: String,
    /// Whether this target is parent-bounded or independent.
    #[serde(default)]
    pub mode: DelegateExecutionMode,
}

impl DelegateTargetConfig {
    /// Construct the legacy-equivalent target shape used for string entries.
    ///
    /// Most tests and migration paths call this instead of spelling out
    /// `mode = Bounded`, making it explicit that a bare delegate alias never
    /// opts into independent execution.
    #[must_use]
    pub fn bounded(agent: impl Into<String>) -> Self {
        Self {
            agent: agent.into(),
            mode: DelegateExecutionMode::Bounded,
        }
    }

    /// Return the raw configured alias.
    ///
    /// Callers that compare aliases should trim at the comparison boundary so
    /// diagnostics can still point at the stored value when validation fails.
    #[must_use]
    pub fn agent(&self) -> &str {
        self.agent.as_str()
    }

    /// Return the configured execution mode for this explicit target.
    #[must_use]
    pub fn mode(&self) -> DelegateExecutionMode {
        self.mode
    }
}

impl<'de> Deserialize<'de> for DelegateTargetConfig {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct DelegateTargetVisitor;

        impl<'de> Visitor<'de> for DelegateTargetVisitor {
            type Value = DelegateTargetConfig;

            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                formatter.write_str("an agent alias string or a delegate target object")
            }

            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
            where
                E: de::Error,
            {
                Ok(DelegateTargetConfig::bounded(value))
            }

            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
            where
                E: de::Error,
            {
                Ok(DelegateTargetConfig::bounded(value))
            }

            fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
            where
                M: MapAccess<'de>,
            {
                // Keep the object arm strict so misspelled mode/agent fields do
                // not silently round-trip as bounded targets. String entries
                // already provide the forgiving legacy shape.
                #[derive(Deserialize)]
                #[serde(deny_unknown_fields)]
                struct DelegateTargetObject {
                    agent: String,
                    #[serde(default)]
                    mode: DelegateExecutionMode,
                }

                let target =
                    DelegateTargetObject::deserialize(de::value::MapAccessDeserializer::new(map))?;
                Ok(DelegateTargetConfig {
                    agent: target.agent,
                    mode: target.mode,
                })
            }
        }

        deserializer.deserialize_any(DelegateTargetVisitor)
    }
}

/// Configuration for an aliased agent. Each `[agents.<alias>]` TOML
/// block deserializes into one of these. The `DelegateTool` looks up
/// entries here to dispatch a subtask to a named sibling agent.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "delegate_agent"]
pub struct AliasedAgentConfig {
    /// Whether this agent is active. Set false to disable without removing the definition.
    #[tab(General)]
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Channel aliases this agent handles (e.g. `["telegram.<alias>", "discord.<alias>"]`).
    /// Each entry is a `ChannelRef` resolving through `[channels.<type>.<alias>]`;
    /// `Config::validate()` fails loud on dangling references.
    #[tab(Channels)]
    #[serde(default)]
    pub channels: Vec<crate::providers::ChannelRef>,
    /// Dotted model-provider alias (e.g. `"anthropic.<alias>"`).
    /// Resolves through `model_providers.<type>.<alias>` at runtime;
    /// `Config::validate()` fails loud on dangling references.
    #[tab(Providers)]
    #[serde(default)]
    pub model_provider: crate::providers::ModelProviderRef,
    /// Risk profile alias (e.g. `"default"`). Resolves delegation guardrails at runtime.
    #[tab(General)]
    #[serde(default)]
    pub risk_profile: crate::providers::RiskProfileRef,
    /// Runtime profile alias (e.g. `"default"`). Resolves agentic/iteration settings.
    #[tab(General)]
    #[serde(default)]
    pub runtime_profile: crate::providers::RuntimeProfileRef,
    /// Skill bundle aliases. Each entry resolves to
    /// `skill_bundles[key].directory` at runtime; the agent loads every
    /// listed bundle.
    #[tab(Bundles)]
    #[serde(default)]
    pub skill_bundles: Vec<String>,
    /// Knowledge bundle aliases. Additive: the agent loads every listed
    /// bundle.
    #[tab(Bundles)]
    #[serde(default)]
    pub knowledge_bundles: Vec<String>,
    /// MCP bundle aliases. Each entry references `mcp_bundles[key]`, a named
    /// group of MCP servers. Secure by default: an agent is granted only the
    /// servers named by its bundles. An agent with no `mcp_bundles` receives
    /// no MCP servers (omission is not a grant). See
    /// `Config::mcp_servers_for_agent`.
    #[tab(Bundles)]
    #[serde(default)]
    pub mcp_bundles: Vec<String>,
    /// Initialize this agent's `mcp_bundles` tools when it serves an ACP
    /// (`session/new`) session.
    ///
    /// Off by default: MCP servers are external processes/services that can
    /// block startup while they connect, and ACP `session/new` is expected to
    /// return promptly. Enable it when this agent must call its `mcp_bundles`
    /// tools over ACP; `session/new` then pays the one-time MCP connection cost
    /// (bounded and non-fatal per server). Set per agent so each ACP profile
    /// opts in independently; when this agent is the ACP default
    /// (`acp.default_agent`, or the sole configured agent), the flag is picked
    /// up automatically for sessions that omit `agentAlias`.
    #[tab(Bundles)]
    #[serde(default)]
    pub acp_enable_mcp: bool,
    /// Cron job aliases. Each entry references `cron[key]`, a declarative
    /// scheduled job invoked by the scheduler on its configured trigger.
    /// When the cron fires, this agent is the actor that executes the job.
    #[tab(Cron)]
    #[serde(default)]
    pub cron_jobs: Vec<String>,
    /// TTS provider as a dotted alias reference (`<type>.<alias>`,
    /// e.g. `"openai.<alias>"`). Resolves through `tts_providers.<type>.<alias>`.
    /// Empty = no TTS for this agent (there is no global default-provider concept;
    /// every agent that wants TTS sets its own `tts_provider`).
    #[tab(Providers)]
    #[serde(default)]
    pub tts_provider: crate::providers::TtsProviderRef,
    /// Transcription / STT provider as a dotted alias reference
    /// (`<type>.<alias>`, e.g. `"groq.<alias>"`). Resolves through
    /// `transcription_providers.<type>.<alias>`. Empty = agent has no
    /// transcription preference; channels that ingest voice still need a
    /// resolved provider (there is no global default), so an inbound voice
    /// flow into an agent with empty `transcription_provider` errors loudly
    /// at the channel boundary.
    #[tab(Providers)]
    #[serde(default)]
    pub transcription_provider: crate::providers::TranscriptionProviderRef,

    /// Optional override for the per-message LLM reply-intent classifier
    /// (`classify_channel_reply_intent` in zeroclaw-channels). When non-empty,
    /// the channel orchestrator routes the "should this message be replied to?"
    /// classification call to `[providers.models.<type>.<alias>]` referenced
    /// here, instead of reusing the main agent's `model_provider`.
    ///
    /// Source of truth for api_key / uri / model / temperature etc. is the
    /// referenced `[providers.models.<type>.<alias>]` entry. This field is
    /// a reference only (NEVER a copy), per AGENTS.md SINGLE SOURCE OF TRUTH.
    ///
    /// Empty (`Default`) = inherit the main agent's resolved provider+model
    /// (preserves pre-PR behavior; backward compatible).
    ///
    /// Use case: classification is a cheap REPLY/NO_REPLY decision, doesn't
    /// need a high-end model. Point this at a fast/free small model
    /// (e.g. `kimi-k2.5`, `qwen-turbo`) while `model_provider` stays on the
    /// expensive answering model (e.g. `qwen3.6-plus`).
    ///
    /// Note: TOML table names cannot contain `.`, so alias `kimi-k2.5`
    /// must be written as `[providers.models.custom.kimi-k2-5]`. The
    /// underlying `model = "kimi-k2.5"` string can still contain dots.
    ///
    /// ACP channels (IDE-direct) always reply and skip the classifier
    /// entirely, so this field has no effect on ACP traffic.
    #[tab(Providers)]
    #[serde(default)]
    pub classifier_provider: crate::providers::ModelProviderRef,

    /// Per-agent reply-intent precheck controls. The classifier call reads
    /// this block at message time; model/provider selection stays on
    /// `classifier_provider` so there is only one routing source of truth.
    #[tab(Channels)]
    #[serde(default)]
    #[nested]
    pub precheck: crate::scattered_types::ChannelPrecheckConfig,

    /// Per-agent override for the context-compression summarizer provider, as
    /// a `providers.models.<type>.<alias>` reference. Empty (Default) = inherit
    /// the runtime profile's `context_compression.summary_provider`, else the
    /// agent's own resolved provider+model. Reference only, never a copy;
    /// resolved by [`Config::effective_summary_provider`]. Validated in
    /// `Config::validate()`.
    #[tab(Providers)]
    #[serde(default)]
    pub summary_provider: crate::providers::ModelProviderRef,

    /// Auto-allow delegation to every agent sharing this agent's risk
    /// profile. Default `true` preserves the historical reach where any
    /// same-profile peer is a delegation target. Set `false` to opt this
    /// agent out so only the explicit `delegates` list is reachable.
    /// Gating (whether delegation is permitted at all) still lives on the
    /// risk profile's `delegation_policy.mode`; this only narrows reach.
    #[tab(General)]
    #[serde(default = "default_true")]
    pub delegate_same_risk_profile: bool,

    /// Explicit delegate roster: additional agent aliases this agent may
    /// delegate to, beyond same-profile peers. Possibly empty. String
    /// entries are accepted for concise manual editing and load as bounded
    /// delegates; saved config emits object entries with explicit modes.
    /// Entries may name agents on a different risk profile. `Config::validate()`
    /// fails loud on a dangling alias, duplicate alias, or self-reference.
    #[tab(General)]
    #[serde(default)]
    pub delegates: Vec<DelegateTargetConfig>,

    // ── Resolved runtime tunables (populated by `resolved_agent_config`
    // from the runtime profile; not config-settable on the agent). ──
    #[serde(skip)]
    pub resolved: ResolvedRuntime,

    /// Per-agent workspace block (`[agents.<alias>.workspace]`).
    /// Holds the agent's filesystem path, cross-agent access allowlist,
    /// filesystem-escape boolean, and cross-agent memory allowlist.
    /// Default is fully jailed (no cross-agent access). See
    /// `crate::multi_agent::AgentWorkspaceConfig`.
    #[tab(Workspace)]
    #[serde(default)]
    #[nested]
    pub workspace: crate::multi_agent::AgentWorkspaceConfig,

    /// Per-agent memory backend selection (`[agents.<alias>.memory]`).
    /// The `backend` field is locked at agent creation and immutable on
    /// subsequent loads. Defaults to `Sqlite`. See
    /// `crate::multi_agent::AgentMemoryConfig`.
    #[tab(Memory)]
    #[serde(default)]
    #[nested]
    pub memory: crate::multi_agent::AgentMemoryConfig,

    /// Per-agent identity format (`[agents.<alias>.identity]`). Each
    /// agent renders its own IDENTITY.md / SOUL.md inside its
    /// per-agent workspace; this block selects the format (OpenClaw or
    /// AIEOS) and optional inline/file source for the agent's identity
    /// document.
    #[tab(Tuning)]
    #[serde(default)]
    #[nested]
    pub identity: IdentityConfig,

    /// Per-agent A2A publication block (`[agents.<alias>.a2a]`). Gates
    /// whether this alias is discoverable as a spec-conforming A2A agent
    /// and which resolved skills appear on its card. Default-closed
    /// (`published = false`, no exposed skills). See
    /// `crate::multi_agent::AgentA2aConfig`.
    #[tab(General)]
    #[serde(default)]
    #[nested]
    pub a2a: crate::multi_agent::AgentA2aConfig,
}

impl Default for AliasedAgentConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            channels: Vec::new(),
            model_provider: crate::providers::ModelProviderRef::default(),
            risk_profile: crate::providers::RiskProfileRef::default(),
            runtime_profile: crate::providers::RuntimeProfileRef::default(),
            skill_bundles: Vec::new(),
            knowledge_bundles: Vec::new(),
            mcp_bundles: Vec::new(),
            acp_enable_mcp: false,
            cron_jobs: Vec::new(),
            tts_provider: crate::providers::TtsProviderRef::default(),
            transcription_provider: crate::providers::TranscriptionProviderRef::default(),
            classifier_provider: crate::providers::ModelProviderRef::default(),
            precheck: crate::scattered_types::ChannelPrecheckConfig::default(),
            summary_provider: crate::providers::ModelProviderRef::default(),
            delegate_same_risk_profile: true,
            delegates: Vec::new(),
            resolved: ResolvedRuntime::default(),
            workspace: crate::multi_agent::AgentWorkspaceConfig::default(),
            memory: crate::multi_agent::AgentMemoryConfig::default(),
            identity: IdentityConfig::default(),
            a2a: crate::multi_agent::AgentA2aConfig::default(),
        }
    }
}

impl AliasedAgentConfig {
    /// True when this agent has the bindings required to dispatch a turn:
    /// enabled, non-empty `model_provider`, `risk_profile`, and
    /// `runtime_profile`. `Config::validate()` emits the per-field errors
    /// that, when all passed, mean this returns `true`.
    #[must_use]
    pub fn is_dispatchable(&self) -> bool {
        self.enabled
            && !self.model_provider.trim().is_empty()
            && !self.risk_profile.trim().is_empty()
            && !self.runtime_profile.trim().is_empty()
    }
}

/// One `[channels.<type>.<alias>]` block, with the owning agent (if any)
/// resolved via `agents.<agent>.channels`. Returned by
/// `Config::channels_by_alias()`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ChannelAliasInfo {
    /// Channel type as the schema emits it (kebab; e.g. `"discord"`,
    /// `"nextcloud-talk"`).
    pub channel_type: String,
    /// Per-alias HashMap key (e.g. `"loneliness"`).
    pub alias: String,
    /// The agent whose `channels` list contains `<type>.<alias>`. `None`
    /// when the block is orphaned (config error caught at startup).
    pub owning_agent: Option<String>,
    /// Resolved value of `[channels.<type>.<alias>].enabled` at scan time.
    /// `false` when the field is unset (matches the serde bool default).
    pub enabled: bool,
}

impl Config {
    /// Resolve the configured alias values valid for an [`crate::traits::AliasSource`].
    /// Two-tier sources return dotted `<type>.<alias>` keys; flat sources
    /// return bare alias keys. The single resolver every surface uses for
    /// `PropKind::AliasRef` pickers and validation.
    #[must_use]
    pub fn resolve_alias_source(&self, source: crate::traits::AliasSource) -> Vec<String> {
        let section = source.section_path();
        if source.is_two_tier() {
            let prefix = format!("{section}.");
            let mut out: Vec<String> = Vec::new();
            for f in self.prop_fields() {
                if let Some(rest) = f.name.strip_prefix(&prefix) {
                    let mut parts = rest.splitn(3, '.');
                    if let (Some(ty), Some(alias), Some(_)) =
                        (parts.next(), parts.next(), parts.next())
                    {
                        let dotted = format!("{ty}.{alias}");
                        if !out.contains(&dotted) {
                            out.push(dotted);
                        }
                    }
                }
            }
            out.sort();
            out
        } else {
            let mut out = self.get_map_keys(section).unwrap_or_default();
            out.sort();
            out
        }
    }

    /// Return the first concrete `model` string available for use as a
    /// default. Scans every typed slot's entries (iteration order is
    /// the macro slot order) for one with `model` set. Returns `None`
    /// only when no model-provider entry has any model configured at
    /// all.
    #[must_use]
    pub fn resolve_default_model(&self) -> Option<String> {
        self.providers
            .models
            .iter_entries()
            .filter_map(|(_, _, base)| base.model.as_deref().map(str::trim))
            .find(|m| !m.is_empty())
            .map(ToString::to_string)
    }

    /// Resolve the risk profile for an explicit agent alias.
    ///
    /// Each agent's `risk_profile` field names a `[risk_profiles.<alias>]`
    /// entry that gates its actions. There is no "global" risk profile in
    /// every callsite must come through an agent. When the agent has
    /// no profile set or names a missing entry, returns `None` and the
    /// caller decides how to handle it (validation rejects this shape at
    /// load time; the runtime treating `None` as a config error).
    #[must_use]
    pub fn risk_profile_for_agent(&self, agent_alias: &str) -> Option<&RiskProfileConfig> {
        let agent = self.agents.get(agent_alias)?;
        let profile_alias = agent.risk_profile.trim();
        if profile_alias.is_empty() {
            return None;
        }
        self.risk_profiles.get(profile_alias)
    }

    /// Resolve the delegate targets `caller_alias` may reach:
    /// same-profile peers when `delegate_same_risk_profile` is set, unioned
    /// with the explicit `delegates` roster, minus the caller. Single
    /// source of truth for delegate reach and mode; gating
    /// (`delegation_policy`) is enforced separately by the caller.
    /// Deduped, sorted; unknown caller yields empty. Disabled targets are
    /// never reachable. Explicit entries override the bounded mode used for
    /// implicit same-profile peers.
    #[must_use]
    pub fn reachable_delegate_target_configs(
        &self,
        caller_alias: &str,
    ) -> Vec<DelegateTargetConfig> {
        let Some(caller) = self.agents.get(caller_alias) else {
            return Vec::new();
        };

        let mut targets: std::collections::BTreeMap<String, DelegateExecutionMode> =
            std::collections::BTreeMap::new();

        if caller.delegate_same_risk_profile {
            let caller_profile = caller.risk_profile.trim();
            if !caller_profile.is_empty() {
                for (alias, agent) in &self.agents {
                    if alias.as_str() != caller_alias
                        && agent.enabled
                        && agent.risk_profile.trim() == caller_profile
                    {
                        targets.insert(alias.clone(), DelegateExecutionMode::Bounded);
                    }
                }
            }
        }

        for explicit in &caller.delegates {
            let trimmed = explicit.agent().trim();
            if trimmed.is_empty() || trimmed == caller_alias {
                continue;
            }
            if self.agents.get(trimmed).is_some_and(|agent| agent.enabled) {
                targets.insert(trimmed.to_string(), explicit.mode());
            }
        }

        targets
            .into_iter()
            .map(|(agent, mode)| DelegateTargetConfig { agent, mode })
            .collect()
    }

    /// Resolve only the agent aliases reachable by `caller_alias`.
    /// Use [`Self::reachable_delegate_target_configs`] when the target mode
    /// matters.
    #[must_use]
    pub fn reachable_delegate_targets(&self, caller_alias: &str) -> Vec<String> {
        self.reachable_delegate_target_configs(caller_alias)
            .into_iter()
            .map(|target| target.agent)
            .collect()
    }

    /// Resolve the execution mode for one reachable delegate target.
    ///
    /// Target aliases are normalized the same way the reachable roster
    /// normalizes explicit `delegates` entries. This keeps callers that need
    /// one target mode aligned with the canonical roster resolver instead of
    /// creating a subtly different admission rule.
    #[must_use]
    pub fn delegate_target_mode(
        &self,
        caller_alias: &str,
        target_alias: &str,
    ) -> Option<DelegateExecutionMode> {
        let target_alias = target_alias.trim();
        if target_alias.is_empty() {
            return None;
        }

        self.reachable_delegate_target_configs(caller_alias)
            .into_iter()
            .find(|target| target.agent == target_alias)
            .map(|target| target.mode)
    }

    /// Resolve the `[runtime_profiles.<alias>]` entry owned by an agent
    /// (via `agents.<alias>.runtime_profile`). Returns `None` when the
    /// agent has no runtime profile set or names a missing entry. Unlike
    /// `risk_profile_for_agent`, the missing case is not a hard error
    /// because runtime budgets and tunables fall back to global defaults.
    #[must_use]
    pub fn runtime_profile_for_agent(&self, agent_alias: &str) -> Option<&RuntimeProfileConfig> {
        let agent = self.agents.get(agent_alias)?;
        let profile_alias = agent.runtime_profile.trim();
        if profile_alias.is_empty() {
            return None;
        }
        self.runtime_profiles.get(profile_alias)
    }

    /// Effective context-compression summarizer provider for an agent:
    /// agent-level `summary_provider` override → the runtime profile's
    /// `context_compression.summary_provider` → `None` (the caller then reuses
    /// the agent's own provider+model, optionally via the deprecated
    /// `summary_model` swap). Unlike the inert agent-inline tunables below, the
    /// agent-level override IS consulted — it's an explicit per-agent choice,
    /// mirroring `classifier_provider`'s "empty = inherit" semantics.
    #[must_use]
    pub fn effective_summary_provider(
        &self,
        agent_alias: &str,
    ) -> Option<crate::providers::ModelProviderRef> {
        if let Some(a) = self.agents.get(agent_alias)
            && !a.summary_provider.trim().is_empty()
        {
            return Some(a.summary_provider.clone());
        }
        self.runtime_profile_for_agent(agent_alias)
            .map(|p| &p.context_compression.summary_provider)
            .filter(|r| !r.trim().is_empty())
            .cloned()
    }

    // ── Effective per-agent runtime tunables ──────────────────────────
    //
    // Runtime tunables live on `[runtime_profiles.<profile>]`, referenced by an
    // agent via `agents.<alias>.runtime_profile`. A profile value that is
    // explicitly set (non-sentinel, i.e. `> 0` for `max_tool_iterations`) is
    // authoritative; otherwise the global default applies. Agent-inline copies
    // of these tunable keys are inert — superseded by runtime profiles (see the
    // `agent_level_tunable_keys_are_inert` test) — so they are deliberately not
    // consulted here as a fallback.

    #[must_use]
    pub fn effective_max_tool_iterations(&self, agent_alias: &str) -> usize {
        self.runtime_profile_for_agent(agent_alias)
            .map(|p| p.max_tool_iterations)
            .filter(|&v| v > 0)
            .unwrap_or(10)
    }

    #[must_use]
    pub fn effective_max_history_messages(&self, agent_alias: &str) -> usize {
        self.runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.max_history_messages)
            .unwrap_or(50)
    }

    /// Resolve the whole-turn history cap used by structured `Agent` sessions.
    ///
    /// An explicit runtime-profile cap remains authoritative. When omitted, the
    /// cap scales with the profile's tool-iteration limit while preserving the
    /// legacy floor of 50 messages.
    #[must_use]
    pub fn effective_structured_max_history_messages(&self, agent_alias: &str) -> usize {
        if let Some(max_history_messages) = self
            .runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.max_history_messages)
        {
            return max_history_messages;
        }

        // Each tool iteration adds two structural messages; user/final assistant
        // add two more, while the floor preserves the structured default cap of 50.
        self.effective_max_tool_iterations(agent_alias)
            .saturating_mul(2)
            .saturating_add(2)
            .max(50)
    }

    #[must_use]
    pub fn effective_max_context_tokens(&self, agent_alias: &str) -> usize {
        // Token budget for preemptive context/history trimming (runtime profile override).
        // This is NOT the provider max_tokens output limit and NOT the model's context window.
        self.runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.max_context_tokens)
            .unwrap_or(32_000)
    }

    /// Returns the model's context window size (max input tokens).
    /// Source: provider config `context_window` → fallback 32,000.
    /// Does NOT check runtime profile (that's for output budget).
    #[must_use]
    pub fn effective_model_context_window(&self, agent_alias: &str) -> usize {
        // 1. Provider config (config.toml) — PRIMARY SOT for model's context window
        if let Some((_, _, provider_config)) = self.resolved_model_provider_for_agent(agent_alias)
            && let Some(ctx) = provider_config.context_window
        {
            return ctx;
        }
        // 2. Hard fallback 32,000 (stub)
        32_000
    }

    #[must_use]
    pub fn effective_memory_recall_limit(&self, agent_alias: &str) -> usize {
        let raw = self
            .runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.memory_recall_limit)
            .unwrap_or(5);
        if raw == 0 { usize::MAX } else { raw }
    }

    #[must_use]
    pub fn effective_compact_context(&self, agent_alias: &str) -> bool {
        self.runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.compact_context)
            .unwrap_or(true)
    }

    #[must_use]
    pub fn effective_parallel_tools(&self, agent_alias: &str) -> bool {
        self.runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.parallel_tools)
            .unwrap_or(false)
    }

    #[must_use]
    pub fn effective_tool_dispatcher(&self, agent_alias: &str) -> String {
        self.runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.tool_dispatcher.as_ref())
            .filter(|s| !s.trim().is_empty())
            .map_or_else(default_agent_tool_dispatcher, Clone::clone)
    }

    #[must_use]
    pub fn effective_tool_call_dedup_exempt(&self, agent_alias: &str) -> Vec<String> {
        self.runtime_profile_for_agent(agent_alias)
            .map(|p| p.tool_call_dedup_exempt.clone())
            .unwrap_or_default()
    }

    #[must_use]
    pub fn effective_max_system_prompt_chars(&self, agent_alias: &str) -> usize {
        self.runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.max_system_prompt_chars)
            .unwrap_or_else(default_max_system_prompt_chars)
    }

    #[must_use]
    pub fn effective_max_tool_result_chars(&self, agent_alias: &str) -> usize {
        self.runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.max_tool_result_chars)
            .unwrap_or_else(default_max_tool_result_chars)
    }

    #[must_use]
    pub fn effective_keep_tool_context_turns(&self, agent_alias: &str) -> usize {
        self.runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.keep_tool_context_turns)
            .unwrap_or_else(default_keep_tool_context_turns)
    }

    /// Return a clone of the named agent's `AliasedAgentConfig` with all
    /// runtime-profile overrides baked in. Use this when an `Agent` (or
    /// any other struct) needs to own a self-contained, already-resolved
    /// view of the agent's runtime knobs without holding a reference to
    /// the full `Config`.
    ///
    /// Returns `None` when `agent_alias` is not present in `agents`.
    ///
    /// Semantics: every field touched here mirrors the matching
    /// `effective_*` helper above. If you add a new runtime_profile knob,
    /// add it both to an `effective_*` helper *and* to this function so
    /// downstream consumers see consistent values regardless of which
    /// surface they read from.
    #[must_use]
    pub fn resolved_agent_config(&self, agent_alias: &str) -> Option<AliasedAgentConfig> {
        let mut out = self.agents.get(agent_alias)?.clone();
        let mut resolved = ResolvedRuntime {
            max_tool_iterations: self.effective_max_tool_iterations(agent_alias),
            max_history_messages: self.effective_max_history_messages(agent_alias),
            // Token budget for context/history trimming — from runtime profile
            max_context_tokens: self.effective_max_context_tokens(agent_alias),
            // Model's context window (max input tokens) — from provider config
            model_context_window: self.effective_model_context_window(agent_alias),
            compact_context: self.effective_compact_context(agent_alias),
            parallel_tools: self.effective_parallel_tools(agent_alias),
            tool_dispatcher: self.effective_tool_dispatcher(agent_alias),
            tool_call_dedup_exempt: self.effective_tool_call_dedup_exempt(agent_alias),
            max_system_prompt_chars: self.effective_max_system_prompt_chars(agent_alias),
            max_tool_result_chars: self.effective_max_tool_result_chars(agent_alias),
            keep_tool_context_turns: self.effective_keep_tool_context_turns(agent_alias),
            prompt_injection_mode: self.effective_skills_prompt_mode(agent_alias),
            ..ResolvedRuntime::default()
        };
        if let Some(profile) = self.runtime_profile_for_agent(agent_alias) {
            resolved.strict_tool_parsing = profile.strict_tool_parsing;
            resolved.thinking = profile.thinking.clone();
            resolved.history_pruning = profile.history_pruning.clone();
            resolved.eval = profile.eval.clone();
            resolved.auto_classify = profile.auto_classify.clone();
            resolved.context_compression = profile.context_compression.clone();
            resolved.tool_receipts = profile.tool_receipts.clone();
            resolved.tool_filter_groups = profile.tool_filter_groups.clone();
        }
        out.resolved = resolved;
        Some(out)
    }

    /// Resolve the effective skills prompt-injection mode for an agent: the
    /// agent's resolved runtime profile's `prompt_injection_mode` override when
    /// set, otherwise the global `[skills] prompt_injection_mode`. Agents with
    /// no runtime profile (or an unknown alias) fall back to the global value.
    ///
    /// Keyed on the resolved runtime profile — the sanctioned surface for
    /// per-agent runtime tunables — so agent-inline knobs stay inert.
    /// Centralizing the fallback here keeps the system-prompt builder and the
    /// `read_skill` tool-registration gate in lockstep on one effective mode,
    /// so a compact prompt is never paired with a missing `read_skill` tool
    /// (or a full prompt with a spurious one).
    #[must_use]
    pub fn effective_skills_prompt_mode(&self, agent_alias: &str) -> SkillsPromptInjectionMode {
        self.runtime_profile_for_agent(agent_alias)
            .and_then(|p| p.prompt_injection_mode)
            .unwrap_or(self.skills.prompt_injection_mode)
    }

    /// Resolve an agent's `model_provider` reference (`"<type>.<alias>"`) to
    /// its concrete `ModelProviderConfig` entry. Returns `None` when the
    /// agent doesn't exist, the reference is unparseable, or the
    /// `<type>.<alias>` pair doesn't resolve in `providers.models`.
    ///
    /// This is the lookup the orchestrator uses to build per-agent
    /// model_provider runtime options via explicit `<type>.<alias>`
    /// resolution — there is no concept of a "first" or "default"
    /// provider. The matching split logic lives in
    /// `crates/zeroclaw-runtime/src/tools/delegate.rs::resolve_brain` for
    /// the delegation path; this helper exposes the same contract for the
    /// channel-server startup path.
    #[must_use]
    pub fn model_provider_for_agent(&self, agent_alias: &str) -> Option<&ModelProviderConfig> {
        let agent = self.agents.get(agent_alias)?;
        let (type_key, alias_key) = agent.model_provider.split_once('.')?;
        self.providers.models.find(type_key, alias_key)
    }

    /// Resolve `(provider_type, provider_alias, &ModelProviderConfig)` for an
    /// agent. Same lookup as `model_provider_for_agent` but also returns the
    /// `'static` type key that downstream provider factories
    /// (`create_routed_model_provider_with_options`, etc.) need. Returns
    /// `None` when the agent has no `model_provider` set, when the reference
    /// is unparseable, or when the resolved entry has been deleted from
    /// `providers.models`.
    #[must_use]
    pub fn resolved_model_provider_for_agent(
        &self,
        agent_alias: &str,
    ) -> Option<(&'static str, &str, &ModelProviderConfig)> {
        let agent = self.agents.get(agent_alias)?;
        let (type_key, alias_key) = agent.model_provider.split_once('.')?;
        self.providers
            .models
            .iter_entries()
            .find(|(ty, al, _)| *ty == type_key && *al == alias_key)
    }

    /// Reverse-lookup the agent alias that owns a configured channel
    /// (`<type>.<alias>`). Returns the first agent listing the channel in
    /// its `channels` field. `None` when no agent owns the channel —
    /// orphaned channels are a config error the orchestrator surfaces at
    /// startup.
    #[must_use]
    pub fn agent_for_channel(&self, channel_alias: &str) -> Option<&str> {
        self.agents
            .iter()
            .find(|(_, agent)| agent.enabled && agent.channels.iter().any(|c| c == channel_alias))
            .map(|(alias, _)| alias.as_str())
    }

    /// Workspace dir a channel's inbound-media handler writes into. Resolves
    /// the channel's owning agent and returns `<install>/agents/<alias>/workspace/`;
    /// falls back to `data_dir` for orphan channels (no owning agent enabled).
    #[must_use]
    pub fn channel_workspace_dir(&self, channel_ref: &str) -> PathBuf {
        self.agent_for_channel(channel_ref)
            .map_or_else(|| self.data_dir.clone(), |a| self.agent_workspace_dir(a))
    }

    /// Schema-walk: every populated `[channels.<type>.<alias>]` block.
    /// Type names come from the `prop_fields()` enumeration (kebab as the
    /// macro emits them) so adding a new channel type via the macro
    /// surfaces here without touching this code. Alias keys are HashMap
    /// keys; not kebab-converted.
    #[must_use]
    pub fn channels_by_alias(&self) -> Vec<ChannelAliasInfo> {
        use std::collections::BTreeMap;
        let mut seen: BTreeMap<(String, String), bool> = BTreeMap::new();
        for field in self.prop_fields() {
            let parts: Vec<&str> = field.name.split('.').collect();
            if parts.len() < 4 || parts[0] != "channels" {
                continue;
            }
            let key = (parts[1].to_string(), parts[2].to_string());
            let entry = seen.entry(key).or_insert(false);
            if parts.len() == 4 && parts[3] == "enabled" {
                *entry = field.display_value == "true";
            }
        }
        seen.into_iter()
            .map(|((channel_type, alias), enabled)| {
                let composite = format!("{channel_type}.{alias}");
                let owning_agent = self.agent_for_channel(&composite).map(str::to_string);
                ChannelAliasInfo {
                    channel_type,
                    alias,
                    owning_agent,
                    enabled,
                }
            })
            .collect()
    }

    /// Reverse-lookup the agent alias that owns a declaratively-configured
    /// cron job (`[cron.<alias>]`). Returns the first agent listing the
    /// alias in its `cron_jobs` field. `None` when no agent claims the
    /// job — orphaned cron jobs are skipped at scheduler time with a
    /// warning. Imperative jobs (created at runtime via `cron_add`) have
    /// UUID-shaped ids that won't match any agent's `cron_jobs`; the
    /// scheduler treats those separately (carrying their owning agent
    /// alongside the DB row is a follow-up).
    #[must_use]
    pub fn agent_for_cron_job(&self, cron_alias: &str) -> Option<&str> {
        self.agents
            .iter()
            .find(|(_, agent)| agent.enabled && agent.cron_jobs.iter().any(|c| c == cron_alias))
            .map(|(alias, _)| alias.as_str())
    }

    /// Resolve the per-agent workspace directory for `alias`.
    ///
    /// Returns the agent's `[agents.<alias>.workspace.path]` override
    /// when set (operator-explicit, e.g. for putting a workspace on a
    /// different disk), otherwise derives
    /// `<install>/agents/<alias>/workspace/` from the install root
    /// (the directory containing `config.toml`).
    ///
    /// Per-agent workspaces live under
    /// `<install>/agents/<alias>/workspace/` and hold the agent's
    /// markdown memory (MEMORY.md), identity files (IDENTITY.md,
    /// SOUL.md), and any other per-agent plaintext state. Shared
    /// databases (SQLite memory, sessions, cost records) live under
    /// `config.data_dir` instead and partition by agent at the row
    /// level. Per-agent overrides via `[agents.<alias>.workspace.path]`
    /// pin an arbitrary filesystem path (e.g. a different mount).
    #[must_use]
    pub fn agent_workspace_dir(&self, agent_alias: &str) -> std::path::PathBuf {
        if let Some(cfg) = self.agents.get(agent_alias)
            && let Some(custom) = cfg.workspace.path.as_ref()
        {
            return custom.clone();
        }
        self.install_root_dir()
            .join("agents")
            .join(agent_alias)
            .join("workspace")
    }

    /// Effective MCP servers granted to an agent by its `mcp_bundles`.
    ///
    /// Secure by default: omission is not a grant. An agent is reached via
    /// `resolved_agent_config`; an unknown alias or one with no `mcp_bundles`
    /// receives NO MCP servers. See [`Self::mcp_servers_for_bundles`] for how
    /// a non-empty bundle list resolves to servers.
    #[must_use]
    pub fn mcp_servers_for_agent(&self, agent_alias: &str) -> Vec<McpServerConfig> {
        match self.resolved_agent_config(agent_alias) {
            Some(agent) => self.mcp_servers_for_bundles(&agent.mcp_bundles),
            None => Vec::new(),
        }
    }

    /// Resolve a set of `[mcp_bundles.<alias>]` references to the concrete
    /// `[mcp.servers]` entries they grant.
    ///
    /// Secure by default: omission is not a grant.
    /// - An empty `bundle_aliases` grants NO servers (`Vec::new()`).
    /// - The grant is the union of each referenced bundle's `servers`, in
    ///   first-seen order with duplicates dropped.
    /// - Deny wins: a server name listed in ANY referenced bundle's `exclude`
    ///   is removed from the grant, regardless of which bundle included it.
    /// - An unknown bundle alias grants nothing (it is skipped, not an error)
    ///   and a server name with no matching `[mcp.servers]` entry grants
    ///   nothing. Both fail closed: a misconfiguration narrows access, never
    ///   widens it.
    #[must_use]
    pub fn mcp_servers_for_bundles(&self, bundle_aliases: &[String]) -> Vec<McpServerConfig> {
        if bundle_aliases.is_empty() {
            return Vec::new();
        }
        // Deny wins: collect every excluded name across the referenced bundles
        // first, so an include in one bundle cannot defeat an exclude in another.
        let mut excluded: std::collections::HashSet<&str> = std::collections::HashSet::new();
        for alias in bundle_aliases {
            if let Some(bundle) = self.mcp_bundles.get(alias) {
                for name in &bundle.exclude {
                    excluded.insert(name.as_str());
                }
            }
        }
        // Union of granted names in first-seen order, skipping excludes.
        let mut granted: Vec<&str> = Vec::new();
        for alias in bundle_aliases {
            if let Some(bundle) = self.mcp_bundles.get(alias) {
                for name in &bundle.servers {
                    let n = name.as_str();
                    if !excluded.contains(n) && !granted.contains(&n) {
                        granted.push(n);
                    }
                }
            }
        }
        // Resolve names against the configured servers. A name that matches no
        // `[mcp.servers]` entry is not granted.
        granted
            .into_iter()
            .filter_map(|name| self.mcp.servers.iter().find(|s| s.name == name).cloned())
            .collect()
    }

    /// `<install>/shared/` — directory shared across every agent on this
    /// host. Holds skills, skill bundles, knowledge bundles, and any
    /// other content not scoped to a single agent's workspace. Distinct
    /// from `agent_workspace_dir(alias)` (per-agent state) and
    /// `data_dir` (databases + runtime state).
    #[must_use]
    pub fn shared_workspace_dir(&self) -> std::path::PathBuf {
        self.install_root_dir().join("shared")
    }

    /// Install root: `<install>/` derived from `config_path`'s parent. Used
    /// to compute `<install>/shared/`, `<install>/agents/`, and the
    /// skill-bundle directory defaults. Public so consumers (gateway, CLI,
    /// SkillsService) share the same anchor.
    #[must_use]
    pub fn install_root_dir(&self) -> std::path::PathBuf {
        self.config_path
            .parent()
            .map(std::path::Path::to_path_buf)
            .unwrap_or_else(|| std::path::PathBuf::from("."))
    }

    /// Resolve an aliased-agent config by alias. `None` when the alias
    /// isn't configured; callers should treat this as a config error
    /// rather than synthesizing a default.
    #[must_use]
    pub fn agent(&self, agent_alias: &str) -> Option<&AliasedAgentConfig> {
        self.agents.get(agent_alias)
    }

    /// Resolve the runtime-active agent alias the orchestrator binds
    /// channels to. Mirrors the same selection logic as
    /// `start_channels()` in zeroclaw-channels: prefer the migration-
    /// synthesized `"default"` agent, otherwise fall back to the
    /// lexicographically-smallest enabled alias. Returns `None` only
    /// when no enabled agent is configured.
    ///
    /// Used by per-agent infrastructure (TtsManager, TranscriptionManager)
    /// to pick which agent's `tts_provider` / `transcription_provider`
    /// drives the manager's resolved alias. Until the per-channel
    /// dispatch refactor lands, the orchestrator runs in single-agent
    /// mode, so all manager instances share the same resolved agent.
    #[must_use]
    pub fn resolved_runtime_agent_alias(&self) -> Option<&str> {
        self.agents
            .keys()
            .find(|k| k.as_str() == "default")
            .map(String::as_str)
            .or_else(|| {
                self.agents
                    .iter()
                    .filter(|(_, a)| a.enabled)
                    .map(|(alias, _)| alias.as_str())
                    .min()
            })
    }

    /// Resolve the active storage backend for the memory subsystem.
    ///
    /// `MemoryConfig.backend` is a dotted reference (`<backend>.<alias>`) into
    /// `Config.storage.<backend>.<alias>`. Bare backend names are interpreted
    /// as `<backend>.default` for back-compat.
    ///
    /// Returns `ActiveStorage::None` when no backend is configured, when the
    /// backend is `"none"`, or when the dotted alias does not resolve to a
    /// configured entry.
    pub fn resolve_active_storage(&self) -> ActiveStorage<'_> {
        let backend = self.memory.backend.trim();
        if backend.is_empty() || backend.eq_ignore_ascii_case("none") {
            return ActiveStorage::None;
        }
        let (kind, alias) = backend.split_once('.').unwrap_or((backend, "default"));
        match kind {
            "sqlite" => self
                .storage
                .sqlite
                .get(alias)
                .map(ActiveStorage::Sqlite)
                .unwrap_or(ActiveStorage::None),
            "postgres" => self
                .storage
                .postgres
                .get(alias)
                .map(ActiveStorage::Postgres)
                .unwrap_or(ActiveStorage::None),
            "qdrant" => self
                .storage
                .qdrant
                .get(alias)
                .map(ActiveStorage::Qdrant)
                .unwrap_or(ActiveStorage::None),
            "markdown" => self
                .storage
                .markdown
                .get(alias)
                .map(ActiveStorage::Markdown)
                .unwrap_or(ActiveStorage::None),
            "lucid" => self
                .storage
                .lucid
                .get(alias)
                .map(ActiveStorage::Lucid)
                .unwrap_or(ActiveStorage::None),
            _ => ActiveStorage::None,
        }
    }
}

/// Resolved storage backend variant.
///
/// Returned from [`Config::resolve_active_storage`]. Each variant carries a
/// borrow of the typed config from the corresponding `Config.storage` map.
#[derive(Debug, Clone, Copy)]
pub enum ActiveStorage<'a> {
    /// No storage configured (`memory.backend = "none"` or unresolved alias).
    None,
    /// SQLite storage instance.
    Sqlite(&'a SqliteStorageConfig),
    /// PostgreSQL storage instance.
    Postgres(&'a PostgresStorageConfig),
    /// Qdrant storage instance.
    Qdrant(&'a QdrantStorageConfig),
    /// Markdown directory storage instance.
    Markdown(&'a MarkdownStorageConfig),
    /// Lucid CLI sync instance.
    Lucid(&'a LucidStorageConfig),
}

impl ActiveStorage<'_> {
    /// Backend type name (`"sqlite"`, `"postgres"`, etc.); `"none"` for unconfigured.
    #[must_use]
    pub fn kind(&self) -> &'static str {
        match self {
            ActiveStorage::None => "none",
            ActiveStorage::Sqlite(_) => "sqlite",
            ActiveStorage::Postgres(_) => "postgres",
            ActiveStorage::Qdrant(_) => "qdrant",
            ActiveStorage::Markdown(_) => "markdown",
            ActiveStorage::Lucid(_) => "lucid",
        }
    }
}

fn default_delegate_timeout_secs() -> u64 {
    DEFAULT_DELEGATE_TIMEOUT_SECS
}

fn default_delegate_agentic_timeout_secs() -> u64 {
    DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS
}

/// Valid temperature range for all paths (config, CLI, env override).
pub const TEMPERATURE_RANGE: std::ops::RangeInclusive<f64> = 0.0..=2.0;

/// Defaults to 0 so configs without an explicit `schema_version` are recognized
/// as pre-versioning and get migrated.
fn default_schema_version() -> u32 {
    0
}

/// Default delegate tool timeout for non-agentic calls: 120 seconds.
pub const DEFAULT_DELEGATE_TIMEOUT_SECS: u64 = 120;

/// Default delegate tool timeout for agentic runs: 300 seconds.
pub const DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS: u64 = 300;

/// Per-channel reply-pacing accessor. Implemented by every `*Config`
/// struct that participates in outbound pacing so validation and
/// wrapper construction can walk all of them through a single
/// abstraction rather than nine duplicated method calls.
pub trait HasReplyPacing {
    fn reply_min_interval_secs(&self) -> u64;
    fn reply_queue_depth_max(&self) -> u16;
}

macro_rules! impl_reply_pacing {
    ($($ty:ty),+ $(,)?) => {
        $(impl HasReplyPacing for $ty {
            fn reply_min_interval_secs(&self) -> u64 { self.reply_min_interval_secs }
            fn reply_queue_depth_max(&self) -> u16 { self.reply_queue_depth_max }
        })+
    };
}

/// Inclusive upper bound (seconds) for per-channel `reply_min_interval_secs`.
pub const REPLY_MIN_INTERVAL_MAX_SECS: u64 = 3600;

/// Inclusive upper bound for per-channel `reply_queue_depth_max`. The lower
/// bound is `0`, where `0` means "use [`DEFAULT_REPLY_QUEUE_DEPTH`] at the
/// pacing-wrapper construction site." A non-zero value pins the bound
/// explicitly. Validator rejects values above this ceiling.
pub const REPLY_QUEUE_DEPTH_CEILING: u16 = 1024;

/// Fallback queue depth applied at the pacing-wrapper construction site
/// when a channel's `reply_queue_depth_max` is left at `0`. Sized for the
/// AI-pacing use case: a paced channel buffering more than this is a sign
/// the agent is producing replies faster than the floor will ever drain
/// them and the overflow log is the right signal.
pub const DEFAULT_REPLY_QUEUE_DEPTH: u16 = 16;

/// Idle-state LRU cap on the pacing wrapper's per-recipient rows.
/// Bounds growth when a bot legitimately serves many thousands of distinct
/// peers. Eviction only reclaims idle rows (no queued sends, no running
/// worker, no in-flight dispatch), so under a pathological all-active burst
/// the row count can temporarily exceed this target until rows become idle —
/// it is not an unconditional hard bound. Each recipient's queue depth stays
/// bounded regardless. Not exposed in config — promote to a schema field if
/// an operator reports hitting it.
pub const PACING_RECIPIENT_CAP: usize = 1024;

/// Validate that a temperature value is within the allowed range.
pub fn validate_temperature(value: f64) -> std::result::Result<f64, String> {
    if TEMPERATURE_RANGE.contains(&value) {
        Ok(value)
    } else {
        Err(format!(
            "temperature {value} is out of range (expected {}..={})",
            TEMPERATURE_RANGE.start(),
            TEMPERATURE_RANGE.end()
        ))
    }
}

fn normalize_reasoning_effort(value: &str) -> std::result::Result<String, String> {
    let normalized = value.trim().to_ascii_lowercase();
    match normalized.as_str() {
        "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(normalized),
        _ => Err(format!(
            "reasoning_effort {value:?} is invalid (expected one of: minimal, low, medium, high, xhigh)"
        )),
    }
}

fn deserialize_reasoning_effort_opt<'de, D>(
    deserializer: D,
) -> std::result::Result<Option<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value: Option<String> = Option::deserialize(deserializer)?;
    value
        .map(|raw| normalize_reasoning_effort(&raw).map_err(serde::de::Error::custom))
        .transpose()
}

fn deserialize_enum_lenient<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
where
    D: serde::Deserializer<'de>,
    T: serde::de::DeserializeOwned + Default,
{
    let raw = serde_json::Value::deserialize(deserializer)?;
    Ok(T::deserialize(raw).unwrap_or_default())
}

/// Deserialize an `Option<String>` that maps an empty literal `""` to
/// `None`. Used by `JiraConfig::email` so a config that round-tripped
/// `email = ""` to disk (the legacy `email: String` had no
/// `skip_serializing_if`) doesn't deserialize as `Some("")` and silently
/// break Basic auth — the email-required validation was removed when
/// Server/DC Bearer-token support landed, so this is the last line of
/// defense.
fn deserialize_optional_email_skip_empty<'de, D>(
    deserializer: D,
) -> std::result::Result<Option<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value: Option<String> = Option::deserialize(deserializer)?;
    Ok(value.filter(|s| !s.trim().is_empty()))
}

// ── Hardware Config (wizard-driven) ─────────────────────────────

/// Hardware transport mode.
#[derive(
    Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub enum HardwareTransport {
    #[default]
    None,
    Native,
    Serial,
    Probe,
}

impl std::fmt::Display for HardwareTransport {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::None => write!(f, "none"),
            Self::Native => write!(f, "native"),
            Self::Serial => write!(f, "serial"),
            Self::Probe => write!(f, "probe"),
        }
    }
}

/// Wizard-driven hardware configuration for physical world interaction.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "hardware"]
pub struct HardwareConfig {
    /// Opt in to direct physical-hardware control — GPIO pins, USB-tethered microcontrollers (Arduino, ESP32, Nucleo), or SWD/JTAG debug probes. Leave off for software-only use; turning it on without the right transport configured does nothing.
    #[serde(default)]
    pub enabled: bool,
    /// How ZeroClaw reaches the hardware: `native` = Linux SBC with direct GPIO access (Raspberry Pi, Orange Pi); `serial` = USB-tethered microcontroller speaking over a TTY; `probe` = SWD/JTAG debug probe driving a target chip via probe-rs; `none` = disabled.
    #[serde(default)]
    pub transport: HardwareTransport,
    /// TTY path for the `serial` transport — e.g. `/dev/ttyACM0` on Linux, `/dev/tty.usbmodem1` on macOS, `COM3` on Windows. Ignored for other transports.
    #[serde(default)]
    pub serial_port: Option<String>,
    /// Baud rate negotiated on the serial link. 115200 matches the common Arduino / ESP32 bootloader default; bump to 230400+ when your firmware explicitly supports faster rates and you need the throughput.
    #[serde(default = "default_baud_rate")]
    pub baud_rate: u32,
    /// Target chip identifier for `transport = probe` (e.g. `STM32F401RE`, `nRF52840_xxAA`). Passed straight to probe-rs for flash/debug operations; must match a chip probe-rs recognizes.
    #[serde(default)]
    pub probe_target: Option<String>,
    /// Index pre-converted `.md` and `.txt` datasheets from the workspace into
    /// a local RAG store, so the agent can look up pin assignments and
    /// electrical specs inline when you ask hardware questions. PDFs are not
    /// parsed as text; download or convert them to `.md`/`.txt` first. Off by
    /// default — turn on once the workspace has relevant text datasheets.
    #[serde(default)]
    pub workspace_datasheets: bool,
}

fn default_baud_rate() -> u32 {
    115_200
}

impl HardwareConfig {
    /// Return the active transport mode.
    pub fn transport_mode(&self) -> HardwareTransport {
        self.transport.clone()
    }
}

impl Default for HardwareConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            transport: HardwareTransport::None,
            serial_port: None,
            baud_rate: default_baud_rate(),
            probe_target: None,
            workspace_datasheets: false,
        }
    }
}

// ── Transcription ────────────────────────────────────────────────

fn default_transcription_api_url() -> String {
    "https://api.groq.com/openai/v1/audio/transcriptions".into()
}

fn default_transcription_model() -> String {
    "whisper-large-v3-turbo".into()
}

fn default_transcription_max_duration_secs() -> u64 {
    120
}

fn default_openai_stt_model() -> String {
    "whisper-1".into()
}

fn default_deepgram_stt_model() -> String {
    "nova-2".into()
}

fn default_google_stt_language_code() -> String {
    "en-US".into()
}

/// Voice transcription configuration with multi-provider support.
///
/// The top-level `api_url`, `model`, and `api_key` fields remain for backward
/// compatibility with existing Groq-based configurations.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "transcription"]
pub struct TranscriptionConfig {
    /// Enable voice transcription for channels that support it.
    #[serde(default)]
    pub enabled: bool,
    /// API key used for transcription requests (Groq transcription provider).
    ///
    /// Use the schema-mirror env grammar (`ZEROCLAW_transcription__api_key=...`)
    /// for runtime env injection. Legacy `GROQ_API_KEY` constructor fallback was
    /// removed in v0.8.0.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
    /// Whisper API endpoint URL (Groq transcription provider).
    #[serde(default = "default_transcription_api_url")]
    pub api_url: String,
    /// Whisper model name (Groq transcription provider).
    #[serde(default = "default_transcription_model")]
    pub model: String,
    /// Optional language hint (ISO-639-1, e.g. "en", "ru") for Groq transcription provider.
    #[serde(default)]
    pub language: Option<String>,
    /// Optional initial prompt to bias transcription toward expected vocabulary
    /// (proper nouns, technical terms, etc.). Sent as the `prompt` field in the
    /// Whisper API request.
    #[serde(default)]
    pub initial_prompt: Option<String>,
    /// Optional global audio size upper bound in bytes, enforced before
    /// dispatching to any transcription provider. Provider-specific caps still
    /// apply.
    #[serde(default)]
    pub max_audio_bytes: Option<usize>,
    /// Maximum voice duration in seconds (messages longer than this are skipped).
    #[serde(default = "default_transcription_max_duration_secs")]
    pub max_duration_secs: u64,
    /// OpenAI Whisper STT model_provider configuration.
    #[serde(default)]
    #[nested]
    pub openai: Option<OpenAiSttConfig>,
    /// Deepgram STT model_provider configuration.
    #[serde(default)]
    #[nested]
    pub deepgram: Option<DeepgramSttConfig>,
    /// AssemblyAI STT model_provider configuration.
    #[serde(default)]
    #[nested]
    pub assemblyai: Option<AssemblyAiSttConfig>,
    /// Google Cloud Speech-to-Text model_provider configuration.
    #[serde(default)]
    #[nested]
    pub google: Option<GoogleSttConfig>,
    /// Local/self-hosted Whisper-compatible STT model_provider.
    #[serde(default)]
    #[nested]
    pub local_whisper: Option<LocalWhisperConfig>,
    /// Also transcribe non-PTT (forwarded/regular) audio messages on WhatsApp,
    /// not just voice notes. Default: `false` (preserves legacy behavior).
    #[serde(default)]
    pub transcribe_non_ptt_audio: bool,
}

impl Default for TranscriptionConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            api_key: None,
            api_url: default_transcription_api_url(),
            model: default_transcription_model(),
            language: None,
            initial_prompt: None,
            max_audio_bytes: None,
            max_duration_secs: default_transcription_max_duration_secs(),
            openai: None,
            deepgram: None,
            assemblyai: None,
            google: None,
            local_whisper: None,
            transcribe_non_ptt_audio: false,
        }
    }
}

// ── MCP ─────────────────────────────────────────────────────────

/// Transport type for MCP server connections.
#[derive(
    Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum McpTransport {
    /// Spawn a local process and communicate over stdin/stdout.
    #[default]
    Stdio,
    /// Connect via HTTP POST.
    Http,
    /// Connect via HTTP + Server-Sent Events.
    Sse,
}

impl McpTransport {
    /// Wire token for this transport, matching the `rename_all = "lowercase"`
    /// serde representation (`stdio`/`http`/`sse`). The exhaustive match makes
    /// a new variant a compile error here rather than a silent miss.
    pub fn wire_name(self) -> &'static str {
        match self {
            McpTransport::Stdio => "stdio",
            McpTransport::Http => "http",
            McpTransport::Sse => "sse",
        }
    }

    /// The `McpServerConfig` leaf this transport requires to be non-empty.
    /// Single source of truth shared by `validate_mcp_config` (runtime
    /// enforcement) and the config-form `x-required-by-transport` schema
    /// metadata the Operator Console reads to mark the right field Required
    /// per transport. Changing the relationship here updates both consumers.
    pub fn required_leaf(self) -> &'static str {
        match self {
            McpTransport::Stdio => "command",
            McpTransport::Http | McpTransport::Sse => "url",
        }
    }
}

/// Every transport, enumerated on-demand from the schema rather than a
/// hand-maintained list, so the set is derived from the enum itself and a new
/// variant shows up here automatically (its `required_leaf` arm then forces a
/// decision). schemars emits a fieldless enum as a top-level `enum` array, or
/// as `oneOf` of `const`s once the variants carry doc comments; handle both,
/// matching the existing `enum_variants` helper. The wire tokens honor the
/// serde rename, so they round-trip straight back to the enum.
#[cfg(feature = "schema-export")]
fn mcp_transports() -> Vec<McpTransport> {
    let schema = serde_json::to_value(schemars::schema_for!(McpTransport)).unwrap_or_default();
    let wire_values: Vec<serde_json::Value> =
        if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) {
            values.clone()
        } else if let Some(variants) = schema.get("oneOf").and_then(serde_json::Value::as_array) {
            variants
                .iter()
                .filter_map(|variant| variant.get("const").cloned())
                .collect()
        } else {
            Vec::new()
        };
    wire_values
        .into_iter()
        .filter_map(|value| serde_json::from_value::<McpTransport>(value).ok())
        .collect()
}

/// Per-transport required-leaf map, projected from [`McpTransport::required_leaf`]
/// onto the schema as the `x-required-by-transport` extension on
/// `McpServerConfig`. The Operator Console config form reads it so the Required
/// badge tracks the selected transport (`stdio` needs `command`, `http`/`sse`
/// need `url`) instead of hardcoding the relationship in the web surface.
#[cfg(feature = "schema-export")]
fn mcp_required_by_transport() -> serde_json::Value {
    let map: serde_json::Map<String, serde_json::Value> = mcp_transports()
        .into_iter()
        .map(|transport| {
            (
                transport.wire_name().to_string(),
                serde_json::Value::String(transport.required_leaf().to_string()),
            )
        })
        .collect();
    serde_json::Value::Object(map)
}

/// Configuration for a single external MCP server.
#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[cfg_attr(
    feature = "schema-export",
    schemars(extend("x-required-by-transport" = mcp_required_by_transport()))
)]
#[prefix = "mcp.servers"]
pub struct McpServerConfig {
    /// Display name used as a tool prefix (`<server>__<tool>`). Filled in
    /// from the supplied `map_key` when the entry is created via
    /// `create_map_key("mcp.servers", "<name>")`; `#[serde(default)]` lets
    /// the macro default-construct from `{}` before the name gets injected.
    #[serde(default)]
    pub name: String,
    /// Transport type (default: stdio).
    #[serde(default)]
    pub transport: McpTransport,
    /// URL for HTTP/SSE transports.
    #[serde(default)]
    pub url: Option<String>,
    /// Executable to spawn for stdio transport.
    #[serde(default)]
    pub command: String,
    /// Command arguments for stdio transport.
    #[serde(default)]
    pub args: Vec<String>,
    /// Optional environment variables for stdio transport.
    #[serde(default)]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub env: HashMap<String, String>,
    /// Optional HTTP headers for HTTP/SSE transports. Treated as secret:
    /// the values commonly carry Bearer tokens for the upstream MCP server.
    #[serde(default)]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub headers: HashMap<String, String>,
    /// Optional per-call timeout in seconds (hard capped in validation).
    #[serde(default)]
    pub tool_timeout_secs: Option<u64>,
    /// Resource URIs to read once at agent startup and inject into the system
    /// prompt as untrusted, server-origin context. Each is read via
    /// `resources/read` on this server; pins on a server that does not advertise
    /// resources, or that the agent's tool policy denies, are skipped with a
    /// warning. Read once per run (not refreshed; no subscriptions).
    #[serde(default)]
    pub pinned_resources: Vec<String>,
}

/// External MCP client configuration (`[mcp]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "mcp"]
pub struct McpConfig {
    /// Enable MCP tool loading.
    #[tab(Settings)]
    #[serde(default = "default_mcp_enabled")]
    pub enabled: bool,
    /// Load MCP tool schemas on-demand via `tool_search` instead of eagerly
    /// including them in the LLM context window. When enabled, only tool names
    /// are listed in the system prompt; the LLM must call `tool_search` to fetch
    /// full schemas before invoking a deferred tool.
    #[tab(Settings)]
    #[serde(default = "default_deferred_loading")]
    pub deferred_loading: bool,
    /// Configured MCP servers. The `#[nested]` annotation makes the macro
    /// expose this as a List section in `map_key_sections()`, so the
    /// dashboard's `+ Add MCP server` affordance and the `POST
    /// /api/config/map-key?path=mcp.servers&key=<name>` endpoint pick it
    /// up automatically (no hand-table on the gateway side).
    ///
    /// `#[natural_key = "name"]` opts the Vec into per-element property
    /// routing (see `route_vec_path` and the `Configurable` derive's
    /// `#[natural_key]` arm). With it, `set_prop("mcp.servers.<name>.url",
    /// ...)` and `get_prop("mcp.servers.<name>.transport")` resolve to
    /// the matching element's own `set_prop` / `get_prop`, and the
    /// `mcp.servers` section behaves like a `HashMap<String,
    /// McpServerConfig>` from the dashboard / TUI's point of view.
    /// `name` itself becomes read-only via `set_prop`; rename goes
    /// through `rename_map_key` which mutates the `name` field in place.
    #[tab(Servers)]
    #[serde(default, alias = "mcpServers")]
    #[nested]
    #[natural_key = "name"]
    pub servers: Vec<McpServerConfig>,
}

fn default_mcp_enabled() -> bool {
    true
}

fn default_deferred_loading() -> bool {
    false
}

impl Default for McpConfig {
    fn default() -> Self {
        Self {
            enabled: default_mcp_enabled(),
            deferred_loading: default_deferred_loading(),
            servers: Vec::new(),
        }
    }
}

/// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "verifiable_intent"]
pub struct VerifiableIntentConfig {
    /// Enable VI credential verification on commerce tool calls (default: false).
    #[serde(default)]
    pub enabled: bool,

    /// Strictness mode for constraint evaluation: "strict" (fail-closed on unknown
    /// constraint types) or "permissive" (skip unknown types with a warning).
    /// Default: "strict".
    #[serde(default = "default_vi_strictness")]
    pub strictness: String,
}

fn default_vi_strictness() -> String {
    "strict".to_owned()
}

impl Default for VerifiableIntentConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            strictness: default_vi_strictness(),
        }
    }
}

// ── Nodes (Dynamic Node Discovery) ───────────────────────────────

fn default_mdns_announce_interval_secs() -> u64 {
    30
}

fn default_mdns_peer_ttl_secs() -> u64 {
    90
}

fn default_mdns_max_peers() -> usize {
    16
}

/// Configuration for LAN-local mDNS peer discovery (`[nodes.mdns]`).
///
/// This config controls only discovery behavior. The advertised gateway
/// endpoint is derived from the running gateway's actual host, port, and path
/// prefix at startup so `[nodes.mdns]` does not duplicate gateway listen state.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "nodes.mdns"]
pub struct MdnsConfig {
    /// Enable mDNS local peer discovery.
    #[serde(default)]
    pub enabled: bool,
    /// Human-readable node name advertised to LAN peers. Defaults to a stable
    /// local fallback when unset.
    #[serde(default)]
    pub node_name: Option<String>,
    /// Maximum number of unauthenticated LAN peer hints retained in memory.
    #[serde(default = "default_mdns_max_peers")]
    pub max_peers: usize,
    /// How often this node re-broadcasts its presence, in seconds.
    #[serde(default = "default_mdns_announce_interval_secs")]
    pub announce_interval_secs: u64,
    /// Seconds after the last announcement before a peer is evicted.
    #[serde(default = "default_mdns_peer_ttl_secs")]
    pub peer_ttl_secs: u64,
}

impl Default for MdnsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            node_name: None,
            max_peers: default_mdns_max_peers(),
            announce_interval_secs: default_mdns_announce_interval_secs(),
            peer_ttl_secs: default_mdns_peer_ttl_secs(),
        }
    }
}

/// Configuration for the dynamic node discovery system (`[nodes]`).
///
/// When enabled, external processes/devices can connect via WebSocket
/// at `/ws/nodes` and advertise their capabilities at runtime.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "nodes"]
pub struct NodesConfig {
    /// Enable dynamic node discovery endpoint.
    #[serde(default)]
    pub enabled: bool,
    /// Maximum number of concurrent node connections.
    #[serde(default = "default_max_nodes")]
    pub max_nodes: usize,
    /// Optional bearer token for node authentication.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub auth_token: Option<String>,
    /// LAN-local mDNS peer discovery.
    #[serde(default)]
    #[nested]
    pub mdns: MdnsConfig,
}

fn default_max_nodes() -> usize {
    16
}

impl Default for NodesConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            max_nodes: default_max_nodes(),
            auth_token: None,
            mdns: MdnsConfig::default(),
        }
    }
}

// ── TTS (Text-to-Speech) ─────────────────────────────────────────

fn default_tts_voice() -> String {
    "alloy".into()
}

fn default_tts_format() -> String {
    "mp3".into()
}

fn default_tts_max_text_length() -> usize {
    4096
}

/// Text-to-Speech subsystem configuration (`[tts]`).
///
/// Per-instance TTS configs live under `[tts_providers.<type>.<alias>]`
/// (parallel to `providers.models`). What remains here are the global
/// runtime knobs that apply to every model_provider invocation.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "tts"]
pub struct TtsConfig {
    /// Enable TTS synthesis.
    #[serde(default)]
    pub enabled: bool,
    /// Default voice ID passed to the selected tts provider.
    #[serde(default = "default_tts_voice")]
    pub default_voice: String,
    /// Default audio output format (`"mp3"`, `"opus"`, `"wav"`).
    #[serde(default = "default_tts_format")]
    pub default_format: String,
    /// Maximum input text length in characters (default 4096).
    #[serde(default = "default_tts_max_text_length")]
    pub max_text_length: usize,
}

impl Default for TtsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            default_voice: default_tts_voice(),
            default_format: default_tts_format(),
            max_text_length: default_tts_max_text_length(),
        }
    }
}

/// Per-instance TTS model_provider configuration (`[tts_providers.<type>.<alias>]`).
///
/// Mirrors `ModelProviderConfig` in shape — one struct holds the union of
/// fields across backends. Only the fields relevant to the selected backend
/// (determined by the outer `<type>` map key) are read at runtime; others
/// are quietly ignored.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "tts_provider"]
#[serde(default)]
pub struct TtsProviderConfig {
    /// API key (openai, elevenlabs, google).
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
    /// Model name. OpenAI uses this for `tts-1`/`tts-1-hd`; elevenlabs uses
    /// it as the model_id (e.g. `eleven_monolingual_v1`).
    pub model: Option<String>,
    /// Voice override for this instance. When empty, falls back to
    /// `[tts].default_voice`.
    pub voice: Option<String>,
    /// Playback speed multiplier (openai only; default `1.0`).
    pub speed: Option<f64>,
    /// Voice stability for elevenlabs (0.0-1.0; default `0.5`).
    pub stability: Option<f64>,
    /// Similarity boost for elevenlabs (0.0-1.0; default `0.5`).
    pub similarity_boost: Option<f64>,
    /// Language code for google (e.g. `en-US`).
    pub language_code: Option<String>,
    /// Path to backend binary (edge-tts subprocess; piper local server).
    pub binary_path: Option<String>,
    /// Audio response format sent to the TTS backend (e.g. `"opus"`, `"mp3"`,
    /// `"wav"`). Defaults to `"opus"` for the OpenAI family. Override to
    /// `"wav"` for Orpheus-class models (e.g. `canopylabs/orpheus-v1-english`
    /// on Groq) or `"mp3"` for broader compatibility.
    pub response_format: Option<String>,
    /// Endpoint URI for HTTP-based backends. Overrides the family default
    /// when pointing at a compatible third-party API (Groq, Azure, self-hosted
    /// proxies). Set to the **full** URL — there is no separate path-suffix
    /// field. Renamed from `api_url` for parity with `ModelProviderConfig.uri`.
    #[serde(alias = "api_url")]
    pub uri: Option<String>,
}

// ── TTS endpoint trait + per-family typed configs ──────────────────────────
//
// Mirrors the model provider typed-family pattern. Each TTS family carries
// its own typed config (composing TtsProviderConfig as the shared base via
// `#[serde(flatten)]`) and a single-variant `*TtsEndpoint` enum impl'ing
// `TtsEndpoint`. Edge and Piper skip the base — they're subprocess / local
// runtimes with no shared `api_key` / `voice` defaults.

/// One trait per family-endpoint enum. Returns the URI for the chosen
/// variant. Mirrors `ModelEndpoint` for parity across model and TTS.
pub trait TtsEndpoint {
    fn uri(&self) -> &'static str;
}

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpenAITtsEndpoint {
    #[default]
    Default,
}
impl TtsEndpoint for OpenAITtsEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.openai.com/v1/audio/speech",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.tts.openai"]
pub struct OpenAITtsProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TtsProviderConfig,
}

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ElevenLabsTtsEndpoint {
    #[default]
    Default,
}
impl TtsEndpoint for ElevenLabsTtsEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.elevenlabs.io/v1/text-to-speech",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.tts.elevenlabs"]
pub struct ElevenLabsTtsProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TtsProviderConfig,
}

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum GoogleTtsEndpoint {
    #[default]
    Default,
}
impl TtsEndpoint for GoogleTtsEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://texttospeech.googleapis.com/v1/text:synthesize",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.tts.google"]
pub struct GoogleTtsProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TtsProviderConfig,
}

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum EdgeTtsEndpoint {
    /// Subprocess — no remote endpoint. Sentinel for trait conformity.
    #[default]
    LocalSubprocess,
}
impl TtsEndpoint for EdgeTtsEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalSubprocess => "subprocess://edge-tts",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.tts.edge"]
pub struct EdgeTtsProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TtsProviderConfig,
}

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum PiperTtsEndpoint {
    #[default]
    LocalDefault,
}
impl TtsEndpoint for PiperTtsEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::LocalDefault => "http://127.0.0.1:5000/v1/audio/speech",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.tts.piper"]
pub struct PiperTtsProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TtsProviderConfig,
}

// ── Transcription providers (typed-family split, mirrors models/tts) ────
//
// Six family slots: `groq`, `openai`, `deepgram`, `assemblyai`, `google`,
// `local_whisper`. Each is a `HashMap<String, *TranscriptionProviderConfig>`
// keyed by operator-chosen alias. The shared `TranscriptionProviderConfig`
// base carries `api_key` + `language` since every cloud STT family takes
// both; `local_whisper` skips the base because it's a self-hosted endpoint
// with its own auth token, not a vendor API key.

/// Shared base for cloud transcription providers. Each cloud family
/// composes this via `#[serde(flatten)] base: TranscriptionProviderConfig`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.transcription"]
pub struct TranscriptionProviderConfig {
    /// API key for the transcription provider.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
    /// Optional language hint passed to the provider (ISO-639-1 like `"en"` /
    /// `"ru"`, or BCP-47 like `"en-US"` for Google). Most providers auto-detect
    /// when this is unset.
    #[serde(default)]
    pub language: Option<String>,
    /// Whisper-style initial prompt to bias the model toward expected
    /// vocabulary (proper nouns, technical terms). Provider-specific support;
    /// silently ignored where not applicable.
    #[serde(default)]
    pub initial_prompt: Option<String>,
}

/// Trait that every transcription endpoint enum implements. Mirrors
/// `ModelEndpoint` / `TtsEndpoint` for parity.
pub trait TranscriptionEndpoint {
    fn uri(&self) -> &'static str;
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum GroqTranscriptionEndpoint {
    #[default]
    Default,
}
impl TranscriptionEndpoint for GroqTranscriptionEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.groq.com/openai/v1/audio/transcriptions",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.transcription.groq"]
pub struct GroqTranscriptionProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TranscriptionProviderConfig,
    /// Whisper model name (default: `"whisper-large-v3-turbo"`).
    #[serde(default)]
    pub model: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OpenAiTranscriptionEndpoint {
    #[default]
    Default,
}
impl TranscriptionEndpoint for OpenAiTranscriptionEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.openai.com/v1/audio/transcriptions",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.transcription.openai"]
pub struct OpenAiTranscriptionProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TranscriptionProviderConfig,
    /// Whisper model name (default: `"whisper-1"`).
    #[serde(default)]
    pub model: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum DeepgramTranscriptionEndpoint {
    #[default]
    Default,
}
impl TranscriptionEndpoint for DeepgramTranscriptionEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.deepgram.com/v1/listen",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.transcription.deepgram"]
pub struct DeepgramTranscriptionProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TranscriptionProviderConfig,
    /// Deepgram model name (default: `"nova-2"`).
    #[serde(default)]
    pub model: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AssemblyAiTranscriptionEndpoint {
    #[default]
    Default,
}
impl TranscriptionEndpoint for AssemblyAiTranscriptionEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://api.assemblyai.com/v2/transcript",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.transcription.assemblyai"]
pub struct AssemblyAiTranscriptionProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TranscriptionProviderConfig,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum GoogleTranscriptionEndpoint {
    #[default]
    Default,
}
impl TranscriptionEndpoint for GoogleTranscriptionEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::Default => "https://speech.googleapis.com/v1/speech:recognize",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.transcription.google"]
pub struct GoogleTranscriptionProviderConfig {
    #[nested]
    #[serde(flatten)]
    pub base: TranscriptionProviderConfig,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LocalWhisperTranscriptionEndpoint {
    /// Self-hosted endpoint — no remote URL. Sentinel for trait conformity.
    /// The actual URL lives on `LocalWhisperTranscriptionProviderConfig.uri`.
    #[default]
    SelfHosted,
}
impl TranscriptionEndpoint for LocalWhisperTranscriptionEndpoint {
    fn uri(&self) -> &'static str {
        match self {
            Self::SelfHosted => "self-hosted",
        }
    }
}

/// Local / self-hosted Whisper-compatible transcription endpoint. Skips the
/// shared `TranscriptionProviderConfig` base because it uses a bearer-token
/// scheme and a per-instance URL rather than a vendor API key.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "providers.transcription.local_whisper"]
pub struct LocalWhisperTranscriptionProviderConfig {
    /// Endpoint URL, e.g. `"http://10.10.0.1:8001/v1/transcribe"`.
    pub uri: String,
    /// Bearer token for endpoint authentication. Omit for unauthenticated
    /// local endpoints.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub bearer_token: Option<String>,
    /// Optional language hint (passed through to the local endpoint).
    #[serde(default)]
    pub language: Option<String>,
    /// Maximum audio file size in bytes accepted by this endpoint.
    /// Defaults to 25 MB to match the cloud cap; raise as needed.
    #[serde(default = "default_local_whisper_max_audio_bytes")]
    pub max_audio_bytes: usize,
    /// Request timeout in seconds.
    #[serde(default = "default_local_whisper_timeout_secs")]
    pub timeout_secs: u64,
}

// `#[derive(Default)]` would leave `max_audio_bytes = 0` and `timeout_secs = 0`
// (Rust's `usize`/`u64` defaults), even though those fields have serde defaults
// pointing at `default_local_whisper_max_audio_bytes` /
// `default_local_whisper_timeout_secs`. `#[serde(default = ...)]` only fires for
// *deserialization* — Rust's `Default::default()` bypasses it.
//
// Without this manual impl, the `Configurable` macro-generated
// `create_map_key(...)` path inserts `LocalWhisperTranscriptionProviderConfig::default()`
// for any newly scaffolded `[providers.transcription.local_whisper.<alias>]`
// entry. The `Configurable` macro exposes `HashMap<String, T>` sections through
// `map_key_sections()` / `create_map_key()`, and the generated create path
// inserts `<T as Default>::default()` for new map entries — leaving
// `max_audio_bytes = 0`, `timeout_secs = 0`. `LocalWhisperProvider::from_typed_config`
// bridges those fields into `LocalWhisperProvider::from_config`, which still
// rejects zero for both fields. Defaults here mirror `LocalWhisperConfig` so
// scaffolded entries load without operator intervention.
impl Default for LocalWhisperTranscriptionProviderConfig {
    fn default() -> Self {
        Self {
            uri: String::new(),
            bearer_token: None,
            language: None,
            max_audio_bytes: default_local_whisper_max_audio_bytes(),
            timeout_secs: default_local_whisper_timeout_secs(),
        }
    }
}

/// Determines when a `ToolFilterGroup` is active.
#[derive(
    Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ToolFilterGroupMode {
    /// Tools in this group are always included in every turn.
    Always,
    /// Tools in this group are included only when the user message contains
    /// at least one of the configured `keywords` (case-insensitive substring match).
    #[default]
    Dynamic,
}

/// A named group of MCP tool patterns with an activation mode.
///
/// Each group lists glob patterns for MCP tool names and an optional set of
/// keywords that trigger inclusion in `dynamic` mode. MCP tools are named
/// `<server>__<tool>` (double-underscore separator, e.g. `filesystem__read_file`).
///
/// Classification is by origin, not name shape: only tools that actually came
/// from the MCP registry are subject to these groups. Built-in tools AND skill
/// tools — which share the `<x>__<y>` naming convention (`{skill}__{tool}`) —
/// always pass through and are never affected by `tool_filter_groups`.
///
/// Under `[mcp] deferred_loading = true`, `mode = "always"` entries are
/// pre-activated at assembly time so matching tools are live from the first
/// turn without a `tool_search` round-trip.
///
/// # Example
/// ```toml
/// [[runtime_profiles.default.tool_filter_groups]]
/// mode = "always"
/// tools = ["filesystem__*"]
/// keywords = []
///
/// [[runtime_profiles.default.tool_filter_groups]]
/// mode = "dynamic"
/// tools = ["browser__*"]
/// keywords = ["browse", "website", "url", "search"]
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ToolFilterGroup {
    /// Activation mode: `"always"` or `"dynamic"`.
    #[serde(default)]
    pub mode: ToolFilterGroupMode,
    /// Glob patterns matching MCP tool names (single `*` wildcard supported).
    #[serde(default)]
    pub tools: Vec<String>,
    /// Keywords that activate this group in `dynamic` mode (case-insensitive substring).
    /// Ignored when `mode = "always"`.
    #[serde(default)]
    pub keywords: Vec<String>,
}

/// OpenAI Whisper STT model_provider configuration (`[transcription.openai]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "transcription.openai"]
pub struct OpenAiSttConfig {
    /// OpenAI API key for Whisper transcription.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
    /// Whisper model name (default: "whisper-1").
    #[serde(default = "default_openai_stt_model")]
    pub model: String,
}

/// Deepgram STT model_provider configuration (`[transcription.deepgram]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "transcription.deepgram"]
pub struct DeepgramSttConfig {
    /// Deepgram API key.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
    /// Deepgram model name (default: "nova-2").
    #[serde(default = "default_deepgram_stt_model")]
    pub model: String,
}

/// AssemblyAI STT model_provider configuration (`[transcription.assemblyai]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "transcription.assemblyai"]
pub struct AssemblyAiSttConfig {
    /// AssemblyAI API key.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
}

/// Google Cloud Speech-to-Text model_provider configuration (`[transcription.google]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "transcription.google"]
pub struct GoogleSttConfig {
    /// Google Cloud API key.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
    /// BCP-47 language code (default: "en-US").
    #[serde(default = "default_google_stt_language_code")]
    pub language_code: String,
}

/// Local/self-hosted Whisper-compatible STT endpoint (`[transcription.local_whisper]`).
///
/// Configures a self-hosted STT endpoint. Can be on localhost, a private network host, or any reachable URL.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "transcription.local_whisper"]
pub struct LocalWhisperConfig {
    /// HTTP or HTTPS endpoint URL, e.g. `"http://10.10.0.1:8001/v1/transcribe"`.
    pub url: String,
    /// Bearer token for endpoint authentication.
    /// Omit for unauthenticated local endpoints.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub bearer_token: Option<String>,
    /// Maximum audio file size in bytes accepted by this endpoint.
    /// Defaults to 25 MB — matching the cloud API cap for a safe out-of-the-box
    /// experience. Self-hosted endpoints can accept much larger files; raise this
    /// as needed, but note that each transcription call clones the audio buffer
    /// into a multipart payload, so peak memory per request is ~2× this value.
    #[serde(default = "default_local_whisper_max_audio_bytes")]
    pub max_audio_bytes: usize,
    /// Request timeout in seconds. Defaults to 300 (large files on local GPU).
    #[serde(default = "default_local_whisper_timeout_secs")]
    pub timeout_secs: u64,
}

fn default_local_whisper_max_audio_bytes() -> usize {
    25 * 1024 * 1024
}

fn default_local_whisper_timeout_secs() -> u64 {
    300
}

// `#[derive(Default)]` would leave `max_audio_bytes = 0` and `timeout_secs = 0`
// (Rust's `usize`/`u64` defaults), even though those fields have serde defaults
// pointing at the helpers above. `#[serde(default = ...)]` only fires for
// *deserialization* — Rust's `Default::default()` bypasses it. Without this
// manual impl, `Config::init_defaults` materializes
// `transcription.local_whisper = Some(LocalWhisperConfig { max_audio_bytes: 0,
// timeout_secs: 0, .. })`; `LocalWhisperProvider::from_config` then rejects it
// at load (`max_audio_bytes must be greater than zero`), the failure poisons
// the parent `[transcription]` block's deserialization, and the daemon logs
// `dropped_config: transcription` while running with `transcription.enabled =
// false` regardless of operator intent.
impl Default for LocalWhisperConfig {
    fn default() -> Self {
        Self {
            url: String::new(),
            bearer_token: None,
            max_audio_bytes: default_local_whisper_max_audio_bytes(),
            timeout_secs: default_local_whisper_timeout_secs(),
        }
    }
}

/// HMAC tool execution receipt configuration, per agent
/// (`[agents.<alias>.tool_receipts]`).
///
/// Receipts are short HMAC-SHA256 tags appended to tool results so the model
/// cannot claim it ran a tool that never actually executed. See
/// `docs/book/src/security/tool-receipts.md`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "delegate_agent.tool_receipts"]
pub struct ToolReceiptsConfig {
    /// Generate HMAC receipts on every tool execution. Default: `false`.
    /// When false, the entire receipt subsystem is inert (no key, no
    /// generation, no append, no system-prompt addendum).
    #[serde(default)]
    pub enabled: bool,
    /// Append a trailing `Tool receipts:` block to user-visible replies so
    /// receipts are auditable from the channel surface, not just the
    /// internal history. Default: `false`.
    #[serde(default)]
    pub show_in_response: bool,
    /// Inject the receipt-echo instruction into the system prompt so the
    /// model carries receipts verbatim into its response. Default: `true`.
    /// No effect when `enabled = false`.
    #[serde(default = "default_inject_system_prompt")]
    pub inject_system_prompt: bool,
}

fn default_inject_system_prompt() -> bool {
    true
}

impl Default for ToolReceiptsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            show_in_response: false,
            inject_system_prompt: default_inject_system_prompt(),
        }
    }
}

fn default_max_tool_result_chars() -> usize {
    50_000
}

fn default_keep_tool_context_turns() -> usize {
    2
}

fn default_agent_tool_dispatcher() -> String {
    "auto".into()
}

fn default_max_system_prompt_chars() -> usize {
    0
}

// ── Pacing ────────────────────────────────────────────────────────

/// Pacing controls for slow/local LLM workloads (`[pacing]` section).
///
/// All fields are optional and default to values that preserve existing
/// behavior. When set, they extend — not replace — the existing timeout
/// and loop-detection subsystems.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "pacing"]
pub struct PacingConfig {
    /// Per-step timeout in seconds: the maximum time allowed for a single
    /// LLM inference turn, independent of the total message budget.
    /// `None` means no per-step timeout (existing behavior).
    #[serde(default)]
    pub step_timeout_secs: Option<u64>,

    /// Minimum elapsed seconds before loop detection activates.
    /// Tasks completing under this threshold get aggressive loop protection;
    /// longer-running tasks receive a grace period before the detector starts
    /// counting. `None` means loop detection is always active (existing behavior).
    #[serde(default)]
    pub loop_detection_min_elapsed_secs: Option<u64>,

    /// Tool names excluded from identical-output / alternating-pattern loop
    /// detection. Useful for browser workflows where `browser_screenshot`
    /// structurally resembles a loop even when making progress.
    #[serde(default)]
    pub loop_ignore_tools: Vec<String>,

    /// Override for the hardcoded timeout scaling cap (default: 4).
    /// The channel message timeout budget is computed as:
    ///   `message_timeout_secs * min(max_tool_iterations, message_timeout_scale_max)`
    /// Raising this value lets long multi-step tasks with slow local models
    /// receive a proportionally larger budget without inflating the base timeout.
    #[serde(default)]
    pub message_timeout_scale_max: Option<u64>,

    /// Enable pattern-based loop detection (exact repeat, ping-pong,
    /// no-progress). Defaults to `true`.
    #[serde(default = "default_loop_detection_enabled")]
    pub loop_detection_enabled: bool,

    /// Sliding window size for the pattern-based loop detector.
    /// Defaults to 20.
    #[serde(default = "default_loop_detection_window_size")]
    pub loop_detection_window_size: usize,

    /// Number of consecutive identical tool+args calls before the first
    /// escalation (Warning). Defaults to 3.
    #[serde(default = "default_loop_detection_max_repeats")]
    pub loop_detection_max_repeats: usize,
}

fn default_loop_detection_enabled() -> bool {
    true
}

fn default_loop_detection_window_size() -> usize {
    20
}

fn default_loop_detection_max_repeats() -> usize {
    3
}

impl Default for PacingConfig {
    fn default() -> Self {
        Self {
            step_timeout_secs: None,
            loop_detection_min_elapsed_secs: None,
            loop_ignore_tools: Vec::new(),
            message_timeout_scale_max: None,
            loop_detection_enabled: default_loop_detection_enabled(),
            loop_detection_window_size: default_loop_detection_window_size(),
            loop_detection_max_repeats: default_loop_detection_max_repeats(),
        }
    }
}

/// Skills loading configuration (`[skills]` section).
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SkillsPromptInjectionMode {
    /// Inline full skill instructions and tool metadata into the system prompt.
    #[default]
    Full,
    /// Inline only compact skill metadata (name/description/location) and load details on demand.
    Compact,
}

/// An external, user-configured skill registry ZeroClaw can install from.
///
/// Reuses the same git-clone mechanism as the default `zeroclaw-skills`
/// registry. Install a skill from it with `registry:<name>/<skill>`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ExternalRegistryKind {
    #[default]
    Git,
    Unsupported(String),
}

impl ExternalRegistryKind {
    pub fn as_str(&self) -> &str {
        match self {
            Self::Git => "git",
            Self::Unsupported(kind) => kind.as_str(),
        }
    }
}

impl From<String> for ExternalRegistryKind {
    fn from(kind: String) -> Self {
        if kind == "git" {
            Self::Git
        } else {
            Self::Unsupported(kind)
        }
    }
}

impl From<ExternalRegistryKind> for String {
    fn from(kind: ExternalRegistryKind) -> Self {
        kind.as_str().to_string()
    }
}

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

impl Serialize for ExternalRegistryKind {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for ExternalRegistryKind {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        Ok(String::deserialize(deserializer)?.into())
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ExternalRegistry {
    /// Short alias used in `registry:<name>/<skill>` install specs.
    pub name: String,
    /// Git repository URL of the registry (must expose a top-level `skills/` dir).
    pub url: String,
    /// Registry protocol. Only `"git"` is supported today; other protocols
    /// are reserved for a future additive release.
    #[serde(default)]
    #[cfg_attr(feature = "schema-export", schemars(with = "String"))]
    pub kind: ExternalRegistryKind,
    /// Whether this registry is eligible for installs. Default: `true`.
    #[serde(default = "default_true")]
    pub enabled: bool,
}

impl ExternalRegistry {
    /// Returns true when `name` can be addressed by `registry:<name>/<skill>`.
    ///
    /// Keep this as the single registry-alias rule used by both config
    /// validation and runtime install-spec parsing. Lowercase aliases also
    /// avoid clone-directory collisions on case-insensitive filesystems.
    pub fn is_valid_name(name: &str) -> bool {
        !name.is_empty()
            && name
                .bytes()
                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
    }
}

/// Skills loading configuration (`[skills]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "skills"]
pub struct SkillsConfig {
    /// Enable loading and syncing the community open-skills repository.
    /// Default: `false` (opt-in).
    #[serde(default)]
    pub open_skills_enabled: bool,
    /// Optional path to a local open-skills repository.
    /// If unset, defaults to `$HOME/open-skills` when enabled.
    #[serde(default)]
    pub open_skills_dir: Option<String>,
    /// Allow script-like files in skills (`.sh`, `.bash`, `.ps1`, shebang shell files).
    /// Default: `false` (secure by default).
    #[serde(default)]
    pub allow_scripts: bool,
    /// URL of the skills registry repository for bare-name installs.
    /// Default: `https://github.com/zeroclaw-labs/zeroclaw-skills`
    #[serde(default)]
    pub registry_url: Option<String>,
    /// Additional user-configured skill registries, installed via
    /// `registry:<name>/<skill>`. Each reuses the git-clone registry path and
    /// is cloned to its own `extra-registry-<name>/` workspace directory.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub extra_registries: Vec<ExternalRegistry>,
    /// Controls how skills are injected into the system prompt.
    /// `full` preserves legacy behavior. `compact` keeps context small and loads skills on demand.
    #[serde(default)]
    pub prompt_injection_mode: SkillsPromptInjectionMode,
    /// Autonomous skill creation from successful multi-step task executions.
    #[serde(default)]
    #[nested]
    pub skill_creation: SkillCreationConfig,
    /// Prompt-triggered install suggestions for missing skills.
    #[serde(default, alias = "install-suggestions")]
    #[nested]
    pub install_suggestions: SkillInstallSuggestionsConfig,
    /// Automatic skill self-improvement after successful skill usage.
    #[serde(default)]
    #[nested]
    pub skill_improvement: SkillImprovementConfig,
}

/// Autonomous skill creation configuration (`[skills.skill_creation]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "skills.skill_creation"]
#[serde(default)]
pub struct SkillCreationConfig {
    /// Enable automatic skill creation after successful multi-step tasks.
    /// Default: `false`.
    pub enabled: bool,
    /// Maximum number of auto-generated skills to keep.
    /// When exceeded, the oldest auto-generated skill is removed (LRU eviction).
    pub max_skills: usize,
    /// Embedding similarity threshold for deduplication.
    /// Skills with descriptions more similar than this value are skipped.
    pub similarity_threshold: f64,
    /// Synthesize a canonical `SKILL.md` from the execution trace via a
    /// bounded model-provider reflection call instead of the deterministic
    /// `SKILL.toml` generator. Requires `enabled = true`. Falls back to
    /// `SKILL.toml` whenever the reflection call or its output is invalid, so
    /// turning this on never leaves a skill un-created. This is distinct from
    /// the `[skills.skill_improvement]` background review fork, which patches
    /// existing skills after use rather than creating new ones from a trace.
    /// Default: `false`.
    #[serde(default = "default_reflection_enabled")]
    pub reflection_enabled: bool,
    /// Maximum characters of the final assistant answer fed into the
    /// reflection prompt. Bounds prompt size so a long answer cannot blow up
    /// the reflection request. Default: `2000`.
    #[serde(default = "default_max_final_answer_chars")]
    pub max_final_answer_chars: usize,
    /// Maximum characters of the rendered tool-call trace fed into the
    /// reflection prompt. Default: `4000`.
    #[serde(default = "default_max_tool_trace_chars")]
    pub max_tool_trace_chars: usize,
    /// Maximum characters of the task description fed into the reflection
    /// prompt. Default: `1000`.
    #[serde(default = "default_max_task_chars")]
    pub max_task_chars: usize,
}

fn default_reflection_enabled() -> bool {
    false
}

fn default_max_final_answer_chars() -> usize {
    2000
}

fn default_max_tool_trace_chars() -> usize {
    4000
}

fn default_max_task_chars() -> usize {
    1000
}

impl Default for SkillCreationConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            max_skills: 500,
            similarity_threshold: 0.85,
            reflection_enabled: default_reflection_enabled(),
            max_final_answer_chars: default_max_final_answer_chars(),
            max_tool_trace_chars: default_max_tool_trace_chars(),
            max_task_chars: default_max_task_chars(),
        }
    }
}

/// Prompt-triggered skill install suggestions (`[skills.install_suggestions]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "skills.install_suggestions"]
#[serde(default)]
pub struct SkillInstallSuggestionsConfig {
    /// Enable suggestions for installable skills before normal agent turns.
    /// Default: `false`.
    pub enabled: bool,
}

/// Skill self-improvement configuration (`[skills.skill-improvement]` section).
///
/// Controls the post-turn background review fork that may patch, expand, or
/// archive skills based on what the conversation revealed. The fork runs in a
/// restricted toolset (only `skills_list`, `skill_view`, `skill_manage`) and
/// never touches the user-visible conversation.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "skills.skill_improvement"]
pub struct SkillImprovementConfig {
    /// Enable the background skill-review fork. Default: `false`.
    /// Opt-in: users must explicitly enable this to allow post-turn skill
    /// mutations.
    #[serde(default)]
    pub enabled: bool,
    /// Minimum interval (in seconds) between reviews for the same skill.
    /// Acts as a durable rate limit on patches to any one skill. Default: `3600`.
    #[serde(default = "default_skill_improvement_cooldown")]
    pub cooldown_secs: u64,
    /// Spawn a review fork once at least this many tool-call iterations have
    /// accumulated in the current run. `0` disables iteration-based triggering.
    /// Default: `10`.
    #[serde(default = "default_nudge_interval_iterations")]
    pub nudge_interval_iterations: u32,
    /// Maximum tool-call iterations the review fork itself is allowed to make.
    /// Caps the fork's LLM cost. Default: `8`.
    #[serde(default = "default_max_review_iterations")]
    pub max_review_iterations: u32,
}

fn default_skill_improvement_cooldown() -> u64 {
    3600
}

fn default_nudge_interval_iterations() -> u32 {
    10
}

fn default_max_review_iterations() -> u32 {
    8
}

impl Default for SkillImprovementConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            cooldown_secs: 3600,
            nudge_interval_iterations: 10,
            max_review_iterations: 8,
        }
    }
}

/// Pipeline tool configuration (`[pipeline]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "pipeline"]
pub struct PipelineConfig {
    /// Enable the `execute_pipeline` meta-tool.
    /// Default: `false`.
    #[serde(default)]
    pub enabled: bool,
    /// Maximum number of steps allowed in a single pipeline invocation.
    /// Default: `20`.
    #[serde(default = "default_pipeline_max_steps")]
    pub max_steps: usize,
    /// Tools allowed in pipeline steps. Steps referencing tools not on this
    /// list are rejected before execution.
    #[serde(default)]
    pub allowed_tools: Vec<String>,
}

fn default_pipeline_max_steps() -> usize {
    20
}

impl Default for PipelineConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            max_steps: 20,
            allowed_tools: Vec::new(),
        }
    }
}

/// Multimodal (image) handling configuration (`[multimodal]` section).
///
/// # Privacy and cost note
///
/// Tool results that print real local image paths (e.g. shell tools doing
/// `ls /pictures` or `find . -name '*.png'`) are canonicalized into
/// `[IMAGE:...]` markers and base64-inlined into the next provider request.
/// This means image bytes that previously stayed local will be uploaded to
/// the configured provider when surfaced by a tool.
///
/// `max_images` (and the `trim_old_images` LRU policy) bounds the per-request
/// image budget, but operators running shell-style tools over directories of
/// personal or sensitive images should be aware of the upload semantics. See
/// `docs/book/src/contributing/privacy.md` for the project's privacy stance.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "multimodal"]
pub struct MultimodalConfig {
    /// Maximum number of image attachments accepted per request.
    ///
    /// Caps the total number of `[IMAGE:...]` markers that survive into the
    /// provider request after multimodal preprocessing. Older images are
    /// dropped first when the cumulative count exceeds this limit. Acts as
    /// the upper bound on per-turn upload cost when tool outputs surface
    /// local image paths.
    #[serde(default = "default_multimodal_max_images")]
    pub max_images: usize,
    /// Maximum image payload size in MiB before base64 encoding.
    #[serde(default = "default_multimodal_max_image_size_mb")]
    pub max_image_size_mb: usize,
    /// Maximum age of images in conversation turns.
    ///
    /// When non-zero, images in user messages that are more than this many
    /// turns back from the end of history are stripped before the request is
    /// sent to the provider. This prevents a single screenshot from being
    /// re-encoded and re-uploaded on every subsequent turn indefinitely.
    /// Tool-result images are already managed by the stale-tool-result
    /// mechanism and are not affected by this setting.
    ///
    /// `0` (the default) disables age-based trimming entirely — images are
    /// only evicted by the `max_images` count cap.
    #[serde(default)]
    pub max_image_turns: usize,
    /// Allow fetching remote image URLs (http/https). Disabled by default.
    #[serde(default)]
    pub allow_remote_fetch: bool,
    /// ModelProvider name to use for vision/image messages (e.g. `"ollama"`).
    /// When set, messages containing `[IMAGE:]` markers are routed to this
    /// model_provider instead of the default text model_provider.
    #[serde(default)]
    pub vision_model_provider: Option<String>,
    /// Model to use when routing to the vision model_provider (e.g. `"llava:7b"`).
    /// Only used when `vision_model_provider` is set.
    #[serde(default)]
    pub vision_model: Option<String>,
}

fn default_multimodal_max_images() -> usize {
    4
}

fn default_multimodal_max_image_size_mb() -> usize {
    5
}

impl MultimodalConfig {
    /// Clamp configured values to safe runtime bounds.
    pub fn effective_limits(&self) -> (usize, usize) {
        let max_images = self.max_images.clamp(1, 16);
        let max_image_size_mb = self.max_image_size_mb.clamp(1, 20);
        (max_images, max_image_size_mb)
    }
}

impl Default for MultimodalConfig {
    fn default() -> Self {
        Self {
            max_images: default_multimodal_max_images(),
            max_image_size_mb: default_multimodal_max_image_size_mb(),
            max_image_turns: 0,
            allow_remote_fetch: false,
            vision_model_provider: None,
            vision_model: None,
        }
    }
}

// ── Media Pipeline ──────────────────────────────────────────────

/// Automatic media understanding pipeline configuration (`[media_pipeline]`).
///
/// When enabled, inbound channel messages with media attachments are
/// pre-processed before reaching the agent: audio is transcribed, images are
/// annotated, and videos are summarised.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "media_pipeline"]
pub struct MediaPipelineConfig {
    /// Master toggle for the media pipeline (default: false).
    #[serde(default)]
    pub enabled: bool,

    /// Transcribe audio attachments using the configured transcription model_provider.
    #[serde(default = "default_true")]
    pub transcribe_audio: bool,

    /// Add image descriptions when a vision-capable model is active.
    #[serde(default = "default_true")]
    pub describe_images: bool,

    /// Summarize video attachments (placeholder — requires external API).
    #[serde(default = "default_true")]
    pub summarize_video: bool,
}

impl Default for MediaPipelineConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            transcribe_audio: true,
            describe_images: true,
            summarize_video: true,
        }
    }
}

// ── Identity (AIEOS / OpenClaw format) ──────────────────────────

/// Identity format configuration (`[identity]` section).
///
/// Supports `"openclaw"` (default) or `"aieos"` identity documents.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "identity"]
pub struct IdentityConfig {
    /// Identity format: "openclaw" (default) or "aieos"
    #[serde(default = "default_identity_format")]
    pub format: String,
    /// Path to AIEOS JSON file (relative to workspace)
    #[serde(default)]
    pub aieos_path: Option<String>,
    /// Inline AIEOS JSON (alternative to file path)
    #[serde(default)]
    pub aieos_inline: Option<String>,
}

fn default_identity_format() -> String {
    "openclaw".into()
}

impl Default for IdentityConfig {
    fn default() -> Self {
        Self {
            format: default_identity_format(),
            aieos_path: None,
            aieos_inline: None,
        }
    }
}

// ── Cost tracking and budget enforcement ───────────────────────────

/// Cost tracking and budget enforcement configuration (`[cost]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cost"]
pub struct CostConfig {
    /// Enable cost tracking (default: true)
    #[tab(Limits)]
    #[serde(default = "default_cost_enabled")]
    pub enabled: bool,

    /// Daily spending limit in USD (default: 10.00)
    #[tab(Limits)]
    #[serde(default = "default_daily_limit")]
    pub daily_limit_usd: f64,

    /// Monthly spending limit in USD (default: 100.00)
    #[tab(Limits)]
    #[serde(default = "default_monthly_limit")]
    pub monthly_limit_usd: f64,

    /// Warn when spending reaches this percentage of limit (default: 80)
    #[tab(Limits)]
    #[serde(default = "default_warn_percent")]
    pub warn_at_percent: u8,

    /// Allow requests to exceed budget with --override flag (default: false)
    #[tab(Limits)]
    #[serde(default)]
    pub allow_override: bool,

    /// Cost enforcement behavior when budget limits are approached or exceeded.
    #[tab(Limits)]
    #[serde(default)]
    #[nested]
    pub enforcement: CostEnforcementConfig,

    /// Stamp each recorded cost entry with the originating agent alias so
    /// `/api/cost?agent=<alias>` and CLI rollups can attribute spend to a
    /// specific agent. Disable on high-volume deployments if the extra
    /// HashMap aggregation shows up in profiles (default: true).
    #[tab(Limits)]
    #[serde(default = "default_track_per_agent")]
    pub track_per_agent: bool,

    /// Operator-managed rate sheet at `[cost.rates.*]`. Sections mirror
    /// the `[providers.*]` dotted-path exactly with the trailing `alias`
    /// segment replaced by the resource the rate applies to (model id,
    /// tool name, …). Layout:
    ///
    /// ```toml
    /// [cost.rates.providers.models.anthropic."claude-opus-4-7"]
    /// input_per_mtok = 15.0
    /// output_per_mtok = 75.0
    /// cached_input_per_mtok = 1.5
    ///
    /// [cost.rates.providers.tts.openai."tts-1-hd"]
    /// per_mchar = 30.0
    ///
    /// [cost.rates.providers.transcription.openai.whisper-1]
    /// per_minute = 0.006
    ///
    /// [cost.rates.tools.web_search]
    /// per_call = 0.005
    /// ```
    #[tab(Costs)]
    #[serde(default)]
    #[nested]
    pub rates: CostRatesConfig,
}

/// Configuration for cost enforcement behavior when budget limits are reached.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cost.enforcement"]
pub struct CostEnforcementConfig {
    /// Enforcement mode: "warn", "block", or "route_down".
    #[serde(default = "default_cost_enforcement_mode")]
    pub mode: String,
    /// Model hint to route to when budget is exceeded (used with "route_down" mode).
    #[serde(default)]
    pub route_down_model: Option<String>,
    /// Reserve this percentage of budget for critical operations.
    #[serde(default = "default_reserve_percent")]
    pub reserve_percent: u8,
}

fn default_cost_enforcement_mode() -> String {
    "warn".to_string()
}

fn default_reserve_percent() -> u8 {
    10
}

impl Default for CostEnforcementConfig {
    fn default() -> Self {
        Self {
            mode: default_cost_enforcement_mode(),
            route_down_model: None,
            reserve_percent: default_reserve_percent(),
        }
    }
}

fn default_daily_limit() -> f64 {
    10.0
}

fn default_monthly_limit() -> f64 {
    100.0
}

fn default_warn_percent() -> u8 {
    80
}

fn default_cost_enabled() -> bool {
    true
}

fn default_track_per_agent() -> bool {
    true
}

/// `[cost.rates]` — top-level rate-sheet namespace. Mirrors the
/// `[providers.*]` shape so each subsection here points at the same
/// kind of resource its `[providers.*]` counterpart configures.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cost.rates"]
pub struct CostRatesConfig {
    /// `[cost.rates.providers.*]` — rates for everything under
    /// `[providers.*]` (models, TTS, transcription, …).
    #[serde(default)]
    #[nested]
    pub providers: ProviderCostRates,

    /// `[cost.rates.tools.<name>]` — per-call rates for tools that
    /// hit paid APIs. Keyed by the tool's registered name.
    #[serde(default)]
    #[nested]
    #[resource_key]
    pub tools: std::collections::HashMap<String, ToolCostRates>,
}

impl CostRatesConfig {
    /// Lookup model token rates by `(provider_type, model)`. Dispatch
    /// lives on the typed wrapper — see [`crate::providers::ModelCostRatesByProvider`].
    #[must_use]
    pub fn model_rates(&self, provider_type: &str, model: &str) -> Option<&ModelCostRates> {
        self.providers.models.get(provider_type, model)
    }

    /// Lookup TTS rates by `(provider_type, voice)`.
    #[must_use]
    pub fn tts_rates(&self, provider_type: &str, voice: &str) -> Option<&TtsCostRates> {
        self.providers.tts.get(provider_type, voice)
    }

    /// Lookup transcription rates by `(provider_type, model)`.
    #[must_use]
    pub fn transcription_rates(
        &self,
        provider_type: &str,
        model: &str,
    ) -> Option<&TranscriptionCostRates> {
        self.providers.transcription.get(provider_type, model)
    }

    /// Lookup tool per-call rate by registered name.
    #[must_use]
    pub fn tool_rates(&self, tool_name: &str) -> Option<&ToolCostRates> {
        self.tools.get(tool_name)
    }
}

/// `[cost.rates.providers.*]` — provider-shaped rate sheets. Each field
/// here mirrors a corresponding field on `[providers.*]` with the
/// trailing alias segment replaced by the resource the rate prices.
/// The inner typed wrappers carry the per-provider-type slot layout
/// and own dispatch (their slot list is the single source of truth,
/// shared with their providers counterpart via the `for_each_*_provider_slot!`
/// macros in [`crate::providers`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cost.rates.providers"]
pub struct ProviderCostRates {
    /// `[cost.rates.providers.models.<type>.<model>]`.
    #[serde(default)]
    #[nested]
    pub models: crate::providers::ModelCostRatesByProvider,
    /// `[cost.rates.providers.tts.<type>.<voice>]`.
    #[serde(default)]
    #[nested]
    pub tts: crate::providers::TtsCostRatesByProvider,
    /// `[cost.rates.providers.transcription.<type>.<model>]`.
    #[serde(default)]
    #[nested]
    pub transcription: crate::providers::TranscriptionCostRatesByProvider,
}

/// The provider families that carry `[cost.rates.providers.*]` rate sheets, as
/// the trailing segment shared by their `[providers.<category>]` section. One
/// entry per `#[nested]` field on `ProviderCostRates`; this list is the registry
/// the TUI and web surfaces resolve cost tabs from, never a per-surface literal.
pub const PROVIDER_COST_CATEGORIES: [&str; 3] = ["models", "tts", "transcription"];

/// Cost-rate category for a `providers.<category>` section key, or `None` for
/// sections that carry no rate sheets. Resolves against
/// [`PROVIDER_COST_CATEGORIES`] so a new rate-bearing family is added in one
/// place.
#[must_use]
pub fn cost_category_for_provider_section(section_key: &str) -> Option<&'static str> {
    let suffix = section_key.strip_prefix("providers.")?;
    PROVIDER_COST_CATEGORIES
        .into_iter()
        .find(|category| *category == suffix)
}

/// Token-cost rates for a single chat / completion model, in USD per
/// 1M tokens. Every field optional so partial sheets work without
/// ceremony (an operator who only knows the input rate can record it).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cost.rates.providers.models"]
pub struct ModelCostRates {
    /// Input tokens (USD per 1M).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub input_per_mtok: Option<f64>,
    /// Output tokens (USD per 1M).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output_per_mtok: Option<f64>,
    /// Cached input tokens (USD per 1M). Optional — leave unset on
    /// providers that don't charge separately for prompt cache hits.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cached_input_per_mtok: Option<f64>,
}

/// Rates for a TTS model, in USD per 1M characters.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cost.rates.providers.tts"]
pub struct TtsCostRates {
    /// Characters synthesised (USD per 1M).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub per_mchar: Option<f64>,
}

/// Rates for a transcription model, in USD per minute of audio.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cost.rates.providers.transcription"]
pub struct TranscriptionCostRates {
    /// Audio transcribed (USD per minute).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub per_minute: Option<f64>,
}

/// Rates for a tool that hits a paid external API. Keyed in
/// `[cost.rates.tools.<name>]` by the tool's registered name.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cost.rates.tools"]
pub struct ToolCostRates {
    /// Per-call cost (USD).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub per_call: Option<f64>,
}

impl Default for CostConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            daily_limit_usd: default_daily_limit(),
            monthly_limit_usd: default_monthly_limit(),
            warn_at_percent: default_warn_percent(),
            allow_override: false,
            enforcement: CostEnforcementConfig::default(),
            track_per_agent: default_track_per_agent(),
            rates: CostRatesConfig::default(),
        }
    }
}

// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ────────────────────────

/// Peripheral board integration configuration (`[peripherals]` section).
///
/// Boards become agent tools when enabled.
#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "peripherals"]
pub struct PeripheralsConfig {
    /// Enable peripheral support (boards become agent tools)
    #[serde(default)]
    pub enabled: bool,
    /// Board configurations (nucleo-f401re, rpi-gpio, etc.)
    #[serde(default)]
    pub boards: Vec<PeripheralBoardConfig>,
    /// Path to datasheet docs (relative to workspace) for RAG retrieval.
    /// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md).
    #[serde(default)]
    pub datasheet_dir: Option<String>,
}

/// Configuration for a single peripheral board (e.g. STM32, RPi GPIO).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PeripheralBoardConfig {
    /// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc.
    pub board: String,
    /// Transport: "serial", "native", "websocket"
    #[serde(default = "default_peripheral_transport")]
    pub transport: String,
    /// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0"
    #[serde(default)]
    pub path: Option<String>,
    /// Baud rate for serial (default: 115200)
    #[serde(default = "default_peripheral_baud")]
    pub baud: u32,
}

fn default_peripheral_transport() -> String {
    "serial".into()
}

fn default_peripheral_baud() -> u32 {
    115_200
}

impl Default for PeripheralBoardConfig {
    fn default() -> Self {
        Self {
            board: String::new(),
            transport: default_peripheral_transport(),
            path: None,
            baud: default_peripheral_baud(),
        }
    }
}

// ── Gateway security ─────────────────────────────────────────────

/// Gateway server configuration (`[gateway]` section).
///
/// Controls the HTTP gateway for webhook and pairing endpoints.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "gateway"]
#[allow(clippy::struct_excessive_bools)]
pub struct GatewayConfig {
    /// Gateway port (default: 42617)
    #[serde(default = "default_gateway_port")]
    pub port: u16,
    /// Gateway host (default: 127.0.0.1)
    #[serde(default = "default_gateway_host")]
    pub host: String,
    /// Require pairing before accepting requests (default: true)
    #[serde(default = "default_true")]
    pub require_pairing: bool,
    /// Allow binding to non-localhost without a tunnel (default: false)
    #[serde(default)]
    pub allow_public_bind: bool,
    /// Allow authenticated remote callers to use admin endpoints that are
    /// otherwise localhost-only. Currently this gates `POST /admin/reload`.
    /// When false (default), those endpoints reject any non-loopback peer.
    /// When true, a non-loopback request is accepted only if it also passes
    /// pairing authentication — which requires `require_pairing = true`; with
    /// pairing off a remote caller cannot be authenticated and is rejected, so
    /// this flag never exposes an anonymous remote reload. `/admin/shutdown`
    /// and the pairing-code endpoints stay localhost-only regardless.
    /// (default: false)
    #[serde(default)]
    pub allow_remote_admin: bool,
    /// Paired bearer tokens (managed automatically, not user-edited)
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub paired_tokens: Vec<String>,

    /// Max `/pair` requests per minute per client key.
    #[serde(default = "default_pair_rate_limit")]
    pub pair_rate_limit_per_minute: u32,

    /// Max `/webhook` requests per minute per client key.
    #[serde(default = "default_webhook_rate_limit")]
    pub webhook_rate_limit_per_minute: u32,

    /// Trust proxy-forwarded client IP headers (`X-Forwarded-For`, `X-Real-IP`).
    /// Disabled by default; enable only behind a trusted reverse proxy.
    #[serde(default)]
    #[credential_class = "public_value"]
    pub trust_forwarded_headers: bool,

    /// Optional URL path prefix for reverse-proxy deployments.
    /// When set, all gateway routes are served under this prefix.
    /// Must start with `/` and must not end with `/`.
    #[serde(default)]
    pub path_prefix: Option<String>,

    /// Maximum distinct client keys tracked by gateway rate limiter maps.
    #[serde(default = "default_gateway_rate_limit_max_keys")]
    pub rate_limit_max_keys: usize,

    /// TTL for webhook idempotency keys.
    #[serde(default = "default_idempotency_ttl_secs")]
    pub idempotency_ttl_secs: u64,

    /// Maximum distinct idempotency keys retained in memory.
    #[serde(default = "default_gateway_idempotency_max_keys")]
    pub idempotency_max_keys: usize,

    /// Persist gateway WebSocket chat sessions to SQLite. Default: true.
    #[serde(default = "default_true")]
    pub session_persistence: bool,

    /// Auto-archive stale gateway sessions older than N hours. 0 = disabled. Default: 0.
    #[serde(default)]
    pub session_ttl_hours: u32,

    /// Pairing dashboard configuration
    #[serde(default)]
    #[nested]
    pub pairing_dashboard: PairingDashboardConfig,

    /// Path to the web dashboard `dist` directory. When set, the gateway
    /// serves the compiled frontend from the filesystem instead of requiring
    /// it to be embedded in the binary. Accepts absolute paths or paths
    /// relative to the working directory. When omitted the gateway runs in
    /// API-only mode (no web dashboard) unless auto-detection finds it.
    #[serde(default)]
    pub web_dist_dir: Option<String>,

    /// TLS configuration for the gateway server (`[gateway.tls]`).
    #[serde(default)]
    #[nested]
    pub tls: Option<GatewayTlsConfig>,

    /// HTTP request timeout (seconds) for gateway routes other than the
    /// long-running cron-trigger endpoint. Default: 30s.
    #[serde(default = "default_gateway_request_timeout_secs")]
    pub request_timeout_secs: u64,

    /// HTTP request timeout (seconds) for `POST /api/cron/{id}/run`, which
    /// runs jobs synchronously and routinely exceeds the 30s default.
    /// Default: 600s (10 minutes).
    #[serde(default = "default_gateway_long_running_request_timeout_secs")]
    pub long_running_request_timeout_secs: u64,

    /// Poll GitHub for newer releases and show an "update available" indicator
    /// on the dashboard version tag. Read-only; does not install anything.
    /// (default: true)
    #[serde(default = "default_true")]
    pub check_updates: bool,

    /// Allow triggering a self-upgrade (binary swap via `zeroclaw update`) from
    /// the dashboard. This is a remote-code-execution-adjacent surface: any
    /// authenticated dashboard user could replace the running binary. Keep off
    /// unless you trust every paired client. (default: false)
    #[serde(default)]
    pub allow_self_upgrade: bool,
}

fn default_gateway_port() -> u16 {
    42617
}

fn default_gateway_request_timeout_secs() -> u64 {
    30
}

fn default_gateway_long_running_request_timeout_secs() -> u64 {
    600
}

fn default_gateway_host() -> String {
    "127.0.0.1".into()
}

fn default_pair_rate_limit() -> u32 {
    10
}

fn default_webhook_rate_limit() -> u32 {
    60
}

fn default_idempotency_ttl_secs() -> u64 {
    300
}

fn default_gateway_rate_limit_max_keys() -> usize {
    10_000
}

fn default_gateway_idempotency_max_keys() -> usize {
    10_000
}

fn default_true() -> bool {
    true
}

fn default_false() -> bool {
    false
}

fn default_memory_backend() -> String {
    "sqlite".into()
}

fn default_tunnel_provider() -> String {
    "none".into()
}

impl Default for GatewayConfig {
    fn default() -> Self {
        Self {
            port: default_gateway_port(),
            host: default_gateway_host(),
            require_pairing: true,
            allow_public_bind: false,
            allow_remote_admin: false,
            paired_tokens: Vec::new(),
            pair_rate_limit_per_minute: default_pair_rate_limit(),
            webhook_rate_limit_per_minute: default_webhook_rate_limit(),
            trust_forwarded_headers: false,
            path_prefix: None,
            rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
            idempotency_ttl_secs: default_idempotency_ttl_secs(),
            idempotency_max_keys: default_gateway_idempotency_max_keys(),
            session_persistence: true,
            session_ttl_hours: 0,
            pairing_dashboard: PairingDashboardConfig::default(),
            web_dist_dir: None,
            tls: None,
            request_timeout_secs: default_gateway_request_timeout_secs(),
            long_running_request_timeout_secs: default_gateway_long_running_request_timeout_secs(),
            check_updates: true,
            allow_self_upgrade: false,
        }
    }
}

/// Pairing dashboard configuration (`[gateway.pairing_dashboard]`).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "gateway.pairing_dashboard"]
pub struct PairingDashboardConfig {
    /// Length of pairing codes (default: 8)
    #[serde(default = "default_pairing_code_length")]
    pub code_length: usize,
    /// Time-to-live for pending pairing codes in seconds (default: 3600)
    #[serde(default = "default_pairing_ttl")]
    pub code_ttl_secs: u64,
    /// Maximum concurrent pending pairing codes (default: 3)
    #[serde(default = "default_max_pending_codes")]
    pub max_pending_codes: usize,
    /// Maximum failed pairing attempts before lockout (default: 5)
    #[serde(default = "default_max_failed_attempts")]
    pub max_failed_attempts: u32,
    /// Lockout duration in seconds after max attempts (default: 300)
    #[serde(default = "default_pairing_lockout_secs")]
    pub lockout_secs: u64,
}

fn default_pairing_code_length() -> usize {
    8
}
fn default_pairing_ttl() -> u64 {
    3600
}
fn default_max_pending_codes() -> usize {
    3
}
fn default_max_failed_attempts() -> u32 {
    5
}
fn default_pairing_lockout_secs() -> u64 {
    300
}

impl Default for PairingDashboardConfig {
    fn default() -> Self {
        Self {
            code_length: default_pairing_code_length(),
            code_ttl_secs: default_pairing_ttl(),
            max_pending_codes: default_max_pending_codes(),
            max_failed_attempts: default_max_failed_attempts(),
            lockout_secs: default_pairing_lockout_secs(),
        }
    }
}

/// TLS configuration for the gateway server (`[gateway.tls]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "gateway.tls"]
pub struct GatewayTlsConfig {
    /// Enable TLS for the gateway (default: false).
    #[serde(default)]
    pub enabled: bool,
    /// Path to the PEM-encoded server certificate file.
    pub cert_path: String,
    /// Path to the PEM-encoded server private key file.
    pub key_path: String,
    /// Client certificate authentication (mutual TLS) settings.
    #[serde(default)]
    #[nested]
    pub client_auth: Option<GatewayClientAuthConfig>,
}

/// Client certificate authentication (mTLS) configuration (`[gateway.tls.client_auth]`).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "gateway.tls.client_auth"]
pub struct GatewayClientAuthConfig {
    /// Enable client certificate verification (default: false).
    #[serde(default)]
    pub enabled: bool,
    /// Path to the PEM-encoded CA certificate used to verify client certs.
    #[serde(default)]
    pub ca_cert_path: String,
    /// Reject connections that do not present a valid client certificate (default: true).
    #[serde(default = "default_true")]
    pub require_client_cert: bool,
    /// Optional SHA-256 fingerprints for certificate pinning.
    /// When non-empty, only client certs matching one of these fingerprints are accepted.
    #[serde(default)]
    pub pinned_certs: Vec<String>,
}

impl Default for GatewayClientAuthConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            ca_cert_path: String::new(),
            require_client_cert: default_true(),
            pinned_certs: Vec::new(),
        }
    }
}

/// WebSocket Secure (WSS) transport for remote TUI-to-daemon connections (`[wss]`).
///
/// When enabled, the daemon listens for TLS-encrypted WebSocket connections
/// on the configured bind address and port. TUI clients connect via
/// `--connect wss://host:port`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "wss"]
pub struct WssConfig {
    /// Enable the WSS listener (default: false).
    #[serde(default)]
    pub enabled: bool,
    /// Bind address for the WSS listener (default: "0.0.0.0").
    #[serde(default = "default_wss_bind")]
    pub bind: String,
    /// Port for the WSS listener (default: 9781).
    #[serde(default = "default_wss_port")]
    pub port: u16,
    /// Path to the PEM-encoded server certificate file.
    #[serde(default)]
    pub cert_path: String,
    /// Path to the PEM-encoded server private key file.
    #[serde(default)]
    pub key_path: String,
}

impl Default for WssConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            bind: default_wss_bind(),
            port: default_wss_port(),
            cert_path: String::new(),
            key_path: String::new(),
        }
    }
}

fn default_wss_bind() -> String {
    "0.0.0.0".into()
}

fn default_wss_port() -> u16 {
    9781
}

/// Secure transport configuration for inter-node communication (`[node_transport]`).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "node_transport"]
pub struct NodeTransportConfig {
    /// Enable the secure transport layer.
    #[serde(default = "default_node_transport_enabled")]
    pub enabled: bool,
    /// Shared secret for HMAC authentication between nodes.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub shared_secret: String,
    /// Maximum age of signed requests in seconds (replay protection).
    #[serde(default = "default_max_request_age")]
    pub max_request_age_secs: i64,
    /// Require HTTPS for all node communication.
    #[serde(default = "default_require_https")]
    pub require_https: bool,
    /// Allow specific node IPs/CIDRs.
    #[serde(default)]
    pub allowed_peers: Vec<String>,
    /// Path to TLS certificate file.
    #[serde(default)]
    pub tls_cert_path: Option<String>,
    /// Path to TLS private key file.
    #[serde(default)]
    pub tls_key_path: Option<String>,
    /// Require client certificates (mutual TLS).
    #[serde(default)]
    pub mutual_tls: bool,
    /// Maximum number of connections per peer.
    #[serde(default = "default_connection_pool_size")]
    pub connection_pool_size: usize,
}

fn default_node_transport_enabled() -> bool {
    true
}
fn default_max_request_age() -> i64 {
    300
}
fn default_require_https() -> bool {
    true
}
fn default_connection_pool_size() -> usize {
    4
}

impl Default for NodeTransportConfig {
    fn default() -> Self {
        Self {
            enabled: default_node_transport_enabled(),
            shared_secret: String::new(),
            max_request_age_secs: default_max_request_age(),
            require_https: default_require_https(),
            allowed_peers: Vec::new(),
            tls_cert_path: None,
            tls_key_path: None,
            mutual_tls: false,
            connection_pool_size: default_connection_pool_size(),
        }
    }
}

// ── Composio (managed tool surface) ─────────────────────────────

/// Composio managed OAuth tools integration (`[composio]` section).
///
/// Provides access to 1000+ OAuth-connected tools via the Composio platform.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "composio"]
pub struct ComposioConfig {
    /// Enable Composio integration for 1000+ OAuth tools
    #[serde(default, alias = "enable")]
    pub enabled: bool,
    /// Composio API key (stored encrypted when secrets.encrypt = true)
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
    /// Default entity ID for multi-user setups
    #[serde(default = "default_entity_id")]
    pub entity_id: String,
}

fn default_entity_id() -> String {
    "default".into()
}

impl Default for ComposioConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            api_key: None,
            entity_id: default_entity_id(),
        }
    }
}

// ── Microsoft 365 (Graph API integration) ───────────────────────

/// Microsoft 365 integration via Microsoft Graph API (`[microsoft365]` section).
///
/// Provides access to Outlook mail, Teams messages, Calendar events,
/// OneDrive files, and SharePoint search.
#[derive(Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "ms365"]
pub struct Microsoft365Config {
    /// Enable Microsoft 365 integration
    #[serde(default, alias = "enable")]
    pub enabled: bool,
    /// Azure AD tenant ID
    #[serde(default)]
    pub tenant_id: Option<String>,
    /// Azure AD application (client) ID
    #[serde(default)]
    pub client_id: Option<String>,
    /// Azure AD client secret (stored encrypted when secrets.encrypt = true)
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub client_secret: Option<String>,
    /// Authentication flow: "client_credentials" or "device_code"
    #[serde(default = "default_ms365_auth_flow")]
    pub auth_flow: String,
    /// OAuth scopes to request
    #[serde(default = "default_ms365_scopes")]
    pub scopes: Vec<String>,
    /// Encrypt the token cache file on disk
    #[serde(default = "default_true")]
    pub token_cache_encrypted: bool,
    /// User principal name or "me" (for delegated flows)
    #[serde(default)]
    pub user_id: Option<String>,
}

fn default_ms365_auth_flow() -> String {
    "client_credentials".to_string()
}

fn default_ms365_scopes() -> Vec<String> {
    vec!["https://graph.microsoft.com/.default".to_string()]
}

impl std::fmt::Debug for Microsoft365Config {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Microsoft365Config")
            .field("enabled", &self.enabled)
            .field("tenant_id", &self.tenant_id)
            .field("client_id", &self.client_id)
            .field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
            .field("auth_flow", &self.auth_flow)
            .field("scopes", &self.scopes)
            .field("token_cache_encrypted", &self.token_cache_encrypted)
            .field("user_id", &self.user_id)
            .finish()
    }
}

impl Default for Microsoft365Config {
    fn default() -> Self {
        Self {
            enabled: false,
            tenant_id: None,
            client_id: None,
            client_secret: None,
            auth_flow: default_ms365_auth_flow(),
            scopes: default_ms365_scopes(),
            token_cache_encrypted: true,
            user_id: None,
        }
    }
}

// ── Secrets (encrypted credential store) ────────────────────────

/// Secrets encryption configuration (`[secrets]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "secrets"]
pub struct SecretsConfig {
    /// Enable encryption for API keys and tokens at rest
    #[serde(default = "default_true")]
    #[credential_class = "public_value"]
    pub encrypt: bool,
}

impl Default for SecretsConfig {
    fn default() -> Self {
        Self { encrypt: true }
    }
}

// ── Browser (friendly-service browsing only) ───────────────────

/// Computer-use sidecar configuration (`[browser.computer_use]` section).
///
/// Delegates OS-level mouse, keyboard, and screenshot actions to a local sidecar.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "browser.computer_use"]
pub struct BrowserComputerUseConfig {
    /// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot)
    #[serde(default = "default_browser_computer_use_endpoint")]
    pub endpoint: String,
    /// Optional bearer token for computer-use sidecar
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
    /// Per-action request timeout in milliseconds
    #[serde(default = "default_browser_computer_use_timeout_ms")]
    pub timeout_ms: u64,
    /// Allow remote/public endpoint for computer-use sidecar (default: false)
    #[serde(default)]
    pub allow_remote_endpoint: bool,
    /// Optional window title/process allowlist forwarded to sidecar policy
    #[serde(default)]
    pub window_allowlist: Vec<String>,
    /// Optional X-axis boundary for coordinate-based actions
    #[serde(default)]
    pub max_coordinate_x: Option<i64>,
    /// Optional Y-axis boundary for coordinate-based actions
    #[serde(default)]
    pub max_coordinate_y: Option<i64>,
}

fn default_browser_computer_use_endpoint() -> String {
    "http://127.0.0.1:8787/v1/actions".into()
}

fn default_browser_computer_use_timeout_ms() -> u64 {
    15_000
}

impl Default for BrowserComputerUseConfig {
    fn default() -> Self {
        Self {
            endpoint: default_browser_computer_use_endpoint(),
            api_key: None,
            timeout_ms: default_browser_computer_use_timeout_ms(),
            allow_remote_endpoint: false,
            window_allowlist: Vec::new(),
            max_coordinate_x: None,
            max_coordinate_y: None,
        }
    }
}

/// Browser automation configuration (`[browser]` section).
///
/// Controls the `browser_open` tool and browser automation backends.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "browser"]
#[integration(
    category = "ToolsAutomation",
    display_name = "Browser",
    description = "Chrome/Chromium control",
    status_field = "enabled"
)]
pub struct BrowserConfig {
    /// Enable `browser_open` tool (opens URLs in the system browser without scraping)
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Allowed domains for `browser_open` (exact or subdomain match)
    #[serde(default = "default_browser_allowed_domains")]
    pub allowed_domains: Vec<String>,
    /// Browser session name (for agent-browser automation)
    #[serde(default)]
    pub session_name: Option<String>,
    /// Browser automation backend: "agent_browser" | "rust_native" | "computer_use" | "auto"
    #[serde(default = "default_browser_backend")]
    pub backend: String,
    /// Show browser window for agent_browser backend. When unset, inherits AGENT_BROWSER_HEADED.
    #[serde(default)]
    pub headed: Option<bool>,
    /// Headless mode for rust-native backend
    #[serde(default = "default_true")]
    pub native_headless: bool,
    /// WebDriver endpoint URL for rust-native backend (e.g. `http://127.0.0.1:9515`)
    #[serde(default = "default_browser_webdriver_url")]
    pub native_webdriver_url: String,
    /// Optional Chrome/Chromium executable path for rust-native backend
    #[serde(default)]
    pub native_chrome_path: Option<String>,
    /// Computer-use sidecar configuration
    #[serde(default)]
    #[nested]
    pub computer_use: BrowserComputerUseConfig,
    /// Private/internal hosts allowed to bypass SSRF protection.
    /// Exact and subdomain matches are supported; `["*"]` permits **all** private/local
    /// hosts (RFC 1918, loopback, link-local, `.local`). Default: empty (deny).
    /// Listed hosts also bypass `allowed_domains`. Both the `browser` tool and
    /// `browser_open` accept `http://` for listed hosts — internal services
    /// frequently lack a public TLS cert.
    /// Warning: `["*"]` also reaches link-local addresses, including the cloud metadata
    /// endpoint (`169.254.169.254`) — list specific hosts unless you accept that exposure.
    #[serde(default)]
    pub allowed_private_hosts: Vec<String>,
}

fn default_browser_allowed_domains() -> Vec<String> {
    vec!["*".into()]
}

fn default_browser_backend() -> String {
    "agent_browser".into()
}

fn default_browser_webdriver_url() -> String {
    "http://127.0.0.1:9515".into()
}

impl Default for BrowserConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            allowed_domains: vec!["*".into()],
            session_name: None,
            backend: default_browser_backend(),
            headed: None,
            native_headless: default_true(),
            native_webdriver_url: default_browser_webdriver_url(),
            native_chrome_path: None,
            computer_use: BrowserComputerUseConfig::default(),
            allowed_private_hosts: vec![],
        }
    }
}

// ── HTTP request tool ───────────────────────────────────────────

/// HTTP request tool configuration (`[http_request]` section).
///
/// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
/// for all public hosts, which is the default). If `allowed_domains` is empty, all
/// requests are rejected.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "http_request"]
pub struct HttpRequestConfig {
    /// Enable `http_request` tool for API interactions
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Allowed domains for HTTP requests (exact or subdomain match)
    #[serde(default = "default_allowed_domains_star")]
    pub allowed_domains: Vec<String>,
    /// Maximum response size in bytes (default: 1MB, 0 = unlimited)
    #[serde(default = "default_http_max_response_size")]
    pub max_response_size: usize,
    /// Request timeout in seconds (default: 30)
    #[serde(default = "default_http_timeout_secs")]
    pub timeout_secs: u64,
    /// Allow requests to private/LAN hosts (RFC 1918, loopback, link-local, .local).
    /// Default: false (deny private hosts for SSRF protection).
    #[serde(default)]
    pub allow_private_hosts: bool,
    /// Private/internal hosts explicitly allowed to bypass SSRF protection.
    /// Exact and subdomain matches are supported; `*` permits all private/local hosts.
    #[serde(default)]
    pub allowed_private_hosts: Vec<String>,
    /// Named authorization secrets for `auth_secret` requests.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub secrets: HashMap<String, String>,
}

impl Default for HttpRequestConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            allowed_domains: vec!["*".into()],
            max_response_size: default_http_max_response_size(),
            timeout_secs: default_http_timeout_secs(),
            allow_private_hosts: false,
            allowed_private_hosts: vec![],
            secrets: HashMap::new(),
        }
    }
}

fn default_http_max_response_size() -> usize {
    1_000_000 // 1MB
}

fn default_http_timeout_secs() -> u64 {
    30
}

fn default_allowed_domains_star() -> Vec<String> {
    vec!["*".into()]
}

// ── Web fetch ────────────────────────────────────────────────────

/// Web fetch tool configuration (`[web_fetch]` section).
///
/// Fetches web pages and converts HTML to plain text for LLM consumption.
/// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
/// for all public hosts). `blocked_domains` takes priority over `allowed_domains`.
/// If `allowed_domains` is empty, all requests are rejected (deny-by-default).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "web_fetch"]
pub struct WebFetchConfig {
    /// Enable `web_fetch` tool for fetching web page content
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Allowed domains for web fetch (exact or subdomain match; `["*"]` = all public hosts)
    #[serde(default = "default_web_fetch_allowed_domains")]
    pub allowed_domains: Vec<String>,
    /// Blocked domains (exact or subdomain match; always takes priority over allowed_domains)
    #[serde(default)]
    pub blocked_domains: Vec<String>,
    /// Private/internal hosts allowed to bypass SSRF protection (e.g. `["192.168.1.10", "internal.local"]`).
    /// Exact and subdomain matches are supported. Listing a host skips the
    /// resolved-IP SSRF check for it; a *literal* private/local host (IP literal,
    /// localhost, .local) listed here also bypasses `allowed_domains`. The `*`
    /// wildcard permits any literal private/local host and lets a name already in
    /// `allowed_domains` resolve to a private IP, but it does NOT widen
    /// `allowed_domains` — a non-private host (matched explicitly or via `*`) still
    /// needs `allowed_domains`, so `*` cannot reach an arbitrary public host.
    #[serde(default)]
    pub allowed_private_hosts: Vec<String>,
    /// Maximum response size in bytes (default: 500KB, plain text is much smaller than raw HTML)
    #[serde(default = "default_web_fetch_max_response_size")]
    pub max_response_size: usize,
    /// Request timeout in seconds (default: 30)
    #[serde(default = "default_web_fetch_timeout_secs")]
    pub timeout_secs: u64,
    /// Firecrawl fallback configuration (`[web_fetch.firecrawl]`)
    #[serde(default)]
    #[nested]
    pub firecrawl: FirecrawlConfig,
}

/// Firecrawl fallback mode: scrape a single page or crawl linked pages.
#[derive(
    Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum FirecrawlMode {
    #[default]
    Scrape,
    /// Reserved for future multi-page crawl support. Accepted in config
    /// deserialization to avoid breaking existing files, but not yet
    /// implemented — `fetch_via_firecrawl` always uses the `/scrape` endpoint.
    Crawl,
}

/// Firecrawl fallback configuration for JS-heavy and bot-blocked sites.
///
/// When enabled, if the standard web fetch fails (HTTP error, empty body, or
/// body shorter than 100 characters suggesting a JS-only page), the tool
/// falls back to the Firecrawl API for stealth content extraction.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "web_fetch.firecrawl"]
pub struct FirecrawlConfig {
    /// Enable Firecrawl fallback
    #[serde(default)]
    pub enabled: bool,
    /// Environment variable name for the Firecrawl API key
    #[serde(default = "default_firecrawl_api_key_env")]
    #[credential_class = "legacy_env_path"]
    pub api_key_env: String,
    /// Firecrawl API base URL
    #[serde(default = "default_firecrawl_api_url")]
    pub api_url: String,
    /// Firecrawl extraction mode
    #[serde(default)]
    pub mode: FirecrawlMode,
}

fn default_firecrawl_api_key_env() -> String {
    "FIRECRAWL_API_KEY".into()
}

fn default_firecrawl_api_url() -> String {
    "https://api.firecrawl.dev/v1".into()
}

impl Default for FirecrawlConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            api_key_env: default_firecrawl_api_key_env(),
            api_url: default_firecrawl_api_url(),
            mode: FirecrawlMode::default(),
        }
    }
}

fn default_web_fetch_max_response_size() -> usize {
    500_000 // 500KB
}

fn default_web_fetch_timeout_secs() -> u64 {
    30
}

fn default_web_fetch_allowed_domains() -> Vec<String> {
    vec!["*".into()]
}

impl Default for WebFetchConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            allowed_domains: vec!["*".into()],
            blocked_domains: vec![],
            allowed_private_hosts: vec![],
            max_response_size: default_web_fetch_max_response_size(),
            timeout_secs: default_web_fetch_timeout_secs(),
            firecrawl: FirecrawlConfig::default(),
        }
    }
}

// ── Link enricher ─────────────────────────────────────────────────

/// Automatic link understanding for inbound channel messages (`[link_enricher]`).
///
/// When enabled, URLs in incoming messages are automatically fetched and
/// summarised. The summary is prepended to the message before the agent
/// processes it, giving the LLM context about linked pages without an
/// explicit tool call.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "link_enricher"]
pub struct LinkEnricherConfig {
    /// Enable the link enricher pipeline stage (default: false)
    #[serde(default)]
    pub enabled: bool,
    /// Maximum number of links to fetch per message (default: 3)
    #[serde(default = "default_link_enricher_max_links")]
    pub max_links: usize,
    /// Per-link fetch timeout in seconds (default: 10)
    #[serde(default = "default_link_enricher_timeout_secs")]
    pub timeout_secs: u64,
}

fn default_link_enricher_max_links() -> usize {
    3
}

fn default_link_enricher_timeout_secs() -> u64 {
    10
}

impl Default for LinkEnricherConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            max_links: default_link_enricher_max_links(),
            timeout_secs: default_link_enricher_timeout_secs(),
        }
    }
}

// ── Text browser ─────────────────────────────────────────────────

/// Text browser tool configuration (`[text_browser]` section).
///
/// Uses text-based browsers (lynx, links, w3m) to render web pages as plain
/// text. Designed for headless/SSH environments without graphical browsers.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "text_browser"]
pub struct TextBrowserConfig {
    /// Enable `text_browser` tool
    #[serde(default)]
    pub enabled: bool,
    /// Preferred text browser ("lynx", "links", or "w3m"). If unset, auto-detects.
    #[serde(default)]
    pub preferred_browser: Option<String>,
    /// Request timeout in seconds (default: 30)
    #[serde(default = "default_text_browser_timeout_secs")]
    pub timeout_secs: u64,
    /// Private/internal hosts allowed to bypass SSRF protection.
    /// Exact and subdomain matches are supported; `["*"]` permits **all** private/local
    /// hosts (RFC 1918, loopback, link-local, `.local`). Default: empty (deny).
    /// Warning: `["*"]` also reaches link-local addresses, including the cloud metadata
    /// endpoint (`169.254.169.254`) — list specific hosts unless you accept that exposure.
    #[serde(default)]
    pub allowed_private_hosts: Vec<String>,
}

fn default_text_browser_timeout_secs() -> u64 {
    30
}

impl Default for TextBrowserConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            preferred_browser: None,
            timeout_secs: default_text_browser_timeout_secs(),
            allowed_private_hosts: vec![],
        }
    }
}

// ── Shell tool ───────────────────────────────────────────────────

/// Shell tool configuration (`[shell_tool]` section).
///
/// Controls the behaviour of the `shell` execution tool. The main
/// tunable is `timeout_secs` — the maximum wall-clock time a single
/// shell command may run before it is killed.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "shell_tool"]
pub struct ShellToolConfig {
    /// Maximum shell command execution time in seconds (default: 60).
    #[serde(default = "default_shell_tool_timeout_secs")]
    pub timeout_secs: u64,
}

fn default_shell_tool_timeout_secs() -> u64 {
    60
}

impl Default for ShellToolConfig {
    fn default() -> Self {
        Self {
            timeout_secs: default_shell_tool_timeout_secs(),
        }
    }
}

// ── Escalation routing ───────────────────────────────────────────

/// Escalation routing configuration (`[escalation]` section).
///
/// Controls which channels receive alert notifications when
/// `escalate_to_human` is called with high or critical urgency.
/// Channels are identified by name (e.g. `"telegram"`, `"slack"`).
/// Alerts are sent best-effort and do not block the escalation.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "escalation"]
pub struct EscalationConfig {
    /// Channel names to alert on high/critical escalations (default: empty).
    ///
    /// Each name must match a configured channel. Unrecognised names are
    /// logged at WARN level and skipped.
    #[serde(default)]
    pub alert_channels: Vec<String>,
}

// ── Web search ───────────────────────────────────────────────────

/// Web search tool configuration (`[web_search]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "web_search"]
pub struct WebSearchConfig {
    /// Enable `web_search_tool` for web searches
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Search provider: "duckduckgo" (free), "brave" (requires API key), "tavily" (requires API key), "searxng" (self-hosted), "jina" (requires API key), or "bocha" (Bocha AI, requires API key — Chinese-friendly, <https://open.bochaai.com>)
    #[serde(default = "default_web_search_provider")]
    pub search_provider: String,
    /// Brave Search API key (required if search_provider is "brave")
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub brave_api_key: Option<String>,
    /// Tavily Search API key (required if search_provider is "tavily")
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub tavily_api_key: Option<String>,
    /// Jina AI API key (required if search_provider is "jina")
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub jina_api_key: Option<String>,
    /// Bocha AI Web Search API key (required if search_provider is `"bocha"`). Obtain at <https://open.bochaai.com>.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub bocha_api_key: Option<String>,
    /// SearXNG instance URL (required if search_provider is `"searxng"`), e.g. `"https://searx.example.com"`.
    #[serde(default)]
    pub searxng_instance_url: Option<String>,
    /// Maximum results per search (1-10)
    #[serde(default = "default_web_search_max_results")]
    pub max_results: usize,
    /// Request timeout in seconds
    #[serde(default = "default_web_search_timeout_secs")]
    pub timeout_secs: u64,
}

fn default_web_search_provider() -> String {
    "duckduckgo".into()
}

fn default_web_search_max_results() -> usize {
    5
}

fn default_web_search_timeout_secs() -> u64 {
    15
}

impl Default for WebSearchConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            search_provider: default_web_search_provider(),
            brave_api_key: None,
            tavily_api_key: None,
            jina_api_key: None,
            bocha_api_key: None,
            searxng_instance_url: None,
            max_results: default_web_search_max_results(),
            timeout_secs: default_web_search_timeout_secs(),
        }
    }
}

// ── Project Intelligence ────────────────────────────────────────

/// Project delivery intelligence configuration (`[project_intel]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "project_intel"]
pub struct ProjectIntelConfig {
    /// Enable the project_intel tool. Default: false.
    #[serde(default)]
    pub enabled: bool,
    /// Default report language (en, de, fr, it). Default: "en".
    #[serde(default = "default_project_intel_language")]
    pub default_language: String,
    /// Output directory for generated reports.
    #[serde(default = "default_project_intel_report_dir")]
    pub report_output_dir: String,
    /// Optional custom templates directory.
    #[serde(default)]
    pub templates_dir: Option<String>,
    /// Risk detection sensitivity: low, medium, high. Default: "medium".
    #[serde(default = "default_project_intel_risk_sensitivity")]
    pub risk_sensitivity: String,
    /// Include git log data in reports. Default: true.
    #[serde(default = "default_true")]
    pub include_git_data: bool,
    /// Include Jira data in reports. Default: false.
    #[serde(default)]
    pub include_jira_data: bool,
    /// Jira instance base URL (required if include_jira_data is true).
    #[serde(default)]
    pub jira_base_url: Option<String>,
}

fn default_project_intel_language() -> String {
    "en".into()
}

fn default_project_intel_report_dir() -> String {
    default_path_under_config_dir("project-reports")
}

fn default_project_intel_risk_sensitivity() -> String {
    "medium".into()
}

impl Default for ProjectIntelConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            default_language: default_project_intel_language(),
            report_output_dir: default_project_intel_report_dir(),
            templates_dir: None,
            risk_sensitivity: default_project_intel_risk_sensitivity(),
            include_git_data: true,
            include_jira_data: false,
            jira_base_url: None,
        }
    }
}

// ── Backup ──────────────────────────────────────────────────────

/// Backup tool configuration (`[backup]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "backup"]
pub struct BackupConfig {
    /// Enable the `backup` tool.
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Maximum number of backups to keep (oldest are pruned).
    #[serde(default = "default_backup_max_keep")]
    pub max_keep: usize,
    /// Workspace subdirectories to include in backups.
    #[serde(default = "default_backup_include_dirs")]
    pub include_dirs: Vec<String>,
    /// Output directory for backup archives (relative to workspace root).
    #[serde(default = "default_backup_destination_dir")]
    pub destination_dir: String,
    /// Optional cron expression for scheduled automatic backups.
    #[serde(default)]
    pub schedule_cron: Option<String>,
    /// IANA timezone for `schedule_cron`.
    #[serde(default)]
    pub schedule_timezone: Option<String>,
    /// Compress backup archives.
    #[serde(default = "default_true")]
    pub compress: bool,
    /// Encrypt backup archives (requires a configured secret store key).
    #[serde(default)]
    pub encrypt: bool,
}

fn default_backup_max_keep() -> usize {
    10
}

fn default_backup_include_dirs() -> Vec<String> {
    vec![
        "config".into(),
        "memory".into(),
        "audit".into(),
        "knowledge".into(),
    ]
}

fn default_backup_destination_dir() -> String {
    "state/backups".into()
}

impl Default for BackupConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            max_keep: default_backup_max_keep(),
            include_dirs: default_backup_include_dirs(),
            destination_dir: default_backup_destination_dir(),
            schedule_cron: None,
            schedule_timezone: None,
            compress: true,
            encrypt: false,
        }
    }
}

// ── Data Retention ──────────────────────────────────────────────

/// Data retention and purge configuration (`[data_retention]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "data_retention"]
pub struct DataRetentionConfig {
    /// Enable the `data_management` tool.
    #[serde(default)]
    pub enabled: bool,
    /// Days of data to retain before purge eligibility.
    #[serde(default = "default_retention_days")]
    pub retention_days: u64,
    /// Preview what would be deleted without actually removing anything.
    #[serde(default)]
    pub dry_run: bool,
    /// Limit retention enforcement to specific data categories (empty = all).
    #[serde(default)]
    pub categories: Vec<String>,
}

fn default_retention_days() -> u64 {
    90
}

impl Default for DataRetentionConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            retention_days: default_retention_days(),
            dry_run: false,
            categories: Vec::new(),
        }
    }
}

// ── Google Workspace ─────────────────────────────────────────────

/// Built-in default service allowlist for the `google_workspace` tool.
///
/// Applied when `allowed_services` is empty. Defined here (not in the tool layer)
/// so that config validation can cross-check `allowed_operations` entries against
/// the effective service set in all cases, including when the operator relies on
/// the default.
pub const DEFAULT_GWS_SERVICES: &[&str] = &[
    "drive",
    "sheets",
    "gmail",
    "calendar",
    "docs",
    "slides",
    "tasks",
    "people",
    "chat",
    "classroom",
    "forms",
    "keep",
    "meet",
    "events",
];

/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
///
/// ## Defaults
/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
/// - `allowed_services`: empty vector, which grants access to the full default
///   service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
///   `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
/// - `credentials_path`: `None` (uses default `gws` credential discovery).
/// - `default_account`: `None` (uses the `gws` active account).
/// - `rate_limit_per_minute`: `60`.
/// - `timeout_secs`: `30`.
/// - `audit_log`: `false`.
/// - `credentials_path`: `None` (uses default `gws` credential discovery).
/// - `default_account`: `None` (uses the `gws` active account).
/// - `rate_limit_per_minute`: `60`.
/// - `timeout_secs`: `30`.
/// - `audit_log`: `false`.
///
/// ## Compatibility
/// Configs that omit the `[google_workspace]` section entirely are treated as
/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
/// the section is purely opt-in and does not affect other config sections.
///
/// ## Rollback / Migration
/// To revert, remove the `[google_workspace]` section from the config file (or
/// set `enabled = false`). No data migration is required; the tool simply stops
/// being registered.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct GoogleWorkspaceAllowedOperation {
    /// Google Workspace service ID (for example `gmail` or `drive`).
    pub service: String,
    /// Top-level resource name for the service (for example `users` for Gmail or `files` for Drive).
    pub resource: String,
    /// Optional sub-resource for 4-segment gws commands
    /// (for example `messages` or `drafts` under `gmail users`).
    /// When present, the entry only matches calls that include this exact sub_resource.
    /// When absent, the entry only matches calls with no sub_resource.
    #[serde(default)]
    pub sub_resource: Option<String>,
    /// Allowed methods for the service/resource/sub_resource combination.
    #[serde(default)]
    pub methods: Vec<String>,
}

/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
///
/// ## Defaults
/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
/// - `allowed_services`: empty vector, which grants access to the full default
///   service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
///   `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
/// - `allowed_operations`: empty vector, which preserves the legacy behavior of
///   allowing any resource/method under the allowed service set.
/// - `credentials_path`: `None` (uses default `gws` credential discovery).
/// - `default_account`: `None` (uses the `gws` active account).
/// - `rate_limit_per_minute`: `60`.
/// - `timeout_secs`: `30`.
/// - `audit_log`: `false`.
///
/// ## Compatibility
/// Configs that omit the `[google_workspace]` section entirely are treated as
/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
/// the section is purely opt-in and does not affect other config sections.
///
/// ## Rollback / Migration
/// To revert, remove the `[google_workspace]` section from the config file (or
/// set `enabled = false`). No data migration is required; the tool simply stops
/// being registered.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "google_workspace"]
#[integration(
    category = "ToolsAutomation",
    display_name = "Google Workspace",
    description = "Drive, Gmail, Calendar, Sheets, Docs via gws CLI",
    status_field = "enabled"
)]
pub struct GoogleWorkspaceConfig {
    /// Enable the `google_workspace` tool. Default: `false`.
    #[serde(default)]
    pub enabled: bool,
    /// Restrict which Google Workspace services the agent can access.
    ///
    /// When empty (the default), the full default service set is allowed (see
    /// struct-level docs). When non-empty, only the listed service IDs are
    /// permitted. Each entry must be non-empty, lowercase alphanumeric with
    /// optional underscores/hyphens, and unique.
    #[serde(default)]
    pub allowed_services: Vec<String>,
    /// Restrict which resource/method combinations the agent can access.
    ///
    /// When empty (the default), all methods under `allowed_services` remain
    /// available for backward compatibility. When non-empty, the runtime denies
    /// any `(service, resource, sub_resource, method)` combination that is not
    /// explicitly listed. `sub_resource` is optional per entry: an entry without
    /// it matches only 3-segment `gws` calls; an entry with it matches only calls
    /// that supply that exact sub_resource value.
    ///
    /// Each entry's `service` must appear in `allowed_services` when that list is
    /// non-empty; config validation rejects entries that would never match at
    /// runtime.
    #[serde(default)]
    pub allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
    /// Path to service account JSON or OAuth client credentials file.
    ///
    /// When `None`, the tool relies on the default `gws` credential discovery
    /// (`gws auth login`). Set this to point at a service-account key or an
    /// OAuth client-secrets JSON for headless / CI environments.
    #[serde(default)]
    pub credentials_path: Option<String>,
    /// Default Google account email to pass to `gws --account`.
    ///
    /// When `None`, the currently active `gws` account is used.
    #[serde(default)]
    pub default_account: Option<String>,
    /// Maximum number of `gws` API calls allowed per minute. Default: `60`.
    #[serde(default = "default_gws_rate_limit")]
    pub rate_limit_per_minute: u32,
    /// Command execution timeout in seconds. Default: `30`.
    #[serde(default = "default_gws_timeout_secs")]
    pub timeout_secs: u64,
    /// Enable audit logging of every `gws` invocation (service, resource,
    /// method, timestamp). Default: `false`.
    #[serde(default)]
    pub audit_log: bool,
}

fn default_gws_rate_limit() -> u32 {
    60
}

fn default_gws_timeout_secs() -> u64 {
    30
}

impl Default for GoogleWorkspaceConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            allowed_services: Vec::new(),
            allowed_operations: Vec::new(),
            credentials_path: None,
            default_account: None,
            rate_limit_per_minute: default_gws_rate_limit(),
            timeout_secs: default_gws_timeout_secs(),
            audit_log: false,
        }
    }
}

// ── Knowledge ───────────────────────────────────────────────────

/// Knowledge graph configuration for capturing and reusing expertise.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "knowledge"]
pub struct KnowledgeConfig {
    /// Enable the knowledge graph tool. Default: false.
    #[serde(default)]
    pub enabled: bool,
    /// Path to the knowledge graph SQLite database.
    #[serde(default = "default_knowledge_db_path")]
    pub db_path: String,
    /// Maximum number of knowledge nodes. Default: 100000.
    #[serde(default = "default_knowledge_max_nodes")]
    pub max_nodes: usize,
    /// Automatically capture knowledge from conversations. Default: false.
    #[serde(default)]
    pub auto_capture: bool,
    /// Proactively suggest relevant knowledge on queries. Default: true.
    #[serde(default = "default_true")]
    pub suggest_on_query: bool,
}

fn default_knowledge_db_path() -> String {
    default_path_under_config_dir("knowledge.db")
}

fn default_knowledge_max_nodes() -> usize {
    100_000
}

impl Default for KnowledgeConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            db_path: default_knowledge_db_path(),
            max_nodes: default_knowledge_max_nodes(),
            auto_capture: false,
            suggest_on_query: true,
        }
    }
}

// ── LinkedIn ────────────────────────────────────────────────────

/// LinkedIn integration configuration (`[linkedin]` section).
///
/// When enabled, the `linkedin` tool is registered in the agent tool surface.
/// Requires `LINKEDIN_*` credentials in the workspace `.env` file.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "linkedin"]
pub struct LinkedInConfig {
    /// Enable the LinkedIn tool.
    #[serde(default)]
    pub enabled: bool,

    /// LinkedIn REST API version header (YYYYMM format).
    #[serde(default = "default_linkedin_api_version")]
    pub api_version: String,

    /// Content strategy for automated posting.
    #[serde(default)]
    #[nested]
    pub content: LinkedInContentConfig,

    /// Image generation for posts (`[linkedin.image]`).
    #[serde(default)]
    #[nested]
    pub image: LinkedInImageConfig,
}

impl Default for LinkedInConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            api_version: default_linkedin_api_version(),
            content: LinkedInContentConfig::default(),
            image: LinkedInImageConfig::default(),
        }
    }
}

fn default_linkedin_api_version() -> String {
    "202602".to_string()
}

/// Per-plugin config section keyed by plugin alias; values are secret so they
/// encrypt at rest under the same adjacent `.secret_key` as every other secret.
#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "plugins.entries"]
pub struct PluginEntryConfig {
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub config: HashMap<String, String>,
}

/// Plugin system configuration.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "plugins"]
pub struct PluginsConfig {
    /// Enable the plugin system (default: false)
    #[serde(default)]
    pub enabled: bool,
    /// Directory where plugins are stored
    #[serde(default = "default_plugins_dir")]
    pub plugins_dir: String,
    /// Auto-discover and load plugins on startup
    #[serde(default)]
    pub auto_discover: bool,
    /// Maximum number of plugins that can be loaded
    #[serde(default = "default_max_plugins")]
    pub max_plugins: usize,
    /// Plugin signature verification security settings
    #[serde(default)]
    #[nested]
    pub security: PluginSecurityConfig,
    /// Per-call WASM execution limits
    #[serde(default)]
    #[nested]
    pub limits: PluginLimitsConfig,
    #[serde(default)]
    #[nested]
    #[natural_key = "name"]
    pub entries: Vec<PluginEntryConfig>,
}

impl PluginsConfig {
    #[must_use]
    pub fn entry_config(&self, alias: &str) -> Option<&HashMap<String, String>> {
        self.entries
            .iter()
            .find(|e| e.name == alias)
            .map(|e| &e.config)
    }
}

impl PluginsConfig {
    /// Resolve `plugins_dir` to an absolute path, expanding a leading `~/`.
    ///
    /// This is the single source of truth for the plugin directory: the CLI,
    /// runtime tool/skill discovery, and the gateway all resolve through it so
    /// that `zeroclaw plugin install` and the agent agree on one location.
    /// Pure — performs no filesystem I/O.
    #[must_use]
    pub fn resolved_plugins_dir(&self) -> PathBuf {
        expand_tilde_path(&self.plugins_dir)
    }
}

/// Plugin signature verification configuration (`[plugins.security]`).
///
/// Controls Ed25519 signature verification for plugin manifests.
/// In `strict` mode, only plugins signed by a trusted publisher key are loaded.
/// In `permissive` mode, unsigned or untrusted plugins produce warnings but are
/// still loaded. In `disabled` mode (the default), no signature checking occurs.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "plugins.security"]
pub struct PluginSecurityConfig {
    /// Signature enforcement mode: "disabled", "permissive", or "strict".
    #[serde(default = "default_signature_mode")]
    pub signature_mode: String,
    /// Hex-encoded Ed25519 public keys of trusted plugin publishers.
    #[serde(default)]
    pub trusted_publisher_keys: Vec<String>,
}

fn default_signature_mode() -> String {
    "disabled".to_string()
}

impl Default for PluginSecurityConfig {
    fn default() -> Self {
        Self {
            signature_mode: default_signature_mode(),
            trusted_publisher_keys: Vec::new(),
        }
    }
}

/// Per-call WASM execution limits (`[plugins.limits]`).
///
/// Bounds a single plugin call so a runaway or malicious component traps
/// instead of hanging the host or exhausting memory. `call_fuel` caps
/// instructions per call; the memory, table, and instance ceilings bound a
/// store's growth. Every value is operator-tunable and validated as non-zero.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "plugins.limits"]
pub struct PluginLimitsConfig {
    /// Fuel budget per plugin call (wasmtime instruction units).
    #[serde(default = "default_plugin_call_fuel")]
    pub call_fuel: u64,
    /// Maximum linear memory a plugin store may grow to, in megabytes.
    #[serde(default = "default_plugin_max_memory_mb")]
    pub max_memory_mb: usize,
    /// Maximum table elements a plugin store may allocate.
    #[serde(default = "default_plugin_max_table_elements")]
    pub max_table_elements: usize,
    /// Maximum component instances a plugin store may create.
    #[serde(default = "default_plugin_max_instances")]
    pub max_instances: usize,
}

fn default_plugin_call_fuel() -> u64 {
    1_000_000_000
}

fn default_plugin_max_memory_mb() -> usize {
    256
}

fn default_plugin_max_table_elements() -> usize {
    100_000
}

fn default_plugin_max_instances() -> usize {
    64
}

impl Default for PluginLimitsConfig {
    fn default() -> Self {
        Self {
            call_fuel: default_plugin_call_fuel(),
            max_memory_mb: default_plugin_max_memory_mb(),
            max_table_elements: default_plugin_max_table_elements(),
            max_instances: default_plugin_max_instances(),
        }
    }
}

fn default_plugins_dir() -> String {
    default_path_under_config_dir("plugins")
}

fn default_max_plugins() -> usize {
    50
}

impl Default for PluginsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            plugins_dir: default_plugins_dir(),
            auto_discover: false,
            max_plugins: default_max_plugins(),
            security: PluginSecurityConfig::default(),
            limits: PluginLimitsConfig::default(),
            entries: Vec::new(),
        }
    }
}

/// Content strategy configuration for LinkedIn auto-posting (`[linkedin.content]`).
///
/// The agent reads this via the `linkedin get_content_strategy` action to know
/// what feeds to check, which repos to highlight, and how to write posts.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "linkedin.content"]
pub struct LinkedInContentConfig {
    /// RSS feed URLs to monitor for topic inspiration (titles only).
    #[serde(default)]
    pub rss_feeds: Vec<String>,

    /// GitHub usernames whose public activity to reference.
    #[serde(default)]
    pub github_users: Vec<String>,

    /// GitHub repositories to highlight (format: `owner/repo`).
    #[serde(default)]
    pub github_repos: Vec<String>,

    /// Topics of expertise and interest for post themes.
    #[serde(default)]
    pub topics: Vec<String>,

    /// Professional persona description (name, role, expertise).
    #[serde(default)]
    pub persona: String,

    /// Freeform posting instructions for the AI agent.
    #[serde(default)]
    pub instructions: String,
}

/// Image generation configuration for LinkedIn posts (`[linkedin.image]`).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "linkedin.image"]
pub struct LinkedInImageConfig {
    /// Enable image generation for posts.
    #[serde(default)]
    pub enabled: bool,

    /// ModelProvider priority order. Tried in sequence; first success wins.
    #[serde(default = "default_image_providers")]
    pub providers: Vec<String>,

    /// Generate a branded SVG text card when all AI model_providers fail.
    #[serde(default = "default_true")]
    pub fallback_card: bool,

    /// Accent color for the fallback card (CSS hex).
    #[serde(default = "default_card_accent_color")]
    pub card_accent_color: String,

    /// Temp directory for generated images, relative to workspace.
    #[serde(default = "default_image_temp_dir")]
    pub temp_dir: String,

    /// Stability AI model_provider settings.
    #[serde(default)]
    #[nested]
    pub stability: ImageProviderStabilityConfig,

    /// Google Imagen (Vertex AI) model_provider settings.
    #[serde(default)]
    #[nested]
    pub imagen: ImageProviderImagenConfig,

    /// OpenAI DALL-E model_provider settings.
    #[serde(default)]
    #[nested]
    pub dalle: ImageProviderDalleConfig,

    /// Flux (fal.ai) model_provider settings.
    #[serde(default)]
    #[nested]
    pub flux: ImageProviderFluxConfig,
}

fn default_image_providers() -> Vec<String> {
    vec![
        "stability".into(),
        "imagen".into(),
        "dalle".into(),
        "flux".into(),
    ]
}

fn default_card_accent_color() -> String {
    "#0A66C2".into()
}

fn default_image_temp_dir() -> String {
    "linkedin/images".into()
}

impl Default for LinkedInImageConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            providers: default_image_providers(),
            fallback_card: true,
            card_accent_color: default_card_accent_color(),
            temp_dir: default_image_temp_dir(),
            stability: ImageProviderStabilityConfig::default(),
            imagen: ImageProviderImagenConfig::default(),
            dalle: ImageProviderDalleConfig::default(),
            flux: ImageProviderFluxConfig::default(),
        }
    }
}

/// Stability AI image generation settings (`[linkedin.image.stability]`).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "linkedin.image.stability"]
pub struct ImageProviderStabilityConfig {
    /// Environment variable name holding the API key.
    #[serde(default = "default_stability_api_key_env")]
    #[credential_class = "legacy_env_path"]
    pub api_key_env: String,
    /// Stability model identifier.
    #[serde(default = "default_stability_model")]
    pub model: String,
}

fn default_stability_api_key_env() -> String {
    "STABILITY_API_KEY".into()
}
fn default_stability_model() -> String {
    "stable-diffusion-xl-1024-v1-0".into()
}

impl Default for ImageProviderStabilityConfig {
    fn default() -> Self {
        Self {
            api_key_env: default_stability_api_key_env(),
            model: default_stability_model(),
        }
    }
}

/// Google Imagen (Vertex AI) settings (`[linkedin.image.imagen]`).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "linkedin.image.imagen"]
pub struct ImageProviderImagenConfig {
    /// Environment variable name holding the API key.
    #[serde(default = "default_imagen_api_key_env")]
    #[credential_class = "legacy_env_path"]
    pub api_key_env: String,
    /// Environment variable for the Google Cloud project ID.
    #[serde(default = "default_imagen_project_id_env")]
    #[credential_class = "legacy_env_path"]
    pub project_id_env: String,
    /// Vertex AI region.
    #[serde(default = "default_imagen_region")]
    pub region: String,
}

fn default_imagen_api_key_env() -> String {
    "GOOGLE_VERTEX_API_KEY".into()
}
fn default_imagen_project_id_env() -> String {
    "GOOGLE_CLOUD_PROJECT".into()
}
fn default_imagen_region() -> String {
    "us-central1".into()
}

impl Default for ImageProviderImagenConfig {
    fn default() -> Self {
        Self {
            api_key_env: default_imagen_api_key_env(),
            project_id_env: default_imagen_project_id_env(),
            region: default_imagen_region(),
        }
    }
}

/// OpenAI DALL-E settings (`[linkedin.image.dalle]`).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "linkedin.image.dalle"]
pub struct ImageProviderDalleConfig {
    /// Environment variable name holding the OpenAI API key.
    #[serde(default = "default_dalle_api_key_env")]
    #[credential_class = "legacy_env_path"]
    pub api_key_env: String,
    /// DALL-E model identifier.
    #[serde(default = "default_dalle_model")]
    pub model: String,
    /// Image dimensions.
    #[serde(default = "default_dalle_size")]
    pub size: String,
}

fn default_dalle_api_key_env() -> String {
    "OPENAI_API_KEY".into()
}
fn default_dalle_model() -> String {
    "dall-e-3".into()
}
fn default_dalle_size() -> String {
    "1024x1024".into()
}

impl Default for ImageProviderDalleConfig {
    fn default() -> Self {
        Self {
            api_key_env: default_dalle_api_key_env(),
            model: default_dalle_model(),
            size: default_dalle_size(),
        }
    }
}

/// Flux (fal.ai) image generation settings (`[linkedin.image.flux]`).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "linkedin.image.flux"]
pub struct ImageProviderFluxConfig {
    /// Environment variable name holding the fal.ai API key.
    #[serde(default = "default_flux_api_key_env")]
    #[credential_class = "legacy_env_path"]
    pub api_key_env: String,
    /// Flux model identifier.
    #[serde(default = "default_flux_model")]
    pub model: String,
}

fn default_flux_api_key_env() -> String {
    "FAL_API_KEY".into()
}
fn default_flux_model() -> String {
    "fal-ai/flux/schnell".into()
}

impl Default for ImageProviderFluxConfig {
    fn default() -> Self {
        Self {
            api_key_env: default_flux_api_key_env(),
            model: default_flux_model(),
        }
    }
}

// ── Standalone Image Generation ─────────────────────────────────

/// Standalone image generation tool configuration (`[image_gen]`).
///
/// When enabled, registers an `image_gen` tool that generates images via
/// fal.ai's synchronous API (Flux / Nano Banana models) and saves them
/// to the workspace `images/` directory.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "image_gen"]
pub struct ImageGenConfig {
    /// Enable the standalone image generation tool. Default: false.
    #[serde(default)]
    pub enabled: bool,

    /// Default fal.ai model identifier.
    #[serde(default = "default_image_gen_model")]
    pub default_model: String,

    /// Environment variable name holding the fal.ai API key.
    #[serde(default = "default_image_gen_api_key_env")]
    #[credential_class = "legacy_env_path"]
    pub api_key_env: String,
}

fn default_image_gen_model() -> String {
    "fal-ai/flux/schnell".into()
}

fn default_image_gen_api_key_env() -> String {
    "FAL_API_KEY".into()
}

impl Default for ImageGenConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            default_model: default_image_gen_model(),
            api_key_env: default_image_gen_api_key_env(),
        }
    }
}

// ── File Upload ─────────────────────────────────────────────────

/// Standalone file upload tool configuration (`[file_upload]`).
///
/// When `url` is set to a non-empty value, registers a `file_upload` tool that
/// POSTs files from the agent's local filesystem to the configured endpoint
/// using `multipart/form-data`. The LLM provides only a file path; the host
/// reads the bytes and uploads them without ever including file content in
/// the model context.
///
/// When `url` is `None` or empty, the tool is not registered.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "file_upload"]
pub struct FileUploadConfig {
    /// Upload endpoint URL. Tool is disabled when this is `None` or empty.
    #[serde(default)]
    pub url: Option<String>,

    /// HTTP method. Only `POST` (default) and `PUT` are accepted.
    #[serde(default = "default_file_upload_method")]
    pub method: String,

    /// Multipart form-field name for the file part. Default: `file`.
    #[serde(default = "default_file_upload_field_name")]
    pub field_name: String,

    /// Maximum file size in bytes. Larger files are rejected before any
    /// bytes hit the network. Default: 25 MiB.
    #[serde(default = "default_file_upload_max_size_bytes")]
    pub max_file_size_bytes: u64,

    /// Request timeout in seconds. Default: 60.
    #[serde(default = "default_file_upload_timeout_secs")]
    pub timeout_secs: u64,

    /// Static HTTP headers attached to every upload request. Same shape as
    /// `[mcp.servers.*.headers]`.
    #[serde(default)]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub headers: HashMap<String, String>,
}

fn default_file_upload_method() -> String {
    "POST".into()
}

fn default_file_upload_field_name() -> String {
    "file".into()
}

fn default_file_upload_max_size_bytes() -> u64 {
    25 * 1024 * 1024
}

fn default_file_upload_timeout_secs() -> u64 {
    60
}

impl Default for FileUploadConfig {
    fn default() -> Self {
        Self {
            url: None,
            method: default_file_upload_method(),
            field_name: default_file_upload_field_name(),
            max_file_size_bytes: default_file_upload_max_size_bytes(),
            timeout_secs: default_file_upload_timeout_secs(),
            headers: HashMap::new(),
        }
    }
}

// ── File Upload Bundle ──────────────────────────────────────────

/// Standalone multi-file bundle upload tool configuration
/// (`[file_upload_bundle]`).
///
/// When `url` is set to a non-empty value, registers a `file_upload_bundle`
/// tool that POSTs N files from the agent's local filesystem to the
/// configured endpoint as a single `multipart/form-data` request. The LLM
/// provides only file paths; the host reads the bytes.
///
/// When `url` is `None` or empty, the tool is not registered.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "file-upload-bundle"]
pub struct FileUploadBundleConfig {
    /// Upload endpoint URL. Tool is disabled when this is `None` or empty.
    #[serde(default)]
    pub url: Option<String>,

    /// HTTP method. Only `POST` (default) and `PUT` are accepted.
    #[serde(default = "default_file_upload_bundle_method")]
    pub method: String,

    /// Multipart form-field name reused across every file part. Default: `file`.
    #[serde(default = "default_file_upload_bundle_field_name")]
    pub field_name: String,

    /// Maximum per-file size in bytes. Default: 10 MiB.
    #[serde(default = "default_file_upload_bundle_max_file_size_bytes")]
    pub max_file_size_bytes: u64,

    /// Maximum cumulative size across every file in one call. Default: 32 MiB.
    #[serde(default = "default_file_upload_bundle_max_total_size_bytes")]
    pub max_total_size_bytes: u64,

    /// Maximum number of files per call. Default: 16.
    #[serde(default = "default_file_upload_bundle_max_files")]
    pub max_files: u32,

    /// Request timeout in seconds. Default: 120.
    #[serde(default = "default_file_upload_bundle_timeout_secs")]
    pub timeout_secs: u64,

    /// Maximum response body bytes to read from the upload endpoint.
    /// Prevents unbounded memory use from a malicious or verbose receiver.
    /// Default: 4096 (4 KiB).
    #[serde(default = "default_file_upload_bundle_max_response_body_bytes")]
    pub max_response_body_bytes: usize,

    /// Static HTTP headers attached to every upload request.
    #[serde(default)]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub headers: HashMap<String, String>,
}

fn default_file_upload_bundle_method() -> String {
    "POST".into()
}

fn default_file_upload_bundle_field_name() -> String {
    "file".into()
}

fn default_file_upload_bundle_max_file_size_bytes() -> u64 {
    10 * 1024 * 1024
}

fn default_file_upload_bundle_max_total_size_bytes() -> u64 {
    32 * 1024 * 1024
}

fn default_file_upload_bundle_max_files() -> u32 {
    16
}

fn default_file_upload_bundle_timeout_secs() -> u64 {
    120
}

fn default_file_upload_bundle_max_response_body_bytes() -> usize {
    4 * 1024
}

impl Default for FileUploadBundleConfig {
    fn default() -> Self {
        Self {
            url: None,
            method: default_file_upload_bundle_method(),
            field_name: default_file_upload_bundle_field_name(),
            max_file_size_bytes: default_file_upload_bundle_max_file_size_bytes(),
            max_total_size_bytes: default_file_upload_bundle_max_total_size_bytes(),
            max_files: default_file_upload_bundle_max_files(),
            timeout_secs: default_file_upload_bundle_timeout_secs(),
            max_response_body_bytes: default_file_upload_bundle_max_response_body_bytes(),
            headers: HashMap::new(),
        }
    }
}

// ── File Download ───────────────────────────────────────────────

/// Standalone file download tool configuration (`[file_download]`).
///
/// When `url` is set to a non-empty value, registers a `file_download` tool
/// that GETs a file from the configured endpoint and writes it to the agent's
/// workspace filesystem. The LLM supplies only a document identifier and a
/// workspace-relative destination path; the endpoint URL comes solely from this
/// config and is never model-controlled. Response bytes are streamed to disk
/// and never loaded into model context.
///
/// When `url` is `None` or empty, the tool is not registered.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "file-download"]
pub struct FileDownloadConfig {
    /// Download endpoint URL. Tool is disabled when this is `None` or empty.
    /// The file to fetch is selected by the `document_id` query parameter.
    #[serde(default)]
    pub url: Option<String>,

    /// Maximum download size in bytes. Enforced while streaming: the transfer
    /// is aborted and the partial file removed once this ceiling is exceeded,
    /// so an oversized or unbounded body never fully buffers in memory or lands
    /// on disk. Default: 25 MiB.
    #[serde(default = "default_file_download_max_size_bytes")]
    pub max_file_size_bytes: u64,

    /// Request timeout in seconds. Default: 120.
    #[serde(default = "default_file_download_timeout_secs")]
    pub timeout_secs: u64,

    /// Static HTTP headers attached to every download request — typically an
    /// `Authorization: Bearer …` token for the upstream endpoint. Same shape as
    /// `[mcp.servers.*.headers]`.
    #[serde(default)]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub headers: HashMap<String, String>,
}

fn default_file_download_max_size_bytes() -> u64 {
    25 * 1024 * 1024
}

fn default_file_download_timeout_secs() -> u64 {
    120
}

impl Default for FileDownloadConfig {
    fn default() -> Self {
        Self {
            url: None,
            max_file_size_bytes: default_file_download_max_size_bytes(),
            timeout_secs: default_file_download_timeout_secs(),
            headers: HashMap::new(),
        }
    }
}

// ── Claude Code ─────────────────────────────────────────────────

/// Claude Code CLI tool configuration (`[claude_code]` section).
///
/// Delegates coding tasks to the `claude -p` CLI. Authentication uses the
/// binary's own OAuth session (Max subscription) by default — no API key
/// needed unless `env_passthrough` includes `ANTHROPIC_API_KEY`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "claude_code"]
pub struct ClaudeCodeConfig {
    /// Enable the `claude_code` tool
    #[serde(default)]
    pub enabled: bool,
    /// Maximum execution time in seconds (coding tasks can be long)
    #[serde(default = "default_claude_code_timeout_secs")]
    pub timeout_secs: u64,
    /// Claude Code tools the subprocess is allowed to use
    #[serde(default = "default_claude_code_allowed_tools")]
    pub allowed_tools: Vec<String>,
    /// Optional system prompt appended to Claude Code invocations
    #[serde(default)]
    pub system_prompt: Option<String>,
    /// Maximum output size in bytes (2MB default)
    #[serde(default = "default_claude_code_max_output_bytes")]
    pub max_output_bytes: usize,
    /// Extra env vars passed to the claude subprocess (e.g. ANTHROPIC_API_KEY for API-key billing)
    #[serde(default)]
    #[credential_class = "legacy_env_path"]
    pub env_passthrough: Vec<String>,
}

fn default_claude_code_timeout_secs() -> u64 {
    600
}

fn default_claude_code_allowed_tools() -> Vec<String> {
    vec!["Read".into(), "Edit".into(), "Bash".into(), "Write".into()]
}

fn default_claude_code_max_output_bytes() -> usize {
    2_097_152
}

impl Default for ClaudeCodeConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            timeout_secs: default_claude_code_timeout_secs(),
            allowed_tools: default_claude_code_allowed_tools(),
            system_prompt: None,
            max_output_bytes: default_claude_code_max_output_bytes(),
            env_passthrough: Vec::new(),
        }
    }
}

// ── Claude Code Runner ──────────────────────────────────────────

/// Claude Code task runner configuration (`[claude_code_runner]` section).
///
/// Spawns Claude Code in a tmux session with HTTP hooks that POST tool
/// execution events back to ZeroClaw's gateway, updating a Slack message
/// in-place with progress plus an SSH handoff link.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "claude_code_runner"]
pub struct ClaudeCodeRunnerConfig {
    /// Enable the `claude_code_runner` tool
    #[serde(default)]
    pub enabled: bool,
    /// SSH host for session handoff links (e.g. "myhost.example.com")
    #[serde(default)]
    pub ssh_host: Option<String>,
    /// Prefix for tmux session names (default: "zc-claude-")
    #[serde(default = "default_claude_code_runner_tmux_prefix")]
    pub tmux_prefix: String,
    /// Session time-to-live in seconds before auto-cleanup (default: 3600)
    #[serde(default = "default_claude_code_runner_session_ttl")]
    pub session_ttl: u64,
}

fn default_claude_code_runner_tmux_prefix() -> String {
    "zc-claude-".into()
}

fn default_claude_code_runner_session_ttl() -> u64 {
    3600
}

impl Default for ClaudeCodeRunnerConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            ssh_host: None,
            tmux_prefix: default_claude_code_runner_tmux_prefix(),
            session_ttl: default_claude_code_runner_session_ttl(),
        }
    }
}

// ── Codex CLI ───────────────────────────────────────────────────

/// Codex CLI tool configuration (`[codex_cli]` section).
///
/// Delegates coding tasks to the `codex exec` CLI. Authentication uses the
/// binary's own session by default — no API key needed unless
/// `env_passthrough` includes `OPENAI_API_KEY`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "codex_cli"]
pub struct CodexCliConfig {
    /// Enable the `codex_cli` tool
    #[serde(default)]
    pub enabled: bool,
    /// Maximum execution time in seconds (coding tasks can be long)
    #[serde(default = "default_codex_cli_timeout_secs")]
    pub timeout_secs: u64,
    /// Maximum output size in bytes (2MB default)
    #[serde(default = "default_codex_cli_max_output_bytes")]
    pub max_output_bytes: usize,
    /// Extra env vars passed to the codex subprocess (e.g. OPENAI_API_KEY)
    #[serde(default)]
    #[credential_class = "legacy_env_path"]
    pub env_passthrough: Vec<String>,
    /// Extra CLI arguments appended to `codex exec` before the prompt.
    ///
    /// Values come from operator-controlled config (same trust level as
    /// `env_passthrough`) and are not validated — the operator is responsible
    /// for understanding the implications of flags passed here.
    ///
    /// **Warning:** `--sandbox=danger-full-access` disables Codex's bubblewrap
    /// isolation; only use in environments where the container itself provides
    /// isolation (e.g. Kubernetes pods with restricted PSS).
    ///
    /// Example: `["--sandbox=danger-full-access", "--skip-git-repo-check"]`
    #[serde(default)]
    pub extra_args: Vec<String>,
}

fn default_codex_cli_timeout_secs() -> u64 {
    600
}

fn default_codex_cli_max_output_bytes() -> usize {
    2_097_152
}

impl Default for CodexCliConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            timeout_secs: default_codex_cli_timeout_secs(),
            max_output_bytes: default_codex_cli_max_output_bytes(),
            env_passthrough: Vec::new(),
            extra_args: Vec::new(),
        }
    }
}

// ── Gemini CLI ──────────────────────────────────────────────────

/// Gemini CLI tool configuration (`[gemini_cli]` section).
///
/// Delegates coding tasks to the `gemini -p` CLI. Authentication uses the
/// binary's own session by default — no API key needed unless
/// `env_passthrough` includes `GOOGLE_API_KEY`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "gemini_cli"]
pub struct GeminiCliConfig {
    /// Enable the `gemini_cli` tool
    #[serde(default)]
    pub enabled: bool,
    /// Maximum execution time in seconds (coding tasks can be long)
    #[serde(default = "default_gemini_cli_timeout_secs")]
    pub timeout_secs: u64,
    /// Maximum output size in bytes (2MB default)
    #[serde(default = "default_gemini_cli_max_output_bytes")]
    pub max_output_bytes: usize,
    /// Extra env vars passed to the gemini subprocess (e.g. GOOGLE_API_KEY)
    #[serde(default)]
    #[credential_class = "legacy_env_path"]
    pub env_passthrough: Vec<String>,
}

fn default_gemini_cli_timeout_secs() -> u64 {
    600
}

fn default_gemini_cli_max_output_bytes() -> usize {
    2_097_152
}

impl Default for GeminiCliConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            timeout_secs: default_gemini_cli_timeout_secs(),
            max_output_bytes: default_gemini_cli_max_output_bytes(),
            env_passthrough: Vec::new(),
        }
    }
}

// ── OpenCode CLI ───────────────────────────────────────────────

/// OpenCode CLI tool configuration (`[opencode_cli]` section).
///
/// Delegates coding tasks to the `opencode run` CLI. Authentication uses the
/// binary's own session by default — no API key needed unless
/// `env_passthrough` includes provider-specific keys.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "opencode_cli"]
pub struct OpenCodeCliConfig {
    /// Enable the `opencode_cli` tool
    #[serde(default)]
    pub enabled: bool,
    /// Maximum execution time in seconds (coding tasks can be long)
    #[serde(default = "default_opencode_cli_timeout_secs")]
    pub timeout_secs: u64,
    /// Maximum output size in bytes (2MB default)
    #[serde(default = "default_opencode_cli_max_output_bytes")]
    pub max_output_bytes: usize,
    /// Extra env vars passed to the opencode subprocess
    #[serde(default)]
    #[credential_class = "legacy_env_path"]
    pub env_passthrough: Vec<String>,
}

fn default_opencode_cli_timeout_secs() -> u64 {
    600
}

fn default_opencode_cli_max_output_bytes() -> usize {
    2_097_152
}

impl Default for OpenCodeCliConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            timeout_secs: default_opencode_cli_timeout_secs(),
            max_output_bytes: default_opencode_cli_max_output_bytes(),
            env_passthrough: Vec::new(),
        }
    }
}

// ── Proxy ───────────────────────────────────────────────────────

/// Proxy application scope — determines which outbound traffic uses the proxy.
#[derive(
    Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ProxyScope {
    /// Use system environment proxy variables only.
    Environment,
    /// Apply proxy to all ZeroClaw-managed HTTP traffic (default).
    #[default]
    Zeroclaw,
    /// Apply proxy only to explicitly listed service selectors.
    Services,
}

/// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "proxy"]
pub struct ProxyConfig {
    /// Enable proxy support for selected scope.
    #[serde(default)]
    pub enabled: bool,
    /// Proxy URL for HTTP requests (supports http, https, socks5, socks5h).
    #[serde(default)]
    pub http_proxy: Option<String>,
    /// Proxy URL for HTTPS requests (supports http, https, socks5, socks5h).
    #[serde(default)]
    pub https_proxy: Option<String>,
    /// Fallback proxy URL for all schemes.
    #[serde(default)]
    pub all_proxy: Option<String>,
    /// No-proxy bypass list. Same format as NO_PROXY.
    #[serde(default)]
    pub no_proxy: Vec<String>,
    /// Proxy application scope.
    #[serde(default)]
    pub scope: ProxyScope,
    /// Service selectors used when scope = "services".
    #[serde(default)]
    pub services: Vec<String>,
}

impl Default for ProxyConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            http_proxy: None,
            https_proxy: None,
            all_proxy: None,
            no_proxy: Vec::new(),
            scope: ProxyScope::Zeroclaw,
            services: Vec::new(),
        }
    }
}

impl ProxyConfig {
    pub fn supported_service_keys() -> &'static [&'static str] {
        SUPPORTED_PROXY_SERVICE_KEYS
    }

    pub fn supported_service_selectors() -> &'static [&'static str] {
        SUPPORTED_PROXY_SERVICE_SELECTORS
    }

    pub fn has_any_proxy_url(&self) -> bool {
        normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
            || normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
            || normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
    }

    pub fn normalized_services(&self) -> Vec<String> {
        normalize_service_list(self.services.clone())
    }

    pub fn normalized_no_proxy(&self) -> Vec<String> {
        normalize_no_proxy_list(self.no_proxy.clone())
    }

    pub fn validate(&self) -> Result<()> {
        for (field, value) in [
            ("http_proxy", self.http_proxy.as_deref()),
            ("https_proxy", self.https_proxy.as_deref()),
            ("all_proxy", self.all_proxy.as_deref()),
        ] {
            if let Some(url) = normalize_proxy_url_option(value) {
                validate_proxy_url(field, &url)?;
            }
        }

        for selector in self.normalized_services() {
            if !is_supported_proxy_service_selector(&selector) {
                anyhow::bail!(
                    "Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
                );
            }
        }

        if self.enabled && !self.has_any_proxy_url() {
            anyhow::bail!(
                "Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
            );
        }

        if self.enabled
            && self.scope == ProxyScope::Services
            && self.normalized_services().is_empty()
        {
            anyhow::bail!(
                "proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
            );
        }

        Ok(())
    }

    pub fn should_apply_to_service(&self, service_key: &str) -> bool {
        if !self.enabled {
            return false;
        }

        match self.scope {
            ProxyScope::Environment => false,
            ProxyScope::Zeroclaw => true,
            ProxyScope::Services => {
                let service_key = service_key.trim().to_ascii_lowercase();
                if service_key.is_empty() {
                    return false;
                }

                self.normalized_services()
                    .iter()
                    .any(|selector| service_selector_matches(selector, &service_key))
            }
        }
    }

    pub fn apply_to_reqwest_builder(
        &self,
        mut builder: reqwest::ClientBuilder,
        service_key: &str,
    ) -> reqwest::ClientBuilder {
        if !self.should_apply_to_service(service_key) {
            return builder;
        }

        let no_proxy = self.no_proxy_value();

        if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
            match reqwest::Proxy::all(&url) {
                Ok(proxy) => {
                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
                }
                Err(error) => {
                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid all_proxy URL: ");
                }
            }
        }

        if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
            match reqwest::Proxy::http(&url) {
                Ok(proxy) => {
                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
                }
                Err(error) => {
                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid http_proxy URL: ");
                }
            }
        }

        if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
            match reqwest::Proxy::https(&url) {
                Ok(proxy) => {
                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
                }
                Err(error) => {
                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid https_proxy URL: ");
                }
            }
        }

        builder
    }

    pub fn apply_to_process_env(&self) {
        set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
        set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
        set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());

        let no_proxy_joined = {
            let list = self.normalized_no_proxy();
            (!list.is_empty()).then(|| list.join(","))
        };
        set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
    }

    pub fn clear_process_env() {
        clear_proxy_env_pair("HTTP_PROXY");
        clear_proxy_env_pair("HTTPS_PROXY");
        clear_proxy_env_pair("ALL_PROXY");
        clear_proxy_env_pair("NO_PROXY");
    }

    fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
        let joined = {
            let list = self.normalized_no_proxy();
            (!list.is_empty()).then(|| list.join(","))
        };
        joined.as_deref().and_then(reqwest::NoProxy::from_string)
    }
}

fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
    proxy.no_proxy(no_proxy)
}

fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
    let value = raw?.trim();
    (!value.is_empty()).then(|| value.to_string())
}

fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
    normalize_comma_values(values)
}

fn normalize_service_list(values: Vec<String>) -> Vec<String> {
    let mut normalized = normalize_comma_values(values)
        .into_iter()
        .map(|value| value.to_ascii_lowercase())
        .collect::<Vec<_>>();
    normalized.sort_unstable();
    normalized.dedup();
    normalized
}

fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
    let mut output = Vec::new();
    for value in values {
        for part in value.split(',') {
            let normalized = part.trim();
            if normalized.is_empty() {
                continue;
            }
            output.push(normalized.to_string());
        }
    }
    output.sort_unstable();
    output.dedup();
    output
}

fn is_supported_proxy_service_selector(selector: &str) -> bool {
    if SUPPORTED_PROXY_SERVICE_KEYS
        .iter()
        .any(|known| known.eq_ignore_ascii_case(selector))
    {
        return true;
    }

    SUPPORTED_PROXY_SERVICE_SELECTORS
        .iter()
        .any(|known| known.eq_ignore_ascii_case(selector))
}

fn service_selector_matches(selector: &str, service_key: &str) -> bool {
    if selector == service_key {
        return true;
    }

    if let Some(prefix) = selector.strip_suffix(".*") {
        return service_key.starts_with(prefix)
            && service_key
                .strip_prefix(prefix)
                .is_some_and(|suffix| suffix.starts_with('.'));
    }

    false
}

const MCP_MAX_TOOL_TIMEOUT_SECS: u64 = 600;

fn validate_mcp_config(config: &McpConfig) -> Result<()> {
    let mut seen_names = std::collections::HashSet::new();
    for (i, server) in config.servers.iter().enumerate() {
        let name = server.name.trim();
        if name.is_empty() {
            validation_bail!(
                RequiredFieldEmpty,
                format!("mcp.servers[{i}].name"),
                "mcp.servers[{i}].name must not be empty"
            );
        }
        if !seen_names.insert(name.to_ascii_lowercase()) {
            anyhow::bail!("mcp.servers contains duplicate name: {name}");
        }

        if let Some(timeout) = server.tool_timeout_secs {
            if timeout == 0 {
                validation_bail!(
                    InvalidNumericRange,
                    format!("mcp.servers[{i}].tool_timeout_secs"),
                    "mcp.servers[{i}].tool_timeout_secs must be greater than 0"
                );
            }
            if timeout > MCP_MAX_TOOL_TIMEOUT_SECS {
                anyhow::bail!(
                    "mcp.servers[{i}].tool_timeout_secs exceeds max {MCP_MAX_TOOL_TIMEOUT_SECS}"
                );
            }
        }

        // The transport -> required-leaf relationship is owned by
        // `McpTransport::required_leaf`, which feeds the `x-required-by-transport`
        // schema metadata. The validator matches on the `McpTransport` enum so a
        // new variant is a compile error here rather than a runtime fall-through.
        match server.transport {
            McpTransport::Stdio => {
                if server.command.trim().is_empty() {
                    anyhow::bail!(
                        "mcp.servers[{i}] with transport=stdio requires non-empty command"
                    );
                }
            }
            McpTransport::Http | McpTransport::Sse => {
                let url = server
                    .url
                    .as_deref()
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    .ok_or_else(|| {
                        let transport_str = server.transport.wire_name();
                        ::zeroclaw_log::record!(
                            WARN,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Reject
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({
                                "index": i,
                                "transport": transport_str,
                            })),
                            "mcp.servers entry rejected: transport requires url"
                        );
                        anyhow::Error::msg(format!(
                            "mcp.servers[{i}] with transport={transport_str} requires url"
                        ))
                    })?;
                let parsed = reqwest::Url::parse(url)
                    .with_context(|| format!("mcp.servers[{i}].url is not a valid URL"))?;
                if !matches!(parsed.scheme(), "http" | "https") {
                    anyhow::bail!("mcp.servers[{i}].url must use http/https");
                }
            }
        }
    }
    Ok(())
}

fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
    let parsed = reqwest::Url::parse(url)
        .with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;

    match parsed.scheme() {
        "http" | "https" | "socks5" | "socks5h" | "socks" => {}
        scheme => {
            anyhow::bail!(
                "Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h, socks"
            );
        }
    }

    if parsed.host_str().is_none() {
        anyhow::bail!("Invalid {field} URL: host is required");
    }

    Ok(())
}

fn validate_http_base_url(field: &str, url: &str) -> Result<()> {
    if url.trim().is_empty() {
        validation_bail!(
            RequiredFieldEmpty,
            field.to_string(),
            "{field} must not be empty"
        );
    }

    let parsed = match reqwest::Url::parse(url) {
        Ok(parsed) => parsed,
        Err(err) => {
            validation_bail!(
                InvalidFormat,
                field.to_string(),
                "{field} must be a valid URL: {err}"
            );
        }
    };

    if !matches!(parsed.scheme(), "http" | "https") {
        validation_bail!(
            InvalidFormat,
            field.to_string(),
            "{field} must use http:// or https://"
        );
    }

    if parsed.host_str().is_none() {
        validation_bail!(
            InvalidFormat,
            field.to_string(),
            "{field} must include a host"
        );
    }

    Ok(())
}

/// Shared bot-token rule for channel structs whose `bot_token` is required
/// once the alias is enabled (Telegram, Discord). `field_path` must be the
/// `channels.<type>.<alias>.bot_token` leaf; the enabled-state message
/// derives the sibling `.enabled` path from it.
fn validate_required_bot_token(field_path: &str, enabled: bool, token: &str) -> Result<()> {
    if token.trim() == crate::traits::UNSET_DISPLAY {
        validation_bail!(
            RequiredFieldEmpty,
            field_path.to_string(),
            "{field_path} must not contain the unset display placeholder",
        );
    }
    if enabled && crate::traits::is_unset_display_value(token) {
        let enabled_path = field_path.strip_suffix("bot_token").map_or_else(
            || field_path.to_string(),
            |prefix| format!("{prefix}enabled"),
        );
        validation_bail!(
            RequiredFieldEmpty,
            field_path.to_string(),
            "{field_path} is required when {enabled_path} = true",
        );
    }
    Ok(())
}

/// Shared "required once enabled" rule for channel-credential fields whose
/// name doesn't end in `bot_token` (Signal's `http_url`/`account`, Voice
/// Call's `account_id`/`auth_token`/`from_number`), so the sibling
/// `.enabled` path can't be derived by suffix-stripping like
/// `validate_required_bot_token` does. An enabled channel built with an
/// empty required credential never connects, so its per-channel supervisor
/// restarts it forever; rejecting at config-load time turns that crashloop
/// into a fail-fast startup error instead.
pub(crate) fn validate_required_field(
    field_path: &str,
    enabled_path: &str,
    enabled: bool,
    value: &str,
) -> Result<()> {
    if value.trim() == crate::traits::UNSET_DISPLAY {
        validation_bail!(
            RequiredFieldEmpty,
            field_path.to_string(),
            "{field_path} must not contain the unset display placeholder",
        );
    }
    if enabled && crate::traits::is_unset_display_value(value) {
        validation_bail!(
            RequiredFieldEmpty,
            field_path.to_string(),
            "{field_path} is required when {enabled_path} = true",
        );
    }
    Ok(())
}

fn set_proxy_env_pair(key: &str, value: Option<&str>) {
    let lowercase_key = key.to_ascii_lowercase();
    if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
        // SAFETY: called during single-threaded config init before async runtime starts.
        unsafe {
            std::env::set_var(key, &value);
            std::env::set_var(lowercase_key, value);
        }
    } else {
        // SAFETY: called during single-threaded config init before async runtime starts.
        unsafe {
            std::env::remove_var(key);
            std::env::remove_var(lowercase_key);
        }
    }
}

fn clear_proxy_env_pair(key: &str) {
    // SAFETY: called during single-threaded config init before async runtime starts.
    unsafe {
        std::env::remove_var(key);
        std::env::remove_var(key.to_ascii_lowercase());
    }
}

fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
    RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
}

fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
    RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}

fn clear_runtime_proxy_client_cache() {
    match runtime_proxy_client_cache().write() {
        Ok(mut guard) => {
            guard.clear();
        }
        Err(poisoned) => {
            poisoned.into_inner().clear();
        }
    }
}

fn runtime_proxy_cache_key(
    service_key: &str,
    timeout_secs: Option<u64>,
    connect_timeout_secs: Option<u64>,
) -> String {
    format!(
        "{}|timeout={}|connect_timeout={}",
        service_key.trim().to_ascii_lowercase(),
        timeout_secs
            .map(|value| value.to_string())
            .unwrap_or_else(|| "none".to_string()),
        connect_timeout_secs
            .map(|value| value.to_string())
            .unwrap_or_else(|| "none".to_string())
    )
}

fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
    match runtime_proxy_client_cache().read() {
        Ok(guard) => guard.get(cache_key).cloned(),
        Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
    }
}

fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
    match runtime_proxy_client_cache().write() {
        Ok(mut guard) => {
            guard.insert(cache_key, client);
        }
        Err(poisoned) => {
            poisoned.into_inner().insert(cache_key, client);
        }
    }
}

pub fn set_runtime_proxy_config(config: ProxyConfig) {
    match runtime_proxy_state().write() {
        Ok(mut guard) => {
            *guard = config;
        }
        Err(poisoned) => {
            *poisoned.into_inner() = config;
        }
    }

    clear_runtime_proxy_client_cache();
}

pub fn runtime_proxy_config() -> ProxyConfig {
    match runtime_proxy_state().read() {
        Ok(guard) => guard.clone(),
        Err(poisoned) => poisoned.into_inner().clone(),
    }
}

pub fn apply_runtime_proxy_to_builder(
    builder: reqwest::ClientBuilder,
    service_key: &str,
) -> reqwest::ClientBuilder {
    runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
}

pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
    let cache_key = runtime_proxy_cache_key(service_key, None, None);
    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
        return client;
    }

    let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
    let client = builder.build().unwrap_or_else(|error| {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(
                    ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
                ),
            "Failed to build proxied client: "
        );
        reqwest::Client::new()
    });
    set_runtime_proxy_cached_client(cache_key, client.clone());
    client
}

pub fn build_runtime_proxy_client_with_timeouts(
    service_key: &str,
    timeout_secs: u64,
    connect_timeout_secs: u64,
) -> reqwest::Client {
    let cache_key =
        runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
        return client;
    }

    let builder = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(timeout_secs))
        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
    let builder = apply_runtime_proxy_to_builder(builder, service_key);
    let client = builder.build().unwrap_or_else(|error| {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(
                    ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
                ),
            "Failed to build proxied timeout client: "
        );
        reqwest::Client::new()
    });
    set_runtime_proxy_cached_client(cache_key, client.clone());
    client
}

/// Build an HTTP client for a channel, using an explicit per-channel proxy URL
/// when configured. Falls back to the global runtime proxy when `proxy_url` is
/// `None` or empty.
pub fn build_channel_proxy_client(service_key: &str, proxy_url: Option<&str>) -> reqwest::Client {
    match normalize_proxy_url_option(proxy_url) {
        Some(url) => build_explicit_proxy_client(service_key, &url, None, None),
        None => build_runtime_proxy_client(service_key),
    }
}

/// Build an HTTP client for a channel with custom timeouts, using an explicit
/// per-channel proxy URL when configured. Falls back to the global runtime
/// proxy when `proxy_url` is `None` or empty.
pub fn build_channel_proxy_client_with_timeouts(
    service_key: &str,
    proxy_url: Option<&str>,
    timeout_secs: u64,
    connect_timeout_secs: u64,
) -> reqwest::Client {
    match normalize_proxy_url_option(proxy_url) {
        Some(url) => build_explicit_proxy_client(
            service_key,
            &url,
            Some(timeout_secs),
            Some(connect_timeout_secs),
        ),
        None => build_runtime_proxy_client_with_timeouts(
            service_key,
            timeout_secs,
            connect_timeout_secs,
        ),
    }
}

/// Apply an explicit proxy URL to a `reqwest::ClientBuilder`, returning the
/// modified builder. Used by channels that specify a per-channel `proxy_url`.
pub fn apply_channel_proxy_to_builder(
    builder: reqwest::ClientBuilder,
    service_key: &str,
    proxy_url: Option<&str>,
) -> reqwest::ClientBuilder {
    match normalize_proxy_url_option(proxy_url) {
        Some(url) => apply_explicit_proxy_to_builder(builder, service_key, &url),
        None => apply_runtime_proxy_to_builder(builder, service_key),
    }
}

/// Build a client with a single explicit proxy URL (http+https via `Proxy::all`).
fn build_explicit_proxy_client(
    service_key: &str,
    proxy_url: &str,
    timeout_secs: Option<u64>,
    connect_timeout_secs: Option<u64>,
) -> reqwest::Client {
    let cache_key = format!(
        "explicit|{}|{}|timeout={}|connect_timeout={}",
        service_key.trim().to_ascii_lowercase(),
        proxy_url,
        timeout_secs
            .map(|v| v.to_string())
            .unwrap_or_else(|| "none".to_string()),
        connect_timeout_secs
            .map(|v| v.to_string())
            .unwrap_or_else(|| "none".to_string()),
    );
    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
        return client;
    }

    let mut builder = reqwest::Client::builder();
    if let Some(t) = timeout_secs {
        builder = builder.timeout(std::time::Duration::from_secs(t));
    }
    if let Some(ct) = connect_timeout_secs {
        builder = builder.connect_timeout(std::time::Duration::from_secs(ct));
    }
    builder = apply_explicit_proxy_to_builder(builder, service_key, proxy_url);
    let client = builder.build().unwrap_or_else(|error| {
        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"service_key": service_key, "proxy_url": proxy_url, "error": format!("{}", error)})), "Failed to build channel proxy client: ");
        reqwest::Client::new()
    });
    set_runtime_proxy_cached_client(cache_key, client.clone());
    client
}

/// Apply a single explicit proxy URL to a builder via `Proxy::all`.
fn apply_explicit_proxy_to_builder(
    mut builder: reqwest::ClientBuilder,
    service_key: &str,
    proxy_url: &str,
) -> reqwest::ClientBuilder {
    match reqwest::Proxy::all(proxy_url) {
        Ok(proxy) => {
            builder = builder.proxy(proxy);
        }
        Err(error) => {
            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": proxy_url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid channel proxy_url: ");
        }
    }
    builder
}

// ── Proxy-aware WebSocket connect ────────────────────────────────
//
// `tokio_tungstenite::connect_async` does not honour proxy settings.
// The helpers below resolve the effective proxy URL for a given service
// key and, when a proxy is active, establish a tunnelled TCP connection
// (HTTP CONNECT for http/https proxies, SOCKS5 for socks5/socks5h)
// before handing the stream to `tokio_tungstenite` for the WebSocket
// handshake.

/// Combined async IO trait for boxed WebSocket transport streams.
trait AsyncReadWrite: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> AsyncReadWrite for T {}

/// A boxed async IO stream used when a WebSocket connection is tunnelled
/// through a proxy. The concrete type varies depending on the proxy
/// kind (HTTP CONNECT vs SOCKS5) and the target scheme (ws vs wss).
///
/// We wrap in a newtype so we can implement `AsyncRead` and `AsyncWrite`
/// via delegation, since Rust trait objects cannot combine multiple
/// non-auto traits.
pub struct BoxedIo(Box<dyn AsyncReadWrite>);

impl tokio::io::AsyncRead for BoxedIo {
    fn poll_read(
        mut self: std::pin::Pin<&mut Self>,
        cx: &mut std::task::Context<'_>,
        buf: &mut tokio::io::ReadBuf<'_>,
    ) -> std::task::Poll<std::io::Result<()>> {
        std::pin::Pin::new(&mut *self.0).poll_read(cx, buf)
    }
}

impl tokio::io::AsyncWrite for BoxedIo {
    fn poll_write(
        mut self: std::pin::Pin<&mut Self>,
        cx: &mut std::task::Context<'_>,
        buf: &[u8],
    ) -> std::task::Poll<std::io::Result<usize>> {
        std::pin::Pin::new(&mut *self.0).poll_write(cx, buf)
    }

    fn poll_flush(
        mut self: std::pin::Pin<&mut Self>,
        cx: &mut std::task::Context<'_>,
    ) -> std::task::Poll<std::io::Result<()>> {
        std::pin::Pin::new(&mut *self.0).poll_flush(cx)
    }

    fn poll_shutdown(
        mut self: std::pin::Pin<&mut Self>,
        cx: &mut std::task::Context<'_>,
    ) -> std::task::Poll<std::io::Result<()>> {
        std::pin::Pin::new(&mut *self.0).poll_shutdown(cx)
    }
}

impl Unpin for BoxedIo {}

/// Convenience alias for the WebSocket stream returned by the proxy-aware
/// connect helpers.
pub type ProxiedWsStream = tokio_tungstenite::WebSocketStream<BoxedIo>;

/// Resolve the effective proxy URL for a WebSocket connection to the
/// given `ws_url`, taking into account the per-channel `proxy_url`
/// override, the runtime proxy config, scope and no_proxy list.
fn resolve_ws_proxy_url(
    service_key: &str,
    ws_url: &str,
    channel_proxy_url: Option<&str>,
) -> Option<String> {
    // 1. Explicit per-channel proxy always wins.
    if let Some(url) = normalize_proxy_url_option(channel_proxy_url) {
        return Some(url);
    }

    // 2. Consult the runtime proxy config.
    let cfg = runtime_proxy_config();
    if !cfg.should_apply_to_service(service_key) {
        return None;
    }

    // Check the no_proxy list against the WebSocket target host.
    if let Ok(parsed) = reqwest::Url::parse(ws_url)
        && let Some(host) = parsed.host_str()
    {
        let no_proxy_entries = cfg.normalized_no_proxy();
        if !no_proxy_entries.is_empty() {
            let host_lower = host.to_ascii_lowercase();
            let matches_no_proxy = no_proxy_entries.iter().any(|entry| {
                let entry = entry.trim().to_ascii_lowercase();
                if entry == "*" {
                    return true;
                }
                if host_lower == entry {
                    return true;
                }
                // Support ".example.com" matching "foo.example.com"
                if let Some(suffix) = entry.strip_prefix('.') {
                    return host_lower.ends_with(suffix) || host_lower == suffix;
                }
                // Support "example.com" also matching "foo.example.com"
                host_lower.ends_with(&format!(".{entry}"))
            });
            if matches_no_proxy {
                return None;
            }
        }
    }

    // For wss:// prefer https_proxy, for ws:// prefer http_proxy, fall
    // back to all_proxy in both cases.
    let is_secure = ws_url.starts_with("wss://") || ws_url.starts_with("wss:");
    let preferred = if is_secure {
        normalize_proxy_url_option(cfg.https_proxy.as_deref())
    } else {
        normalize_proxy_url_option(cfg.http_proxy.as_deref())
    };
    preferred.or_else(|| normalize_proxy_url_option(cfg.all_proxy.as_deref()))
}

/// Connect a WebSocket through the configured proxy (if any).
///
/// When no proxy applies, this is a thin wrapper around
/// `tokio_tungstenite::connect_async`. When a proxy is active the
/// function tunnels the TCP connection through the proxy before
/// performing the WebSocket upgrade.
///
/// `service_key` is the proxy-service selector (e.g. `"channel.discord"`).
/// `channel_proxy_url` is the optional per-channel proxy override.
pub async fn ws_connect_with_proxy(
    ws_url: &str,
    service_key: &str,
    channel_proxy_url: Option<&str>,
) -> anyhow::Result<(
    ProxiedWsStream,
    tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
)> {
    let proxy_url = resolve_ws_proxy_url(service_key, ws_url, channel_proxy_url);

    match proxy_url {
        None => {
            // No proxy — establish TCP+TLS manually, wrap in BoxedIo, then
            // perform the WebSocket handshake over the wrapped stream.
            //
            // Previous implementation used `connect_async` followed by
            // `into_inner()` + `from_raw_socket` to normalize the return
            // type. That pattern discards data already buffered by the
            // tungstenite frame codec, causing channels (Slack Socket Mode,
            // Discord, etc.) to silently miss the first frames sent by the
            // server and all subsequent events.
            use tokio::net::TcpStream;

            let target = reqwest::Url::parse(ws_url)
                .with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
            let target_host = target
                .host_str()
                .ok_or_else(|| {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({"ws_url": ws_url})),
                        "WebSocket URL has no host"
                    );
                    anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
                })?
                .to_string();
            let target_port = target
                .port_or_known_default()
                .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });

            let tcp = TcpStream::connect(format!("{target_host}:{target_port}"))
                .await
                .with_context(|| format!("TCP connect to {target_host}:{target_port}"))?;

            let is_secure = target.scheme() == "wss";
            let stream: BoxedIo = if is_secure {
                let mut root_store = rustls::RootCertStore::empty();
                root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
                let tls_config = std::sync::Arc::new(
                    rustls::ClientConfig::builder()
                        .with_root_certificates(root_store)
                        .with_no_client_auth(),
                );
                let connector = tokio_rustls::TlsConnector::from(tls_config);
                let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
                    .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
                let tls_stream = connector
                    .connect(server_name, tcp)
                    .await
                    .with_context(|| format!("TLS handshake with {target_host}"))?;
                BoxedIo(Box::new(tls_stream))
            } else {
                BoxedIo(Box::new(tcp))
            };

            let default_port = if is_secure { 443 } else { 80 };
            let host_header = if target_port == default_port {
                target_host.clone()
            } else {
                format!("{target_host}:{target_port}")
            };

            let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
                .uri(ws_url)
                .header("Host", host_header)
                .header("Connection", "Upgrade")
                .header("Upgrade", "websocket")
                .header(
                    "Sec-WebSocket-Key",
                    tokio_tungstenite::tungstenite::handshake::client::generate_key(),
                )
                .header("Sec-WebSocket-Version", "13")
                .body(())
                .with_context(|| "Failed to build WebSocket upgrade request")?;

            let (ws_stream, response) =
                tokio_tungstenite::client_async(ws_request, stream)
                    .await
                    .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;

            Ok((ws_stream, response))
        }
        Some(proxy) => ws_connect_via_proxy(ws_url, &proxy).await,
    }
}

/// Establish a WebSocket connection tunnelled through the given proxy URL.
async fn ws_connect_via_proxy(
    ws_url: &str,
    proxy_url: &str,
) -> anyhow::Result<(
    ProxiedWsStream,
    tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
)> {
    use tokio::io::{AsyncReadExt, AsyncWriteExt as _};
    use tokio::net::TcpStream;

    let target =
        reqwest::Url::parse(ws_url).with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
    let target_host = target
        .host_str()
        .ok_or_else(|| {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({"ws_url": ws_url})),
                "WebSocket URL has no host"
            );
            anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
        })?
        .to_string();
    let target_port = target
        .port_or_known_default()
        .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });

    let proxy = reqwest::Url::parse(proxy_url)
        .with_context(|| format!("Invalid proxy URL: {proxy_url}"))?;

    let stream: BoxedIo = match proxy.scheme() {
        "socks5" | "socks5h" | "socks" => {
            let proxy_addr = format!(
                "{}:{}",
                proxy.host_str().unwrap_or("127.0.0.1"),
                proxy.port_or_known_default().unwrap_or(1080)
            );
            let target_addr = format!("{target_host}:{target_port}");
            let socks_stream = if proxy.username().is_empty() {
                tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target_addr.as_str())
                    .await
                    .with_context(|| format!("SOCKS5 connect to {target_addr} via {proxy_addr}"))?
            } else {
                let password = proxy.password().unwrap_or("");
                tokio_socks::tcp::Socks5Stream::connect_with_password(
                    proxy_addr.as_str(),
                    target_addr.as_str(),
                    proxy.username(),
                    password,
                )
                .await
                .with_context(|| format!("SOCKS5 auth connect to {target_addr} via {proxy_addr}"))?
            };
            let tcp: TcpStream = socks_stream.into_inner();
            BoxedIo(Box::new(tcp))
        }
        "http" | "https" => {
            let proxy_host = proxy.host_str().unwrap_or("127.0.0.1");
            let proxy_port = proxy.port_or_known_default().unwrap_or(8080);
            let proxy_addr = format!("{proxy_host}:{proxy_port}");

            let mut tcp = TcpStream::connect(&proxy_addr)
                .await
                .with_context(|| format!("TCP connect to HTTP proxy {proxy_addr}"))?;

            // Send HTTP CONNECT request.
            let connect_req = format!(
                "CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n"
            );
            tcp.write_all(connect_req.as_bytes()).await?;

            // Read the response (we only need the status line).
            let mut buf = vec![0u8; 4096];
            let mut total = 0usize;
            loop {
                let n = tcp.read(&mut buf[total..]).await?;
                if n == 0 {
                    anyhow::bail!("HTTP CONNECT proxy closed connection before response");
                }
                total += n;
                // Look for end of HTTP headers.
                if let Some(pos) = find_header_end(&buf[..total]) {
                    let status_line = std::str::from_utf8(&buf[..pos])
                        .unwrap_or("")
                        .lines()
                        .next()
                        .unwrap_or("");
                    if !status_line.contains("200") {
                        anyhow::bail!(
                            "HTTP CONNECT proxy returned non-200 response: {status_line}"
                        );
                    }
                    break;
                }
                if total >= buf.len() {
                    anyhow::bail!("HTTP CONNECT proxy response too large");
                }
            }

            BoxedIo(Box::new(tcp))
        }
        scheme => {
            anyhow::bail!("Unsupported proxy scheme '{scheme}' for WebSocket connections");
        }
    };

    // If the target is wss://, wrap in TLS.
    let is_secure = target.scheme() == "wss";
    let stream: BoxedIo = if is_secure {
        let mut root_store = rustls::RootCertStore::empty();
        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
        let tls_config = std::sync::Arc::new(
            rustls::ClientConfig::builder()
                .with_root_certificates(root_store)
                .with_no_client_auth(),
        );
        let connector = tokio_rustls::TlsConnector::from(tls_config);
        let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
            .with_context(|| format!("Invalid TLS server name: {target_host}"))?;

        // `stream` is `BoxedIo` — we need a concrete `AsyncRead + AsyncWrite`
        // for `TlsConnector::connect`. Since `BoxedIo` already satisfies
        // those bounds we can pass it directly.
        let tls_stream = connector
            .connect(server_name, stream)
            .await
            .with_context(|| format!("TLS handshake with {target_host}"))?;
        BoxedIo(Box::new(tls_stream))
    } else {
        stream
    };

    // Perform the WebSocket client handshake over the tunnelled stream.
    let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
        .uri(ws_url)
        .header("Host", format!("{target_host}:{target_port}"))
        .header("Connection", "Upgrade")
        .header("Upgrade", "websocket")
        .header(
            "Sec-WebSocket-Key",
            tokio_tungstenite::tungstenite::handshake::client::generate_key(),
        )
        .header("Sec-WebSocket-Version", "13")
        .body(())
        .with_context(|| "Failed to build WebSocket upgrade request")?;

    let (ws_stream, response) = tokio_tungstenite::client_async(ws_request, stream)
        .await
        .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;

    Ok((ws_stream, response))
}

/// Find the `\r\n\r\n` boundary marking the end of HTTP headers.
fn find_header_end(buf: &[u8]) -> Option<usize> {
    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
}

// ── Memory ───────────────────────────────────────────────────

/// Persistent storage configuration (`[storage]` section).
///
/// Storage is a two-tier alias-keyed map: `[storage.<backend>.<alias>]`,
/// parallel to `[providers.models.<type>.<alias>]`. Each backend has its own typed
/// config struct. `MemoryConfig.backend` carries a dotted reference (`"sqlite.default"`,
/// `"postgres.work"`) that resolves to one of these entries via
/// [`Config::resolve_active_storage`].
#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "storage"]
pub struct StorageConfig {
    /// SQLite storage instances (`[storage.sqlite.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub sqlite: HashMap<String, SqliteStorageConfig>,
    /// PostgreSQL storage instances (`[storage.postgres.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub postgres: HashMap<String, PostgresStorageConfig>,
    /// Qdrant storage instances (`[storage.qdrant.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub qdrant: HashMap<String, QdrantStorageConfig>,
    /// Markdown storage instances (`[storage.markdown.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub markdown: HashMap<String, MarkdownStorageConfig>,
    /// Lucid CLI sync instances (`[storage.lucid.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub lucid: HashMap<String, LucidStorageConfig>,
}

/// SQLite storage backend (`[storage.sqlite.<alias>]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "storage_sqlite"]
#[serde(default)]
pub struct SqliteStorageConfig {
    /// Optional override for the SQLite database path.
    /// When unset, defaults to `<workspace_dir>/brain.db`.
    pub path: Option<String>,
    /// Maximum seconds to wait when opening the DB if it's locked.
    /// `None` waits indefinitely. Recommended max: 300.
    pub open_timeout_secs: Option<u64>,
}

/// PostgreSQL storage backend (`[storage.postgres.<alias>]`).
///
/// Holds connection parameters AND pgvector settings on one alias-keyed
/// entry; previously these lived in two separate sections.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "storage_postgres"]
#[serde(default)]
pub struct PostgresStorageConfig {
    /// Connection URL (e.g. `"postgres://user:pass@host/db"`).
    /// Accepts legacy aliases: dbURL, database_url, databaseUrl.
    #[serde(alias = "dbURL", alias = "database_url", alias = "databaseUrl")]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub db_url: Option<String>,
    /// Database schema for the memory table.
    pub schema: String,
    /// Table name for memory entries.
    pub table: String,
    /// Optional connection timeout in seconds.
    pub connect_timeout_secs: Option<u64>,
    /// Enable pgvector extension for hybrid vector+keyword recall.
    pub vector_enabled: bool,
    /// Vector dimensions for pgvector embeddings.
    pub vector_dimensions: usize,
}

impl Default for PostgresStorageConfig {
    fn default() -> Self {
        Self {
            db_url: None,
            schema: default_storage_schema(),
            table: default_storage_table(),
            connect_timeout_secs: None,
            vector_enabled: false,
            vector_dimensions: default_pgvector_dimensions(),
        }
    }
}

/// Qdrant vector database backend (`[storage.qdrant.<alias>]`).
///
/// URL, collection, and API key are typed config values. Use schema-mirror env
/// overrides such as `ZEROCLAW_storage__qdrant__<alias>__url=...` for runtime
/// env injection.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "storage_qdrant"]
#[serde(default)]
pub struct QdrantStorageConfig {
    /// Qdrant server URL (e.g. `"http://localhost:6333"`).
    /// Use `ZEROCLAW_storage__qdrant__<alias>__url=...` for env injection.
    pub url: Option<String>,
    /// Collection name for storing memories.
    /// Use `ZEROCLAW_storage__qdrant__<alias>__collection=...` for env injection.
    pub collection: String,
    /// API key for Qdrant Cloud or secured instances.
    /// Use `ZEROCLAW_storage__qdrant__<alias>__api_key=...` for env injection.
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
}

impl Default for QdrantStorageConfig {
    fn default() -> Self {
        Self {
            url: None,
            collection: default_qdrant_collection(),
            api_key: None,
        }
    }
}

/// Markdown directory storage (`[storage.markdown.<alias>]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "storage_markdown"]
#[serde(default)]
pub struct MarkdownStorageConfig {
    /// Optional override for the markdown root directory.
    /// When unset, defaults to `<workspace_dir>/memory/`.
    pub directory: Option<String>,
}

/// Lucid CLI sync backend (`[storage.lucid.<alias>]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "storage_lucid"]
#[serde(default)]
pub struct LucidStorageConfig {
    /// Optional path to the lucid-memory binary. When unset, the bare `lucid`
    /// executable is resolved on `PATH`. A blank or whitespace-only value is
    /// rejected at config validation and remains invalid if startup continues,
    /// so an invalid explicit selector never falls through to `PATH`.
    pub binary_path: Option<String>,
    /// Recall (context) timeout override, in milliseconds. Lucid CLI cold
    /// starts (loading the local embedding model) can exceed 1.5s on ARM
    /// hosts; raise this if recalls still time out on your hardware.
    /// Unset falls back to the built-in default (3000ms). `0` is rejected
    /// at config validation; because startup warns and continues after
    /// validation errors, a `0` that reaches the runtime is treated as
    /// unset and falls back to the default.
    pub recall_timeout_ms: Option<u64>,
    /// Store timeout override, in milliseconds. Same cold-start
    /// consideration as `recall_timeout_ms`. Unset falls back to the
    /// built-in default (3000ms); `0` is rejected at validation and, if
    /// startup continues after the warning, is likewise treated as unset.
    pub store_timeout_ms: Option<u64>,
}

fn default_storage_schema() -> String {
    "public".into()
}

fn default_storage_table() -> String {
    "memories".into()
}

fn default_qdrant_collection() -> String {
    "zeroclaw_memories".into()
}

/// Search strategy for memory recall.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, zeroclaw_macros::ConfigEnum)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SearchMode {
    /// Pure keyword search (FTS5 BM25)
    Bm25,
    /// Pure vector/semantic search
    Embedding,
    /// Weighted combination of keyword + vector (default)
    #[default]
    Hybrid,
}

/// Memory backend configuration (`[memory]` section).
///
/// Controls conversation memory storage, embeddings, hybrid search, response
/// caching, and memory snapshot/hydration. Backend-specific connection settings
/// live under `[storage.<backend>.<alias>]`; this section selects which storage
/// instance to use via the `backend` dotted reference.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "memory"]
#[allow(clippy::struct_excessive_bools)]
pub struct MemoryConfig {
    /// Dotted reference to the active storage instance: `<backend>.<alias>`
    /// (e.g. `"sqlite.default"`, `"postgres.work"`). Resolves through
    /// `Config.storage.<backend>.<alias>` at runtime. Bare backend names
    /// (`"sqlite"`) are treated as `"<backend>.default"`. Set to `"none"` to
    /// disable persistence entirely.
    #[serde(default = "default_memory_backend")]
    pub backend: String,
    /// Auto-save what *you* tell ZeroClaw into memory as conversation history — the agent's own replies are not saved. Turn off if you want memory to only hold things you explicitly record via the memory tool.
    #[serde(default = "default_auto_save")]
    pub auto_save: bool,
    /// Run the periodic hygiene pass that archives stale daily/session files and enforces retention windows. Leave on unless you want to manage cleanup yourself.
    #[serde(default = "default_hygiene_enabled")]
    pub hygiene_enabled: bool,
    /// Also extract atomic durable facts from each consolidated turn and store
    /// them as individual Core memories. Default off; the flip is sequenced in
    /// a later phase. SQLite-only: enabling this requires the sqlite memory
    /// backend globally and on every agent (validated at config load).
    #[serde(default)]
    pub consolidation_extract_facts: bool,
    /// Move daily/session files to the archive directory after this many days. Keeps the hot working set small without deleting history.
    #[serde(default = "default_archive_after_days")]
    pub archive_after_days: u32,
    /// Delete archived files permanently after this many days. Set high if you need long-term history; set low for privacy / disk-space reasons.
    #[serde(default = "default_purge_after_days")]
    pub purge_after_days: u32,
    /// Delete conversation rows older than this many days from the DB (sqlite backend only). Age is measured by `updated_at` (last write time). 0 = keep forever.
    #[serde(default = "default_conversation_retention_days")]
    pub conversation_retention_days: u32,
    /// Delete daily memory rows older than this many days from the DB. Age is measured by `updated_at` (last write time). 0 = keep forever.
    #[serde(default = "default_zero_retention")]
    pub daily_retention_days: u32,
    /// Delete core memory rows older than this many days from the DB. Age is measured by `created_at` (first-write time). Neither recall nor ordinary rewrites refresh `created_at` under the current SQLite upsert, so core retention is an absolute age limit from first write. Set this to a generously large window for durable core memories, or keep 0 = keep forever.
    #[serde(default = "default_zero_retention")]
    pub core_retention_days: u32,
    /// Source of embedding vectors for semantic search. `none` = keyword-only retrieval (no API calls, no vector cost); `openai` = OpenAI's embedding API; `custom:URL` = any OpenAI-compatible embedding endpoint (LiteLLM, local gateway, etc.).
    #[serde(default = "default_embedding_provider")]
    pub embedding_provider: String,
    /// Embedding model identifier — must match a model your chosen embedding model_provider serves (e.g. `text-embedding-3-small` for OpenAI). Changing this invalidates existing embeddings: the change is detected at startup and stale vectors are cleared automatically; run `zeroclaw memory reindex` to re-embed (or set `auto_reindex_on_identity_change`).
    #[serde(default = "default_embedding_model")]
    pub embedding_model: String,
    /// Vector width produced by the embedding model — must match the model's native dimension or vectors won't store correctly. Look up the number on the model_provider's model page.
    #[serde(default = "default_embedding_dims")]
    pub embedding_dimensions: usize,
    /// Automatically re-embed all memories in the background when a change of embedding provider/model/dimensions is detected at startup (after the stale vectors have been cleared). Costs one embedding API call per memory, so it's off by default — leave it off for large stores and run `zeroclaw memory reindex` explicitly instead.
    #[serde(default)]
    pub auto_reindex_on_identity_change: bool,
    /// Optional API key for the embedding endpoint. When set, embedding calls use this key instead of inheriting one from the seed model provider — decoupling embeddings from the chat model. Use it when the chat model runs on a provider that carries no usable embedding credential (e.g. an OAuth-only provider) while embeddings keep hitting an `openai`/`custom:` endpoint with their own key. Leave unset to inherit the seed provider's key (backward-compatible default).
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub embedding_api_key: Option<String>,
    /// How heavily vector (semantic) similarity counts when `search_mode = hybrid`. Raise toward 1.0 to favor meaning-based matches; lower it to lean on keyword overlap instead.
    #[serde(default = "default_vector_weight")]
    pub vector_weight: f64,
    /// How heavily BM25 (keyword) overlap counts when `search_mode = hybrid`. Raise toward 1.0 for exact-term matching; lower it when paraphrases should still score well.
    #[serde(default = "default_keyword_weight")]
    pub keyword_weight: f64,
    /// How memories are retrieved: `bm25` = keyword-only (no embeddings, cheapest); `embedding` = vector similarity only (needs an embedding model_provider); `hybrid` = blended keyword + vector score using the weights above (most robust).
    #[serde(default)]
    pub search_mode: SearchMode,
    /// Minimum hybrid score (0.0–1.0) for a memory to be included in context.
    /// Memories scoring below this threshold are dropped to prevent irrelevant
    /// context from bleeding into conversations. Default: 0.4
    #[serde(default = "default_min_relevance_score")]
    pub min_relevance_score: f64,
    /// Max embedding cache entries before LRU eviction
    #[serde(default = "default_cache_size")]
    pub embedding_cache_size: usize,
    /// Max tokens per chunk for document splitting
    #[serde(default = "default_chunk_size")]
    pub chunk_max_tokens: usize,

    // ── Response Cache (saves tokens on repeated prompts) ──────
    /// Enable LLM response caching to avoid paying for duplicate prompts
    #[serde(default)]
    pub response_cache_enabled: bool,
    /// TTL in minutes for cached responses (default: 60)
    #[serde(default = "default_response_cache_ttl")]
    pub response_cache_ttl_minutes: u32,
    /// Max number of cached responses before LRU eviction (default: 5000)
    #[serde(default = "default_response_cache_max")]
    pub response_cache_max_entries: usize,
    /// Max in-memory hot cache entries for the two-tier response cache (default: 256)
    #[serde(default = "default_response_cache_hot_entries")]
    pub response_cache_hot_entries: usize,

    // ── Memory Snapshot (soul backup to Markdown) ─────────────
    /// Enable periodic export of core memories to MEMORY_SNAPSHOT.md
    #[serde(default)]
    pub snapshot_enabled: bool,
    /// Run snapshot during hygiene passes (heartbeat-driven)
    #[serde(default)]
    pub snapshot_on_hygiene: bool,
    /// Auto-hydrate from MEMORY_SNAPSHOT.md when brain.db is missing
    #[serde(default = "default_true")]
    pub auto_hydrate: bool,

    // ── Retrieval Pipeline ─────────────────────────────────────
    /// Retrieval stages for per-agent recall. Only `"cache"` is active: it
    /// enables an in-process, per-handle hot cache over recall results and is
    /// omitted from the default so recall stays coherent across handles.
    /// `"fts"` and `"vector"` are reserved for when the backend exposes
    /// distinct FTS and vector operations; recall is a single hybrid backend
    /// call today, so those names have no effect (kept for forward compat).
    #[serde(default = "default_retrieval_stages")]
    pub retrieval_stages: Vec<String>,
    /// Candidate pool multiplier over the final recall limit before blend/rerank trimming.
    /// Values must be in 1..=20; runtime also enforces a bounded candidate pool.
    #[serde(default = "default_candidate_multiplier")]
    pub candidate_multiplier: usize,
    /// Enable the recall rerank stage: blend retrieval score with importance
    /// and recency, collapse near-duplicate entries, then trim back to the
    /// recall limit. The advanced strategy below runs when the candidate
    /// count reaches `rerank_threshold`.
    #[serde(default)]
    pub rerank_enabled: bool,
    /// Minimum candidate count to trigger the advanced rerank strategy.
    #[serde(default = "default_rerank_threshold")]
    pub rerank_threshold: usize,
    /// Advanced rerank strategy. Valid: "none", "mmr".
    #[serde(default = "default_rerank_strategy")]
    pub rerank_strategy: String,
    /// MMR relevance-vs-diversity weight, where 1.0 means relevance-only.
    #[serde(default = "default_mmr_lambda")]
    pub mmr_lambda: f64,
    /// Importance weight used by the recall blend.
    #[serde(default = "default_importance_weight")]
    pub importance_weight: f64,
    /// Recency weight used by the recall blend.
    #[serde(default = "default_recency_weight")]
    pub recency_weight: f64,
    /// Reserved (0.0-1.0): the FTS score above which recall would skip the
    /// vector stage. Inert until the backend exposes distinct FTS and vector
    /// operations; recall is a single hybrid call today, so this has no effect.
    #[serde(default = "default_fts_early_return_score")]
    pub fts_early_return_score: f64,

    // ── Namespace Isolation ─────────────────────────────────────
    /// Default namespace for memory entries.
    #[serde(default = "default_namespace")]
    pub default_namespace: String,

    // ── Conflict Resolution ─────────────────────────────────────
    /// Cosine similarity threshold for conflict detection (0.0–1.0).
    #[serde(default = "default_conflict_threshold")]
    pub conflict_threshold: f64,
    /// Enable reversible supersede soft-hide machinery when wired.
    #[serde(default = "default_conflict_supersede_enabled")]
    pub conflict_supersede_enabled: bool,
    /// Enable write-time near-duplicate detection.
    #[serde(default)]
    pub dedup_on_write: bool,
    /// Jaccard threshold for text-only duplicate detection.
    #[serde(default = "default_dedup_jaccard_threshold")]
    pub dedup_jaccard_threshold: f64,
    /// Action to take when a duplicate is detected.
    #[serde(default)]
    pub dedup_action: MemoryDedupAction,

    // ── Memory Budget / Pinning ────────────────────────────────
    /// Maximum Core rows before budget compaction. 0 = unbounded.
    #[serde(default)]
    pub core_max_rows: u64,
    /// Maximum Core bytes before budget compaction. 0 = unbounded.
    #[serde(default)]
    pub core_max_bytes: u64,
    /// Maximum Daily rows before budget compaction. 0 = unbounded.
    #[serde(default)]
    pub daily_max_rows: u64,
    /// Eviction ordering for budget compaction.
    #[serde(default)]
    pub evict_order: MemoryEvictOrder,
    /// Namespaces protected from budget eviction.
    #[serde(default)]
    pub pin_namespaces: Vec<String>,
    /// Pin entries at or above this importance. >1.0 means disabled.
    #[serde(default = "default_pin_min_importance")]
    pub pin_min_importance: f64,

    // ── Audit Trail ─────────────────────────────────────────────
    /// Enable audit logging of memory operations.
    #[serde(default)]
    pub audit_enabled: bool,
    /// Retention period for audit entries in days (default: 30).
    #[serde(default = "default_audit_retention_days")]
    pub audit_retention_days: u32,

    // ── Policy Engine ───────────────────────────────────────────
    /// Memory policy configuration.
    #[serde(default)]
    #[nested]
    pub policy: MemoryPolicyConfig,

    /// Typed memory configuration (`[memory.types]` section).
    #[serde(default)]
    #[nested]
    pub types: MemoryTypesConfig,
    // Backend-specific config fields (sqlite_open_timeout_secs, qdrant.*,
    // postgres.*) live on `[storage.<backend>.<alias>]`. The `backend` field
    // carries a dotted alias reference and the runtime looks up the typed
    // config via `Config::resolve_active_storage`.
}

/// Typed memory configuration (`[memory.types]` section).
///
/// Behaviour-neutral by default: `enabled` gates MemoryKind assignment on new
/// consolidation writes and defaults off; the flip is sequenced in a later
/// phase. SQLite-only: enabling requires the sqlite memory backend globally
/// and on every agent (validated at config load).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "memory.types"]
pub struct MemoryTypesConfig {
    /// Assign a first-class MemoryKind to new consolidation writes.
    #[serde(default)]
    pub enabled: bool,
}

/// Memory policy configuration (`[memory.policy]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "memory.policy"]
pub struct MemoryPolicyConfig {
    /// Maximum entries per namespace (0 = unlimited).
    #[serde(default)]
    pub max_entries_per_namespace: usize,
    /// Maximum entries per category (0 = unlimited).
    #[serde(default)]
    pub max_entries_per_category: usize,
    /// Retention days by category (overrides global). Keys: "core", "daily", "conversation".
    #[serde(default)]
    pub retention_days_by_category: std::collections::HashMap<String, u32>,
    /// Namespaces that are read-only (writes are rejected).
    #[serde(default)]
    pub read_only_namespaces: Vec<String>,
    /// Content scan mode for durable memory writes: "off", "on", or "strict".
    #[serde(default = "default_memory_threat_scan")]
    pub threat_scan: String,
    /// Re-scan stored entries at recall/read time and withhold flagged entries.
    #[serde(default = "default_true")]
    pub threat_scan_load_time: bool,
    /// Behavior when a write-time content scan matches: "reject" or
    /// "block-on-read".
    #[serde(default = "default_memory_threat_scan_on_hit")]
    pub threat_scan_on_hit: String,
    /// Redact configured secret/PII categories before persistence.
    #[serde(default)]
    pub redact_on_write: bool,
    /// Redaction categories applied when `redact_on_write` is true.
    #[serde(default = "default_memory_redact_categories")]
    pub redact_categories: Vec<String>,
}

impl Default for MemoryPolicyConfig {
    fn default() -> Self {
        Self {
            max_entries_per_namespace: 0,
            max_entries_per_category: 0,
            retention_days_by_category: std::collections::HashMap::new(),
            read_only_namespaces: Vec::new(),
            threat_scan: default_memory_threat_scan(),
            threat_scan_load_time: true,
            threat_scan_on_hit: default_memory_threat_scan_on_hit(),
            redact_on_write: false,
            redact_categories: default_memory_redact_categories(),
        }
    }
}

fn default_memory_threat_scan() -> String {
    "on".into()
}

fn default_memory_threat_scan_on_hit() -> String {
    "reject".into()
}

fn default_memory_redact_categories() -> Vec<String> {
    ["secret", "api_key", "private_key", "email", "phone"]
        .into_iter()
        .map(String::from)
        .collect()
}

fn default_retrieval_stages() -> Vec<String> {
    // No "cache": the hot cache is opt-in so activating the retrieval decorator
    // does not change default per-agent recall. "fts"/"vector" are reserved and
    // inert (recall is a single hybrid backend call today).
    vec!["fts".into(), "vector".into()]
}
fn default_rerank_threshold() -> usize {
    5
}

/// Largest allowed multiplier for the bounded query-time rerank candidate pool.
pub const MAX_MEMORY_RERANK_CANDIDATE_MULTIPLIER: usize = 20;

fn default_candidate_multiplier() -> usize {
    4
}
fn default_rerank_strategy() -> String {
    "none".into()
}
fn default_mmr_lambda() -> f64 {
    0.7
}
fn default_importance_weight() -> f64 {
    0.2
}
fn default_recency_weight() -> f64 {
    0.1
}
fn default_fts_early_return_score() -> f64 {
    0.85
}

fn validate_memory_rerank_config(memory: &MemoryConfig) -> Result<()> {
    if !(1..=MAX_MEMORY_RERANK_CANDIDATE_MULTIPLIER).contains(&memory.candidate_multiplier) {
        anyhow::bail!(
            "memory.candidate_multiplier must be in 1..={MAX_MEMORY_RERANK_CANDIDATE_MULTIPLIER} (got {})",
            memory.candidate_multiplier
        );
    }

    for (path, value) in [
        ("memory.min_relevance_score", memory.min_relevance_score),
        ("memory.mmr_lambda", memory.mmr_lambda),
        ("memory.importance_weight", memory.importance_weight),
        ("memory.recency_weight", memory.recency_weight),
    ] {
        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
            anyhow::bail!("{path} must be a finite number in 0.0..=1.0 (got {value})");
        }
    }

    Ok(())
}
fn default_namespace() -> String {
    "default".into()
}
fn default_conflict_threshold() -> f64 {
    0.85
}
fn default_conflict_supersede_enabled() -> bool {
    true
}
fn default_dedup_jaccard_threshold() -> f64 {
    0.80
}
fn default_pin_min_importance() -> f64 {
    1.01
}

/// Surface `[memory]` knobs that the schema accepts but that have no runtime
/// consumer yet, so an operator who sets them learns they currently have no
/// effect instead of debugging missing behavior (same class of check as the
/// `wire_api_not_supported_for_family` warning).
///
/// A PR that wires a consumer for one of these knobs must drop its check
/// here in the same change; this list mirrors what is inert on the current
/// tree, not what is planned.
///
/// Called from `Config::collect_warnings`, so each warning reaches both the
/// CLI (via `validate()`'s tracing emission) and the gateway dashboard.
pub fn validate_memory_semantics(
    memory: &MemoryConfig,
) -> Vec<crate::validation_warnings::ValidationWarning> {
    let mut inert: Vec<(&'static str, &'static str)> = Vec::new();

    // The staged retrieval pipeline (`RetrievalPipeline`) exists but is not
    // wired into the production recall path, so its tuning knobs are inert.
    if memory.retrieval_stages != default_retrieval_stages() {
        inert.push((
            "memory.retrieval_stages",
            "the staged retrieval pipeline is not wired into the recall path yet",
        ));
    }
    if (memory.fts_early_return_score - default_fts_early_return_score()).abs() > f64::EPSILON {
        inert.push((
            "memory.fts_early_return_score",
            "the staged retrieval pipeline is not wired into the recall path yet",
        ));
    }

    // The proposed rerank stage was never landed, so these knobs remain inert.
    // `DefaultMemoryStrategy::new` logs the same fact at agent start; this
    // config-time copy reaches `config validate` and dashboard callers too.
    if memory.rerank_enabled {
        inert.push((
            "memory.rerank_enabled",
            "the rerank stage is not yet implemented",
        ));
    }
    if memory.rerank_threshold != default_rerank_threshold() {
        inert.push((
            "memory.rerank_threshold",
            "the rerank stage is not yet implemented",
        ));
    }

    inert
        .into_iter()
        .map(|(path, reason)| {
            crate::validation_warnings::ValidationWarning::new(
                "memory_config_knob_inert",
                format!("{path} is set but {reason}; this setting currently has no effect"),
                path,
            )
        })
        .collect()
}

/// Surface WhatsApp chat-policy keys that are accepted but never consulted.
///
/// `dm_policy`, `group_policy` and `self_chat_mode` are read only by the Web
/// transport inside its `mode == Personal` block, so under `mode = "business"`
/// they validate cleanly and have no effect. That is easy to miss because
/// `dm_policy` DEFAULTS to `Allowlist`: a business-mode Web channel reads as
/// restrictive while answering every DM it receives.
///
/// `allowed_groups` is separate. `is_group_chat_allowed` returns true when the
/// list is empty, and that gate does run in both modes, which makes an empty
/// list the only group protection under `mode = "business"` and an open one.
///
/// Warnings only, no behaviour change: an operator who is relying on the
/// current defaults keeps working, and learns about it at `config validate`.
///
/// Called from `Config::collect_warnings`, so this reaches the CLI and the
/// gateway dashboard on the same path as the other warnings.
pub fn validate_whatsapp_semantics(
    alias: &str,
    wa: &WhatsAppConfig,
) -> Vec<crate::validation_warnings::ValidationWarning> {
    let mut out = Vec::new();
    if !wa.enabled || !wa.is_web_config() {
        return out;
    }

    if wa.mode != WhatsAppWebMode::Personal {
        let mut inert: Vec<(&'static str, String)> = Vec::new();
        if wa.dm_policy != WhatsAppChatPolicy::All {
            inert.push((
                "dm_policy",
                "every direct message is answered regardless of this setting".to_string(),
            ));
        }
        if wa.group_policy != WhatsAppChatPolicy::All {
            inert.push((
                "group_policy",
                "group messages are not filtered by this setting".to_string(),
            ));
        }
        if wa.self_chat_mode {
            inert.push((
                "self_chat_mode",
                "self-chat handling is unchanged by this setting".to_string(),
            ));
        }
        for (key, effect) in inert {
            out.push(crate::validation_warnings::ValidationWarning::new(
                "whatsapp_chat_policy_inert",
                format!(
                    "channels.whatsapp.{alias}.{key} is set but the Web transport only \
                     consults it under mode = \"personal\"; as configured, {effect}"
                ),
                format!("channels.whatsapp.{alias}.{key}"),
            ));
        }
    }

    // An empty allowed_groups only creates UNINTENDED open access where the
    // effective policy would otherwise have consulted the list. Two personal-mode
    // configurations must stay quiet:
    //
    //   group_policy = "ignore"    the channel gate drops every group message
    //                              downstream, so nothing is permitted. Warning here
    //                              also told the operator to set exactly this, and
    //                              then kept firing after they did.
    //   group_policy = "all"       an explicit opt-in to open group access. Warning
    //                              here reports a deliberate choice as unsafe.
    //
    // So the warning applies to business mode (where the list is consulted no matter
    // what group_policy says) and to personal mode with group_policy = "allowlist"
    // (where an empty list is an allowlist that admits everything).
    let empty_list_permits_all = if wa.mode == WhatsAppWebMode::Personal {
        wa.group_policy == WhatsAppChatPolicy::Allowlist
    } else {
        true
    };

    if wa.allowed_groups.is_empty() && empty_list_permits_all {
        out.push(crate::validation_warnings::ValidationWarning::new(
            "whatsapp_empty_group_allowlist_permits_all",
            format!(
                "channels.whatsapp.{alias}.allowed_groups is empty, which permits EVERY \
                 group the linked account belongs to. List the group JIDs you intend to \
                 serve, or set group_policy = \"ignore\" under mode = \"personal\"."
            ),
            format!("channels.whatsapp.{alias}.allowed_groups"),
        ));
    }

    out
}

/// Write-time duplicate handling policy for memory entries.
#[derive(
    Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MemoryDedupAction {
    /// Reject an incoming near-duplicate.
    #[default]
    Reject,
    /// Merge an incoming near-duplicate into a survivor.
    Merge,
}

/// Memory budget eviction order.
#[derive(
    Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MemoryEvictOrder {
    /// Evict the lowest-value rows first.
    #[default]
    Value,
    /// Evict the oldest rows first.
    Oldest,
}

fn default_audit_retention_days() -> u32 {
    30
}

fn default_pgvector_dimensions() -> usize {
    1536
}

fn default_embedding_provider() -> String {
    "none".into()
}
fn default_auto_save() -> bool {
    true
}
fn default_hygiene_enabled() -> bool {
    true
}
fn default_archive_after_days() -> u32 {
    7
}
fn default_purge_after_days() -> u32 {
    30
}
fn default_conversation_retention_days() -> u32 {
    30
}
fn default_zero_retention() -> u32 {
    0
}
fn default_embedding_model() -> String {
    "text-embedding-3-small".into()
}
fn default_embedding_dims() -> usize {
    1536
}
fn default_vector_weight() -> f64 {
    0.7
}
fn default_keyword_weight() -> f64 {
    0.3
}
fn default_min_relevance_score() -> f64 {
    0.4
}
fn default_cache_size() -> usize {
    10_000
}
fn default_chunk_size() -> usize {
    512
}
fn default_response_cache_ttl() -> u32 {
    60
}
fn default_response_cache_max() -> usize {
    5_000
}

fn default_response_cache_hot_entries() -> usize {
    256
}

impl Default for MemoryConfig {
    fn default() -> Self {
        Self {
            backend: "sqlite".into(),
            auto_save: true,
            hygiene_enabled: default_hygiene_enabled(),
            consolidation_extract_facts: false,
            archive_after_days: default_archive_after_days(),
            purge_after_days: default_purge_after_days(),
            conversation_retention_days: default_conversation_retention_days(),
            daily_retention_days: default_zero_retention(),
            core_retention_days: default_zero_retention(),
            embedding_provider: default_embedding_provider(),
            embedding_model: default_embedding_model(),
            embedding_dimensions: default_embedding_dims(),
            auto_reindex_on_identity_change: false,
            embedding_api_key: None,
            vector_weight: default_vector_weight(),
            keyword_weight: default_keyword_weight(),
            search_mode: SearchMode::default(),
            min_relevance_score: default_min_relevance_score(),
            embedding_cache_size: default_cache_size(),
            chunk_max_tokens: default_chunk_size(),
            response_cache_enabled: false,
            response_cache_ttl_minutes: default_response_cache_ttl(),
            response_cache_max_entries: default_response_cache_max(),
            response_cache_hot_entries: default_response_cache_hot_entries(),
            snapshot_enabled: false,
            snapshot_on_hygiene: false,
            auto_hydrate: true,
            retrieval_stages: default_retrieval_stages(),
            candidate_multiplier: default_candidate_multiplier(),
            rerank_enabled: false,
            rerank_threshold: default_rerank_threshold(),
            rerank_strategy: default_rerank_strategy(),
            mmr_lambda: default_mmr_lambda(),
            importance_weight: default_importance_weight(),
            recency_weight: default_recency_weight(),
            fts_early_return_score: default_fts_early_return_score(),
            default_namespace: default_namespace(),
            conflict_threshold: default_conflict_threshold(),
            conflict_supersede_enabled: default_conflict_supersede_enabled(),
            dedup_on_write: false,
            dedup_jaccard_threshold: default_dedup_jaccard_threshold(),
            dedup_action: MemoryDedupAction::default(),
            core_max_rows: 0,
            core_max_bytes: 0,
            daily_max_rows: 0,
            evict_order: MemoryEvictOrder::default(),
            pin_namespaces: Vec::new(),
            pin_min_importance: default_pin_min_importance(),
            audit_enabled: false,
            audit_retention_days: default_audit_retention_days(),
            policy: MemoryPolicyConfig::default(),
            types: MemoryTypesConfig::default(),
        }
    }
}

// ── Observability ─────────────────────────────────────────────────

/// Observability sink backend.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ObservabilityBackend {
    #[default]
    #[serde(alias = "noop")]
    None,
    Log,
    Verbose,
    Prometheus,
    #[serde(alias = "opentelemetry", alias = "otlp")]
    Otel,
}

impl ObservabilityBackend {
    #[must_use]
    pub fn as_wire(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::Log => "log",
            Self::Verbose => "verbose",
            Self::Prometheus => "prometheus",
            Self::Otel => "otel",
        }
    }
}

/// JSONL log persistence mode.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LogPersistence {
    None,
    #[default]
    Rolling,
    Full,
    /// Persist all events, but with ZeroClaw-managed archive rotation: the
    /// active file is rotated to a timestamped archive on a size and/or daily
    /// boundary, and old archives are pruned by count and/or age. Unlike
    /// `rolling` (entry-count trim of the active file), rotated events are
    /// preserved in archive files for later diagnostics. Rotation is tuned via
    /// the `log_persistence_max_bytes`, `log_persistence_rotate_daily`,
    /// `log_persistence_retention_max_files`, and
    /// `log_persistence_retention_max_age_days` settings.
    Rotating,
}

impl LogPersistence {
    #[must_use]
    pub fn as_wire(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::Rolling => "rolling",
            Self::Full => "full",
            Self::Rotating => "rotating",
        }
    }
}

/// Tool I/O capture policy.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LogToolIo {
    Off,
    #[default]
    Redacted,
    Full,
}

impl LogToolIo {
    #[must_use]
    pub fn as_wire(self) -> &'static str {
        match self {
            Self::Off => "off",
            Self::Redacted => "redacted",
            Self::Full => "full",
        }
    }
}

/// LLM request payload capture policy. Mirrors [`LogToolIo`] but gates the
/// prompt/messages sent on each `llm_request`. Defaults to `Off` because the
/// full payload is the entire system prompt plus conversation every turn.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LogLlmRequestPayload {
    #[default]
    Off,
    Redacted,
    Full,
}

impl LogLlmRequestPayload {
    #[must_use]
    pub fn as_wire(self) -> &'static str {
        match self {
            Self::Off => "off",
            Self::Redacted => "redacted",
            Self::Full => "full",
        }
    }
}

/// OTel content capture policy. Mirrors [`LogToolIo`] but gates OTel span
/// attribute emission, not log persistence. Defaults to `Off` for privacy.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OtelContentPolicy {
    #[default]
    Off,
    Redacted,
    Full,
}

impl OtelContentPolicy {
    #[must_use]
    pub fn as_wire(self) -> &'static str {
        match self {
            Self::Off => "off",
            Self::Redacted => "redacted",
            Self::Full => "full",
        }
    }
}

/// Observability backend configuration (`[observability]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "observability"]
pub struct ObservabilityConfig {
    /// Observability sink: none | log | verbose | prometheus | otel.
    #[serde(default, deserialize_with = "deserialize_enum_lenient")]
    pub backend: ObservabilityBackend,

    /// OTLP endpoint (e.g. `"http://localhost:4318"`). Only used when backend = `"otel"`.
    #[serde(default)]
    pub otel_endpoint: Option<String>,

    /// Service name reported to the OTel collector. Defaults to "zeroclaw".
    #[serde(default)]
    pub otel_service_name: Option<String>,

    /// Optional HTTP headers sent with every OTLP export request (e.g. authorization).
    /// Specified as key-value pairs in TOML:
    /// ```toml
    /// [observability.otel_headers]
    /// Authorization = "Bearer sk-..."
    /// ```
    #[serde(default)]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub otel_headers: Option<std::collections::HashMap<String, String>>,

    /// Log persistence mode: "none" | "rolling" | "full".
    /// Controls whether every event passing through `zeroclaw_log::record!`
    /// is appended to the on-disk JSONL log.
    #[serde(
        default = "default_log_persistence",
        alias = "runtime_trace_mode",
        deserialize_with = "deserialize_enum_lenient"
    )]
    pub log_persistence: LogPersistence,

    /// Log persistence file path. Relative paths resolve under workspace_dir.
    #[serde(default = "default_log_persistence_path", alias = "runtime_trace_path")]
    pub log_persistence_path: String,

    /// Maximum entries retained when `log_persistence = "rolling"`.
    #[serde(
        default = "default_log_persistence_max_entries",
        alias = "runtime_trace_max_entries"
    )]
    pub log_persistence_max_entries: usize,

    /// Size threshold in bytes that triggers an archive rotation when
    /// `log_persistence = "rotating"`. The active file is rotated once an
    /// append leaves it at or above this size. `0` disables size-based
    /// rotation. Ignored unless `log_persistence = "rotating"`.
    #[serde(default = "default_log_persistence_max_bytes")]
    pub log_persistence_max_bytes: u64,

    /// Rotate the active file to an archive on a UTC day boundary when
    /// `log_persistence = "rotating"`: before the first event of a new day is
    /// appended, a file whose last write fell on an earlier day is archived.
    /// Ignored unless `log_persistence = "rotating"`.
    #[serde(default = "default_log_persistence_rotate_daily")]
    pub log_persistence_rotate_daily: bool,

    /// Retention cap on the number of rotated archive files kept alongside the
    /// active file when `log_persistence = "rotating"`. After a rotation the
    /// oldest archives beyond this count are deleted. `0` keeps all archives.
    /// Ignored unless `log_persistence = "rotating"`.
    #[serde(default = "default_log_persistence_retention_max_files")]
    pub log_persistence_retention_max_files: usize,

    /// Retention cap on the age (in days) of rotated archive files when
    /// `log_persistence = "rotating"`. After a rotation, archives older than
    /// this many days are deleted. `0` disables age-based cleanup. Ignored
    /// unless `log_persistence = "rotating"`.
    #[serde(default = "default_log_persistence_retention_max_age_days")]
    pub log_persistence_retention_max_age_days: u64,

    /// Tool I/O capture policy: "off" | "redacted" | "full".
    /// - `off`: only tool name + outcome + duration land in the log.
    /// - `redacted` (default): tool input + output are leak-scanned and
    ///   truncated at `log_tool_io_truncate_bytes` before persisting.
    /// - `full`: full input + output, still leak-scanned. For operators
    ///   who need replay fidelity and accept the disk cost.
    #[serde(
        default = "default_log_tool_io",
        deserialize_with = "deserialize_enum_lenient"
    )]
    pub log_tool_io: LogToolIo,

    /// Truncate the captured tool input and output at this many bytes when
    /// `log_tool_io = "redacted"`. Truncated events carry an explicit
    /// `tool_output_truncated: true` flag plus `tool_output_original_bytes`.
    #[serde(default = "default_log_tool_io_truncate_bytes")]
    pub log_tool_io_truncate_bytes: usize,

    /// Tool names whose I/O is never logged beyond name + outcome + duration
    /// regardless of `log_tool_io`. Use for tools whose I/O is intrinsically
    /// sensitive (e.g. memory recall against personal namespaces, agent
    /// secret reads). Empty by default.
    #[serde(default)]
    pub log_tool_io_denylist: Vec<String>,

    /// LLM request payload capture: "off" | "redacted" | "full".
    /// - `off` (default): only `messages_count` lands on the `llm_request`
    ///   event. No prompt or conversation content is persisted.
    /// - `redacted`: the full message history (role + content) is leak-scanned
    ///   and truncated at `log_tool_io_truncate_bytes` before persisting.
    /// - `full`: full message history, still leak-scanned, untruncated. For
    ///   operators who need replay fidelity and accept the disk cost.
    ///
    /// Opt-in (default off) because `full`/`redacted` capture the entire system
    /// prompt and conversation on every turn.
    #[serde(
        default = "default_log_llm_request_payload",
        deserialize_with = "deserialize_enum_lenient"
    )]
    pub log_llm_request_payload: LogLlmRequestPayload,

    /// OTel GenAI content capture: "off" | "redacted" | "full".
    /// Controls whether `gen_ai.system_instructions`, `gen_ai.input.messages`,
    /// and `gen_ai.output.messages` are emitted on OTel spans.
    /// - `off` (default): no content attributes, only metadata.
    /// - `redacted`: content is leak-scanned and truncated at `otel_genai_content_max_chars`.
    /// - `full`: content is leak-scanned but not truncated.
    #[serde(default, deserialize_with = "deserialize_enum_lenient")]
    pub otel_genai_content: OtelContentPolicy,

    /// Per-field character truncation limit for OTel GenAI content when
    /// `otel_genai_content = "redacted"`. Each string field is truncated
    /// independently. `0` is treated as `off`.
    #[serde(default = "default_otel_genai_content_max_chars")]
    pub otel_genai_content_max_chars: usize,

    /// OTel tool I/O capture: "off" | "redacted" | "full".
    /// Controls whether `gen_ai.tool.arguments`, `input.value`,
    /// `gen_ai.tool.result`, and `output.value` are emitted on OTel spans.
    /// - `off` (default): no content attributes, only tool name + outcome.
    /// - `redacted`: content is leak-scanned and truncated at `otel_tool_io_max_chars`.
    /// - `full`: content is leak-scanned but not truncated.
    #[serde(default, deserialize_with = "deserialize_enum_lenient")]
    pub otel_tool_io: OtelContentPolicy,

    /// Per-field character truncation limit for OTel tool I/O when
    /// `otel_tool_io = "redacted"`. Each string field is truncated
    /// independently. `0` is treated as `off`.
    #[serde(default = "default_otel_tool_io_max_chars")]
    pub otel_tool_io_max_chars: usize,
}

impl Default for ObservabilityConfig {
    fn default() -> Self {
        Self {
            backend: ObservabilityBackend::None,
            otel_endpoint: None,
            otel_service_name: None,
            otel_headers: None,
            log_persistence: default_log_persistence(),
            log_persistence_path: default_log_persistence_path(),
            log_persistence_max_entries: default_log_persistence_max_entries(),
            log_persistence_max_bytes: default_log_persistence_max_bytes(),
            log_persistence_rotate_daily: default_log_persistence_rotate_daily(),
            log_persistence_retention_max_files: default_log_persistence_retention_max_files(),
            log_persistence_retention_max_age_days: default_log_persistence_retention_max_age_days(
            ),
            log_tool_io: default_log_tool_io(),
            log_tool_io_truncate_bytes: default_log_tool_io_truncate_bytes(),
            log_tool_io_denylist: Vec::new(),
            log_llm_request_payload: default_log_llm_request_payload(),
            otel_genai_content: OtelContentPolicy::Off,
            otel_genai_content_max_chars: default_otel_genai_content_max_chars(),
            otel_tool_io: OtelContentPolicy::Off,
            otel_tool_io_max_chars: default_otel_tool_io_max_chars(),
        }
    }
}

fn default_log_persistence() -> LogPersistence {
    LogPersistence::Rolling
}

fn default_log_persistence_path() -> String {
    "state/runtime-trace.jsonl".to_string()
}

fn default_log_persistence_max_entries() -> usize {
    200
}

/// Size rotation off by default; operators opt in with an explicit byte budget.
fn default_log_persistence_max_bytes() -> u64 {
    0
}

/// Daily rotation is on by default so `rotating` mode produces day-scoped
/// archives out of the box even without a configured size budget.
fn default_log_persistence_rotate_daily() -> bool {
    true
}

/// Keep a week of rotated archives by default.
fn default_log_persistence_retention_max_files() -> usize {
    7
}

/// Age-based cleanup off by default; count-based retention governs unless set.
fn default_log_persistence_retention_max_age_days() -> u64 {
    0
}

fn default_log_tool_io() -> LogToolIo {
    LogToolIo::Redacted
}

fn default_log_llm_request_payload() -> LogLlmRequestPayload {
    LogLlmRequestPayload::Off
}

fn default_log_tool_io_truncate_bytes() -> usize {
    40960
}

fn default_otel_genai_content_max_chars() -> usize {
    1000
}

fn default_otel_tool_io_max_chars() -> usize {
    1000
}

// ── Hooks ────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "hooks"]
pub struct HooksConfig {
    /// Enable lifecycle hook execution.
    ///
    /// Hooks run in-process with the same privileges as the main runtime.
    /// Keep enabled hook handlers narrowly scoped and auditable.
    #[serde(default = "default_true")]
    pub enabled: bool,
    #[serde(default)]
    #[nested]
    pub builtin: BuiltinHooksConfig,
}

impl Default for HooksConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            builtin: BuiltinHooksConfig::default(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "hooks.builtin"]
pub struct BuiltinHooksConfig {
    /// Enable the command-logger hook (logs tool calls for auditing).
    #[serde(default)]
    pub command_logger: bool,
    /// Configuration for the webhook-audit hook.
    ///
    /// When enabled, POSTs a JSON payload to `url` for every tool invocation
    /// that matches one of `tool_patterns`.
    #[serde(default)]
    #[nested]
    pub webhook_audit: WebhookAuditConfig,
}

/// Configuration for the webhook-audit builtin hook.
///
/// Sends an HTTP POST with a JSON body to an external endpoint each time
/// a tool call matches one of the configured patterns. Useful for
/// centralised audit logging, SIEM ingestion, or compliance pipelines.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "hooks.builtin.webhook_audit"]
pub struct WebhookAuditConfig {
    /// Enable the webhook-audit hook. Default: `false`.
    #[serde(default)]
    pub enabled: bool,
    /// Target URL that will receive the audit POST requests.
    #[serde(default)]
    pub url: String,
    /// Glob patterns for tool names to audit (e.g. `["Bash", "Write"]`).
    /// An empty list means **no** tools are audited.
    #[serde(default)]
    pub tool_patterns: Vec<String>,
    /// Include tool call arguments in the audit payload. Default: `false`.
    ///
    /// Be mindful of sensitive data — arguments may contain secrets or PII.
    #[serde(default)]
    pub include_args: bool,
    /// Maximum size (in bytes) of serialised arguments included in a single
    /// audit payload. Arguments exceeding this limit are truncated.
    /// Default: `4096`.
    #[serde(default = "default_max_args_bytes")]
    pub max_args_bytes: u64,
}

fn default_max_args_bytes() -> u64 {
    4096
}

impl Default for WebhookAuditConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            url: String::new(),
            tool_patterns: Vec::new(),
            include_args: false,
            max_args_bytes: default_max_args_bytes(),
        }
    }
}

// ── Autonomy / Security ──────────────────────────────────────────
//
// All policy fields live on per-agent `[risk_profiles.<alias>]` entries
// (see `RiskProfileConfig` below). `Config::active_risk_profile(agent_alias)`
// resolves the active profile for any callsite (agent-driven or non-agent
// contexts). Configs from older schema versions are folded into
// `risk_profiles.default` by the migration in `schema/v2.rs`.

pub fn default_auto_approve() -> Vec<String> {
    vec![
        "file_read".into(),
        "memory_recall".into(),
        "web_search_tool".into(),
        "web_fetch".into(),
        "calculator".into(),
        "glob_search".into(),
        "content_search".into(),
        "image_info".into(),
        "weather".into(),
        "tool_search".into(),
        "browser".into(),
        "browser_open".into(),
    ]
}

fn default_always_ask() -> Vec<String> {
    vec![]
}

impl RiskProfileConfig {
    /// Merge the built-in default `auto_approve` entries into the current
    /// list, preserving any user-supplied additions.
    pub fn ensure_default_auto_approve(&mut self) {
        let defaults = default_auto_approve();
        for entry in defaults {
            if !self.auto_approve.iter().any(|existing| existing == &entry) {
                self.auto_approve.push(entry);
            }
        }
    }

    /// Synthesize a [`SandboxConfig`] from this profile's flattened sandbox
    /// fields. Sandbox config is stored flat on the profile; callsites that
    /// still want a `SandboxConfig` instance (sandbox detection in
    /// `zeroclaw-runtime::security::detect`) can call this helper.
    #[must_use]
    pub fn sandbox_config(&self) -> SandboxConfig {
        let backend = self
            .sandbox_backend
            .as_deref()
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(parse_sandbox_backend)
            .unwrap_or_default();
        SandboxConfig {
            enabled: self.sandbox_enabled,
            backend,
            firejail_args: self.firejail_args.clone(),
        }
    }
}

fn parse_sandbox_backend(name: &str) -> SandboxBackend {
    match name.to_ascii_lowercase().as_str() {
        "auto" => SandboxBackend::Auto,
        "landlock" => SandboxBackend::Landlock,
        "firejail" => SandboxBackend::Firejail,
        "bubblewrap" => SandboxBackend::Bubblewrap,
        "docker" => SandboxBackend::Docker,
        "sandbox-exec" | "sandboxexec" | "seatbelt" => SandboxBackend::SandboxExec,
        "none" => SandboxBackend::None,
        _ => SandboxBackend::default(),
    }
}

fn is_valid_env_var_name(name: &str) -> bool {
    let mut chars = name.chars();
    match chars.next() {
        Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
        _ => return false,
    }
    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}

// ── Profiles & Bundles ───────────────────────────────────────────

/// Named risk/autonomy profile (`[risk_profiles.<alias>]`).
///
/// Unified policy surface. Agents reference a profile by alias and the
/// runtime resolves through it for shell command allowlists, approval gates,
/// sandbox/resource limits, and delegation guardrails. The conventional
/// `risk_profiles["default"]` is the resolution target for non-agent
/// contexts (orchestrator init, cron worker startup); the `Default` impl
/// below mirrors the legacy safety-first defaults so a fresh install
/// behaves the same as a config from before the per-profile split.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "risk_profile"]
#[serde(default)]
pub struct RiskProfileConfig {
    /// Autonomy level applied to this profile. Default: `supervised`.
    pub level: AutonomyLevel,
    /// Restrict filesystem access to workspace-relative paths. Default: `false`.
    pub workspace_only: bool,
    /// Allowlist of executable names for shell execution.
    pub allowed_commands: Vec<String>,
    /// Explicit path denylist.
    pub forbidden_paths: Vec<String>,
    /// Require approval for medium-risk operations.
    pub require_approval_for_medium_risk: bool,
    /// Block high-risk commands even when allowlisted.
    pub block_high_risk_commands: bool,
    /// Environment variable names passed through to shell subprocesses.
    #[credential_class = "legacy_env_path"]
    pub shell_env_passthrough: Vec<String>,
    /// Tools that never require approval in this profile.
    pub auto_approve: Vec<String>,
    /// Tools that always require approval in this profile.
    pub always_ask: Vec<String>,
    /// Extra directory roots the agent may access.
    #[serde(alias = "allowed_path", alias = "allowed_paths")]
    pub allowed_roots: Vec<String>,
    /// Whether agents using this profile may initiate delegation. Defaults to
    /// `Forbidden`. Reachable delegate targets and bounded/independent mode
    /// are resolved from each caller agent's config.
    #[serde(default)]
    #[nested]
    pub delegation_policy: DelegationPolicy,
    /// Route this profile's tool approvals to a DISTINCT approver channel instead of the
    /// channel that triggered the run (closes the cross-channel-HITL gap). Absent ⇒ the
    /// originating channel approves (today's behavior). See [`crate::autonomy::ApprovalRoute`].
    ///
    /// Honored on both the interactive channel-driven path and the non-interactive turn path
    /// (gateway chat/webhook dispatch and agent-to-agent peer messages). On the non-interactive
    /// path the approver is resolved from the live daemon channel registry; if no registry is
    /// available or the approver is not live, the gate keeps the non-interactive default
    /// (fail-closed deny under the default `on_no_approver`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval_route: Option<crate::autonomy::ApprovalRoute>,
    /// Tools the agent may call in agentic mode. Empty = inherit / no
    /// authorization constraint. Authorization decision: which tools is
    /// the agent permitted to invoke at all. See `excluded_tools` for
    /// the inverse denylist scoped to non-CLI channels.
    ///
    /// The TOML config does not distinguish an omitted field from
    /// `allowed_tools = []`; both deserialize to `Vec::new()` and
    /// `SecurityPolicy::from_profiles` maps that to "no authorization
    /// constraint" at this layer. If you need an explicit deny-all gate,
    /// apply it on the caller-supplied per-run `allowed_tools` (cron
    /// jobs and other narrowers pass that list in directly to
    /// `ToolAccessPolicy`, which honors `Some(vec![])` as deny-all) or
    /// via `excluded_tools` covering the specific tools you want blocked.
    ///
    /// MCP exception: when the list is non-empty, runtime-discovered MCP
    /// tools (any name containing `__`, which is the `<server>__<tool>`
    /// convention used by the MCP wrapper) are auto-admitted into the
    /// effective allow-list without needing to be listed here individually.
    /// This keeps the post-change eager-MCP default usable for agents with an
    /// explicit allow-list. Block individual MCP tools via `excluded_tools`.
    ///
    /// Scope of the exception: the `__` auto-admit applies only to this
    /// risk-profile allow-list, **not** to caller-supplied per-run
    /// `allowed_tools` (cron job `allowed_tools`, narrowed delegate
    /// invocations, etc.). Per-run lists are still strict explicit-list
    /// intersections, so a job that narrows `allowed_tools = ["cron_add"]`
    /// will not see runtime-discovered MCP tools unless it names them.
    ///
    pub allowed_tools: Vec<String>,
    /// Tools excluded from non-CLI channels under this profile.
    ///
    /// Also subtracts from the agentic-delegate allow-list resolved at
    /// runtime, which is the only way to block individual
    /// `<server>__<tool>` MCP names that would otherwise be auto-admitted
    /// by the `allowed_tools` MCP exception described above.
    pub excluded_tools: Vec<String>,
    // ── Sandbox (from security.sandbox) ─────────────────────────────
    /// Whether the sandbox is enabled for this profile. `None` inherits global.
    pub sandbox_enabled: Option<bool>,
    /// Sandbox backend identifier (e.g. `"firejail"`, `"landlock"`). `None` inherits.
    pub sandbox_backend: Option<String>,
    /// Extra arguments forwarded to firejail when sandbox_backend = "firejail".
    pub firejail_args: Vec<String>,
}

impl Default for RiskProfileConfig {
    fn default() -> Self {
        Self {
            level: AutonomyLevel::Supervised,
            workspace_only: true,
            allowed_commands: crate::policy::default_allowed_commands(),
            forbidden_paths: crate::policy::default_forbidden_paths(),
            require_approval_for_medium_risk: true,
            block_high_risk_commands: true,
            shell_env_passthrough: vec![],
            auto_approve: default_auto_approve(),
            always_ask: default_always_ask(),
            allowed_roots: Vec::new(),
            delegation_policy: DelegationPolicy::default(),
            approval_route: None,
            allowed_tools: Vec::new(),
            excluded_tools: Vec::new(),
            sandbox_enabled: None,
            sandbox_backend: None,
            firejail_args: Vec::new(),
        }
    }
}

/// Named runtime/LLM execution profile (`[runtime_profiles.<alias>]`).
///
/// Reusable operational tuning: agentic mode, iteration caps, context
/// budget, parallel dispatch, resource ceilings, recursion depth, and
/// the budget knobs that `SecurityPolicy` enforces with subagent
/// parent-subset discipline. Anything authorization-shaped (allowed
/// commands/tools/paths, approval gates, sandbox) lives on
/// `[risk_profiles.<alias>]`. Anything model-provider shaped (model,
/// temperature, max_tokens, timeout_secs) lives on
/// `[providers.models.<type>.<alias>]`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "runtime_profile"]
#[serde(default)]
pub struct RuntimeProfileConfig {
    /// Enable agentic (multi-turn tool-call loop) mode.
    pub agentic: bool,
    /// Maximum tool-call iterations in agentic mode. `0` inherits the global default.
    pub max_tool_iterations: usize,
    // ── Budget caps (enforced with subagent parent-subset discipline) ──
    /// Maximum actions allowed per hour. `0` is a hard zero budget — the
    /// per-sender rate tracker treats a max of 0 as always exhausted
    /// (`PerSenderTracker::is_exhausted`), blocking every action. For an
    /// effectively-unlimited budget use a high value (e.g. `u32::MAX`), not 0.
    /// `SecurityPolicy::ensure_no_escalation_beyond` rejects subagents
    /// that try to raise this above the parent's value.
    pub max_actions_per_hour: u32,
    /// Maximum cost per day in cents. `0` inherits the global limit.
    /// Parent-subset enforced for subagents.
    pub max_cost_per_day_cents: u32,
    /// Shell subprocess timeout in seconds. `0` inherits the global timeout.
    /// Parent-subset enforced for subagents.
    pub shell_timeout_secs: u64,
    // ── Delegation tuning ──
    /// Maximum delegation recursion depth. `0` inherits the default.
    pub max_delegation_depth: u32,
    /// Delegate call timeout in seconds. `None` inherits global delegate timeout.
    pub delegation_timeout_secs: Option<u64>,
    /// Agentic delegate run timeout in seconds. `None` inherits global.
    pub agentic_timeout_secs: Option<u64>,
    // ── Per-agent runtime tunables (also live on AliasedAgentConfig) ─
    /// Maximum conversation history messages retained per session. `None` inherits.
    pub max_history_messages: Option<usize>,
    /// Maximum estimated tokens for context before compaction. `None` inherits.
    pub max_context_tokens: Option<usize>,
    /// Use compact bootstrap (6000 chars / 2 RAG chunks). `None` inherits.
    pub compact_context: Option<bool>,
    /// Enable parallel tool execution per iteration. `None` inherits.
    pub parallel_tools: Option<bool>,
    /// Tool dispatch strategy (e.g. `"auto"`). `None` inherits.
    pub tool_dispatcher: Option<String>,
    /// Tools exempt from within-turn dedup check.
    pub tool_call_dedup_exempt: Vec<String>,
    /// Maximum characters for the assembled system prompt. `None` inherits.
    pub max_system_prompt_chars: Option<usize>,
    /// Maximum characters for a single tool result. `None` inherits.
    pub max_tool_result_chars: Option<usize>,
    /// Number of recent turns whose full tool context is preserved. `None` inherits.
    pub keep_tool_context_turns: Option<usize>,
    /// Maximum memory entries injected per turn. `None` inherits global default (5).
    /// Set to `0` for unlimited.
    pub memory_recall_limit: Option<usize>,
    /// How skills are injected into the system prompt for agents on this
    /// profile. `None` inherits the global `[skills] prompt_injection_mode`;
    /// `compact` inlines only compact skill metadata and registers the
    /// `read_skill` tool, `full` inlines full skill instructions. Resolved
    /// through [`Config::effective_skills_prompt_mode`], the single point both
    /// the system-prompt builder and the `read_skill` tool-registration gate
    /// consult so they always agree on the effective mode.
    pub prompt_injection_mode: Option<SkillsPromptInjectionMode>,
    pub strict_tool_parsing: bool,
    #[nested]
    pub thinking: crate::scattered_types::ThinkingConfig,
    #[nested]
    pub history_pruning: crate::scattered_types::HistoryPrunerConfig,
    #[nested]
    pub eval: crate::scattered_types::EvalConfig,
    #[nested]
    pub auto_classify: Option<crate::scattered_types::AutoClassifyConfig>,
    #[nested]
    pub context_compression: crate::scattered_types::ContextCompressionConfig,
    #[nested]
    pub tool_receipts: ToolReceiptsConfig,
    pub tool_filter_groups: Vec<ToolFilterGroup>,
}

impl Default for RuntimeProfileConfig {
    fn default() -> Self {
        Self {
            agentic: false,
            max_tool_iterations: 0,
            max_actions_per_hour: 20,
            max_cost_per_day_cents: 500,
            shell_timeout_secs: 60,
            max_delegation_depth: 0,
            delegation_timeout_secs: None,
            agentic_timeout_secs: None,
            max_history_messages: None,
            max_context_tokens: None,
            compact_context: None,
            parallel_tools: None,
            tool_dispatcher: None,
            tool_call_dedup_exempt: Vec::new(),
            max_system_prompt_chars: None,
            max_tool_result_chars: None,
            keep_tool_context_turns: None,
            memory_recall_limit: None,
            prompt_injection_mode: None,
            strict_tool_parsing: false,
            thinking: crate::scattered_types::ThinkingConfig::default(),
            history_pruning: crate::scattered_types::HistoryPrunerConfig::default(),
            eval: crate::scattered_types::EvalConfig::default(),
            auto_classify: None,
            context_compression: crate::scattered_types::ContextCompressionConfig::default(),
            tool_receipts: ToolReceiptsConfig::default(),
            tool_filter_groups: Vec::new(),
        }
    }
}

/// Named skill bundle (`[skill_bundles.<alias>]`).
///
/// A reusable group of skills that can be attached to an agent or channel
/// by alias, controlling which skills are loaded and from where.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "skill_bundle"]
#[serde(default)]
pub struct SkillBundleConfig {
    /// Directory path (relative to workspace root) to load skills from.
    pub directory: Option<String>,
    /// Skill names to include. Empty means include all skills in `directory`.
    pub include: Vec<String>,
    /// Skill names to exclude from this bundle.
    pub exclude: Vec<String>,
}

impl SkillBundleConfig {
    /// Whether a skill name survives this bundle's include/exclude filter.
    /// Empty `include` admits every name; `exclude` always wins.
    pub fn admits_skill(&self, name: &str) -> bool {
        if !self.include.is_empty() && !self.include.iter().any(|s| s == name) {
            return false;
        }
        !self.exclude.iter().any(|s| s == name)
    }
}

/// Named knowledge bundle (`[knowledge_bundles.<alias>]`).
///
/// A reusable set of knowledge sources (documents, URLs, or RAG corpus paths)
/// that can be attached to an agent by alias.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "knowledge_bundle"]
#[serde(default)]
pub struct KnowledgeBundleConfig {
    /// Paths or URLs to include in this knowledge bundle.
    pub sources: Vec<String>,
    /// Tags for filtering or categorising sources within the bundle.
    pub tags: Vec<String>,
}

/// Named MCP server bundle (`[mcp_bundles.<alias>]`).
///
/// A reusable group of MCP servers granted to an agent that references the
/// bundle by alias in `agents.<alias>.mcp_bundles`. Server IDs are matched
/// against `[mcp.servers]` by `name`. Resolution is secure by default (see
/// `Config::mcp_servers_for_bundles`): an ID with no matching server grants
/// nothing, and `exclude` wins over `servers` across every bundle an agent
/// references.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "mcp_bundle"]
#[serde(default)]
pub struct McpBundleConfig {
    /// MCP server IDs (`[mcp.servers].name`) granted by this bundle.
    pub servers: Vec<String>,
    /// MCP server IDs removed from the grant. Deny wins: a name listed here is
    /// excluded even if another referenced bundle includes it.
    pub exclude: Vec<String>,
}

// ── Runtime ──────────────────────────────────────────────────────

/// Runtime adapter kind.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeKind {
    #[default]
    Native,
    Docker,
    Cloudflare,
}

impl RuntimeKind {
    #[must_use]
    pub fn as_wire(self) -> &'static str {
        match self {
            Self::Native => "native",
            Self::Docker => "docker",
            Self::Cloudflare => "cloudflare",
        }
    }
}

/// Runtime adapter configuration (`[runtime]` section).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "runtime"]
pub struct RuntimeConfig {
    /// Runtime kind: native | docker | cloudflare.
    #[serde(default, deserialize_with = "deserialize_enum_lenient")]
    pub kind: RuntimeKind,

    /// Docker runtime settings (used when `kind = "docker"`).
    #[serde(default)]
    #[nested]
    pub docker: DockerRuntimeConfig,

    /// Shell binary the native runtime uses for command execution.
    ///
    /// Applies only to `runtime.kind = "native"`; other runtimes ignore it.
    /// When unset or `null`, the system default `sh` is used. The shell is
    /// invoked as `<shell> -c "<command>"`, so it must be a POSIX-compatible
    /// shell binary.
    ///
    /// Accepted forms (Unix):
    /// - a bare command name resolved via `PATH` (e.g. `"bash"`), or
    /// - an absolute path (e.g. `"/bin/bash"`, `"/usr/bin/zsh"`).
    ///
    /// The value is validated when the native runtime is constructed, so a bad
    /// value is reported up front rather than failing on the first shell
    /// command. Rejected: empty/whitespace; a relative path with separators
    /// (e.g. `"./sh"`, `"bin/sh"` — use a bare `PATH` name or an absolute path
    /// instead); a bare name not found on `PATH`; and a path that does not
    /// exist or is not executable.
    ///
    /// **Ignored on Windows and Android** (and not validated there): Windows
    /// always uses `cmd.exe`, and Android always uses `/system/bin/sh`
    /// (its shell is not on `PATH` for spawned processes).
    ///
    /// **Examples:**
    /// ```toml
    /// [runtime]
    /// shell = "bash" # resolves via PATH
    /// shell = "/bin/zsh" # absolute path
    /// ```
    #[serde(default)]
    pub shell: Option<String>,

    /// Global reasoning override for model_providers that expose explicit controls.
    /// - `None`: model_provider default behavior
    /// - `Some(true)`: request reasoning/thinking when supported
    /// - `Some(false)`: disable reasoning/thinking when supported
    #[serde(default)]
    pub reasoning_enabled: Option<bool>,
    /// Optional reasoning effort for model_providers that expose a level control.
    #[serde(default, deserialize_with = "deserialize_reasoning_effort_opt")]
    pub reasoning_effort: Option<String>,
}

/// Docker runtime configuration (`[runtime.docker]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "runtime.docker"]
pub struct DockerRuntimeConfig {
    /// Runtime image used to execute shell commands.
    #[serde(default = "default_docker_image")]
    pub image: String,

    /// Docker network mode (`none`, `bridge`, etc.).
    #[serde(default = "default_docker_network")]
    pub network: String,

    /// Optional memory limit in MB (`None` = no explicit limit).
    #[serde(default = "default_docker_memory_limit_mb")]
    pub memory_limit_mb: Option<u64>,

    /// Optional CPU limit (`None` = no explicit limit).
    #[serde(default = "default_docker_cpu_limit")]
    pub cpu_limit: Option<f64>,

    /// Mount root filesystem as read-only.
    #[serde(default = "default_true")]
    pub read_only_rootfs: bool,

    /// Mount configured workspace into `/workspace`.
    #[serde(default = "default_true")]
    pub mount_workspace: bool,

    /// Optional workspace root allowlist for Docker mount validation.
    #[serde(default)]
    pub allowed_workspace_roots: Vec<String>,
}

fn default_docker_image() -> String {
    "alpine:3.20".into()
}

fn default_docker_network() -> String {
    "none".into()
}

fn default_docker_memory_limit_mb() -> Option<u64> {
    Some(512)
}

fn default_docker_cpu_limit() -> Option<f64> {
    Some(1.0)
}

impl Default for DockerRuntimeConfig {
    fn default() -> Self {
        Self {
            image: default_docker_image(),
            network: default_docker_network(),
            memory_limit_mb: default_docker_memory_limit_mb(),
            cpu_limit: default_docker_cpu_limit(),
            read_only_rootfs: true,
            mount_workspace: true,
            allowed_workspace_roots: Vec::new(),
        }
    }
}

// ── Reliability / supervision ────────────────────────────────────

/// Reliability and supervision configuration (`[reliability]` section).
///
/// Controls model_provider retries, API key rotation, and channel restart backoff.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "reliability"]
pub struct ReliabilityConfig {
    /// Retries per model_provider before bailing.
    #[serde(default = "default_provider_retries")]
    pub provider_retries: u32,
    /// Base backoff (ms) for model_provider retry delay.
    #[serde(default = "default_provider_backoff_ms")]
    pub provider_backoff_ms: u64,
    /// Additional API keys for round-robin rotation on rate-limit (429) errors.
    /// The primary `api_key` is always tried first; these are extras.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_keys: Vec<String>,
    /// Initial backoff for channel/daemon restarts.
    #[serde(default = "default_channel_backoff_secs")]
    pub channel_initial_backoff_secs: u64,
    /// Max backoff for channel/daemon restarts.
    #[serde(default = "default_channel_backoff_max_secs")]
    pub channel_max_backoff_secs: u64,
    /// Scheduler polling cadence in seconds.
    #[serde(default = "default_scheduler_poll_secs")]
    pub scheduler_poll_secs: u64,
    /// Max retries for cron job execution attempts.
    #[serde(default = "default_scheduler_retries")]
    pub scheduler_retries: u32,
}

fn default_provider_retries() -> u32 {
    2
}

fn default_provider_backoff_ms() -> u64 {
    500
}

fn default_channel_backoff_secs() -> u64 {
    2
}

fn default_channel_backoff_max_secs() -> u64 {
    60
}

fn default_scheduler_poll_secs() -> u64 {
    15
}

fn default_scheduler_retries() -> u32 {
    2
}

impl Default for ReliabilityConfig {
    fn default() -> Self {
        Self {
            provider_retries: default_provider_retries(),
            provider_backoff_ms: default_provider_backoff_ms(),
            api_keys: Vec::new(),
            channel_initial_backoff_secs: default_channel_backoff_secs(),
            channel_max_backoff_secs: default_channel_backoff_max_secs(),
            scheduler_poll_secs: default_scheduler_poll_secs(),
            scheduler_retries: default_scheduler_retries(),
        }
    }
}

// ── Scheduler ────────────────────────────────────────────────────

/// Scheduler configuration for periodic task execution (`[scheduler]` section).
///
/// Owns the cron-runtime knobs: per-job declarations live on
/// `Config.cron: HashMap<String, CronJobDecl>` (alias-keyed), while the
/// scheduler loop's runtime behavior (`enabled`, polling cap, catch-up) lives here.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "scheduler"]
pub struct SchedulerConfig {
    /// Enable the built-in scheduler loop. When false, no cron jobs run.
    #[serde(default = "default_scheduler_enabled")]
    pub enabled: bool,
    /// Maximum number of persisted scheduled tasks per polling cycle.
    #[serde(default = "default_scheduler_max_tasks")]
    pub max_tasks: usize,
    /// Maximum tasks executed in parallel within a single polling cycle.
    #[serde(default = "default_scheduler_max_concurrent")]
    pub max_concurrent: usize,
    /// Run all overdue jobs at scheduler startup. Default: `true`.
    ///
    /// When the daemon restarts late, jobs whose `next_run` is in the past
    /// fire once before normal polling resumes. Disable to wait for the
    /// next scheduled occurrence instead.
    #[serde(default = "default_true")]
    pub catch_up_on_startup: bool,
    /// Maximum number of historical cron run records to retain. Default: `50`.
    #[serde(default = "default_max_run_history")]
    pub max_run_history: u32,
}

fn default_scheduler_enabled() -> bool {
    true
}

fn default_scheduler_max_tasks() -> usize {
    64
}

fn default_scheduler_max_concurrent() -> usize {
    4
}

impl Default for SchedulerConfig {
    fn default() -> Self {
        Self {
            enabled: default_scheduler_enabled(),
            max_tasks: default_scheduler_max_tasks(),
            max_concurrent: default_scheduler_max_concurrent(),
            catch_up_on_startup: true,
            max_run_history: default_max_run_history(),
        }
    }
}

// ── Model routing ────────────────────────────────────────────────

/// Route a task hint to a specific model_provider + model.
///
/// ```toml
/// [[model_routes]]
/// hint = "reasoning"
/// model_provider = "openrouter.default"
/// model = "anthropic/claude-opus-4-20250514"
///
/// [[model_routes]]
/// hint = "fast"
/// model_provider = "groq.low-latency"
/// model = "llama-3.3-70b-versatile"
/// ```
///
/// Usage: pass `hint:reasoning` as the model parameter to route the request.
#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "model_routes"]
pub struct ModelRouteConfig {
    /// Task hint name (e.g. "reasoning", "fast", "code", "summarize")
    /// `#[serde(default)]` lets the `create_map_key` macro default-construct
    /// from `{}` before the hint gets injected via `set_prop`. Empty strings
    /// are rejected by `Config::validate()`.
    #[serde(default)]
    pub hint: String,
    /// Dotted provider profile ref to route to (must resolve to `providers.models.<type>.<alias>`)
    /// `#[serde(default)]` is required for `Default` + `create_map_key` construction.
    /// Empty strings are rejected by `Config::validate()`.
    #[serde(default)]
    pub model_provider: String,
    /// Provider-local model identifier to use with that provider profile
    /// `#[serde(default)]` is required for `Default` + `create_map_key` construction.
    /// Empty strings are rejected by `Config::validate()`.
    #[serde(default)]
    pub model: String,
    /// Optional API key override for this route's model provider
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
}

// ── Model cache (shared between CLI refresh and channel reader) ──

/// Canonical on-disk model cache schema. Written by `zeroclaw models refresh`
/// and read by the channel `/model` command. Both sides MUST use this type
/// to prevent schema drift (single-source-of-truth rule).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ModelCacheState {
    pub entries: Vec<ModelCacheEntry>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ModelCacheEntry {
    pub model_provider: String,
    pub models: Vec<String>,
}

// ── Embedding routing ───────────────────────────────────────────

/// Route an embedding hint to a specific model_provider + model.
///
/// ```toml
/// [[embedding_routes]]
/// hint = "semantic"
/// model_provider = "openai.embeddings"
/// model = "text-embedding-3-small"
/// dimensions = 1536
///
/// [memory]
/// embedding_model = "hint:semantic"
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "embedding_routes"]
pub struct EmbeddingRouteConfig {
    /// Route hint name (e.g. "semantic", "archive", "faq")
    /// `#[serde(default)]` lets the `create_map_key` macro default-construct
    /// from `{}` before the hint gets injected via `set_prop`. Empty strings
    /// are rejected by `Config::validate()`.
    #[serde(default)]
    pub hint: String,
    /// Dotted embedding-capable provider profile ref
    /// `#[serde(default)]` is required for `Default` + `create_map_key` construction.
    /// Empty strings are rejected by `Config::validate()`.
    #[serde(default)]
    pub model_provider: String,
    /// Provider-local embedding model identifier to use with that provider profile
    /// `#[serde(default)]` is required for `Default` + `create_map_key` construction.
    /// Empty strings are rejected by `Config::validate()`.
    #[serde(default)]
    pub model: String,
    /// Optional embedding dimension override for this route
    #[serde(default)]
    pub dimensions: Option<usize>,
    /// Optional API key override for this route's model_provider
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: Option<String>,
}

// ── Query Classification ─────────────────────────────────────────

/// Automatic query classification — classifies user messages by keyword/pattern
/// and routes to the appropriate model hint. Disabled by default.
#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "query_classification"]
pub struct QueryClassificationConfig {
    /// Enable automatic query classification. Default: `false`.
    #[serde(default)]
    pub enabled: bool,
    /// Classification rules evaluated in priority order.
    #[serde(default)]
    pub rules: Vec<ClassificationRule>,
}

/// A single classification rule mapping message patterns to a model hint.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ClassificationRule {
    /// Must match a `[[model_routes]]` hint value.
    pub hint: String,
    /// Case-insensitive substring matches.
    #[serde(default)]
    pub keywords: Vec<String>,
    /// Case-sensitive literal matches (for "```", "fn ", etc.).
    #[serde(default)]
    pub patterns: Vec<String>,
    /// Only match if message length >= N chars.
    #[serde(default)]
    pub min_length: Option<usize>,
    /// Only match if message length <= N chars.
    #[serde(default)]
    pub max_length: Option<usize>,
    /// Higher priority rules are checked first.
    #[serde(default)]
    pub priority: i32,
}

// ── Heartbeat ────────────────────────────────────────────────────

/// Heartbeat configuration for periodic health pings (`[heartbeat]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "heartbeat"]
#[allow(clippy::struct_excessive_bools)]
pub struct HeartbeatConfig {
    /// Enable periodic heartbeat pings. Default: `false`. When enabled,
    /// `agent` must name a configured agent — there is no default agent
    /// for heartbeat to fall through to.
    #[serde(default)]
    pub enabled: bool,
    /// Configured agent alias the heartbeat worker runs as. Required
    /// when `enabled = true`; refers to a `[agents.<alias>]` entry.
    #[serde(default)]
    pub agent: String,
    /// Interval in minutes between heartbeat pings. Minimum: `1`. Default: `30`.
    #[serde(default = "default_heartbeat_interval")]
    pub interval_minutes: u32,
    /// Enable two-phase heartbeat: Phase 1 asks LLM whether to run, Phase 2
    /// executes only when the LLM decides there is work to do. Saves API cost
    /// during quiet periods. Default: `true`.
    #[serde(default = "default_two_phase")]
    pub two_phase: bool,
    /// Optional fallback task text when `HEARTBEAT.md` has no task entries.
    #[serde(default)]
    pub message: Option<String>,
    /// Optional delivery channel for heartbeat output (for example: `telegram`).
    /// When omitted, auto-selects the first configured channel.
    #[serde(default, alias = "channel")]
    pub target: Option<String>,
    /// Optional delivery recipient/chat identifier (required when `target` is
    /// explicitly set).
    #[serde(default, alias = "recipient")]
    pub to: Option<String>,
    /// Enable adaptive intervals that back off on failures and speed up for
    /// high-priority tasks. Default: `false`.
    #[serde(default)]
    pub adaptive: bool,
    /// Minimum interval in minutes when adaptive mode is enabled. Default: `5`.
    #[serde(default = "default_heartbeat_min_interval")]
    pub min_interval_minutes: u32,
    /// Maximum interval in minutes when adaptive mode backs off. Default: `120`.
    #[serde(default = "default_heartbeat_max_interval")]
    pub max_interval_minutes: u32,
    /// Dead-man's switch timeout in minutes. If the heartbeat has not ticked
    /// within this window, an alert is sent. `0` disables. Default: `0`.
    #[serde(default)]
    pub deadman_timeout_minutes: u32,
    /// Channel for dead-man's switch alerts (e.g. `telegram`). Falls back to
    /// the heartbeat delivery channel.
    #[serde(default)]
    pub deadman_channel: Option<String>,
    /// Recipient for dead-man's switch alerts. Falls back to `to`.
    #[serde(default)]
    pub deadman_to: Option<String>,
    /// Maximum number of heartbeat run history records to retain. Default: `100`.
    #[serde(default = "default_heartbeat_max_run_history")]
    pub max_run_history: u32,
    /// Load the channel session history before each heartbeat task execution so
    /// the LLM has conversational context. Default: `false`.
    ///
    /// When `true`, the session file for the configured `target`/`to` is passed
    /// to the agent as `session_state_file`, giving it access to the recent
    /// conversation history — just as if the user had sent a message.
    #[serde(default)]
    pub load_session_context: bool,
    /// Maximum wall-clock seconds allowed for a single agent invocation
    /// (Phase 1 decision or Phase 2 task execution). `0` disables.
    /// Default: `600` (10 minutes).
    #[serde(default = "default_heartbeat_task_timeout")]
    pub task_timeout_secs: u64,
}

fn default_heartbeat_interval() -> u32 {
    30
}

fn default_two_phase() -> bool {
    true
}

fn default_heartbeat_min_interval() -> u32 {
    5
}

fn default_heartbeat_max_interval() -> u32 {
    120
}

fn default_heartbeat_max_run_history() -> u32 {
    100
}

fn default_heartbeat_task_timeout() -> u64 {
    600
}

impl Default for HeartbeatConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            agent: String::new(),
            interval_minutes: default_heartbeat_interval(),
            two_phase: true,
            message: None,
            target: None,
            to: None,
            adaptive: false,
            min_interval_minutes: default_heartbeat_min_interval(),
            max_interval_minutes: default_heartbeat_max_interval(),
            deadman_timeout_minutes: 0,
            deadman_channel: None,
            deadman_to: None,
            max_run_history: default_heartbeat_max_run_history(),
            load_session_context: false,
            task_timeout_secs: default_heartbeat_task_timeout(),
        }
    }
}

// ── TodoTracker ──────────────────────────────────────────────────

/// Location of the ZeroCode TodoWrite tracker panel.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum TodoTrackerLocation {
    /// Horizontal strip between the transcript and the input bar (Claude Code style).
    Bottom,
    /// Vertical side panel on the left.
    Left,
    /// Vertical side panel on the right (OpenCode style). Default.
    #[default]
    Right,
}

/// ZeroCode live task tracker configuration (`[todotracker]` section).
///
/// Read-only visual tracker driven by the model's `TodoWrite` tool.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "todotracker"]
pub struct TodoTrackerConfig {
    /// Master switch. When `false` the tracker never renders and never
    /// auto-pops. Default: `true`.
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Whether the panel is visible at launch (when `enabled`). When
    /// `false` it stays hidden until toggled or auto-popped by the first
    /// plan. Default: `false`.
    #[serde(default)]
    pub enabled_at_start: bool,
    /// Panel location: `bottom` (between transcript and input), `left`,
    /// or `right` (default).
    #[serde(default)]
    pub location: TodoTrackerLocation,
    /// Side-panel target column width (left/right). Runtime-clamped to at
    /// most half the terminal width. Ignored for `bottom`. Default: `32`.
    #[serde(default = "default_todotracker_width")]
    pub width: u16,
    /// Bottom-strip maximum height in rows (grows up to this). Ignored for
    /// left/right. Default: `5`.
    #[serde(default = "default_todotracker_max_height")]
    pub max_height: u16,
}

fn default_todotracker_width() -> u16 {
    32
}

fn default_todotracker_max_height() -> u16 {
    5
}

impl Default for TodoTrackerConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            enabled_at_start: false,
            location: TodoTrackerLocation::Right,
            width: default_todotracker_width(),
            max_height: default_todotracker_max_height(),
        }
    }
}

// ── Cron ────────────────────────────────────────────────────────

/// A declarative cron job definition (`[cron.<alias>]`).
///
/// Stored alias-keyed on `Config.cron`. The map key serves as the stable job id.
/// Synced into the database at scheduler startup with `source = "declarative"`,
/// distinguishing them from jobs created imperatively via CLI or API.
/// Declarative config takes precedence on each sync: if the config changes,
/// the DB is updated to match. Imperative jobs are never deleted by sync.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cron"]
pub struct CronJobDecl {
    /// Human-readable name.
    #[serde(default)]
    pub name: Option<String>,
    /// Job type: `"shell"` (default) or `"agent"`.
    #[serde(default = "default_job_type_decl")]
    pub job_type: String,
    /// Schedule for the job.
    #[serde(default)]
    pub schedule: CronScheduleDecl,
    /// Shell command to run (required when `job_type = "shell"`).
    #[serde(default)]
    pub command: Option<String>,
    /// Agent prompt (required when `job_type = "agent"`).
    #[serde(default)]
    pub prompt: Option<String>,
    /// Whether the job is enabled. Default: `true`.
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Model override for agent jobs.
    #[serde(default)]
    pub model: Option<String>,
    /// Optional allowlist of tool names for agent jobs. When omitted, scheduler
    /// defaults may still exclude scheduler mutation tools for cron agent jobs.
    #[serde(default)]
    pub allowed_tools: Option<Vec<String>>,
    /// Whether to recall and inject memory context before this agent job runs.
    /// Defaults to `true`; set to `false` for stateless digest jobs.
    #[serde(default = "default_true")]
    pub uses_memory: bool,
    /// Session target: `"isolated"` (default) or `"main"`.
    #[serde(default)]
    pub session_target: Option<String>,
    /// Delivery configuration.
    #[serde(default)]
    #[nested]
    pub delivery: Option<DeliveryConfigDecl>,
    /// Output format for shell jobs: `"wrapped"` (default) or `"raw"`.
    ///
    /// - `"wrapped"` (default): returns stdout and stderr wrapped in a
    ///   `status=... / stdout: / stderr:` envelope.
    /// - `"raw"`: returns trimmed stdout when the process exits zero. A
    ///   non-zero process exit still gets the wrapped status/stdout/stderr
    ///   envelope for diagnosis. Failures that never reach a process exit —
    ///   a security-policy denial, a shell setup/spawn failure, or a
    ///   timeout — return a plain error string in either format.
    #[serde(default)]
    pub shell_output_format: CronShellOutputFormat,
}

impl Default for CronJobDecl {
    fn default() -> Self {
        Self {
            name: None,
            job_type: default_job_type_decl(),
            schedule: CronScheduleDecl::default(),
            command: None,
            prompt: None,
            enabled: true,
            model: None,
            allowed_tools: None,
            uses_memory: true,
            session_target: None,
            delivery: None,
            shell_output_format: CronShellOutputFormat::default(),
        }
    }
}

/// Output format for shell cron job stdout.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, zeroclaw_macros::ConfigEnum)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CronShellOutputFormat {
    /// Wrapped format: `status=...\nstdout:\n...\nstderr:\n...` (default).
    #[default]
    Wrapped,
    /// Raw stdout on a zero process exit; wrapped status/stdout/stderr on a
    /// non-zero exit; a plain error string for failures that never reach a
    /// process exit (security denial, spawn/setup failure, timeout).
    Raw,
}

/// Schedule variant for declarative cron jobs.
#[derive(Debug, Clone, Serialize, Deserialize, zeroclaw_macros::ConfigEnum)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum CronScheduleDecl {
    /// Classic cron expression.
    Cron {
        expr: String,
        #[serde(default)]
        tz: Option<String>,
    },
    /// Interval in milliseconds.
    Every { every_ms: u64 },
    /// One-shot at an RFC 3339 timestamp.
    At { at: String },
}

impl Default for CronScheduleDecl {
    fn default() -> Self {
        // Empty cron expression — `validate_decl` rejects it. Used only as
        // a placeholder when a fresh map entry is auto-created via the
        // schema's `create_map_key` path; the user fills it in immediately.
        Self::Cron {
            expr: String::new(),
            tz: None,
        }
    }
}

/// Delivery configuration for declarative cron jobs.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cron_delivery"]
pub struct DeliveryConfigDecl {
    /// Delivery mode: `"none"` or `"announce"`.
    #[serde(default = "default_delivery_mode")]
    pub mode: String,
    /// Channel name (e.g. `"telegram"`, `"discord"`).
    #[serde(default)]
    pub channel: Option<String>,
    /// Target/recipient identifier.
    #[serde(default)]
    pub to: Option<String>,
    /// Optional thread/conversation identifier carried into the outbound send.
    /// Required by channels that route on a separate `thread_id` field (e.g.
    /// webhook callbacks bridging into agent-chat platforms).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub thread_id: Option<String>,
    /// Best-effort delivery. Default: `true`.
    #[serde(default = "default_true")]
    pub best_effort: bool,
}

impl Default for DeliveryConfigDecl {
    fn default() -> Self {
        Self {
            mode: default_delivery_mode(),
            channel: None,
            to: None,
            thread_id: None,
            best_effort: true,
        }
    }
}

fn default_job_type_decl() -> String {
    "shell".to_string()
}

fn default_delivery_mode() -> String {
    "none".to_string()
}

fn default_max_run_history() -> u32 {
    50
}

// ── ACP ──────────────────────────────────────────────────────────

/// ACP (Agent Client Protocol) server configuration (`[acp]` section).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "acp"]
pub struct AcpConfig {
    /// Agent alias to use when `session/new` omits `agentAlias` and more than
    /// one agent is configured. When exactly one agent exists it is
    /// auto-selected regardless of this field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_agent: Option<String>,
    /// Maximum number of concurrent ACP sessions. Default: `10`.
    #[serde(default = "default_acp_max_sessions")]
    pub max_sessions: usize,
    /// Idle session timeout in seconds. Sessions with no activity for this
    /// duration are eligible for eviction. Default: `3600` (1 hour).
    #[serde(default = "default_acp_session_timeout_secs")]
    pub session_timeout_secs: u64,
}

fn default_acp_max_sessions() -> usize {
    10
}

fn default_acp_session_timeout_secs() -> u64 {
    3600
}

impl Default for AcpConfig {
    fn default() -> Self {
        Self {
            default_agent: None,
            max_sessions: default_acp_max_sessions(),
            session_timeout_secs: default_acp_session_timeout_secs(),
        }
    }
}

// ── Tunnel ──────────────────────────────────────────────────────

/// Tunnel configuration for exposing the gateway publicly (`[tunnel]` section).
///
/// Supported model_providers: `"none"` (default), `"cloudflare"`, `"tailscale"`, `"ngrok"`, `"openvpn"`, `"pinggy"`, `"custom"`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "tunnel"]
pub struct TunnelConfig {
    /// How the gateway gets exposed to the public internet so webhooks (Telegram, Slack, etc.) can reach it. `none` = keep it local, no tunnel; `cloudflare` = Cloudflare Tunnel via cloudflared (needs a Zero Trust account and token); `tailscale` = Tailscale Funnel/Serve (tailnet-only or public, no account beyond tailscale); `ngrok` = ngrok agent with auth token; `openvpn` = bring-your-own OpenVPN egress; `pinggy` = Pinggy SSH tunnels (quick one-shot URLs); `custom` = run an arbitrary command you define under `[tunnel.custom]`.
    #[serde(default = "default_tunnel_provider")]
    pub tunnel_provider: String,

    /// Cloudflare Tunnel configuration (used when `tunnel_provider = "cloudflare"`).
    #[serde(default)]
    #[nested]
    pub cloudflare: Option<CloudflareTunnelConfig>,

    /// Tailscale Funnel/Serve configuration (used when `tunnel_provider = "tailscale"`).
    #[serde(default)]
    #[nested]
    pub tailscale: Option<TailscaleTunnelConfig>,

    /// ngrok tunnel configuration (used when `tunnel_provider = "ngrok"`).
    #[serde(default)]
    #[nested]
    pub ngrok: Option<NgrokTunnelConfig>,

    /// OpenVPN tunnel configuration (used when `tunnel_provider = "openvpn"`).
    #[serde(default)]
    #[nested]
    pub openvpn: Option<OpenVpnTunnelConfig>,

    /// Custom tunnel command configuration (used when `tunnel_provider = "custom"`).
    #[serde(default)]
    #[nested]
    pub custom: Option<CustomTunnelConfig>,

    /// Pinggy tunnel configuration (used when `tunnel_provider = "pinggy"`).
    #[serde(default)]
    #[nested]
    pub pinggy: Option<PinggyTunnelConfig>,
}

impl Default for TunnelConfig {
    fn default() -> Self {
        Self {
            tunnel_provider: "none".into(),
            cloudflare: None,
            tailscale: None,
            ngrok: None,
            openvpn: None,
            custom: None,
            pinggy: None,
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "tunnel.cloudflare"]
pub struct CloudflareTunnelConfig {
    /// Cloudflare Tunnel token (from Zero Trust dashboard)
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub token: String,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "tunnel.tailscale"]
pub struct TailscaleTunnelConfig {
    /// Use Tailscale Funnel (public internet) vs Serve (tailnet only)
    #[serde(default)]
    pub funnel: bool,
    /// Optional hostname override
    #[serde(default)]
    pub hostname: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "tunnel.ngrok"]
pub struct NgrokTunnelConfig {
    /// ngrok auth token
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub auth_token: String,
    /// Optional custom domain
    #[serde(default)]
    pub domain: Option<String>,
}

/// OpenVPN tunnel configuration (`[tunnel.openvpn]`).
///
/// Required when `tunnel.tunnel_provider = "openvpn"`. Omitting this section entirely
/// preserves previous behavior. Setting `tunnel.tunnel_provider = "none"` (or removing
/// the `[tunnel.openvpn]` block) cleanly reverts to no-tunnel mode.
///
/// Defaults: `connect_timeout_secs = 30`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "tunnel.openvpn"]
pub struct OpenVpnTunnelConfig {
    /// Path to `.ovpn` configuration file (must not be empty).
    pub config_file: String,
    /// Optional path to auth credentials file (`--auth-user-pass`).
    #[serde(default)]
    #[credential_class = "path_only_reference"]
    pub auth_file: Option<String>,
    /// Advertised address once VPN is connected (e.g., `"10.8.0.2:42617"`).
    /// When omitted the tunnel falls back to `http://{local_host}:{local_port}`.
    #[serde(default)]
    pub advertise_address: Option<String>,
    /// Connection timeout in seconds (default: 30, must be > 0).
    #[serde(default = "default_openvpn_timeout")]
    pub connect_timeout_secs: u64,
    /// Extra openvpn CLI arguments forwarded verbatim.
    #[serde(default)]
    pub extra_args: Vec<String>,
}

fn default_openvpn_timeout() -> u64 {
    30
}

impl Default for OpenVpnTunnelConfig {
    fn default() -> Self {
        Self {
            config_file: String::new(),
            auth_file: None,
            advertise_address: None,
            connect_timeout_secs: default_openvpn_timeout(),
            extra_args: Vec::new(),
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "tunnel.pinggy"]
pub struct PinggyTunnelConfig {
    /// Pinggy access token (optional — free tier works without one).
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub token: Option<String>,
    /// Server region: `"us"` (USA), `"eu"` (Europe), `"ap"` (Asia), `"br"` (South America), `"au"` (Australia), or omit for auto.
    #[serde(default)]
    pub region: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "tunnel.custom"]
pub struct CustomTunnelConfig {
    /// Command template to start the tunnel. Use {port} and {host} placeholders.
    /// Example: "bore local {port} --to bore.pub"
    #[serde(default)]
    pub start_command: String,
    /// Optional URL to check tunnel health
    #[serde(default)]
    pub health_url: Option<String>,
    /// Optional regex to extract public URL from command stdout
    #[serde(default)]
    pub url_pattern: Option<String>,
}

// ── Channels ─────────────────────────────────────────────────────

/// Top-level channel configurations (`[channels]` section).
///
/// each channel type is a keyed table of named instances (aliases).
/// `[channels.telegram.default]` is the conventional single-instance key.
/// Access via `config.channels.telegram.get("default")`.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels"]
pub struct ChannelsConfig {
    /// Enable the CLI interactive channel. Default: `true`.
    #[serde(default = "default_true")]
    pub cli: bool,
    /// Telegram bot channel instances (`[channels.telegram.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub telegram: HashMap<String, TelegramConfig>,
    /// Discord bot channel instances (`[channels.discord.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub discord: HashMap<String, DiscordConfig>,
    /// Slack bot channel instances (`[channels.slack.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub slack: HashMap<String, SlackConfig>,
    /// Mattermost bot channel instances (`[channels.mattermost.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub mattermost: HashMap<String, MattermostConfig>,
    /// Webhook channel instances (`[channels.webhook.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub webhook: HashMap<String, WebhookConfig>,
    /// iMessage channel instances (`[channels.imessage.<alias>]`, macOS only).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub imessage: HashMap<String, IMessageConfig>,
    /// Matrix channel instances (`[channels.matrix.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub matrix: HashMap<String, MatrixConfig>,
    /// Signal channel instances (`[channels.signal.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub signal: HashMap<String, SignalConfig>,
    /// WhatsApp channel instances (`[channels.whatsapp.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub whatsapp: HashMap<String, WhatsAppConfig>,
    /// Linq Partner API channel instances (`[channels.linq.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub linq: HashMap<String, LinqConfig>,
    /// WATI WhatsApp Business API channel instances (`[channels.wati.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub wati: HashMap<String, WatiConfig>,
    /// Nextcloud Talk bot channel instances (`[channels.nextcloud_talk.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub nextcloud_talk: HashMap<String, NextcloudTalkConfig>,
    /// Email channel instances (`[channels.email.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub email: HashMap<String, crate::scattered_types::EmailConfig>,
    /// Gmail Pub/Sub push notification channel instances (`[channels.gmail_push.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub gmail_push: HashMap<String, crate::scattered_types::GmailPushConfig>,
    /// IRC channel instances (`[channels.irc.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub irc: HashMap<String, IrcConfig>,
    /// Twitch chat channel instances (`[channels.twitch.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub twitch: HashMap<String, TwitchConfig>,
    /// Lark channel instances (`[channels.lark.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub lark: HashMap<String, LarkConfig>,
    /// LINE Messaging API channel instances (`[channels.line.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub line: HashMap<String, LineConfig>,
    /// DingTalk channel instances (`[channels.dingtalk.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub dingtalk: HashMap<String, DingTalkConfig>,
    /// WeCom (WeChat Enterprise) Bot Webhook channel instances (`[channels.wecom.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub wecom: HashMap<String, WeComConfig>,
    /// WeCom AI Bot WebSocket channel instances (`[channels.wecom_ws.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub wecom_ws: HashMap<String, WeComWsConfig>,
    /// WeChat personal iLink Bot channel instances (`[channels.wechat.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub wechat: HashMap<String, WeChatConfig>,
    /// QQ Official Bot channel instances (`[channels.qq.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub qq: HashMap<String, QQConfig>,
    /// X/Twitter channel instances (`[channels.twitter.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub twitter: HashMap<String, TwitterConfig>,
    /// Mochat customer service channel instances (`[channels.mochat.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub mochat: HashMap<String, MochatConfig>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub nostr: HashMap<String, NostrConfig>,
    /// ClawdTalk voice channel instances (`[channels.clawdtalk.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub clawdtalk: HashMap<String, crate::scattered_types::ClawdTalkConfig>,
    /// Reddit channel instances (`[channels.reddit.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub reddit: HashMap<String, RedditConfig>,
    /// Bluesky channel instances (`[channels.bluesky.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub bluesky: HashMap<String, BlueskyConfig>,
    /// Git-forge channel instances (`[channels.git.<alias>]`). GitHub is
    /// the first provider; the `provider` field selects the forge.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub git: HashMap<String, GitConfig>,
    /// Voice call channel instances (`[channels.voice_call.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub voice_call: HashMap<String, crate::scattered_types::VoiceCallConfig>,
    /// Voice wake word detection channel instances (`[channels.voice_wake.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub voice_wake: HashMap<String, VoiceWakeConfig>,
    /// Voice duplex instances (`[channels.voice_duplex.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub voice_duplex: HashMap<String, VoiceDuplexConfig>,
    /// MQTT channel instances (`[channels.mqtt.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub mqtt: HashMap<String, MqttConfig>,
    /// AMQP channel instances (`[channels.amqp.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub amqp: HashMap<String, AmqpConfig>,
    /// Filesystem SOP listener instances (`[channels.filesystem.<alias>]`).
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    #[nested]
    pub filesystem: HashMap<String, FilesystemConfig>,
    /// Base timeout in seconds for processing a single channel message (LLM + tools).
    /// Runtime uses this as a per-turn budget that scales with tool-loop depth
    /// (up to 4x, capped) so one slow/retried model call does not consume the
    /// entire conversation budget.
    /// Default: 300s for on-device LLMs (Ollama) which are slower than cloud APIs.
    #[serde(default = "default_channel_message_timeout_secs")]
    pub message_timeout_secs: u64,
    /// Per-channel multiplier for the global channel message in-flight budget.
    /// Runtime multiplies this value by the configured channel count, then
    /// applies its global minimum and maximum bounds to one shared dispatcher
    /// semaphore. Default: `4`.
    #[serde(default = "default_channel_max_concurrent_per_channel")]
    pub max_concurrent_per_channel: usize,
    /// Whether to add acknowledgement reactions (👀 on receipt, ✅/⚠️ on
    /// completion) to incoming channel messages. Default: `true`.
    #[serde(default = "default_true")]
    pub ack_reactions: bool,
    /// Whether to send tool-call notification messages (e.g. `🔧 web_search_tool: …`)
    /// to channel users. When `false`, tool calls are still logged server-side but
    /// not forwarded as individual channel messages. Default: `false`.
    #[serde(default = "default_false")]
    pub show_tool_calls: bool,
    /// Persist channel conversation history to JSONL files so sessions survive
    /// daemon restarts. Files are stored in `{workspace}/sessions/`. Default: `true`.
    #[serde(default = "default_true")]
    pub session_persistence: bool,
    /// Session persistence backend: `"jsonl"` (legacy) or `"sqlite"` (new default).
    /// SQLite provides FTS5 search, metadata tracking, and TTL cleanup.
    #[serde(default = "default_session_backend")]
    pub session_backend: String,
    /// Auto-archive stale sessions older than this many hours. `0` disables. Default: `0`.
    #[serde(default)]
    pub session_ttl_hours: u32,
    /// Inbound message debounce window in milliseconds. When a sender fires
    /// multiple messages within this window, they are accumulated and dispatched
    /// as a single concatenated message. `0` disables debouncing. Default: `0`.
    #[serde(default)]
    pub debounce_ms: u64,
}

impl ChannelsConfig {
    /// Returns metadata and configuration status for every known channel type.
    ///
    /// Always returns the full set of channel types regardless of compile-time
    /// feature flags — the `configured` flag reflects whether the operator has
    /// populated that channel's config section. For a list restricted to only
    /// the channels compiled into this binary use
    /// `zeroclaw_channels::listing::compiled_channels` instead.
    pub fn channels(&self) -> Vec<super::traits::ChannelInfo> {
        use super::traits::ChannelInfo;
        vec![
            ChannelInfo {
                kind: "telegram",
                name: "Telegram",
                desc: "connect your bot",
                configured: !self.telegram.is_empty(),
            },
            ChannelInfo {
                kind: "discord",
                name: "Discord",
                desc: "connect your bot",
                configured: !self.discord.is_empty(),
            },
            ChannelInfo {
                kind: "slack",
                name: "Slack",
                desc: "connect your bot",
                configured: !self.slack.is_empty(),
            },
            ChannelInfo {
                kind: "mattermost",
                name: "Mattermost",
                desc: "connect to your bot",
                configured: !self.mattermost.is_empty(),
            },
            ChannelInfo {
                kind: "imessage",
                name: "iMessage",
                desc: "macOS only",
                configured: !self.imessage.is_empty(),
            },
            ChannelInfo {
                kind: "matrix",
                name: "Matrix",
                desc: "self-hosted chat",
                configured: !self.matrix.is_empty(),
            },
            ChannelInfo {
                kind: "signal",
                name: "Signal",
                desc: "An open-source, encrypted messaging service",
                configured: !self.signal.is_empty(),
            },
            ChannelInfo {
                kind: "whatsapp",
                name: "WhatsApp",
                desc: "Business Cloud API",
                configured: !self.whatsapp.is_empty(),
            },
            ChannelInfo {
                kind: "whatsapp-web",
                name: "WhatsApp Web",
                desc: "native WhatsApp Web (wa-rs)",
                configured: self.whatsapp.values().any(|c| c.is_web_config()),
            },
            ChannelInfo {
                kind: "linq",
                name: "Linq",
                desc: "iMessage/RCS/SMS via Linq API",
                configured: !self.linq.is_empty(),
            },
            ChannelInfo {
                kind: "wati",
                name: "WATI",
                desc: "WhatsApp via WATI Business API",
                configured: !self.wati.is_empty(),
            },
            ChannelInfo {
                kind: "nextcloud",
                name: "NextCloud Talk",
                desc: "NextCloud Talk platform",
                configured: !self.nextcloud_talk.is_empty(),
            },
            ChannelInfo {
                kind: "email",
                name: "Email",
                desc: "Email over IMAP/SMTP",
                configured: !self.email.is_empty(),
            },
            ChannelInfo {
                kind: "gmail-push",
                name: "Gmail Push",
                desc: "Gmail Pub/Sub push notifications",
                configured: !self.gmail_push.is_empty(),
            },
            ChannelInfo {
                kind: "twitch",
                name: "Twitch",
                desc: "Twitch chat (IRC)",
                configured: !self.twitch.is_empty(),
            },
            ChannelInfo {
                kind: "irc",
                name: "IRC",
                desc: "IRC over TLS",
                configured: !self.irc.is_empty(),
            },
            ChannelInfo {
                kind: "lark",
                name: "Lark",
                desc: "Lark Bot",
                configured: !self.lark.is_empty(),
            },
            ChannelInfo {
                kind: "dingtalk",
                name: "DingTalk",
                desc: "DingTalk Stream Mode",
                configured: !self.dingtalk.is_empty(),
            },
            ChannelInfo {
                kind: "wecom",
                name: "WeCom",
                desc: "WeCom Bot Webhook",
                configured: !self.wecom.is_empty(),
            },
            ChannelInfo {
                kind: "wecom-ws",
                name: "WeCom WebSocket",
                desc: "WeCom AI Bot long connection",
                configured: !self.wecom_ws.is_empty(),
            },
            ChannelInfo {
                kind: "wechat",
                name: "WeChat",
                desc: "WeChat iLink Bot",
                configured: !self.wechat.is_empty(),
            },
            ChannelInfo {
                kind: "qq",
                name: "QQ Official",
                desc: "Tencent QQ Bot",
                configured: !self.qq.is_empty(),
            },
            ChannelInfo {
                kind: "nostr",
                name: "Nostr",
                desc: "Nostr DMs",
                configured: !self.nostr.is_empty(),
            },
            ChannelInfo {
                kind: "clawdtalk",
                name: "ClawdTalk",
                desc: "ClawdTalk Channel",
                configured: !self.clawdtalk.is_empty(),
            },
            ChannelInfo {
                kind: "reddit",
                name: "Reddit",
                desc: "Reddit bot (OAuth2)",
                configured: !self.reddit.is_empty(),
            },
            ChannelInfo {
                kind: "bluesky",
                name: "Bluesky",
                desc: "AT Protocol",
                configured: !self.bluesky.is_empty(),
            },
            ChannelInfo {
                kind: "git",
                name: "Git",
                desc: "Git forge (GitHub, Gitea, Forgejo): issues, PRs & events",
                configured: !self.git.is_empty(),
            },
            ChannelInfo {
                kind: "twitter",
                name: "X/Twitter",
                desc: "X/Twitter Bot via API v2",
                configured: !self.twitter.is_empty(),
            },
            ChannelInfo {
                kind: "mochat",
                name: "Mochat",
                desc: "Mochat Customer Service",
                configured: !self.mochat.is_empty(),
            },
            ChannelInfo {
                kind: "line",
                name: "LINE",
                desc: "connect your LINE bot",
                configured: !self.line.is_empty(),
            },
            ChannelInfo {
                kind: "voice-call",
                name: "Voice Call",
                desc: "outbound voice call channel",
                configured: !self.voice_call.is_empty(),
            },
            ChannelInfo {
                kind: "voice-wake",
                name: "VoiceWake",
                desc: "voice wake word detection",
                configured: !self.voice_wake.is_empty(),
            },
            ChannelInfo {
                kind: "mqtt",
                name: "MQTT",
                desc: "MQTT SOP Listener",
                configured: !self.mqtt.is_empty(),
            },
            ChannelInfo {
                kind: "amqp",
                name: "AMQP",
                desc: "AMQP topic consumer",
                configured: !self.amqp.is_empty(),
            },
            ChannelInfo {
                kind: "filesystem",
                name: "Filesystem",
                desc: "filesystem change SOP listener",
                configured: !self.filesystem.is_empty(),
            },
            ChannelInfo {
                kind: "webhook",
                name: "Webhook",
                desc: "HTTP endpoint",
                configured: !self.webhook.is_empty(),
            },
        ]
    }

    /// Returns `true` when at least one channel entry across all channel types
    /// has `enabled = true`. Used by the daemon to decide whether the channels
    /// supervisor should be started — a config with only `enabled = false`
    /// entries (e.g. partially-configured or disabled bots) must not start the
    /// supervisor, otherwise it exits immediately and restarts in a tight loop.
    pub fn has_any_enabled(&self) -> bool {
        self.telegram.values().any(|c| c.enabled)
            || self.discord.values().any(|c| c.enabled)
            || self.slack.values().any(|c| c.enabled)
            || self.mattermost.values().any(|c| c.enabled)
            || self.webhook.values().any(|c| c.enabled)
            || self.imessage.values().any(|c| c.enabled)
            || self.matrix.values().any(|c| c.enabled)
            || self.signal.values().any(|c| c.enabled)
            || self.whatsapp.values().any(|c| c.enabled)
            || self.linq.values().any(|c| c.enabled)
            || self.wati.values().any(|c| c.enabled)
            || self.nextcloud_talk.values().any(|c| c.enabled)
            || self.email.values().any(|c| c.enabled)
            || self.gmail_push.values().any(|c| c.enabled)
            || self.irc.values().any(|c| c.enabled)
            || self.twitch.values().any(|c| c.enabled)
            || self.lark.values().any(|c| c.enabled)
            || self.line.values().any(|c| c.enabled)
            || self.dingtalk.values().any(|c| c.enabled)
            || self.wecom.values().any(|c| c.enabled)
            || self.wecom_ws.values().any(|c| c.enabled)
            || self.wechat.values().any(|c| c.enabled)
            || self.qq.values().any(|c| c.enabled)
            || self.twitter.values().any(|c| c.enabled)
            || self.mochat.values().any(|c| c.enabled)
            || self.nostr.values().any(|c| c.enabled)
            || self.clawdtalk.values().any(|c| c.enabled)
            || self.reddit.values().any(|c| c.enabled)
            || self.bluesky.values().any(|c| c.enabled)
            || self.voice_call.values().any(|c| c.enabled)
            || self.voice_wake.values().any(|c| c.enabled)
            || self.voice_duplex.values().any(|c| c.enabled)
            || self.mqtt.values().any(|c| c.enabled)
            || self.amqp.values().any(|c| c.enabled)
            || self.filesystem.values().any(|c| c.enabled)
            || self.git.values().any(|c| c.enabled)
    }

    /// One `(canonical_name, configured, deliverable)` row per channel in the
    /// registry. Single source for name-addressed channel lookups so no surface
    /// has to hardcode a subset of the channel list. `deliverable` is `false`
    /// for input-only transports whose `Channel::send` is a no-op (mqtt and
    /// amqp are fan-in listeners; voice_wake is input-only), so a name-addressed
    /// outbound surface such as `heartbeat.target` can refuse them at validation
    /// instead of accepting a target the delivery layer silently drops.
    pub fn channel_presence(&self) -> [(&'static str, bool, bool); 36] {
        [
            ("telegram", !self.telegram.is_empty(), true),
            ("discord", !self.discord.is_empty(), true),
            ("slack", !self.slack.is_empty(), true),
            ("mattermost", !self.mattermost.is_empty(), true),
            ("webhook", !self.webhook.is_empty(), true),
            ("imessage", !self.imessage.is_empty(), true),
            ("matrix", !self.matrix.is_empty(), true),
            ("signal", !self.signal.is_empty(), true),
            ("whatsapp", !self.whatsapp.is_empty(), true),
            ("linq", !self.linq.is_empty(), true),
            ("wati", !self.wati.is_empty(), true),
            ("nextcloud_talk", !self.nextcloud_talk.is_empty(), true),
            ("email", !self.email.is_empty(), true),
            ("gmail_push", !self.gmail_push.is_empty(), true),
            ("irc", !self.irc.is_empty(), true),
            ("twitch", !self.twitch.is_empty(), true),
            ("lark", !self.lark.is_empty(), true),
            ("line", !self.line.is_empty(), true),
            ("dingtalk", !self.dingtalk.is_empty(), true),
            ("wecom", !self.wecom.is_empty(), true),
            ("wecom_ws", !self.wecom_ws.is_empty(), true),
            ("wechat", !self.wechat.is_empty(), true),
            ("qq", !self.qq.is_empty(), true),
            ("twitter", !self.twitter.is_empty(), true),
            ("mochat", !self.mochat.is_empty(), true),
            ("nostr", !self.nostr.is_empty(), true),
            ("clawdtalk", !self.clawdtalk.is_empty(), true),
            ("reddit", !self.reddit.is_empty(), true),
            ("bluesky", !self.bluesky.is_empty(), true),
            ("git", !self.git.is_empty(), true),
            ("voice_call", !self.voice_call.is_empty(), true),
            ("voice_wake", !self.voice_wake.is_empty(), false),
            ("voice_duplex", !self.voice_duplex.is_empty(), false),
            ("mqtt", !self.mqtt.is_empty(), false),
            ("amqp", !self.amqp.is_empty(), false),
            ("filesystem", !self.filesystem.is_empty(), false),
        ]
    }

    /// Whether a channel named `name` (case-insensitive) is a known channel
    /// with at least one configured instance. Used by name-addressed surfaces
    /// (e.g. `heartbeat.target`) without hardcoding a subset of the registry.
    pub fn is_channel_configured(&self, name: &str) -> bool {
        let needle = name.to_ascii_lowercase();
        self.channel_presence()
            .iter()
            .any(|(canonical, configured, _)| *configured && *canonical == needle)
    }

    /// Whether `name` (case-insensitive) names a channel in the registry,
    /// regardless of whether it is configured.
    pub fn is_known_channel(&self, name: &str) -> bool {
        let needle = name.to_ascii_lowercase();
        self.channel_presence()
            .iter()
            .any(|(canonical, _, _)| *canonical == needle)
    }

    /// Whether `name` (case-insensitive) names a channel that can actually
    /// deliver an outbound message. Input-only transports (mqtt, amqp,
    /// voice_wake) are known and may be configured, but their `Channel::send`
    /// is a no-op, so they are not valid outbound targets.
    pub fn is_channel_deliverable(&self, name: &str) -> bool {
        let needle = name.to_ascii_lowercase();
        self.channel_presence()
            .iter()
            .any(|(canonical, _, deliverable)| *deliverable && *canonical == needle)
    }
}

fn default_channel_message_timeout_secs() -> u64 {
    300
}

fn default_channel_max_concurrent_per_channel() -> usize {
    4
}

fn default_session_backend() -> String {
    "sqlite".into()
}

impl Default for ChannelsConfig {
    fn default() -> Self {
        Self {
            cli: true,
            telegram: HashMap::new(),
            discord: HashMap::new(),
            slack: HashMap::new(),
            mattermost: HashMap::new(),
            webhook: HashMap::new(),
            imessage: HashMap::new(),
            matrix: HashMap::new(),
            signal: HashMap::new(),
            whatsapp: HashMap::new(),
            linq: HashMap::new(),
            wati: HashMap::new(),
            nextcloud_talk: HashMap::new(),
            email: HashMap::new(),
            gmail_push: HashMap::new(),
            irc: HashMap::new(),
            twitch: HashMap::new(),
            lark: HashMap::new(),
            line: HashMap::new(),
            dingtalk: HashMap::new(),
            wecom: HashMap::new(),
            wecom_ws: HashMap::new(),
            wechat: HashMap::new(),
            qq: HashMap::new(),
            twitter: HashMap::new(),
            mochat: HashMap::new(),
            nostr: HashMap::new(),
            clawdtalk: HashMap::new(),
            reddit: HashMap::new(),
            bluesky: HashMap::new(),
            git: HashMap::new(),
            voice_call: HashMap::new(),
            voice_wake: HashMap::new(),
            voice_duplex: HashMap::new(),
            mqtt: HashMap::new(),
            amqp: HashMap::new(),
            filesystem: HashMap::new(),
            message_timeout_secs: default_channel_message_timeout_secs(),
            max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
            ack_reactions: true,
            show_tool_calls: false,
            session_persistence: true,
            session_backend: default_session_backend(),
            session_ttl_hours: 0,
            debounce_ms: 0,
        }
    }
}

/// Streaming mode for channels that support progressive message updates.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum StreamMode {
    /// No streaming -- send the complete response as a single message (default).
    #[default]
    Off,
    /// Update a draft message with every flush interval.
    Partial,
    /// Send the response as multiple separate messages at paragraph boundaries.
    #[serde(rename = "multi_message")]
    MultiMessage,
}

/// Where a channel registers its skill slash commands. `global` (default)
/// registers application-wide - the commands work everywhere the bot is, but
/// Discord takes up to ~1h to propagate changes. `guild` registers to each
/// guild in `guild_ids`, which propagates instantly (the right choice for fast
/// iteration and single-server bots). When `guild` is set with an empty
/// `guild_ids`, the channel logs a warning and falls back to global.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum SlashCommandScope {
    /// Application-wide commands (propagate in up to ~1h). Default.
    #[default]
    Global,
    /// Commands registered to each guild in `guild_ids` (instant propagation).
    Guild,
}

fn default_draft_update_interval_ms() -> u64 {
    1000
}

fn default_multi_message_delay_ms() -> u64 {
    800
}

fn default_telegram_approval_timeout_secs() -> u64 {
    120
}

pub const TELEGRAM_OFFICIAL_API_BASE_URL: &str = "https://api.telegram.org";

fn default_telegram_api_base_url() -> String {
    TELEGRAM_OFFICIAL_API_BASE_URL.to_string()
}

fn default_channel_approval_timeout_secs() -> u64 {
    300
}

fn default_matrix_draft_update_interval_ms() -> u64 {
    1500
}

/// Telegram bot channel configuration.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.telegram"]
pub struct TelegramConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Telegram Bot API token (from @BotFather). `#[serde(default)]` so a
    /// config that omits or later has it pruned (e.g. a freshly created
    /// alias with an empty token, stripped by `prune_empty_leaves` before
    /// write) still deserializes as an empty string - instead of failing
    /// with `missing field 'bot_token'` and getting dropped by the resilient
    /// salvage pass. `validate_bot_token` below still requires a real token
    /// once `enabled = true`.
    #[secret]
    #[tab(Connection)]
    #[serde(default)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub bot_token: String,
    /// Telegram Bot API base URL. Defaults to the official Telegram endpoint;
    /// set to a local Bot API server URL when self-hosting Telegram's bot API.
    #[tab(Connection)]
    #[serde(default = "default_telegram_api_base_url")]
    pub api_base_url: String,
    /// Streaming mode for progressive response delivery via message edits.
    #[tab(Behavior)]
    #[serde(default)]
    pub stream_mode: StreamMode,
    /// Minimum interval (ms) between draft message edits to avoid rate limits.
    #[tab(Behavior)]
    #[serde(default = "default_draft_update_interval_ms")]
    pub draft_update_interval_ms: u64,
    /// Inbound message debounce window in milliseconds for this Telegram alias.
    /// When set, overrides the global `[channels].debounce_ms` for this channel
    /// only. `0` or unset falls back to the global value.
    #[tab(Behavior)]
    #[serde(default)]
    pub debounce_ms: Option<u64>,
    /// When true, a newer Telegram message from the same sender in the same chat
    /// cancels the in-flight request and starts a fresh response with preserved history.
    #[tab(Behavior)]
    #[serde(default)]
    pub interrupt_on_new_message: bool,
    /// When true, only respond to messages that @-mention the bot in groups.
    /// Direct messages are always processed.
    #[tab(Behavior)]
    #[serde(default)]
    pub mention_only: bool,
    /// Override for the top-level `ack_reactions` setting. When `None`, the
    /// channel falls back to `[channels].ack_reactions`. When set
    /// explicitly, it takes precedence.
    #[tab(Behavior)]
    #[serde(default)]
    pub ack_reactions: Option<bool>,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,
    /// How long (seconds) to wait for the operator to tap an inline-keyboard
    /// button on a tool approval prompt before auto-denying. Default: 120.
    #[tab(Behavior)]
    #[serde(default = "default_telegram_approval_timeout_secs")]
    pub approval_timeout_secs: u64,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Per-(channel, recipient) outbound pacing floor in seconds.
    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
    #[serde(default)]
    pub reply_min_interval_secs: u64,
    /// Per-(channel, recipient) outbound pacing queue depth.
    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
    /// and this value is `0`, the pacing wrapper substitutes
    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
    /// newest send is dropped and a `WARN` is logged.
    #[serde(default)]
    pub reply_queue_depth_max: u16,
}

impl Default for TelegramConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            bot_token: String::new(),
            api_base_url: default_telegram_api_base_url(),
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: default_draft_update_interval_ms(),
            interrupt_on_new_message: false,
            mention_only: false,
            ack_reactions: None,
            proxy_url: None,
            approval_timeout_secs: default_telegram_approval_timeout_secs(),
            excluded_tools: Vec::new(),
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
            debounce_ms: None,
        }
    }
}

impl TelegramConfig {
    /// Validate this alias's bot-token placeholder and enabled-state rules.
    pub fn validate_bot_token(&self, alias: &str) -> Result<()> {
        validate_required_bot_token(
            &format!("channels.telegram.{alias}.bot_token"),
            self.enabled,
            &self.bot_token,
        )
    }
}

impl ChannelConfig for TelegramConfig {
    fn name() -> &'static str {
        "Telegram"
    }
    fn desc() -> &'static str {
        "connect your bot"
    }
}

/// Scope of inbound Discord reaction events the bot records.
///
/// Anything other than `Off` adds the GUILD_MESSAGE_REACTIONS and
/// DIRECT_MESSAGE_REACTIONS gateway intents (both unprivileged; no
/// Developer Portal toggle needed) and archives matching reactions to the
/// channel's `discord.db` sidecar when `archive` is enabled.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum DiscordReactionScope {
    /// Ignore inbound reaction events entirely (no reaction intents requested).
    #[default]
    Off,
    /// Record reactions to the bot's own messages only.
    Own,
    /// Record reactions to any message that passes the channel's filters.
    All,
}

/// Discord bot channel configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.discord"]
#[allow(clippy::struct_excessive_bools)]
pub struct DiscordConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Discord bot token (from Discord Developer Portal). `#[serde(default)]`
    /// for the same reason as `TelegramConfig::bot_token`: a missing token
    /// must deserialize as an empty string instead of salvage-dropping the
    /// alias; `validate_bot_token` still requires a real token once
    /// `enabled = true`.
    #[secret]
    #[tab(Connection)]
    #[serde(default)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub bot_token: String,
    /// Guild (server) IDs to restrict the bot to. Empty = listen across all
    /// guilds the bot is invited to. Migrated from the legacy `guild_id`
    /// singular field.
    #[tab(Advanced)]
    #[serde(default)]
    pub guild_ids: Vec<String>,
    /// Channel IDs to watch. Empty = watch every channel the bot can see.
    /// Used by the archive sidecar (when `archive = true`) and by the
    /// in-channel filter when set.
    #[tab(Advanced)]
    #[serde(default)]
    pub channel_ids: Vec<String>,
    /// When true, the channel opens a sidecar `discord.db` SQLite memory
    /// backend, archives every non-bot message it sees, and registers the
    /// `discord_search` tool against it. Default: false. Folded in from
    /// the legacy `[channels.discord-history]` block.
    #[tab(Advanced)]
    #[serde(default)]
    pub archive: bool,
    /// When true, process messages from other bots (not just humans).
    /// The bot still ignores its own messages to prevent feedback loops.
    #[tab(Advanced)]
    #[serde(default)]
    pub listen_to_bots: bool,
    /// When true, a newer Discord message from the same sender in the same channel
    /// cancels the in-flight request and starts a fresh response with preserved history.
    #[tab(Behavior)]
    #[serde(default)]
    pub interrupt_on_new_message: bool,
    /// When true, only respond to messages that @-mention the bot.
    /// Other messages in the guild are silently ignored.
    #[tab(Behavior)]
    #[serde(default)]
    pub mention_only: bool,
    /// When true, register and serve Discord slash commands (e.g. `/ask`)
    /// over the Gateway WebSocket, in addition to message handling. Default
    /// false. (Prototype: currently registers a single `/ask <prompt>`.)
    #[tab(Behavior)]
    #[serde(default)]
    pub slash_commands: bool,
    /// Scope for registered slash commands: `global` (default, application-wide,
    /// ~1h propagation) or `guild` (registered to each `guild_ids` entry,
    /// instant). Only meaningful when `slash_commands = true`; `guild` with an
    /// empty `guild_ids` warns and falls back to global. Switching scope reaps
    /// owned commands from the now-inactive scope; note that *removing* a guild
    /// from `guild_ids` (without switching scope) does not reap that guild's
    /// commands - remove the bot from the guild, or switch scope, to clear them.
    #[tab(Behavior)]
    #[serde(default)]
    pub slash_command_scope: SlashCommandScope,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,
    /// Streaming mode for progressive response delivery.
    /// `off` (default): single message. `partial`: editable draft updates.
    /// `multi_message`: split response into separate messages at paragraph boundaries.
    #[tab(Behavior)]
    #[serde(default)]
    pub stream_mode: StreamMode,
    /// Minimum interval (ms) between draft message edits to avoid rate limits.
    /// Only used when `stream_mode = "partial"`.
    #[tab(Behavior)]
    #[serde(default = "default_draft_update_interval_ms")]
    pub draft_update_interval_ms: u64,
    /// Delay (ms) between sending each message chunk in multi-message mode.
    /// Only used when `stream_mode = "multi_message"`.
    #[tab(Behavior)]
    #[serde(default = "default_multi_message_delay_ms")]
    pub multi_message_delay_ms: u64,
    /// Stall-watchdog timeout in seconds. When non-zero, the bot will abort
    /// and retry if no progress is made within this duration. 0 = disabled.
    #[tab(Advanced)]
    #[serde(default)]
    pub stall_timeout_secs: u64,
    /// Raw gateway intent mask override. When set, this exact value is sent
    /// in IDENTIFY instead of the derived mask: an operator escape hatch
    /// for downstream deployments that consume gateway events through
    /// custom builds or forks. The operator owns the consequences:
    /// privileged bits still need their Developer Portal toggles, and
    /// dropping baseline bits silences message handling (a warning is
    /// logged). Unset (default) = derive the mask from the channel's
    /// feature config.
    #[tab(Advanced)]
    #[serde(default)]
    pub intents_mask: Option<u64>,
    /// Which inbound reactions to record: `off` (default: reaction events
    /// are not even requested from the gateway), `own` (reactions to the
    /// bot's messages), or `all` (reactions to any message passing the
    /// channel's filters). Recorded reactions are archived to `discord.db`
    /// when `archive` is enabled, and removed again when a user removes
    /// their reaction (bulk clears, remove-all / remove-emoji, are not
    /// yet swept). A set `intents_mask` overrides the derived mask: if it
    /// omits the reaction intents, nothing is recorded.
    #[tab(Behavior)]
    #[serde(default)]
    pub reaction_notifications: DiscordReactionScope,
    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
    #[tab(Behavior)]
    #[serde(default = "default_channel_approval_timeout_secs")]
    pub approval_timeout_secs: u64,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Per-(channel, recipient) outbound pacing floor in seconds.
    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
    #[serde(default)]
    pub reply_min_interval_secs: u64,
    /// Per-(channel, recipient) outbound pacing queue depth.
    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
    /// and this value is `0`, the pacing wrapper substitutes
    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
    /// newest send is dropped and a `WARN` is logged.
    #[serde(default)]
    pub reply_queue_depth_max: u16,
}

impl DiscordConfig {
    /// Validate this alias's bot-token placeholder and enabled-state rules.
    /// Mirrors `TelegramConfig::validate_bot_token`.
    pub fn validate_bot_token(&self, alias: &str) -> Result<()> {
        validate_required_bot_token(
            &format!("channels.discord.{alias}.bot_token"),
            self.enabled,
            &self.bot_token,
        )
    }
}

impl ChannelConfig for DiscordConfig {
    fn name() -> &'static str {
        "Discord"
    }
    fn desc() -> &'static str {
        "connect your bot"
    }
}

/// Slack bot channel configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.slack"]
#[allow(clippy::struct_excessive_bools)]
pub struct SlackConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Slack bot OAuth token (xoxb-...). Optional in config: when unset or
    /// empty it is resolved at channel construction from
    /// `ZEROCLAW_SLACK_BOT_TOKEN`, then `SLACK_BOT_TOKEN`. `#[serde(default)]`
    /// so a config that omits it still deserializes - the env fallback then
    /// supplies it - instead of failing with `missing field 'bot_token'`.
    ///
    #[secret]
    #[serde(default)]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub bot_token: Option<String>,
    /// Slack app-level token for Socket Mode (xapp-...). When unset or empty,
    /// resolved at channel construction from `ZEROCLAW_SLACK_APP_TOKEN`, then
    /// `SLACK_APP_TOKEN`. `#[serde(default)]` makes omission explicit (an
    /// `Option` field is already omittable, but this keeps it consistent with
    /// `bot_token`).
    #[secret]
    #[serde(default)]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub app_token: Option<String>,
    /// Explicit list of channel IDs to watch.
    /// Empty = listen across all accessible channels.
    /// Migrated from the legacy `channel_id` singular field.
    #[tab(Advanced)]
    #[serde(default)]
    pub channel_ids: Vec<String>,
    /// When true, a newer Slack message from the same sender in the same channel
    /// cancels the in-flight request and starts a fresh response with preserved history.
    #[tab(Behavior)]
    #[serde(default)]
    pub interrupt_on_new_message: bool,
    /// When true (default), replies stay in the originating Slack thread.
    /// When false, replies go to the channel root instead.
    #[tab(Advanced)]
    #[serde(default)]
    pub thread_replies: Option<bool>,
    /// When true, only respond to messages that @-mention the bot in groups.
    /// Direct messages remain allowed.
    #[tab(Behavior)]
    #[serde(default)]
    pub mention_only: bool,
    /// When true (and `mention_only` is also true), messages inside a Slack
    /// thread must also @-mention the bot to trigger a response. By default,
    /// thread replies are allowed through without a mention so the bot can
    /// keep a back-and-forth going without the user repeating @-mentions.
    /// Set this to true in channels shared with human discussion where the
    /// bot should stay silent unless explicitly addressed.
    #[tab(Advanced)]
    #[serde(default)]
    pub strict_mention_in_thread: bool,
    /// Use the newer Slack `markdown` block type (12 000 char limit, richer formatting).
    /// Defaults to false (uses universally supported `section` blocks with `mrkdwn`).
    /// Enable this only if your Slack workspace supports the `markdown` block type.
    #[tab(Advanced)]
    #[serde(default)]
    pub use_markdown_blocks: bool,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,
    /// Enable progressive draft message streaming via `chat.update`.
    #[tab(Behavior)]
    #[serde(default)]
    pub stream_drafts: bool,
    /// Minimum interval (ms) between draft message edits to avoid Slack rate limits.
    #[tab(Behavior)]
    #[serde(default = "default_slack_draft_update_interval_ms")]
    pub draft_update_interval_ms: u64,
    /// Emoji reaction name (without colons) that cancels an in-flight request.
    /// For example, `"x"` means reacting with `:x:` cancels the task.
    /// Leave unset to disable reaction-based cancellation.
    #[tab(Advanced)]
    #[serde(default)]
    pub cancel_reaction: Option<String>,
    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
    #[tab(Behavior)]
    #[serde(default = "default_channel_approval_timeout_secs")]
    pub approval_timeout_secs: u64,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Per-(channel, recipient) outbound pacing floor in seconds.
    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
    #[serde(default)]
    pub reply_min_interval_secs: u64,
    /// Per-(channel, recipient) outbound pacing queue depth.
    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
    /// and this value is `0`, the pacing wrapper substitutes
    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
    /// newest send is dropped and a `WARN` is logged.
    #[serde(default)]
    pub reply_queue_depth_max: u16,
}

fn default_slack_draft_update_interval_ms() -> u64 {
    1200
}

impl ChannelConfig for SlackConfig {
    fn name() -> &'static str {
        "Slack"
    }
    fn desc() -> &'static str {
        "connect your bot"
    }
}

impl SlackConfig {
    /// Resolve the effective Slack bot token: the configured `bot_token` when
    /// set and non-empty, else the `ZEROCLAW_SLACK_BOT_TOKEN` env var, else
    /// `SLACK_BOT_TOKEN`. Returns `None` when none is available. Resolving here
    /// (rather than writing the env value back into the config struct) keeps an
    /// env-supplied secret out of any config that might later be persisted to
    /// disk.
    pub fn resolved_bot_token(&self) -> Option<String> {
        resolve_slack_token(self.bot_token.as_deref(), "BOT")
    }

    /// Resolve the effective Slack app-level (Socket Mode) token: configured
    /// `app_token` when set and non-empty, else `ZEROCLAW_SLACK_APP_TOKEN`,
    /// else `SLACK_APP_TOKEN`. Returns `None` when none is available.
    pub fn resolved_app_token(&self) -> Option<String> {
        resolve_slack_token(self.app_token.as_deref(), "APP")
    }
}

/// Shared token resolution for [`SlackConfig`]: a non-empty configured value
/// wins; otherwise the `ZEROCLAW_SLACK_<KIND>_TOKEN` env var takes precedence
/// over the conventional `SLACK_<KIND>_TOKEN`. Blank values (config or env) are
/// treated as absent. `kind` is `"BOT"` or `"APP"`.
fn resolve_slack_token(configured: Option<&str>, kind: &str) -> Option<String> {
    if let Some(value) = configured {
        let value = value.trim();
        if !value.is_empty() {
            return Some(value.to_string());
        }
    }
    for var in [
        format!("ZEROCLAW_SLACK_{kind}_TOKEN"),
        format!("SLACK_{kind}_TOKEN"),
    ] {
        if let Ok(value) = std::env::var(&var) {
            let value = value.trim();
            if !value.is_empty() {
                return Some(value.to_string());
            }
        }
    }
    None
}

/// How the Mattermost channel receives inbound messages.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum MattermostListenMode {
    /// Poll REST API every 3 seconds. The default — all existing configs
    /// continue to work without changes.
    #[default]
    Polling,
    /// Connect to `/api/v4/websocket` for near-real-time event delivery.
    Websocket,
}

/// Mattermost bot channel configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.mattermost"]
pub struct MattermostConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Mattermost server URL (e.g. `"https://mattermost.example.com"`).
    #[tab(Connection)]
    pub url: String,
    /// Mattermost bot access token. When unset, the channel falls back to
    /// the login flow using `login_id` + `password`.
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    #[serde(default)]
    pub bot_token: Option<String>,
    /// Login ID (email or username) for the password login flow. Used only
    /// when `bot_token` is unset; both `login_id` and `password` must be
    /// set together.
    #[tab(Connection)]
    #[serde(default)]
    pub login_id: Option<String>,
    /// Account password for the login flow. Used only when `bot_token` is
    /// unset; both `login_id` and `password` must be set together.
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    #[serde(default)]
    pub password: Option<String>,
    /// Channel IDs to restrict the bot to. Empty or `["*"]` = auto-discover
    /// every channel the bot can read (public, private, DMs, group DMs) and
    /// poll them all. Explicit IDs disable discovery and pin the bot to the
    /// listed channels only. Migrated from the legacy `channel_id` singular
    /// field.
    #[tab(Advanced)]
    #[serde(default)]
    pub channel_ids: Vec<String>,
    /// Team IDs to restrict auto-discovery to. Empty = discover across every
    /// team the bot belongs to. Non-empty = only discover public/private
    /// channels whose `team_id` is in this list. DMs and group DMs (which
    /// have no team) are governed by `discover_dms` instead.
    #[tab(Advanced)]
    #[serde(default)]
    pub team_ids: Vec<String>,
    /// When true (default), auto-discovery includes DM (`type=D`) and group
    /// DM (`type=G`) channels. Set false to restrict the bot to public and
    /// private team channels only. Has no effect when `channel_ids` lists
    /// explicit IDs. Defaults to `true` at the call site via
    /// `discover_dms.unwrap_or(true)`.
    #[tab(Advanced)]
    #[serde(default)]
    pub discover_dms: Option<bool>,
    /// When true (default), replies thread on the original post.
    /// When false, replies go to the channel root.
    #[tab(Advanced)]
    #[serde(default)]
    pub thread_replies: Option<bool>,
    /// When true, only respond to messages that @-mention the bot. Other
    /// messages in the channel are silently ignored. DM and group DM
    /// channels always bypass this filter: a 1:1 (or small-group) direct
    /// conversation has no ambient noise to gate against, so every message
    /// is treated as addressed to the bot.
    #[tab(Behavior)]
    #[serde(default)]
    pub mention_only: Option<bool>,
    /// When true, a newer Mattermost message from the same sender in the same channel
    /// cancels the in-flight request and starts a fresh response with preserved history.
    #[tab(Behavior)]
    #[serde(default)]
    pub interrupt_on_new_message: bool,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,

    /// Listen mode: `"polling"` (REST API every 3s, default) or `"websocket"`
    /// (persistent WebSocket connection to `/api/v4/websocket` for near-real-time
    /// event delivery). WebSocket mode reduces server load and delivers events
    /// faster but requires a WebSocket-capable Mattermost server (v4.0+).
    #[serde(default)]
    pub listen_mode: MattermostListenMode,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Per-(channel, recipient) outbound pacing floor in seconds.
    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
    #[serde(default)]
    pub reply_min_interval_secs: u64,
    /// Per-(channel, recipient) outbound pacing queue depth.
    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
    /// and this value is `0`, the pacing wrapper substitutes
    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
    /// newest send is dropped and a `WARN` is logged.
    #[serde(default)]
    pub reply_queue_depth_max: u16,
}

impl ChannelConfig for MattermostConfig {
    fn name() -> &'static str {
        "Mattermost"
    }
    fn desc() -> &'static str {
        "connect to your bot"
    }
}

/// Webhook channel configuration.
///
/// Receives messages via HTTP POST and sends replies to a configurable outbound URL.
/// This is the "universal adapter" for any system that supports webhooks.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.webhook"]
pub struct WebhookConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Port to listen on for incoming webhooks.
    #[tab(Advanced)]
    #[serde(default = "default_webhook_channel_port")]
    pub port: u16,
    /// URL path to listen on (default: `/webhook`).
    #[tab(Advanced)]
    #[serde(default)]
    pub listen_path: Option<String>,
    /// URL to POST/PUT outbound messages to.
    #[tab(Advanced)]
    #[serde(default)]
    pub send_url: Option<String>,
    /// HTTP method for outbound messages (`POST` or `PUT`). Default: `POST`.
    #[tab(Advanced)]
    #[serde(default)]
    pub send_method: Option<String>,
    /// Optional `Authorization` header value for outbound requests.
    #[tab(Connection)]
    #[serde(default)]
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub auth_header: Option<String>,
    /// Shared secret for webhook signature verification (HMAC-SHA256).
    /// The channel will refuse to start without one. Set
    /// `[channels.webhook.<alias>].secret` in config.
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub secret: Option<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Per-(channel, recipient) outbound pacing floor in seconds.
    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
    #[serde(default)]
    pub reply_min_interval_secs: u64,
    /// Per-(channel, recipient) outbound pacing queue depth.
    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
    /// and this value is `0`, the pacing wrapper substitutes
    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
    /// newest send is dropped and a `WARN` is logged.
    #[serde(default)]
    pub reply_queue_depth_max: u16,

    /// Maximum number of retry attempts for outbound sends on transient failures
    /// (network errors, 429, 5xx). Set to `0` to disable retries. Default: `3`.
    #[serde(default)]
    pub max_retries: Option<u32>,
    /// Base delay in milliseconds for exponential backoff between retries. Default: `500`.
    /// Values below `1` are clamped to `1ms` at runtime to avoid busy-retry loops.
    #[serde(default)]
    pub retry_base_delay_ms: Option<u64>,
    /// Maximum delay cap in milliseconds for any single retry wait. Default: `30000` (30s).
    /// Values below `1` are clamped to `1ms` at runtime to avoid busy-retry loops.
    #[serde(default)]
    pub retry_max_delay_ms: Option<u64>,
}

fn default_webhook_channel_port() -> u16 {
    8090
}

impl ChannelConfig for WebhookConfig {
    fn name() -> &'static str {
        "Webhook"
    }
    fn desc() -> &'static str {
        "HTTP endpoint"
    }
}

/// iMessage channel configuration (macOS only).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.imessage"]
pub struct IMessageConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Per-(channel, recipient) outbound pacing floor in seconds.
    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
    #[serde(default)]
    pub reply_min_interval_secs: u64,
    /// Per-(channel, recipient) outbound pacing queue depth.
    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
    /// and this value is `0`, the pacing wrapper substitutes
    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
    /// newest send is dropped and a `WARN` is logged.
    #[serde(default)]
    pub reply_queue_depth_max: u16,
}

impl ChannelConfig for IMessageConfig {
    fn name() -> &'static str {
        "iMessage"
    }
    fn desc() -> &'static str {
        "macOS only"
    }
}

/// Matrix channel configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.matrix"]
pub struct MatrixConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Matrix homeserver URL (e.g. `"https://matrix.org"`).
    #[tab(Connection)]
    pub homeserver: String,
    /// Matrix access token for the bot account. When unset, the channel
    /// falls back to password login using `user_id` + `password`.
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    #[serde(default)]
    pub access_token: Option<String>,
    /// Optional Matrix user ID (e.g. `"@bot:matrix.org"`).
    #[tab(Connection)]
    #[serde(default)]
    pub user_id: Option<String>,
    /// Optional Matrix device ID.
    #[tab(Connection)]
    #[serde(default)]
    pub device_id: Option<String>,
    /// Allowed Matrix room IDs. Empty = allow all rooms the bot has joined.
    /// Entries are matched literally against the canonical room ID
    /// (`!abc:server`) of each incoming message; `#room:server` aliases are
    /// not resolved for this allowlist (they are resolved only for outbound
    /// delivery targets such as cron `delivery.to`).
    #[tab(Behavior)]
    #[serde(default)]
    pub allowed_rooms: Vec<String>,
    /// Whether to interrupt an in-flight agent response when a new message arrives.
    #[tab(Behavior)]
    #[serde(default)]
    pub interrupt_on_new_message: bool,
    /// Streaming mode for progressive response delivery.
    /// `"off"` (default): single message. `"partial"`: edit-in-place draft.
    /// `"multi_message"`: paragraph-split delivery.
    #[tab(Behavior)]
    #[serde(default)]
    pub stream_mode: StreamMode,
    /// Minimum interval (ms) between draft message edits in Partial mode.
    #[tab(Behavior)]
    #[serde(default = "default_matrix_draft_update_interval_ms")]
    pub draft_update_interval_ms: u64,
    /// Delay (ms) between sending each paragraph in MultiMessage mode.
    #[tab(Behavior)]
    #[serde(default = "default_multi_message_delay_ms")]
    pub multi_message_delay_ms: u64,
    /// When true, only respond to messages that @-mention the bot in groups.
    /// Direct messages are always processed.
    #[tab(Behavior)]
    #[serde(default)]
    pub mention_only: bool,
    /// Optional Matrix recovery key for automatic E2EE key backup restore.
    /// When set, ZeroClaw recovers room keys and cross-signing secrets on startup.
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    #[serde(default)]
    pub recovery_key: Option<String>,
    /// Optional login password for Matrix account (used for initial login flow).
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    #[serde(default)]
    pub password: Option<String>,
    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
    #[tab(Behavior)]
    #[serde(default = "default_channel_approval_timeout_secs")]
    pub approval_timeout_secs: u64,
    /// When true (default), replies are sent as thread replies. Starts a new thread from the
    /// incoming message when none exists. When false, only continues existing threads.
    #[tab(Behavior)]
    #[serde(default = "default_true")]
    pub reply_in_thread: bool,
    /// Override for the top-level `[channels].ack_reactions`. When
    /// `None`, falls back to the channels-wide default. When set
    /// explicitly (`true`/`false`), takes precedence for this Matrix
    /// instance only.
    #[tab(Behavior)]
    #[serde(default)]
    pub ack_reactions: Option<bool>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Per-(channel, recipient) outbound pacing floor in seconds.
    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
    #[serde(default)]
    pub reply_min_interval_secs: u64,
    /// Per-(channel, recipient) outbound pacing queue depth.
    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
    /// and this value is `0`, the pacing wrapper substitutes
    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
    /// newest send is dropped and a `WARN` is logged.
    #[serde(default)]
    pub reply_queue_depth_max: u16,
}

impl ChannelConfig for MatrixConfig {
    fn name() -> &'static str {
        "Matrix"
    }
    fn desc() -> &'static str {
        "self-hosted chat"
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.signal"]
pub struct SignalConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Base URL for the signal-cli HTTP daemon (e.g. `"http://127.0.0.1:8686"`).
    #[tab(Connection)]
    pub http_url: String,
    /// E.164 phone number of the signal-cli account (e.g. "+1234567890").
    #[tab(Connection)]
    pub account: String,
    /// Group IDs to filter messages. Empty = accept all messages (DMs and
    /// groups). When non-empty, only messages from listed groups are
    /// accepted (DMs are still accepted unless `dm_only` flips the policy
    /// to DMs-only). Migrated from the legacy `group_id` singular field.
    #[tab(Advanced)]
    #[serde(default)]
    pub group_ids: Vec<String>,
    /// When true, only accept direct messages and ignore all group traffic.
    /// Mutually exclusive with `group_ids` (which is ignored when this is
    /// set). Migrated from the legacy `group_id = "dm"` sentinel.
    #[tab(Advanced)]
    #[serde(default)]
    pub dm_only: bool,
    /// Skip messages that are attachment-only (no text body).
    #[tab(Advanced)]
    #[serde(default)]
    pub ignore_attachments: bool,
    /// Skip incoming story messages.
    #[tab(Advanced)]
    #[serde(default)]
    pub ignore_stories: bool,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,
    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
    #[tab(Behavior)]
    #[serde(default = "default_channel_approval_timeout_secs")]
    pub approval_timeout_secs: u64,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Per-(channel, recipient) outbound pacing floor in seconds.
    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
    #[serde(default)]
    pub reply_min_interval_secs: u64,
    /// Per-(channel, recipient) outbound pacing queue depth.
    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
    /// and this value is `0`, the pacing wrapper substitutes
    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
    /// newest send is dropped and a `WARN` is logged.
    #[serde(default)]
    pub reply_queue_depth_max: u16,
}

impl SignalConfig {
    /// Whether both required credentials (`http_url`, `account`) are
    /// present. Mirrors `WhatsAppConfig::is_cloud_config`'s role: the
    /// channel orchestrator uses this bool to decide whether to build the
    /// channel at all, skipping (with a warning) an enabled-but-uncredentialed
    /// alias instead of building a listener that can never connect and
    /// crashloops its per-channel supervisor.
    pub fn has_required_credentials(&self) -> bool {
        !crate::traits::is_unset_display_value(&self.http_url)
            && !crate::traits::is_unset_display_value(&self.account)
    }

    /// Validate this alias's required credentials once enabled. Mirrors
    /// `TelegramConfig::validate_bot_token` / `DiscordConfig::validate_bot_token`:
    /// an enabled Signal channel built with an empty `http_url` or `account`
    /// never connects and crashloops its per-channel supervisor, so reject
    /// it at config-load time instead.
    pub fn validate_required(&self, alias: &str) -> Result<()> {
        let enabled_path = format!("channels.signal.{alias}.enabled");
        validate_required_field(
            &format!("channels.signal.{alias}.http_url"),
            &enabled_path,
            self.enabled,
            &self.http_url,
        )?;
        validate_required_field(
            &format!("channels.signal.{alias}.account"),
            &enabled_path,
            self.enabled,
            &self.account,
        )
    }
}

impl ChannelConfig for SignalConfig {
    fn name() -> &'static str {
        "Signal"
    }
    fn desc() -> &'static str {
        "An open-source, encrypted messaging service"
    }
}

/// WhatsApp Web usage mode.
///
/// `Personal` treats the account as a personal phone — the bot only responds to
/// incoming messages that pass the DM/group/self-chat policy filters.
/// `Business` (default) responds to all incoming messages, subject only to the
/// `allowed_numbers` allowlist.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, zeroclaw_macros::ConfigEnum)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum WhatsAppWebMode {
    /// Respond to all messages passing the allowlist (default).
    #[default]
    Business,
    /// Apply per-chat-type policies (dm_policy, group_policy, self_chat_mode).
    Personal,
}

/// Policy for a particular WhatsApp chat type (DMs or groups) when
/// `mode = "personal"`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, zeroclaw_macros::ConfigEnum)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum WhatsAppChatPolicy {
    /// Only respond to senders on the `allowed_numbers` list (default).
    #[default]
    Allowlist,
    /// Ignore all messages in this chat type.
    Ignore,
    /// Respond to every message regardless of allowlist.
    All,
}

/// WhatsApp channel configuration (Cloud API or Web mode).
///
/// Set `phone_number_id` for Cloud API mode, or `session_path` for Web mode.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.whatsapp"]
pub struct WhatsAppConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Access token from Meta Business Suite (Cloud API mode)
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub access_token: Option<String>,
    /// Phone number ID from Meta Business API (Cloud API mode)
    #[tab(Connection)]
    #[serde(default)]
    pub phone_number_id: Option<String>,
    /// Webhook verify token (you define this, Meta sends it back for verification)
    /// Only used in Cloud API mode
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub verify_token: Option<String>,
    /// App secret from Meta Business Suite (for webhook signature verification)
    /// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable
    /// Only used in Cloud API mode
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub app_secret: Option<String>,
    /// Session database path for WhatsApp Web client (Web mode)
    /// When set, enables native WhatsApp Web mode with wa-rs
    #[tab(Connection)]
    #[serde(default)]
    pub session_path: Option<String>,
    /// Phone number for pair code linking (Web mode, optional)
    /// Format: country code + number (e.g., "15551234567")
    /// If not set, QR code pairing will be used
    #[tab(Connection)]
    #[serde(default)]
    pub pair_phone: Option<String>,
    /// Custom pair code for linking (Web mode, optional)
    /// Leave empty to let WhatsApp generate one
    #[tab(Connection)]
    #[serde(default)]
    pub pair_code: Option<String>,
    /// Override the WhatsApp Web WebSocket URL (Web mode, optional). Used
    /// by integration tests and proxy setups; leave unset to use the
    /// default endpoint that ships with `wa-rs`.
    #[tab(Connection)]
    #[serde(default)]
    pub ws_url: Option<String>,
    /// When true, only respond to messages that @-mention the bot in groups (Web mode only).
    /// Direct messages are always processed.
    /// Bot identity is resolved from the wa-rs device at runtime; `pair_phone` seeds it on first connect.
    #[tab(Behavior)]
    #[serde(default)]
    pub mention_only: bool,
    /// When true in WhatsApp Web group chats, unaddressed messages that pass
    /// sender/chat authorization are recorded as passive conversation context
    /// without starting an agent turn. Default: `false`.
    #[tab(Behavior)]
    #[serde(default)]
    pub passive_group_context: bool,
    /// Cancel an in-flight response from this channel sender when a newer
    /// WhatsApp message arrives. Default: `false`.
    #[serde(default)]
    pub interrupt_on_new_message: bool,
    /// Usage mode for WhatsApp Web: "business" (default) or "personal".
    /// In personal mode the bot applies dm_policy, group_policy, and
    /// self_chat_mode to decide which chats to respond in.
    #[tab(Advanced)]
    #[serde(default)]
    pub mode: WhatsAppWebMode,
    /// Policy for direct messages when mode = "personal".
    /// "allowlist" (default) | "ignore" | "all".
    #[tab(Advanced)]
    #[serde(default)]
    pub dm_policy: WhatsAppChatPolicy,
    /// Policy for group chats when mode = "personal".
    /// "allowlist" (default) | "ignore" | "all".
    #[tab(Advanced)]
    #[serde(default)]
    pub group_policy: WhatsAppChatPolicy,
    /// When true and mode = "personal", always respond to messages in the
    /// user's own self-chat (Notes to Self). Defaults to false.
    #[tab(Advanced)]
    #[serde(default)]
    pub self_chat_mode: bool,
    /// Regex patterns for DM mention gating (case-insensitive).
    /// When non-empty, only direct messages matching at least one pattern are
    /// processed; matched fragments are stripped from the forwarded content.
    /// Example: `["@?ZeroClaw", "\\+?15555550123"]`
    #[tab(Advanced)]
    #[serde(default)]
    pub dm_mention_patterns: Vec<String>,
    /// Regex patterns for group-chat mention gating (case-insensitive).
    /// When non-empty, only group messages matching at least one pattern are
    /// processed; matched fragments are stripped from the forwarded content.
    /// Example: `["@?ZeroClaw", "\\+?15555550123"]`
    #[tab(Advanced)]
    #[serde(default)]
    pub group_mention_patterns: Vec<String>,
    /// Allowed group chats by JID (Web mode). An empty list (the default)
    /// permits all groups; a non-empty list drops every group message whose
    /// chat JID matches no entry. Each entry matches either the full group
    /// JID (`123456789012345@g.us`) or the JID user part - the segment before
    /// `@` (`123456789012345`) - compared exactly, not as a string prefix.
    /// Direct messages bypass this filter regardless of list contents.
    /// Modeled on the Matrix channel's `allowed_rooms`; it gates group
    /// identity, which `dm_policy`/`group_policy` (chat type) and the
    /// sender allowlist (sender) do not.
    #[tab(Advanced)]
    #[serde(default)]
    pub allowed_groups: Vec<String>,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,
    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
    #[tab(Behavior)]
    #[serde(default = "default_channel_approval_timeout_secs")]
    pub approval_timeout_secs: u64,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Per-(channel, recipient) outbound pacing floor in seconds.
    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
    #[serde(default)]
    pub reply_min_interval_secs: u64,
    /// Per-(channel, recipient) outbound pacing queue depth.
    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
    /// and this value is `0`, the pacing wrapper substitutes
    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
    /// newest send is dropped and a `WARN` is logged.
    #[serde(default)]
    pub reply_queue_depth_max: u16,
}

impl ChannelConfig for WhatsAppConfig {
    fn name() -> &'static str {
        "WhatsApp"
    }
    fn desc() -> &'static str {
        "Business Cloud API"
    }
}

impl_reply_pacing!(
    TelegramConfig,
    DiscordConfig,
    SlackConfig,
    MattermostConfig,
    WebhookConfig,
    IMessageConfig,
    MatrixConfig,
    SignalConfig,
    WhatsAppConfig,
);

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.linq"]
pub struct LinqConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Linq Partner API token (Bearer auth)
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_token: String,
    /// Phone number to send from (E.164 format)
    #[tab(Advanced)]
    pub from_phone: String,
    /// Webhook signing secret for signature verification
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub signing_secret: Option<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for LinqConfig {
    fn name() -> &'static str {
        "Linq"
    }
    fn desc() -> &'static str {
        "iMessage/RCS/SMS via Linq API"
    }
}

/// WATI WhatsApp Business API channel configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.wati"]
pub struct WatiConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// WATI API token (Bearer auth).
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_token: String,
    /// WATI API base URL (default: <https://live-mt-server.wati.io>).
    #[tab(Advanced)]
    #[serde(default = "default_wati_api_url")]
    pub api_url: String,
    /// Tenant ID for multi-channel setups (optional).
    #[tab(Advanced)]
    #[serde(default)]
    pub tenant_id: Option<String>,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

fn default_wati_api_url() -> String {
    "https://live-mt-server.wati.io".to_string()
}

impl ChannelConfig for WatiConfig {
    fn name() -> &'static str {
        "WATI"
    }
    fn desc() -> &'static str {
        "WhatsApp via WATI Business API"
    }
}

/// Nextcloud Talk bot configuration (webhook receive + signed bot send API).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.nextcloud_talk"]
pub struct NextcloudTalkConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Nextcloud base URL (e.g. `"https://cloud.example.com"`).
    #[tab(Connection)]
    pub base_url: String,
    /// Deprecated, unused. Nextcloud Talk sends do not authenticate via
    /// OCS bearer auth (see `webhook_secret`); this field is only accepted so
    /// existing configs that set it don't fail to parse. Safe to remove
    /// from your config.
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub app_token: Option<String>,
    /// DEPRECATED alias for `webhook_secret`, kept for migration.
    ///
    /// Nextcloud issues ONE secret per installed bot and uses it for both
    /// directions, so this cannot hold a different outbound secret. When both
    /// are set to different non-empty values the channel logs the conflict and
    /// fails closed as unconfigured: inbound `401` and no outbound send.
    /// Prefer `webhook_secret`; this alias will be removed.
    ///
    /// Upgrade from a `bot_token`-only configuration: copy the installed bot
    /// secret to `webhook_secret`, verify replies still send, then delete
    /// `bot_token`.
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub bot_token: Option<String>,
    /// The bot secret Nextcloud installed for this bot. Canonical field.
    ///
    /// Used for BOTH directions: verifying inbound webhook signatures and
    /// signing outbound bot-API requests. When it is unset, inbound webhooks
    /// are rejected and no outbound request is sent (fail closed).
    ///
    /// Can also be set via `ZEROCLAW_NEXTCLOUD_TALK_WEBHOOK_SECRET`.
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub webhook_secret: Option<String>,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,
    /// Display name of the bot in Nextcloud Talk (e.g. "zeroclaw").
    /// Used to filter out the bot's own messages and prevent feedback loops.
    /// If not set, defaults to an empty string (no self-message filtering by name).
    #[tab(Advanced)]
    #[serde(default)]
    pub bot_name: Option<String>,
    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
    /// Retained for configuration compatibility. Nextcloud Talk's bot API does
    /// not provide message IDs or edit/delete operations, so draft updates are
    /// disabled and responses are currently sent as one final message for every
    /// value.
    #[tab(Behavior)]
    #[serde(default)]
    pub stream_mode: StreamMode,
    /// Retained for configuration compatibility. Currently inert while draft
    /// updates are disabled for this channel. Default: 1000 ms.
    #[tab(Behavior)]
    #[serde(default = "default_draft_update_interval_ms")]
    pub draft_update_interval_ms: u64,
}
impl NextcloudTalkConfig {
    /// Resolve the single bot secret Nextcloud installs for this bot.
    ///
    /// Nextcloud issues ONE secret per installed bot and uses it for both directions:
    /// verifying inbound webhook signatures and signing outbound bot-API requests. So
    /// both directions must resolve it identically, or one of them cannot match the
    /// installed bot.
    ///
    /// `webhook_secret` is canonical; `bot_token` is a deprecated alias kept for
    /// migration. Both are trimmed, and a blank value is treated as absent so an empty
    /// or whitespace alias cannot mask a real secret. Two conflicting non-empty values
    /// are rejected rather than silently split into two authorities.
    ///
    /// Returns `Ok(None)` when no secret is configured. Callers must fail closed on
    /// `Ok(None)` and on `Err`: send no outbound request, and reject inbound webhooks
    /// rather than skipping verification.
    pub fn resolve_bot_secret(&self) -> Result<Option<String>, NextcloudTalkSecretConflict> {
        let norm = |v: &Option<String>| -> Option<String> {
            v.as_deref()
                .map(str::trim)
                .filter(|s| !s.is_empty())
                .map(ToOwned::to_owned)
        };
        let canonical = norm(&self.webhook_secret);
        let alias = norm(&self.bot_token);
        match (canonical, alias) {
            (Some(a), Some(b)) if a != b => Err(NextcloudTalkSecretConflict),
            (Some(a), _) => Ok(Some(a)),
            (None, Some(b)) => Ok(Some(b)),
            (None, None) => Ok(None),
        }
    }
}

/// `webhook_secret` and the deprecated `bot_token` alias hold different non-empty
/// values. Nextcloud installs one secret per bot, so there is no correct choice
/// between them and guessing would leave one direction unable to match.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NextcloudTalkSecretConflict;

impl std::fmt::Display for NextcloudTalkSecretConflict {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(
            "nextcloud_talk: `webhook_secret` and the deprecated `bot_token` alias are set \
to different values; Nextcloud installs one secret per bot. Set `webhook_secret` only.",
        )
    }
}

impl std::error::Error for NextcloudTalkSecretConflict {}

impl ChannelConfig for NextcloudTalkConfig {
    fn name() -> &'static str {
        "NextCloud Talk"
    }
    fn desc() -> &'static str {
        "NextCloud Talk platform"
    }
}

impl WhatsAppConfig {
    /// Detect which backend to use based on config fields.
    /// Cloud API when `phone_number_id` is set; otherwise Web when any
    /// Web-only selector (`session_path`, `pair_phone`, `pair_code`,
    /// `ws_url`, or `mode = personal`) is present. Falls back to Cloud
    /// for an otherwise-empty config (env-injected Cloud credentials).
    pub fn backend_type(&self) -> &'static str {
        if self.phone_number_id.is_some() {
            "cloud"
        } else if self.has_web_selector() {
            "web"
        } else {
            "cloud"
        }
    }

    /// Check if this is a valid Cloud API config
    pub fn is_cloud_config(&self) -> bool {
        self.phone_number_id.is_some() && self.access_token.is_some() && self.verify_token.is_some()
    }

    /// Any Web-only selector that signals WhatsApp Web intent. `mode`
    /// defaults to `Business` on every config, so only a non-default
    /// `Personal` mode counts; the rest are `Option` fields absent by
    /// default.
    pub fn has_web_selector(&self) -> bool {
        self.session_path.is_some()
            || self.pair_phone.is_some()
            || self.pair_code.is_some()
            || self.ws_url.is_some()
            || self.mode == WhatsAppWebMode::Personal
    }

    /// Check if this is a valid Web config. The Web client defaults a
    /// missing `session_path`, so any Web selector is sufficient.
    pub fn is_web_config(&self) -> bool {
        self.has_web_selector()
    }

    /// Returns true when both Cloud and Web selectors are present.
    ///
    /// Runtime currently prefers Cloud mode in this case for backward compatibility.
    pub fn is_ambiguous_config(&self) -> bool {
        self.phone_number_id.is_some() && self.has_web_selector()
    }
}

/// MQTT channel configuration (SOP listener).
///
/// Subscribes to MQTT topics and dispatches incoming messages
/// to the SOP engine for processing.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.mqtt"]
pub struct MqttConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// MQTT broker URL (e.g., `mqtt://localhost:1883` or `mqtts://broker.example.com:8883`).
    /// Use `mqtt://` for plain connections or `mqtts://` for TLS.
    #[tab(Connection)]
    pub broker_url: String,
    /// MQTT client ID (must be unique per broker).
    #[tab(Advanced)]
    pub client_id: String,
    /// Topics to subscribe to (e.g., `sensors/#`, `alerts/+/critical`).
    /// At least one topic is required.
    #[tab(Advanced)]
    #[serde(default)]
    pub topics: Vec<String>,
    /// MQTT QoS level (0 = at-most-once, 1 = at-least-once, 2 = exactly-once). Default: 1.
    #[tab(Advanced)]
    #[serde(default = "default_mqtt_qos")]
    pub qos: u8,
    /// Username for authentication (optional).
    #[tab(Connection)]
    pub username: Option<String>,
    /// Password for authentication (optional).
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub password: Option<String>,
    /// Enable TLS encryption. Must match the broker_url scheme:
    /// - `mqtt://` → `use_tls: false`
    /// - `mqtts://` → `use_tls: true`
    #[tab(Advanced)]
    #[serde(default)]
    pub use_tls: bool,
    /// Keep-alive interval in seconds (default: 30). Prevents broker disconnect on idle.
    #[tab(Advanced)]
    #[serde(default = "default_mqtt_keep_alive_secs")]
    pub keep_alive_secs: u64,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl MqttConfig {
    /// Validate the MQTT configuration.
    ///
    /// Checks:
    /// - QoS is 0, 1, or 2
    /// - broker_url uses valid scheme (`mqtt://` or `mqtts://`)
    /// - `use_tls` flag matches broker_url scheme
    /// - At least one topic is configured
    /// - client_id is non-empty
    pub fn validate(&self) -> anyhow::Result<()> {
        // QoS validation
        if self.qos > 2 {
            anyhow::bail!("qos must be 0, 1, or 2, got {}", self.qos);
        }

        // Broker URL validation
        let is_tls_scheme = self.broker_url.starts_with("mqtts://");
        let is_mqtt_scheme = self.broker_url.starts_with("mqtt://");

        if !is_tls_scheme && !is_mqtt_scheme {
            anyhow::bail!(
                "broker_url must start with 'mqtt://' or 'mqtts://', got: {}",
                self.broker_url
            );
        }

        // TLS flag validation
        if is_mqtt_scheme && self.use_tls {
            anyhow::bail!("use_tls is true but broker_url uses 'mqtt://' (not 'mqtts://')");
        }

        if is_tls_scheme && !self.use_tls {
            anyhow::bail!(
                "use_tls is false but broker_url uses 'mqtts://' (requires use_tls: true)"
            );
        }

        // Topics validation
        if self.topics.is_empty() {
            anyhow::bail!("at least one topic must be configured");
        }

        // Client ID validation
        if self.client_id.is_empty() {
            validation_bail!(
                RequiredFieldEmpty,
                "client_id",
                "client_id must not be empty"
            );
        }

        Ok(())
    }
}

impl ChannelConfig for MqttConfig {
    fn name() -> &'static str {
        "MQTT"
    }
    fn desc() -> &'static str {
        "MQTT SOP Listener"
    }
}

fn default_mqtt_qos() -> u8 {
    1
}

fn default_mqtt_keep_alive_secs() -> u64 {
    30
}

/// Filesystem SOP listener configuration.
///
/// Watches configured paths and dispatches file create/modify/delete/rename
/// events to the SOP engine. This is a fan-in listener, not a chat channel:
/// its `Channel::send` has no outbound surface.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.filesystem"]
pub struct FilesystemConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Root paths to watch. At least one is required.
    #[tab(Connection)]
    #[serde(default)]
    pub paths: Vec<String>,
    /// Watch nested paths beneath each root.
    #[tab(Behavior)]
    #[serde(default = "default_true")]
    pub recursive: bool,
    /// Glob patterns to include. Empty matches all paths under the roots.
    #[tab(Behavior)]
    #[serde(default)]
    pub include: Vec<String>,
    /// Glob patterns to exclude. Applied after `include`.
    #[tab(Behavior)]
    #[serde(default)]
    pub exclude: Vec<String>,
    /// Event kinds to emit (`created`, `modified`, `deleted`, `renamed`).
    #[tab(Behavior)]
    #[serde(default = "default_filesystem_events")]
    pub events: Vec<String>,
    /// Collapse rapid repeated events per path/kind within this window.
    #[tab(Advanced)]
    #[serde(default = "default_filesystem_debounce_ms")]
    pub debounce_ms: u64,
    /// Wait this long after the last event before reading metadata or content,
    /// giving atomic writes and slow copies time to finish.
    #[tab(Advanced)]
    #[serde(default = "default_filesystem_settle_ms")]
    pub settle_ms: u64,
    /// Read file content into the event payload. Opt-in.
    #[tab(Behavior)]
    #[serde(default)]
    pub read_content: bool,
    /// Follow symlinks when reading event-path metadata, hash, and content.
    /// Off by default: a symlink under a watched root is rejected before any
    /// metadata/hash/content handling so its target cannot escape the root. When
    /// on, the canonical target must still resolve inside a configured root.
    #[tab(Advanced)]
    #[serde(default)]
    pub follow_symlinks: bool,
    /// Maximum file content bytes admitted into a payload when `read_content`,
    /// and the size ceiling for hashing. `Some(n)` caps reads and hashing at
    /// `n` bytes; oversize files are skipped rather than truncated. `None`
    /// removes the ceiling entirely (unbounded reads). Defaults to
    /// `Some(65536)` (64 KiB), so the cap applies by default whenever
    /// `read_content` or hashing runs.
    #[tab(Advanced)]
    #[serde(default = "default_filesystem_max_content_bytes")]
    pub max_content_bytes: Option<usize>,
    /// Watch broad system roots (`/`, `/home`, `/etc`, `/var`, `/proc`,
    /// `/sys`, `/dev`, `/tmp`) despite the deny-broad-roots default. The
    /// pseudo-filesystems `/proc` and `/sys` would surface kernel object
    /// paths in SOP payloads and flood the watcher with events. Off unless
    /// explicitly enabled.
    #[tab(Advanced)]
    #[serde(default)]
    pub allow_broad_roots: bool,
    /// Tools excluded from this channel's tool spec.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl Default for FilesystemConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            paths: Vec::new(),
            recursive: true,
            include: Vec::new(),
            exclude: Vec::new(),
            events: default_filesystem_events(),
            debounce_ms: default_filesystem_debounce_ms(),
            settle_ms: default_filesystem_settle_ms(),
            read_content: false,
            follow_symlinks: false,
            max_content_bytes: default_filesystem_max_content_bytes(),
            allow_broad_roots: false,
            excluded_tools: Vec::new(),
        }
    }
}

const FILESYSTEM_BROAD_ROOTS: [&str; 8] = [
    "/", "/home", "/etc", "/var", "/proc", "/sys", "/dev", "/tmp",
];

impl FilesystemConfig {
    /// Validate the filesystem listener configuration.
    pub fn validate(&self) -> anyhow::Result<()> {
        if self.paths.is_empty() {
            anyhow::bail!("at least one path must be configured");
        }
        for path in &self.paths {
            let trimmed = path.trim_end_matches('/');
            let normalized = if trimmed.is_empty() { "/" } else { trimmed };
            if !self.allow_broad_roots && FILESYSTEM_BROAD_ROOTS.contains(&normalized) {
                anyhow::bail!(
                    "path '{path}' is a broad system root; set allow_broad_roots = true to watch it"
                );
            }
        }
        for kind in &self.events {
            if !matches!(
                kind.as_str(),
                "created" | "modified" | "deleted" | "renamed"
            ) {
                anyhow::bail!(
                    "event '{kind}' is invalid; expected created, modified, deleted, or renamed"
                );
            }
        }
        Ok(())
    }
}

impl ChannelConfig for FilesystemConfig {
    fn name() -> &'static str {
        "Filesystem"
    }
    fn desc() -> &'static str {
        "Filesystem SOP Listener"
    }
}

fn default_filesystem_events() -> Vec<String> {
    vec![
        "created".to_string(),
        "modified".to_string(),
        "deleted".to_string(),
        "renamed".to_string(),
    ]
}

fn default_filesystem_debounce_ms() -> u64 {
    500
}

fn default_filesystem_settle_ms() -> u64 {
    250
}

fn default_filesystem_max_content_bytes() -> Option<usize> {
    Some(65536)
}

/// Generic AMQP 0-9-1 channel configuration (RabbitMQ, Fedora Messaging, etc.).
///
/// Subscribes to an exchange via routing keys and lifts each delivery into an
/// inbound `ChannelMessage`. The mapping from a JSON delivery body to message
/// fields is config-driven (`content_template`, `thread_id_field`) so a new
/// source — Anitya, an internal bus, anything publishing JSON — is onboarded by
/// configuration rather than code.
/// Where a fan-in delivery is routed once consumed. Used by AMQP, which carries
/// this field to choose between the agent loop, the SOP engine, or both.
/// Agent-loop channels (Telegram, Discord, Slack, ...) do not carry a `dispatch`
/// field: their fan-in is trigger-driven, opting in via a SOP `channel` trigger
/// while the normal agent turn always runs. The mode is source-agnostic.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SopDispatch {
    /// Drive a normal agent turn: the delivery becomes a `ChannelMessage`.
    /// This is the default and preserves each channel's original behavior.
    #[default]
    AgentLoop,
    /// Dispatch the delivery to the SOP engine as a `SopEvent` (`topic` =
    /// source identifier, `payload` = body), matching SOPs whose trigger for
    /// this source matches. No agent turn is started.
    Sop,
    /// Do both: dispatch to the SOP engine and drive an agent turn from the
    /// same delivery.
    SopAndAgentLoop,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.amqp"]
pub struct AmqpConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// AMQP broker URL. Use `amqp://` for plain or `amqps://` for TLS
    /// (e.g. `amqps://fedora:@rabbitmq.fedoraproject.org/%2Fpublic_pubsub`).
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub amqp_url: String,
    /// Exchange to bind the consumer queue to (e.g. `amq.topic`).
    #[tab(Advanced)]
    pub exchange: String,
    /// Routing keys to bind. Scope these to the topics of interest; binding
    /// `#` consumes the entire exchange and is almost never what you want.
    #[tab(Advanced)]
    #[serde(default)]
    pub routing_keys: Vec<String>,
    /// Queue name. Leave unset for a server-generated, transient,
    /// auto-deleted, exclusive queue. Set a stable name (UUID recommended)
    /// only when durable delivery across reconnects is required.
    #[tab(Advanced)]
    pub queue: Option<String>,
    /// Path to the CA certificate bundle for `amqps://` connections.
    #[tab(Connection)]
    pub ca_cert: Option<PathBuf>,
    /// Path to the client certificate for broker mutual-TLS auth
    /// (Fedora Messaging requires a client cert).
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub client_cert: Option<PathBuf>,
    /// Path to the client private key matching `client_cert`.
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub client_key: Option<PathBuf>,
    /// Value placed in `ChannelMessage.sender` for every delivery from this
    /// source (e.g. `anitya`). Lets the orchestrator's self-loop guard and
    /// per-channel routing identify the origin.
    #[tab(Behavior)]
    #[serde(default = "default_amqp_sender_label")]
    pub sender_label: String,
    /// Template for the inbound message content. `{field}` placeholders are
    /// interpolated from the JSON delivery body's top-level keys. When empty,
    /// the raw delivery body is used verbatim.
    #[tab(Behavior)]
    #[serde(default)]
    pub content_template: String,
    /// Dotted path into the JSON delivery body whose value becomes the
    /// message `thread_ts`, correlating replies to the originating event
    /// (e.g. `message.project.name`). Empty disables threading.
    #[tab(Behavior)]
    #[serde(default)]
    pub thread_id_field: String,
    /// Acknowledgement mode. When `true` (default), deliveries are acked only
    /// after the message is durably handed to the agent loop, giving
    /// at-least-once semantics: a crash before hand-off redelivers the event.
    /// Set `false` for at-most-once (broker acks on dispatch), which silently
    /// drops in-flight events on crash and is only appropriate for
    /// non-side-effecting, drop-on-overload consumers.
    #[tab(Behavior)]
    #[serde(default = "default_amqp_durable_ack")]
    pub durable_ack: bool,
    /// Where consumed deliveries are routed: drive an agent turn
    /// (`agent_loop`, default), dispatch to the SOP engine (`sop`), or both
    /// (`sop_and_agent_loop`). The `sop` and `sop_and_agent_loop` modes match
    /// the delivery against SOP `amqp` triggers by routing key.
    #[tab(Behavior)]
    #[serde(default)]
    pub dispatch: SopDispatch,
    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl AmqpConfig {
    /// Validate the AMQP configuration.
    ///
    /// Checks:
    /// - `amqp_url` uses a valid scheme (`amqp://` or `amqps://`)
    /// - `amqps://` connections carry a CA certificate
    /// - `client_cert` and `client_key` are supplied together (mutual TLS)
    /// - the exchange is non-empty
    /// - at least one routing key is bound
    pub fn validate(&self) -> anyhow::Result<()> {
        let is_tls = self.amqp_url.starts_with("amqps://");
        let is_plain = self.amqp_url.starts_with("amqp://");

        if !is_tls && !is_plain {
            anyhow::bail!(
                "amqp_url must start with 'amqp://' or 'amqps://', got: {}",
                self.amqp_url
            );
        }

        if is_tls && self.ca_cert.is_none() {
            anyhow::bail!("amqps:// requires ca_cert to verify the broker");
        }

        match (self.client_cert.is_some(), self.client_key.is_some()) {
            (true, false) => {
                anyhow::bail!(
                    "client_cert is set but client_key is missing (both are required for mutual TLS)"
                )
            }
            (false, true) => {
                anyhow::bail!(
                    "client_key is set but client_cert is missing (both are required for mutual TLS)"
                )
            }
            _ => {}
        }

        if self.exchange.is_empty() {
            validation_bail!(RequiredFieldEmpty, "exchange", "exchange must not be empty");
        }

        if self.routing_keys.is_empty() {
            anyhow::bail!("at least one routing key must be configured");
        }

        Ok(())
    }
}

impl ChannelConfig for AmqpConfig {
    fn name() -> &'static str {
        "AMQP"
    }
    fn desc() -> &'static str {
        "AMQP topic consumer"
    }
}

fn default_amqp_sender_label() -> String {
    "amqp".to_string()
}

fn default_amqp_durable_ack() -> bool {
    true
}

/// IRC channel configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.irc"]
pub struct IrcConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// IRC server hostname
    #[tab(Advanced)]
    pub server: String,
    /// IRC server port (default: 6697 for TLS)
    #[tab(Advanced)]
    #[serde(default = "default_irc_port")]
    pub port: u16,
    /// Bot nickname
    #[tab(Advanced)]
    pub nickname: String,
    /// Username (defaults to nickname if not set)
    #[tab(Connection)]
    pub username: Option<String>,
    /// Channels to join on connect
    #[tab(Advanced)]
    #[serde(default)]
    pub channels: Vec<String>,
    /// Server password (for bouncers like ZNC)
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub server_password: Option<String>,
    /// NickServ IDENTIFY password
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub nickserv_password: Option<String>,
    /// SASL PLAIN password (IRCv3)
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub sasl_password: Option<String>,
    /// Verify TLS certificate (default: true)
    #[tab(Advanced)]
    pub verify_tls: Option<bool>,
    /// When true, only respond to messages that mention the bot.
    /// Other messages in the channel are silently ignored.
    #[tab(Behavior)]
    #[serde(default)]
    pub mention_only: bool,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for IrcConfig {
    fn name() -> &'static str {
        "IRC"
    }
    fn desc() -> &'static str {
        "IRC over TLS"
    }
}

/// Twitch chat channel configuration. A thin adapter over IRC
/// (`irc.chat.twitch.tv:6697` over TLS); see the `twitch` channel module.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.twitch"]
pub struct TwitchConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false`.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Twitch login name of the bot account (case-insensitive — lowercased
    /// before send).
    #[tab(Connection)]
    pub bot_username: String,
    /// Twitch OAuth user-access token. The `oauth:` prefix is added
    /// automatically if missing, so both `"oauth:abcdef"` and `"abcdef"`
    /// work. Mint via <https://twitchapps.com/tmi/> or the Twitch CLI.
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub oauth_token: String,
    /// Twitch channels to join. Each entry receives a `#` prefix if missing
    /// and is lowercased before send (Twitch channel names are
    /// case-insensitive). E.g. `["mychannel", "#anotherchannel"]`.
    #[tab(Advanced)]
    #[serde(default)]
    pub channels: Vec<String>,
    /// When true, only respond to messages that mention the bot's login
    /// name. Default: `false`.
    #[tab(Behavior)]
    #[serde(default)]
    pub mention_only: bool,
}

impl ChannelConfig for TwitchConfig {
    fn name() -> &'static str {
        "Twitch"
    }
    fn desc() -> &'static str {
        "Twitch chat (IRC)"
    }
}

fn default_irc_port() -> u16 {
    6697
}

/// How ZeroClaw receives events from Feishu / Lark.
///
/// - `websocket` (default) — persistent WSS long-connection; no public URL required.
/// - `webhook` — HTTP callback server; requires a public HTTPS endpoint.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum LarkReceiveMode {
    #[default]
    Websocket,
    Webhook,
}

/// Lark/Feishu configuration for messaging integration.
/// Lark is the international version; Feishu is the Chinese version.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.lark"]
pub struct LarkConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// App ID from Lark/Feishu developer console
    #[tab(Connection)]
    pub app_id: String,
    /// App Secret from Lark/Feishu developer console
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub app_secret: String,
    /// Encrypt key for webhook message decryption (optional)
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub encrypt_key: Option<String>,
    /// Verification token for webhook validation (optional)
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub verification_token: Option<String>,
    /// When true, only respond to messages that @-mention the bot in groups.
    /// Direct messages are always processed.
    #[tab(Behavior)]
    #[serde(default)]
    pub mention_only: bool,
    /// Whether to use the Feishu (Chinese) endpoint instead of Lark (International)
    #[tab(Advanced)]
    #[serde(default)]
    pub use_feishu: bool,
    /// Event receive mode: "websocket" (default) or "webhook"
    #[tab(Advanced)]
    #[serde(default)]
    pub receive_mode: LarkReceiveMode,
    /// HTTP port for webhook mode only. Must be set when receive_mode = "webhook".
    /// Not required (and ignored) for websocket mode.
    #[tab(Advanced)]
    #[serde(default)]
    pub port: Option<u16>,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,

    /// Time in seconds an approval card waits for user response before
    /// the runtime auto-denies. Default: 300 (5 minutes).
    #[tab(Behavior)]
    #[serde(default = "default_channel_approval_timeout_secs")]
    pub approval_timeout_secs: u64,
    /// When `true`, group-chat sessions key on the sender's open_id, so
    /// distinct members of the same group chat don't share conversation
    /// context. When `false` (default), all members of a group share one
    /// session keyed on chat_id (matches the existing behavior). 1-on-1
    /// chats are unaffected (chat_id is already unique per user-bot pair).
    #[tab(Behavior)]
    #[serde(default)]
    pub per_user_session: bool,

    /// Override for the top-level `ack_reactions` setting. When `None`, the
    /// channel falls back to `[channels].ack_reactions`. When set
    /// explicitly, it takes precedence.
    #[tab(Behavior)]
    #[serde(default)]
    pub ack_reactions: Option<bool>,

    /// Streaming mode for the LLM response: `off` (default) routes every
    /// response through `send()`; `partial` opens a Feishu interactive
    /// card and edits it incrementally via `update_draft` /
    /// `finalize_draft`; `multi_message` is rejected for Lark (Feishu has
    /// no equivalent surface — falls back to `off` with a warning).
    #[tab(Behavior)]
    #[serde(default)]
    pub stream_mode: StreamMode,

    /// Minimum interval between consecutive `update_draft` PATCH calls in
    /// milliseconds. Default 1000 ms tunes to Feishu's 5 QPS-per-message
    /// edit cap; raise on enterprise plans with higher quotas.
    #[tab(Behavior)]
    #[serde(default = "default_draft_update_interval_ms")]
    pub draft_update_interval_ms: u64,
}

impl ChannelConfig for LarkConfig {
    fn name() -> &'static str {
        "Lark"
    }
    fn desc() -> &'static str {
        "Lark Bot"
    }
}

/// DM (1:1 chat) access policy for the LINE channel.
#[derive(
    Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum LineDmPolicy {
    /// Respond to every DM regardless of who sent it.
    Open,
    /// Require a one-time `/bind <code>` handshake before responding (default).
    /// ZeroClaw prints the bind code on startup; send it once to unlock access.
    #[default]
    Pairing,
    /// Respond only to LINE user IDs listed in `allowed_users`.
    Allowlist,
}

/// Group / multi-person chat policy for the LINE channel.
#[derive(
    Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum LineGroupPolicy {
    /// Respond to every message in group/room chats.
    Open,
    /// Respond only when the bot is @mentioned (default).
    #[default]
    Mention,
    /// Ignore all messages in group/room chats.
    Disabled,
}

/// LINE Messaging API channel configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.line"]
pub struct LineConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Long-lived channel access token (from LINE Developers Console).
    /// Used for both the Reply API and the Push API fallback.
    /// Falls back to the `LINE_CHANNEL_ACCESS_TOKEN` environment variable if empty.
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub channel_access_token: String,
    /// Channel secret (from LINE Developers Console).
    /// Used to verify the `X-Line-Signature` header on incoming webhooks.
    /// Falls back to the `LINE_CHANNEL_SECRET` environment variable if empty.
    #[serde(default)]
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub channel_secret: String,
    /// DM (1:1 chat) access policy. Default: `pairing`.
    ///
    /// - `open`: respond to everyone
    /// - `pairing`: require one-time `/bind <code>` handshake on first contact
    /// - `allowlist`: respond only to user IDs listed in `allowed_users`
    #[tab(Advanced)]
    #[serde(default)]
    pub dm_policy: LineDmPolicy,
    /// Group / multi-person chat policy. Default: `mention`.
    ///
    /// - `open`: respond to every message
    /// - `mention`: respond only when @mentioned
    /// - `disabled`: ignore all group messages
    #[tab(Advanced)]
    #[serde(default)]
    pub group_policy: LineGroupPolicy,
    /// TCP port the embedded webhook server listens on. Default: `8443`.
    #[tab(Advanced)]
    #[serde(default = "default_line_webhook_port")]
    pub webhook_port: u16,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,

    /// Display name shown as the sender in LINE chat bubbles.
    /// Defaults to `"AI"` when unset or empty.
    #[tab(Behavior)]
    #[serde(default)]
    pub sender_name: Option<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

fn default_line_webhook_port() -> u16 {
    8443
}

impl ChannelConfig for LineConfig {
    fn name() -> &'static str {
        "LINE"
    }
    fn desc() -> &'static str {
        "connect your LINE bot"
    }
}

// ── Security Config ─────────────────────────────────────────────────

/// Security configuration for audit logging, OTP, e-stop, IAM/SSO, and WebAuthn.
///
/// Sandbox backend and resource limits live on per-agent risk profiles
/// (see `RiskProfileConfig::sandbox_*` and `RiskProfileConfig::max_*`); the
/// runtime resolves them via `Config::active_risk_profile(agent_alias)`.
#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "security"]
pub struct SecurityConfig {
    /// Audit logging configuration
    #[serde(default)]
    #[nested]
    pub audit: AuditConfig,

    /// Outbound credential leak detection and redaction configuration. See
    /// `[security.leak_detection]` for the detector controls.
    #[serde(default)]
    #[nested]
    pub leak_detection: LeakDetectionConfig,

    /// OTP gating configuration for sensitive actions/domains.
    #[serde(default)]
    #[nested]
    pub otp: OtpConfig,

    /// Emergency-stop state machine configuration.
    #[serde(default)]
    #[nested]
    pub estop: EstopConfig,

    /// Nevis IAM integration for SSO/MFA authentication and role-based access.
    #[serde(default)]
    #[nested]
    pub nevis: NevisConfig,

    /// WebAuthn / FIDO2 hardware key authentication configuration.
    #[serde(default)]
    #[nested]
    pub webauthn: WebAuthnConfig,
}

/// Outbound credential leak detection configuration.
///
/// These settings control the final guardrail pass over outbound channel
/// responses before they are delivered. Deterministic credential patterns
/// include API keys, private keys, database URLs, bot tokens, and related
/// token syntax. The high-entropy pass is a separate heuristic for standalone
/// opaque tokens.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "security.leak_detection"]
pub struct LeakDetectionConfig {
    /// Enable outbound credential leak detection and redaction.
    #[serde(default = "default_leak_detection_enabled")]
    pub enabled: bool,

    /// Detection sensitivity from 0.0 to 1.0; higher is more aggressive.
    #[serde(default = "default_leak_detection_sensitivity")]
    pub sensitivity: f64,

    /// Enable high-entropy token redaction; deterministic patterns still run when false.
    #[serde(default = "default_leak_detection_high_entropy_tokens")]
    pub high_entropy_tokens: bool,
}

fn default_leak_detection_enabled() -> bool {
    true
}

fn default_leak_detection_sensitivity() -> f64 {
    0.7
}

fn default_leak_detection_high_entropy_tokens() -> bool {
    true
}

impl Default for LeakDetectionConfig {
    fn default() -> Self {
        Self {
            enabled: default_leak_detection_enabled(),
            sensitivity: default_leak_detection_sensitivity(),
            high_entropy_tokens: default_leak_detection_high_entropy_tokens(),
        }
    }
}

/// WebAuthn / FIDO2 hardware key authentication configuration (`[security.webauthn]`).
///
/// Enables registration and authentication via hardware security keys
/// (YubiKey, SoloKey, etc.) and platform authenticators (Touch ID, Windows Hello).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "security.webauthn"]
pub struct WebAuthnConfig {
    /// Enable WebAuthn authentication. Default: false.
    #[serde(default)]
    pub enabled: bool,
    /// Relying Party ID (domain name, e.g. "example.com"). Default: "localhost".
    #[serde(default = "default_webauthn_rp_id")]
    pub rp_id: String,
    /// Relying Party origin URL (e.g. `"https://example.com"`). Default: `"http://localhost:42617"`.
    #[serde(default = "default_webauthn_rp_origin")]
    pub rp_origin: String,
    /// Relying Party display name. Default: "ZeroClaw".
    #[serde(default = "default_webauthn_rp_name")]
    pub rp_name: String,
}

impl Default for WebAuthnConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            rp_id: default_webauthn_rp_id(),
            rp_origin: default_webauthn_rp_origin(),
            rp_name: default_webauthn_rp_name(),
        }
    }
}

fn default_webauthn_rp_id() -> String {
    "localhost".into()
}

fn default_webauthn_rp_origin() -> String {
    "http://localhost:42617".into()
}

fn default_webauthn_rp_name() -> String {
    "ZeroClaw".into()
}

/// OTP validation strategy.
#[derive(
    Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum OtpMethod {
    /// Time-based one-time password (RFC 6238).
    #[default]
    Totp,
    /// Future method for paired-device confirmations.
    Pairing,
    /// Future method for local CLI challenge prompts.
    CliPrompt,
}

/// Security OTP configuration.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "security.otp"]
#[serde(deny_unknown_fields)]
pub struct OtpConfig {
    /// Enable OTP gating. Defaults to disabled for backward compatibility.
    #[serde(default)]
    pub enabled: bool,

    /// OTP method.
    #[serde(default)]
    pub method: OtpMethod,

    /// TOTP time-step in seconds.
    #[serde(default = "default_otp_token_ttl_secs")]
    pub token_ttl_secs: u64,

    /// Reuse window for recently validated OTP codes.
    #[serde(default = "default_otp_cache_valid_secs")]
    pub cache_valid_secs: u64,

    /// Tool/action names gated by OTP. Empty or malformed entries are rejected
    /// at config load; an entry that does not match a known gated action is
    /// accepted but logged at WARN, since it cannot be enforced.
    #[serde(default = "default_otp_gated_actions")]
    pub gated_actions: Vec<String>,

    /// Explicit domain patterns gated by OTP.
    #[serde(default)]
    pub gated_domains: Vec<String>,

    /// Domain-category presets expanded into `gated_domains`.
    #[serde(default)]
    pub gated_domain_categories: Vec<String>,

    /// Maximum number of OTP challenge attempts before lockout.
    #[serde(default = "default_otp_challenge_max_attempts")]
    pub challenge_max_attempts: u32,
}

fn default_otp_token_ttl_secs() -> u64 {
    30
}

fn default_otp_cache_valid_secs() -> u64 {
    300
}

fn default_otp_challenge_max_attempts() -> u32 {
    3
}

fn default_otp_gated_actions() -> Vec<String> {
    vec![
        "shell".to_string(),
        "file_write".to_string(),
        "browser_open".to_string(),
        "browser".to_string(),
        "memory_forget".to_string(),
    ]
}

impl Default for OtpConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            method: OtpMethod::Totp,
            token_ttl_secs: default_otp_token_ttl_secs(),
            cache_valid_secs: default_otp_cache_valid_secs(),
            gated_actions: default_otp_gated_actions(),
            gated_domains: Vec::new(),
            gated_domain_categories: Vec::new(),
            challenge_max_attempts: default_otp_challenge_max_attempts(),
        }
    }
}

/// Emergency stop configuration.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "security.estop"]
#[serde(deny_unknown_fields)]
pub struct EstopConfig {
    /// Enable emergency stop controls.
    #[serde(default)]
    pub enabled: bool,

    /// File path used to persist estop state.
    #[serde(default = "default_estop_state_file")]
    pub state_file: String,

    /// Require a valid OTP before resume operations.
    #[serde(default = "default_true")]
    pub require_otp_to_resume: bool,
}

fn default_estop_state_file() -> String {
    default_path_under_config_dir("estop-state.json")
}

impl Default for EstopConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            state_file: default_estop_state_file(),
            require_otp_to_resume: true,
        }
    }
}

/// Nevis IAM integration configuration.
///
/// When `enabled` is true, ZeroClaw validates incoming requests against a Nevis
/// Security Suite instance and maps Nevis roles to tool/workspace permissions.
#[derive(Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "security.nevis"]
#[serde(deny_unknown_fields)]
pub struct NevisConfig {
    /// Enable Nevis IAM integration. Defaults to false for backward compatibility.
    #[serde(default)]
    pub enabled: bool,

    /// Base URL of the Nevis instance (e.g. `https://nevis.example.com`).
    #[serde(default)]
    pub instance_url: String,

    /// Nevis realm to authenticate against.
    #[serde(default = "default_nevis_realm")]
    pub realm: String,

    /// OAuth2 client ID registered in Nevis.
    #[serde(default)]
    pub client_id: String,

    /// OAuth2 client secret. Encrypted via SecretStore when stored on disk.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub client_secret: Option<String>,

    /// Token validation strategy: `"local"` (JWKS) or `"remote"` (introspection).
    #[serde(default = "default_nevis_token_validation")]
    pub token_validation: String,

    /// JWKS endpoint URL for local token validation.
    #[serde(default)]
    pub jwks_url: Option<String>,

    /// Nevis role to ZeroClaw permission mappings.
    #[serde(default)]
    pub role_mapping: Vec<NevisRoleMappingConfig>,

    /// Require MFA verification for all Nevis-authenticated requests.
    #[serde(default)]
    pub require_mfa: bool,

    /// Session timeout in seconds.
    #[serde(default = "default_nevis_session_timeout_secs")]
    pub session_timeout_secs: u64,
}

impl std::fmt::Debug for NevisConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("NevisConfig")
            .field("enabled", &self.enabled)
            .field("instance_url", &self.instance_url)
            .field("realm", &self.realm)
            .field("client_id", &self.client_id)
            .field(
                "client_secret",
                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
            )
            .field("token_validation", &self.token_validation)
            .field("jwks_url", &self.jwks_url)
            .field("role_mapping", &self.role_mapping)
            .field("require_mfa", &self.require_mfa)
            .field("session_timeout_secs", &self.session_timeout_secs)
            .finish()
    }
}

impl NevisConfig {
    /// Validate that required fields are present when Nevis is enabled.
    ///
    /// Call at config load time to fail fast on invalid configuration rather
    /// than deferring errors to the first authentication request.
    pub fn validate(&self) -> Result<(), String> {
        if !self.enabled {
            return Ok(());
        }

        if self.instance_url.trim().is_empty() {
            return Err("nevis.instance_url is required when Nevis IAM is enabled".into());
        }

        if self.client_id.trim().is_empty() {
            return Err("nevis.client_id is required when Nevis IAM is enabled".into());
        }

        if self.realm.trim().is_empty() {
            return Err("nevis.realm is required when Nevis IAM is enabled".into());
        }

        match self.token_validation.as_str() {
            "local" | "remote" => {}
            other => {
                return Err(format!(
                    "nevis.token_validation has invalid value '{other}': \
                     expected 'local' or 'remote'"
                ));
            }
        }

        if self.token_validation == "local" && self.jwks_url.is_none() {
            return Err("nevis.jwks_url is required when token_validation is 'local'".into());
        }

        if self.session_timeout_secs == 0 {
            return Err("nevis.session_timeout_secs must be greater than 0".into());
        }

        Ok(())
    }
}

fn default_nevis_realm() -> String {
    "master".into()
}

fn default_nevis_token_validation() -> String {
    "local".into()
}

fn default_nevis_session_timeout_secs() -> u64 {
    3600
}

impl Default for NevisConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            instance_url: String::new(),
            realm: default_nevis_realm(),
            client_id: String::new(),
            client_secret: None,
            token_validation: default_nevis_token_validation(),
            jwks_url: None,
            role_mapping: Vec::new(),
            require_mfa: false,
            session_timeout_secs: default_nevis_session_timeout_secs(),
        }
    }
}

/// Maps a Nevis role to ZeroClaw tool permissions and workspace access.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct NevisRoleMappingConfig {
    /// Nevis role name (case-insensitive).
    pub nevis_role: String,

    /// Tool names this role can access. Use `"all"` for unrestricted tool access.
    #[serde(default)]
    pub zeroclaw_permissions: Vec<String>,

    /// Workspace names this role can access. Use `"all"` for unrestricted.
    #[serde(default)]
    pub workspace_access: Vec<String>,
}

/// Sandbox configuration for OS-level isolation
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "security.sandbox"]
pub struct SandboxConfig {
    /// Enable sandboxing (None = auto-detect, Some = explicit)
    #[serde(default)]
    pub enabled: Option<bool>,

    /// Sandbox backend to use
    #[serde(default)]
    pub backend: SandboxBackend,

    /// Custom Firejail arguments (when backend = firejail)
    #[serde(default)]
    pub firejail_args: Vec<String>,
}

impl Default for SandboxConfig {
    fn default() -> Self {
        Self {
            enabled: None, // Auto-detect
            backend: SandboxBackend::Auto,
            firejail_args: Vec::new(),
        }
    }
}

/// Sandbox backend selection
#[derive(Debug, Clone, Serialize, Deserialize, Default, zeroclaw_macros::ConfigEnum)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum SandboxBackend {
    /// Auto-detect best available (default)
    #[default]
    Auto,
    /// Landlock (Linux kernel LSM, native)
    Landlock,
    /// Firejail (user-space sandbox)
    Firejail,
    /// Bubblewrap (user namespaces)
    Bubblewrap,
    /// Docker container isolation
    Docker,
    /// macOS sandbox-exec (Seatbelt)
    #[serde(alias = "sandbox-exec")]
    SandboxExec,
    /// No sandboxing (application-layer only)
    None,
}

/// Audit logging configuration
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "security.audit"]
pub struct AuditConfig {
    /// Enable audit logging
    #[serde(default = "default_audit_enabled")]
    pub enabled: bool,

    /// Path to audit log file (relative to zeroclaw dir)
    #[serde(default = "default_audit_log_path")]
    pub log_path: String,

    /// Maximum log size in MB before rotation
    #[serde(default = "default_audit_max_size_mb")]
    pub max_size_mb: u32,

    /// Sign events with HMAC for tamper evidence
    #[serde(default)]
    pub sign_events: bool,
}

fn default_audit_enabled() -> bool {
    true
}

fn default_audit_log_path() -> String {
    "audit.log".to_string()
}

fn default_audit_max_size_mb() -> u32 {
    100
}

impl Default for AuditConfig {
    fn default() -> Self {
        Self {
            enabled: default_audit_enabled(),
            log_path: default_audit_log_path(),
            max_size_mb: default_audit_max_size_mb(),
            sign_events: false,
        }
    }
}

/// DingTalk configuration for Stream Mode messaging
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.dingtalk"]
pub struct DingTalkConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Client ID (AppKey) from DingTalk developer console
    #[tab(Connection)]
    pub client_id: String,
    /// Client Secret (AppSecret) from DingTalk developer console
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub client_secret: String,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for DingTalkConfig {
    fn name() -> &'static str {
        "DingTalk"
    }
    fn desc() -> &'static str {
        "DingTalk Stream Mode"
    }
}

/// WeCom (WeChat Enterprise) Bot Webhook configuration
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.wecom"]
pub struct WeComConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Webhook key from WeCom Bot configuration
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub webhook_key: String,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for WeComConfig {
    fn name() -> &'static str {
        "WeCom"
    }
    fn desc() -> &'static str {
        "WeCom Bot Webhook"
    }
}

fn default_wecom_ws_file_retention_days() -> u32 {
    7
}

fn default_wecom_ws_max_file_size_mb() -> u64 {
    20
}

fn default_wecom_ws_stream_mode() -> StreamMode {
    StreamMode::Partial
}

/// WeCom AI Bot WebSocket configuration.
///
/// This is distinct from webhook-based [`WeComConfig`] and uses the WeCom AI
/// Bot long-connection API for inbound messages and active-session replies.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.wecom_ws"]
pub struct WeComWsConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[serde(default)]
    pub enabled: bool,
    /// Bot ID for WeCom WebSocket subscription.
    pub bot_id: String,
    /// Secret for WeCom WebSocket subscription authentication.
    #[secret]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub secret: String,
    /// Allowed WeCom user IDs. Empty = deny all, "*" = allow all users.
    #[serde(default)]
    pub allowed_users: Vec<String>,
    /// Allowed WeCom group chat IDs. Empty = deny all groups, "*" = allow all groups.
    #[serde(default)]
    pub allowed_groups: Vec<String>,
    /// Display name or mention alias of the WeCom AI bot, for example `danya`.
    ///
    /// WeCom group text often arrives as plain text such as `@danya say hi`;
    /// passing this name through lets the generic reply-intent precheck
    /// recognize that a group message was addressed to the bot.
    #[serde(default)]
    pub bot_name: Option<String>,
    /// File retention days for downloaded WeCom attachments under the workspace cache.
    #[serde(default = "default_wecom_ws_file_retention_days")]
    pub file_retention_days: u32,
    /// Maximum accepted file size in MiB for WeCom attachment download attempts.
    #[serde(default = "default_wecom_ws_max_file_size_mb")]
    pub max_file_size_mb: u64,
    /// Streaming mode for progressive draft delivery over the WeCom long connection.
    #[serde(default = "default_wecom_ws_stream_mode")]
    pub stream_mode: StreamMode,
    /// Optional per-channel proxy override. Falls back to the global proxy config when empty.
    #[serde(default)]
    pub proxy_url: Option<String>,
    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl Default for WeComWsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            bot_id: String::new(),
            secret: String::new(),
            allowed_users: Vec::new(),
            allowed_groups: Vec::new(),
            bot_name: None,
            file_retention_days: default_wecom_ws_file_retention_days(),
            max_file_size_mb: default_wecom_ws_max_file_size_mb(),
            stream_mode: default_wecom_ws_stream_mode(),
            proxy_url: None,
            excluded_tools: Vec::new(),
        }
    }
}

impl ChannelConfig for WeComWsConfig {
    fn name() -> &'static str {
        "WeCom WebSocket"
    }
    fn desc() -> &'static str {
        "WeCom AI Bot long connection"
    }
}

/// WeChat personal iLink Bot channel configuration.
///
/// Uses the iLink Bot API (`ilinkai.weixin.qq.com`) with QR-code login.
/// The bot token is obtained by scanning a QR code and persisted to disk
/// so subsequent restarts do not require re-scanning.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.wechat"]
pub struct WeChatConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Override the iLink API base URL. Default: `https://ilinkai.weixin.qq.com`.
    #[tab(Advanced)]
    #[serde(default)]
    pub api_base_url: Option<String>,
    /// Override the CDN base URL. Default: `https://novac2c.cdn.weixin.qq.com/c2c`.
    #[tab(Advanced)]
    #[serde(default)]
    pub cdn_base_url: Option<String>,
    /// Directory to persist bot token and sync cursor.
    /// Default: `~/.zeroclaw/wechat/`.
    #[tab(Advanced)]
    #[serde(default)]
    pub state_dir: Option<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for WeChatConfig {
    fn name() -> &'static str {
        "WeChat"
    }
    fn desc() -> &'static str {
        "WeChat iLink Bot"
    }
}

/// QQ Official Bot configuration (Tencent QQ Bot SDK)
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.qq"]
pub struct QQConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// App ID from QQ Bot developer console
    #[tab(Connection)]
    pub app_id: String,
    /// App Secret from QQ Bot developer console
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub app_secret: String,
    /// Per-channel proxy URL (http, https, socks5, socks5h).
    /// Overrides the global `[proxy]` setting for this channel only.
    #[tab(Advanced)]
    #[serde(default)]
    pub proxy_url: Option<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for QQConfig {
    fn name() -> &'static str {
        "QQ Official"
    }
    fn desc() -> &'static str {
        "Tencent QQ Bot"
    }
}

/// X/Twitter channel configuration (Twitter API v2)
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.twitter"]
pub struct TwitterConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Twitter API v2 Bearer Token (OAuth 2.0)
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub bearer_token: String,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for TwitterConfig {
    fn name() -> &'static str {
        "X/Twitter"
    }
    fn desc() -> &'static str {
        "X/Twitter Bot via API v2"
    }
}

/// Mochat channel configuration (Mochat customer service API)
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.mochat"]
pub struct MochatConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Mochat API base URL
    #[tab(Advanced)]
    pub api_url: String,
    /// Mochat API token
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_token: String,
    /// Poll interval in seconds for new messages. Default: 5
    #[tab(Advanced)]
    #[serde(default = "default_mochat_poll_interval")]
    pub poll_interval_secs: u64,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

fn default_mochat_poll_interval() -> u64 {
    5
}

impl ChannelConfig for MochatConfig {
    fn name() -> &'static str {
        "Mochat"
    }
    fn desc() -> &'static str {
        "Mochat Customer Service"
    }
}

/// Reddit channel configuration (OAuth2 bot).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.reddit"]
pub struct RedditConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Reddit OAuth2 client ID.
    #[tab(Connection)]
    pub client_id: String,
    /// Reddit OAuth2 client secret.
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub client_secret: String,
    /// Reddit OAuth2 refresh token for persistent access.
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub refresh_token: String,
    /// Reddit bot username (without `u/` prefix).
    #[tab(Advanced)]
    pub username: String,
    /// Subreddits to filter messages (without `r/` prefix). Empty = accept
    /// from any subreddit the bot has access to. Migrated from the legacy
    /// `subreddit` singular field.
    #[tab(Advanced)]
    #[serde(default)]
    pub subreddits: Vec<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for RedditConfig {
    fn name() -> &'static str {
        "Reddit"
    }
    fn desc() -> &'static str {
        "Reddit bot (OAuth2)"
    }
}

/// Bluesky channel configuration (AT Protocol).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.bluesky"]
pub struct BlueskyConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Bluesky handle (e.g. `"mybot.bsky.social"`).
    #[tab(Connection)]
    pub handle: String,
    /// App-specific password (from Bluesky settings).
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub app_password: String,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for BlueskyConfig {
    fn name() -> &'static str {
        "Bluesky"
    }
    fn desc() -> &'static str {
        "AT Protocol"
    }
}

/// Git-forge channel configuration (polling-based; no webhook required).
///
/// A `provider` selects the forge. GitHub authenticates as a GitHub App:
/// a short-lived RS256 JWT signed with the app's private key is exchanged
/// for per-installation access tokens. Gitea and Forgejo use a personal
/// access token against the instance's `/api/v1` endpoint, named by the
/// required `api_base_url`. Inbound issue/PR comments are polled from the
/// forge REST API, so the daemon needs no inbound network exposure.
#[derive(Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.git"]
pub struct GitConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Git forge provider. Supported: `"github"`, `"gitea"`, and
    /// `"forgejo"` (Forgejo uses the Gitea-compatible REST provider).
    /// Default: `"github"`.
    #[tab(Connection)]
    #[serde(default = "default_git_provider")]
    pub provider: String,
    /// GitHub App ID (shown on the app's settings page). GitHub provider only.
    #[tab(Connection)]
    #[serde(default)]
    pub app_id: u64,
    /// RS256 private key PEM, the contents of the `.pem` file GitHub
    /// generates on the app's settings page, inline and encrypted at rest.
    /// Include the BEGIN/END lines. GitHub provider only.
    #[secret]
    #[multiline]
    #[credential_class = "encrypted_secret"]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub private_key: Option<String>,
    /// Filesystem path to the RS256 private key `.pem` file, read at startup
    /// when `private_key` (inline PEM) is unset. Backward-compatible fallback
    /// for configs that predate the inline-PEM field. GitHub provider only.
    #[tab(Connection)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub private_key_path: Option<String>,
    /// Installation ID to act as. When unset, the app's installations are
    /// listed on first use and a sole installation is auto-selected;
    /// startup fails if the app has zero or multiple installations.
    #[tab(Connection)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub installation_id: Option<u64>,
    /// Gitea/Forgejo API base URL, including `/api/v1`, for example
    /// `https://git.example.org/api/v1` (for the public Gitea service:
    /// `https://gitea.com/api/v1`). Required when `provider` is `"gitea"`
    /// or `"forgejo"` - there is no default host, because every API
    /// request carries `access_token`; the channel fails closed at
    /// startup rather than send the token to an endpoint the operator
    /// never named. Gitea/Forgejo provider only.
    #[tab(Connection)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_base_url: Option<String>,
    /// Personal access token for Gitea/Forgejo API requests. The token needs
    /// repository read access plus issue/PR comment write access for replies
    /// and reactions. Gitea/Forgejo provider only.
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    #[serde(default)]
    pub access_token: String,
    /// Repositories to poll, as `owner/repo`. Empty = every repository
    /// visible to the installation.
    #[tab(Advanced)]
    #[serde(default)]
    pub repos: Vec<String>,
    /// Poll interval in seconds for new issues and comments. Values below
    /// 15 are clamped to 15. Default: `30`.
    #[tab(Advanced)]
    #[serde(default = "default_github_poll_interval_secs")]
    pub poll_interval_secs: u64,
    /// Only respond to comments that @-mention the app's bot login.
    /// Default: `true`.
    #[tab(Behavior)]
    #[serde(default = "default_true")]
    pub mention_only: bool,
    /// Process comments authored by other bot accounts. The app's own
    /// comments are always ignored. Default: `false`.
    #[tab(Behavior)]
    #[serde(default)]
    pub listen_to_bots: bool,
    /// Per-channel proxy override for GitHub API requests.
    #[tab(Advanced)]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub proxy_url: Option<String>,
    /// Per-event routing table, keyed by normalized event type
    /// (`"issue_comment.created"`, `"pull_request.opened"`,
    /// `"workflow_run.failed"`, …). Event types absent from the table fall
    /// back to the conversational default: `issue_comment.created`,
    /// `issues.opened`, and `pull_request.opened` are delivered as
    /// messages (mention-gated); everything else is ignored. Which API
    /// endpoints are polled is derived from this table; routing an event
    /// type is also subscribing to it.
    #[tab(Behavior)]
    #[serde(default)]
    #[nested]
    pub events: HashMap<String, GitEventRoute>,
    /// Also poll the repository Events API (`/repos/{owner}/{repo}/events`)
    /// as a broad backbone transport: one conditional (ETag) request per
    /// repository per tick, so an idle repo costs almost nothing. Caveats:
    /// events arrive with a delay of up to ~5 minutes and the feed carries
    /// no Actions/check events (workflow runs always use their dedicated
    /// endpoint). Items also surfaced by a targeted endpoint are
    /// de-duplicated. Default: `false`.
    #[tab(Advanced)]
    #[serde(default)]
    pub events_backbone: bool,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl std::fmt::Debug for GitConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GitConfig")
            .field("enabled", &self.enabled)
            .field("provider", &self.provider)
            .field("app_id", &self.app_id)
            .field("private_key", &self.private_key.as_ref().map(|_| "***"))
            .field("private_key_path", &self.private_key_path)
            .field("installation_id", &self.installation_id)
            .field("api_base_url", &self.api_base_url)
            .field(
                "access_token",
                &if self.access_token.is_empty() {
                    ""
                } else {
                    "***"
                },
            )
            .field("repos", &self.repos)
            .field("poll_interval_secs", &self.poll_interval_secs)
            .field("mention_only", &self.mention_only)
            .field("listen_to_bots", &self.listen_to_bots)
            .field("proxy_url", &self.proxy_url)
            .field("events", &self.events)
            .field("events_backbone", &self.events_backbone)
            .field("excluded_tools", &self.excluded_tools)
            .finish()
    }
}

fn default_github_poll_interval_secs() -> u64 {
    30
}

fn default_git_provider() -> String {
    "github".to_string()
}

impl Default for GitConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            provider: default_git_provider(),
            app_id: 0,
            private_key: None,
            private_key_path: None,
            installation_id: None,
            api_base_url: None,
            access_token: String::new(),
            repos: Vec::new(),
            poll_interval_secs: default_github_poll_interval_secs(),
            mention_only: true,
            listen_to_bots: false,
            proxy_url: None,
            events: HashMap::new(),
            events_backbone: false,
            excluded_tools: Vec::new(),
        }
    }
}

impl ChannelConfig for GitConfig {
    fn name() -> &'static str {
        "Git"
    }
    fn desc() -> &'static str {
        "Git forge (GitHub, Gitea, Forgejo): issues, PRs & events"
    }
}

/// Routing for one normalized git-forge event type, keyed in
/// `[channels.git.<alias>.events]` by the event type string
/// (`"pull_request.opened" = { sop = "pr-triage" }`).
///
/// An entry with neither `message = true` nor a `sop` explicitly ignores the
/// event type (overriding the conversational default).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.git.events"]
pub struct GitEventRoute {
    /// Deliver the event into the agent loop as a normal channel message.
    #[serde(default)]
    pub message: bool,
    /// Route the event to the named SOP through channel-sourced SOP ingress.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sop: Option<String>,
}

/// Voice duplex configuration (`[channels.voice_duplex]`).
///
/// Enables full-duplex voice event handling over WebSocket.
/// When disabled (default), voice events are rejected as unknown types.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct VoiceDuplexConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[serde(default)]
    pub enabled: bool,
    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

/// Voice wake word detection channel configuration.
///
/// Listens on the default microphone for a configurable wake word,
/// then captures the following utterance and transcribes it via the
/// existing transcription API.
#[derive(Debug, Clone, Serialize, Deserialize, zeroclaw_macros::Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "voice_wake"]
pub struct VoiceWakeConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[serde(default)]
    pub enabled: bool,
    /// Wake word phrase to listen for (case-insensitive substring match).
    /// Default: `"hey zeroclaw"`.
    #[serde(default = "default_voice_wake_word")]
    pub wake_word: String,
    /// Silence timeout in milliseconds: how long to wait after the last
    /// energy spike before finalizing a capture window. Default: `2000`.
    #[serde(default = "default_voice_wake_silence_timeout_ms")]
    pub silence_timeout_ms: u32,
    /// RMS energy threshold for voice activity detection. Samples below
    /// this level are treated as silence. Default: `0.01`.
    #[serde(default = "default_voice_wake_energy_threshold")]
    pub energy_threshold: f32,
    /// Maximum capture duration in seconds before forcing transcription.
    /// Default: `30`.
    #[serde(default = "default_voice_wake_max_capture_secs")]
    pub max_capture_secs: u32,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

fn default_voice_wake_word() -> String {
    "hey zeroclaw".into()
}

fn default_voice_wake_silence_timeout_ms() -> u32 {
    2000
}

fn default_voice_wake_energy_threshold() -> f32 {
    0.01
}

fn default_voice_wake_max_capture_secs() -> u32 {
    30
}

impl Default for VoiceWakeConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            wake_word: default_voice_wake_word(),
            silence_timeout_ms: default_voice_wake_silence_timeout_ms(),
            energy_threshold: default_voice_wake_energy_threshold(),
            max_capture_secs: default_voice_wake_max_capture_secs(),
            excluded_tools: Vec::new(),
        }
    }
}

impl ChannelConfig for VoiceWakeConfig {
    fn name() -> &'static str {
        "VoiceWake"
    }
    fn desc() -> &'static str {
        "voice wake word detection"
    }
}

/// Nostr channel configuration (NIP-04 + NIP-17 private messages)
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "channels.nostr"]
pub struct NostrConfig {
    /// Whether this channel is active. The runtime only loads channels whose
    /// `enabled = true`. Default: `false` so an operator who pastes a partial
    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
    /// live before the rest of its config is filled in.
    #[tab(Behavior)]
    #[serde(default)]
    pub enabled: bool,
    /// Private key in hex or nsec bech32 format
    #[secret]
    #[tab(Connection)]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub private_key: String,
    /// Relay URLs (wss://). Defaults to popular public relays if omitted.
    #[tab(Advanced)]
    #[serde(default = "default_nostr_relays")]
    pub relays: Vec<String>,

    /// Tools excluded from this channel's tool spec. When set, these tools
    /// are not exposed to the model when responding via this channel.
    #[tab(Behavior)]
    #[serde(default)]
    pub excluded_tools: Vec<String>,
}

impl ChannelConfig for NostrConfig {
    fn name() -> &'static str {
        "Nostr"
    }
    fn desc() -> &'static str {
        "Nostr DMs"
    }
}

pub fn default_nostr_relays() -> Vec<String> {
    vec![
        "wss://relay.damus.io".to_string(),
        "wss://nos.lol".to_string(),
        "wss://relay.primal.net".to_string(),
        "wss://relay.snort.social".to_string(),
    ]
}

// -- Notion --

/// Notion integration configuration (`[notion]`).
///
/// When `enabled = true`, the agent polls a Notion database for pending tasks
/// and exposes a `notion` tool for querying, reading, creating, and updating pages.
/// Requires `api_key` (or the `NOTION_API_KEY` env var) and `database_id`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "notion"]
pub struct NotionConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_key: String,
    #[serde(default)]
    pub database_id: String,
    #[serde(default = "default_notion_poll_interval")]
    pub poll_interval_secs: u64,
    #[serde(default = "default_notion_status_prop")]
    pub status_property: String,
    #[serde(default = "default_notion_input_prop")]
    pub input_property: String,
    #[serde(default = "default_notion_result_prop")]
    pub result_property: String,
    #[serde(default = "default_notion_max_concurrent")]
    pub max_concurrent: usize,
    #[serde(default = "default_notion_recover_stale")]
    pub recover_stale: bool,
}

fn default_notion_poll_interval() -> u64 {
    5
}
fn default_notion_status_prop() -> String {
    "Status".into()
}
fn default_notion_input_prop() -> String {
    "Input".into()
}
fn default_notion_result_prop() -> String {
    "Result".into()
}
fn default_notion_max_concurrent() -> usize {
    4
}
fn default_notion_recover_stale() -> bool {
    true
}

impl Default for NotionConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            api_key: String::new(),
            database_id: String::new(),
            poll_interval_secs: default_notion_poll_interval(),
            status_property: default_notion_status_prop(),
            input_property: default_notion_input_prop(),
            result_property: default_notion_result_prop(),
            max_concurrent: default_notion_max_concurrent(),
            recover_stale: default_notion_recover_stale(),
        }
    }
}

/// Jira integration configuration (`[jira]`).
///
/// When `enabled = true`, registers the `jira` tool which can get tickets,
/// search with JQL, and add comments. Requires `base_url` and `api_token`
/// (or the `JIRA_API_TOKEN` env var).
///
/// ## Defaults
/// - `enabled`: `false`
/// - `allowed_actions`: `["get_ticket"]` — read-only by default.
///   Add `"search_tickets"` or `"comment_ticket"` to unlock them.
/// - `timeout_secs`: `30`
///
/// ## Auth
/// Jira Cloud uses HTTP Basic auth: `email` + `api_token`.
/// Jira Server/Data Center uses Bearer token auth: omit `email` and set
/// `api_token` to a personal access token.
/// `api_token` is stored encrypted at rest; set it here or via `JIRA_API_TOKEN`.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "jira"]
pub struct JiraConfig {
    /// Enable the `jira` tool. Default: `false`.
    #[serde(default)]
    pub enabled: bool,
    /// Atlassian instance base URL, e.g. `https://yourco.atlassian.net`.
    #[serde(default)]
    pub base_url: String,
    /// Jira account email used for Basic auth (Cloud).
    /// Omit for Server/DC deployments using Bearer token auth.
    /// An empty string (`email = ""`) deserializes as `None`. Configs
    /// that round-tripped the empty default to disk would otherwise
    /// silently regress to Basic auth with empty username, since the
    /// email-required validation was dropped when Server/DC Bearer-token
    /// support landed.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "deserialize_optional_email_skip_empty"
    )]
    pub email: Option<String>,
    /// Jira API token. Encrypted at rest. Falls back to `JIRA_API_TOKEN` env var.
    #[serde(default)]
    #[secret]
    #[credential_class = "encrypted_secret"]
    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
    pub api_token: String,
    /// Actions the agent is permitted to call.
    /// Valid values: `"get_ticket"`, `"search_tickets"`, `"comment_ticket"`,
    /// `"list_projects"`, `"myself"`, `"list_transitions"`,
    /// `"transition_ticket"`, `"create_ticket"`.
    /// Defaults to `["get_ticket"]` (read-only).
    #[serde(default = "default_jira_allowed_actions")]
    pub allowed_actions: Vec<String>,
    /// Request timeout in seconds. Default: `30`.
    #[serde(default = "default_jira_timeout_secs")]
    pub timeout_secs: u64,
}

fn default_jira_allowed_actions() -> Vec<String> {
    vec!["get_ticket".to_string()]
}

fn default_jira_timeout_secs() -> u64 {
    30
}

impl Default for JiraConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            base_url: String::new(),
            email: None,
            api_token: String::new(),
            allowed_actions: default_jira_allowed_actions(),
            timeout_secs: default_jira_timeout_secs(),
        }
    }
}

///
/// Controls the read-only cloud transformation analysis tools:
/// IaC review, migration assessment, cost analysis, and architecture review.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "cloud_ops"]
pub struct CloudOpsConfig {
    /// Enable cloud operations tools. Default: false.
    #[serde(default)]
    pub enabled: bool,
    /// Default cloud model_provider for analysis context. Default: "aws".
    #[serde(default = "default_cloud_ops_cloud")]
    pub default_cloud: String,
    /// Supported cloud model_providers. Default: [`aws`, `azure`, `gcp`].
    #[serde(default = "default_cloud_ops_supported_clouds")]
    pub supported_clouds: Vec<String>,
    /// Supported IaC tools for review. Default: \[`terraform`\].
    #[serde(default = "default_cloud_ops_iac_tools")]
    pub iac_tools: Vec<String>,
    /// Monthly USD threshold to flag cost items. Default: 100.0.
    #[serde(default = "default_cloud_ops_cost_threshold")]
    pub cost_threshold_monthly_usd: f64,
    /// Well-Architected Frameworks to check against. Default: \[`aws-waf`\].
    #[serde(default = "default_cloud_ops_waf")]
    pub well_architected_frameworks: Vec<String>,
}

impl Default for CloudOpsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            default_cloud: default_cloud_ops_cloud(),
            supported_clouds: default_cloud_ops_supported_clouds(),
            iac_tools: default_cloud_ops_iac_tools(),
            cost_threshold_monthly_usd: default_cloud_ops_cost_threshold(),
            well_architected_frameworks: default_cloud_ops_waf(),
        }
    }
}

impl CloudOpsConfig {
    pub fn validate(&self) -> Result<()> {
        if self.enabled {
            if self.default_cloud.trim().is_empty() {
                anyhow::bail!(
                    "cloud_ops.default_cloud must not be empty when cloud_ops is enabled"
                );
            }
            if self.supported_clouds.is_empty() {
                anyhow::bail!(
                    "cloud_ops.supported_clouds must not be empty when cloud_ops is enabled"
                );
            }
            for (i, cloud) in self.supported_clouds.iter().enumerate() {
                if cloud.trim().is_empty() {
                    validation_bail!(
                        RequiredFieldEmpty,
                        format!("cloud_ops.supported_clouds[{i}]"),
                        "cloud_ops.supported_clouds[{i}] must not be empty"
                    );
                }
            }
            if !self.supported_clouds.contains(&self.default_cloud) {
                anyhow::bail!(
                    "cloud_ops.default_cloud '{}' is not in cloud_ops.supported_clouds {:?}",
                    self.default_cloud,
                    self.supported_clouds
                );
            }
            if self.cost_threshold_monthly_usd < 0.0 {
                anyhow::bail!(
                    "cloud_ops.cost_threshold_monthly_usd must be non-negative, got {}",
                    self.cost_threshold_monthly_usd
                );
            }
            if self.iac_tools.is_empty() {
                anyhow::bail!("cloud_ops.iac_tools must not be empty when cloud_ops is enabled");
            }
        }
        Ok(())
    }
}

fn default_cloud_ops_cloud() -> String {
    "aws".into()
}

fn default_cloud_ops_supported_clouds() -> Vec<String> {
    vec!["aws".into(), "azure".into(), "gcp".into()]
}

fn default_cloud_ops_iac_tools() -> Vec<String> {
    vec!["terraform".into()]
}

fn default_cloud_ops_cost_threshold() -> f64 {
    100.0
}

fn default_cloud_ops_waf() -> Vec<String> {
    vec!["aws-waf".into()]
}

// ── Conversational AI ──────────────────────────────────────────────

fn default_conversational_ai_language() -> String {
    "en".into()
}

fn default_conversational_ai_supported_languages() -> Vec<String> {
    vec!["en".into(), "de".into(), "fr".into(), "it".into()]
}

fn default_conversational_ai_escalation_threshold() -> f64 {
    0.3
}

fn default_conversational_ai_max_turns() -> usize {
    50
}

fn default_conversational_ai_timeout_secs() -> u64 {
    1800
}

/// Conversational AI agent builder configuration (`[conversational_ai]` section).
///
/// **Status: Reserved for future use.** This configuration is parsed but not yet
/// consumed by the runtime. Setting `enabled = true` will produce a startup warning.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "conversational_ai"]
pub struct ConversationalAiConfig {
    /// Enable conversational AI features. Default: false.
    #[serde(default)]
    pub enabled: bool,
    /// Default language for conversations (BCP-47 tag). Default: "en".
    #[serde(default = "default_conversational_ai_language")]
    pub default_language: String,
    /// Supported languages for conversations. Default: [`en`, `de`, `fr`, `it`].
    #[serde(default = "default_conversational_ai_supported_languages")]
    pub supported_languages: Vec<String>,
    /// Automatically detect user language from message content. Default: true.
    #[serde(default = "default_true")]
    pub auto_detect_language: bool,
    /// Intent confidence below this threshold triggers escalation. Default: 0.3.
    #[serde(default = "default_conversational_ai_escalation_threshold")]
    pub escalation_confidence_threshold: f64,
    /// Maximum conversation turns before auto-ending. Default: 50.
    #[serde(default = "default_conversational_ai_max_turns")]
    pub max_conversation_turns: usize,
    /// Conversation timeout in seconds (inactivity). Default: 1800.
    #[serde(default = "default_conversational_ai_timeout_secs")]
    pub conversation_timeout_secs: u64,
    /// Enable conversation analytics tracking. Default: false (privacy-by-default).
    #[serde(default)]
    pub analytics_enabled: bool,
    /// Optional tool name for RAG-based knowledge base lookup during conversations.
    #[serde(default)]
    pub knowledge_base_tool: Option<String>,
}

impl ConversationalAiConfig {
    /// Returns `true` when the feature is disabled (the default).
    ///
    /// Used by `#[serde(skip_serializing_if)]` to omit the entire
    /// `[conversational_ai]` section from newly-generated config files,
    /// avoiding user confusion over an undocumented / experimental section.
    pub fn is_disabled(&self) -> bool {
        !self.enabled
    }
}

impl Default for ConversationalAiConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            default_language: default_conversational_ai_language(),
            supported_languages: default_conversational_ai_supported_languages(),
            auto_detect_language: true,
            escalation_confidence_threshold: default_conversational_ai_escalation_threshold(),
            max_conversation_turns: default_conversational_ai_max_turns(),
            conversation_timeout_secs: default_conversational_ai_timeout_secs(),
            analytics_enabled: false,
            knowledge_base_tool: None,
        }
    }
}

// ── Security ops config ─────────────────────────────────────────

/// Managed Cybersecurity Service (MCSS) dashboard agent configuration (`[security_ops]`).
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "security_ops"]
pub struct SecurityOpsConfig {
    /// Enable security operations tools.
    #[serde(default)]
    pub enabled: bool,
    /// Directory containing incident response playbook definitions (JSON).
    #[serde(default = "default_playbooks_dir")]
    pub playbooks_dir: String,
    /// Automatically triage incoming alerts without user prompt.
    #[serde(default)]
    pub auto_triage: bool,
    /// Require human approval before executing playbook actions.
    #[serde(default = "default_require_approval")]
    pub require_approval_for_actions: bool,
    /// Maximum severity level that can be auto-remediated without approval.
    /// One of: "low", "medium", "high", "critical". Default: "low".
    #[serde(default = "default_max_auto_severity")]
    pub max_auto_severity: String,
    /// Directory for generated security reports.
    #[serde(default = "default_report_output_dir")]
    pub report_output_dir: String,
    /// Optional SIEM webhook URL for alert ingestion.
    #[serde(default)]
    pub siem_integration: Option<String>,
}

fn default_playbooks_dir() -> String {
    default_path_under_config_dir("playbooks")
}

fn default_require_approval() -> bool {
    true
}

fn default_max_auto_severity() -> String {
    "low".into()
}

fn default_report_output_dir() -> String {
    default_path_under_config_dir("security-reports")
}

impl Default for SecurityOpsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            playbooks_dir: default_playbooks_dir(),
            auto_triage: false,
            require_approval_for_actions: true,
            max_auto_severity: default_max_auto_severity(),
            report_output_dir: default_report_output_dir(),
            siem_integration: None,
        }
    }
}

// ── Config impl ──────────────────────────────────────────────────

impl Default for Config {
    fn default() -> Self {
        let home =
            UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
        let zeroclaw_dir = home.join(".zeroclaw");

        Self {
            data_dir: zeroclaw_dir.join("data"),
            config_path: zeroclaw_dir.join("config.toml"),
            env_overridden_paths: std::collections::HashSet::new(),
            pre_override_snapshots: std::collections::HashMap::new(),
            onepassword_reference_snapshots: std::collections::HashMap::new(),
            dirty_paths: std::collections::HashSet::new(),
            degraded_security: Vec::new(),
            degraded_sections: Vec::new(),
            schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
            providers: crate::providers::Providers::default(),
            model_routes: Vec::new(),
            embedding_routes: Vec::new(),
            observability: ObservabilityConfig::default(),
            trust: crate::scattered_types::TrustConfig::default(),
            backup: BackupConfig::default(),
            data_retention: DataRetentionConfig::default(),
            cloud_ops: CloudOpsConfig::default(),
            conversational_ai: ConversationalAiConfig::default(),
            security: SecurityConfig::default(),
            security_ops: SecurityOpsConfig::default(),
            runtime: RuntimeConfig::default(),
            reliability: ReliabilityConfig::default(),
            scheduler: SchedulerConfig::default(),
            eval: crate::scattered_types::EvalHarnessConfig::default(),
            pacing: PacingConfig::default(),
            skills: SkillsConfig::default(),
            pipeline: PipelineConfig::default(),
            heartbeat: HeartbeatConfig::default(),
            todotracker: TodoTrackerConfig::default(),
            cron: HashMap::new(),
            acp: AcpConfig::default(),
            channels: ChannelsConfig::default(),
            memory: MemoryConfig::default(),
            storage: StorageConfig::default(),
            tunnel: TunnelConfig::default(),
            gateway: GatewayConfig::default(),
            a2a: crate::multi_agent::A2aServerSection::default(),
            wss: WssConfig::default(),
            composio: ComposioConfig::default(),
            microsoft365: Microsoft365Config::default(),
            secrets: SecretsConfig::default(),
            browser: BrowserConfig::default(),
            browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
            http_request: HttpRequestConfig::default(),
            multimodal: MultimodalConfig::default(),
            media_pipeline: MediaPipelineConfig::default(),
            web_fetch: WebFetchConfig::default(),
            link_enricher: LinkEnricherConfig::default(),
            text_browser: TextBrowserConfig::default(),
            web_search: WebSearchConfig::default(),
            project_intel: ProjectIntelConfig::default(),
            google_workspace: GoogleWorkspaceConfig::default(),
            proxy: ProxyConfig::default(),
            cost: CostConfig::default(),
            peripherals: PeripheralsConfig::default(),
            delegate: DelegateToolConfig::default(),
            agents: HashMap::new(),
            risk_profiles: HashMap::new(),
            runtime_profiles: HashMap::new(),
            skill_bundles: HashMap::new(),
            knowledge_bundles: HashMap::new(),
            mcp_bundles: HashMap::new(),
            peer_groups: HashMap::new(),
            hooks: HooksConfig::default(),
            hardware: HardwareConfig::default(),
            query_classification: QueryClassificationConfig::default(),
            transcription: TranscriptionConfig::default(),
            tts: TtsConfig::default(),
            mcp: McpConfig::default(),
            nodes: NodesConfig::default(),
            onboard_state: OnboardStateConfig::default(),
            notion: NotionConfig::default(),
            jira: JiraConfig::default(),
            node_transport: NodeTransportConfig::default(),
            knowledge: KnowledgeConfig::default(),
            linkedin: LinkedInConfig::default(),
            image_gen: ImageGenConfig::default(),
            file_upload: FileUploadConfig::default(),
            file_upload_bundle: FileUploadBundleConfig::default(),
            file_download: FileDownloadConfig::default(),
            plugins: PluginsConfig::default(),
            locale: None,
            verifiable_intent: VerifiableIntentConfig::default(),
            claude_code: ClaudeCodeConfig::default(),
            claude_code_runner: ClaudeCodeRunnerConfig::default(),
            codex_cli: CodexCliConfig::default(),
            gemini_cli: GeminiCliConfig::default(),
            opencode_cli: OpenCodeCliConfig::default(),
            sop: SopConfig::default(),
            shell_tool: ShellToolConfig::default(),
            escalation: EscalationConfig::default(),
        }
    }
}

fn default_config_and_data_dirs() -> Result<(PathBuf, PathBuf)> {
    let config_dir = default_config_dir()?;
    // The second value is the shared instance data directory
    // (databases + state files). Per-agent identity + markdown lives
    // at `<config-dir>/agents/<alias>/workspace/`, resolved separately
    // via `Config::agent_workspace_dir`.
    Ok((config_dir.clone(), config_dir.join("data")))
}

fn default_config_dir() -> Result<PathBuf> {
    if let Ok(custom) = std::env::var("ZEROCLAW_CONFIG_DIR") {
        let custom = custom.trim();
        if !custom.is_empty() {
            return Ok(expand_tilde_path(custom));
        }
    }

    if let Ok(home) = std::env::var("HOME")
        && !home.is_empty()
    {
        return Ok(PathBuf::from(home).join(".zeroclaw"));
    }

    let home = UserDirs::new()
        .map(|u| u.home_dir().to_path_buf())
        .context("Could not find home directory")?;
    Ok(home.join(".zeroclaw"))
}

/// Canonical on-disk directory for a locale's runtime/zerocode FTL catalogues:
/// `<config_dir>/data/ftl/<locale>/`. This is where `zeroclaw locales fetch`
/// writes downloaded translations and where the runtime i18n loader reads them.
/// `<config_dir>` honors `ZEROCLAW_CONFIG_DIR` and otherwise defaults to
/// `~/.zeroclaw`. The zerocode binary mirrors this path inline (it carries no
/// `zeroclaw-*` dependency).
pub fn ftl_locale_dir(locale: &str) -> Result<PathBuf> {
    Ok(default_config_dir()?.join("data").join("ftl").join(locale))
}

/// The FTL catalogues that `zeroclaw locales fetch` / the daemon's
/// `locales/fetch` RPC can download, as `(name, upstream-path-template,
/// output-filename)`. `{locale}` is substituted per request. This is the single
/// source of truth — a caller supplies only a catalog *name* matched against
/// this table, never a path.
pub const FTL_CATALOGS: &[(&str, &str, &str)] = &[
    (
        "cli",
        "crates/zeroclaw-runtime/locales/{locale}/cli.ftl",
        "cli.ftl",
    ),
    (
        "tools",
        "crates/zeroclaw-runtime/locales/{locale}/tools.ftl",
        "tools.ftl",
    ),
    (
        "zerocode",
        "apps/zerocode/locales/{locale}/zerocode.ftl",
        "zerocode.ftl",
    ),
];

/// Build a default path string by joining `relative` onto the resolved
/// platform config dir. The form sees the resolved absolute path
/// (`/home/<user>/.zeroclaw/<relative>` on Linux,
/// `C:\Users\<user>\.zeroclaw\<relative>` on Windows, etc.) instead of a
/// literal `~/...` token that doesn't expand on Windows. Falls back to
/// `~/.zeroclaw/<relative>` if the platform dir can't be resolved (rare —
/// e.g. no HOME and `directories::UserDirs` returns None); the runtime's
/// `expand_tilde_path()` handles that literal at use-time.
///
/// Switching to platform-native config locations (`~/Library/Application
/// Support/zeroclaw/` on macOS, `%APPDATA%\zeroclaw\` on Windows) is the
/// schema-v3 follow-up tracked in — that needs a migration to move
/// existing users' configs.
fn default_path_under_config_dir(relative: &str) -> String {
    match default_config_dir() {
        Ok(dir) => dir.join(relative).to_string_lossy().into_owned(),
        Err(_) => format!("~/.zeroclaw/{relative}"),
    }
}

pub fn resolve_config_dir_for_data(data_dir: &Path) -> (PathBuf, PathBuf) {
    let data_config_dir = data_dir.to_path_buf();
    if data_config_dir.join("config.toml").exists() {
        return (data_config_dir.clone(), data_config_dir.join("data"));
    }

    let legacy_config_dir = data_dir.parent().map(|parent| parent.join(".zeroclaw"));
    if let Some(legacy_dir) = legacy_config_dir {
        if legacy_dir.join("config.toml").exists() {
            return (legacy_dir, data_config_dir);
        }

        // Accept either the new "data" suffix or the legacy "workspace"
        // suffix; the V2->V3 filesystem migration renames the on-disk
        // dir but operator-set env-var paths from before the rename
        // still resolve correctly.
        if data_dir.file_name().is_some_and(|name| {
            name == std::ffi::OsStr::new("data") || name == std::ffi::OsStr::new("workspace")
        }) {
            return (legacy_dir, data_config_dir);
        }
    }

    (data_config_dir.clone(), data_config_dir.join("data"))
}

pub async fn classify_runtime_config_kind(config_path: &Path) -> RuntimeConfigKind {
    if path_is_under(config_path, &std::env::temp_dir()) {
        return RuntimeConfigKind::Temporary;
    }

    let Ok((default_config_dir, default_data_dir)) = default_config_and_data_dirs() else {
        return RuntimeConfigKind::Custom;
    };
    let Ok((resolved_config_dir, _data_dir, source)) =
        resolve_runtime_config_dirs(&default_config_dir, &default_data_dir).await
    else {
        return RuntimeConfigKind::Custom;
    };

    if !paths_equal_lexically(config_path, &resolved_config_dir.join("config.toml")) {
        return RuntimeConfigKind::Custom;
    }

    match source {
        ConfigResolutionSource::DefaultConfigDir | ConfigResolutionSource::HomebrewConfigDir => {
            RuntimeConfigKind::Default
        }
        ConfigResolutionSource::EnvConfigDir
        | ConfigResolutionSource::EnvDataDir
        | ConfigResolutionSource::EnvWorkspaceLegacy => RuntimeConfigKind::Custom,
    }
}

/// Resolve the current runtime config/data directories.
///
/// This mirrors the same precedence used by `Config::load_or_init()`:
/// `ZEROCLAW_CONFIG_DIR` > `ZEROCLAW_DATA_DIR` > `ZEROCLAW_WORKSPACE`
/// (deprecated) > defaults.
pub async fn resolve_runtime_dirs() -> Result<(PathBuf, PathBuf)> {
    let (default_zeroclaw_dir, default_data_dir) = default_config_and_data_dirs()?;
    let (config_dir, data_dir, _) =
        resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_data_dir).await?;
    Ok((config_dir, data_dir))
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ConfigResolutionSource {
    EnvConfigDir,
    EnvDataDir,
    EnvWorkspaceLegacy,
    DefaultConfigDir,
    HomebrewConfigDir,
}

impl ConfigResolutionSource {
    const fn as_str(self) -> &'static str {
        match self {
            Self::EnvConfigDir => "ZEROCLAW_CONFIG_DIR",
            Self::EnvDataDir => "ZEROCLAW_DATA_DIR",
            Self::EnvWorkspaceLegacy => "ZEROCLAW_WORKSPACE",
            Self::DefaultConfigDir => "default",
            Self::HomebrewConfigDir => "homebrew",
        }
    }
}

/// Expand tilde in paths, falling back to `UserDirs` when HOME is unset.
///
/// In non-TTY environments (e.g. cron), HOME may not be set, causing
/// `shellexpand::tilde` to return the literal `~` unexpanded. This helper
/// detects that case and uses `directories::UserDirs` as a fallback.
fn expand_tilde_path(path: &str) -> PathBuf {
    let expanded = shellexpand::tilde(path);
    let expanded_str = expanded.as_ref();

    // If the path still starts with '~', tilde expansion failed (HOME unset)
    if expanded_str.starts_with('~') {
        if let Some(user_dirs) = UserDirs::new() {
            let home = user_dirs.home_dir();
            // Replace leading ~ with home directory
            if let Some(rest) = expanded_str.strip_prefix('~') {
                return home.join(rest.trim_start_matches(['/', '\\']));
            }
        }
        // If UserDirs also fails, log a warning and use the literal path
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({"path": path})),
            "Failed to expand tilde: HOME environment variable is not set and UserDirs failed. \
             In cron/non-TTY environments, use absolute paths or set HOME explicitly."
        );
    }

    PathBuf::from(expanded_str)
}

fn path_is_under(path: &Path, root: &Path) -> bool {
    path.components()
        .zip(root.components())
        .all(|(a, b)| a == b)
        && path.components().count() >= root.components().count()
}

fn paths_equal_lexically(a: &Path, b: &Path) -> bool {
    a.components().eq(b.components())
}

/// Returns the legacy plugin directories that still hold installed plugins not
/// visible to the runtime, which now scans [`PluginsConfig::resolved_plugins_dir`].
///
/// Historically `zeroclaw plugin install` wrote to `<data_dir>/plugins` (and,
/// before the data-dir rename, `<install_root>/workspace/plugins`). A directory
/// is reported only when it differs from the configured plugins dir and contains
/// at least one plugin (a subdirectory with a `manifest.toml`). Used to surface a
/// migration hint and to drive `zeroclaw plugin migrate`.
#[must_use]
pub fn legacy_plugin_dirs_with_entries(config: &Config) -> Vec<PathBuf> {
    let target = config.plugins.resolved_plugins_dir();
    [
        config.data_dir.join("plugins"),
        config.install_root_dir().join("workspace").join("plugins"),
    ]
    .into_iter()
    .filter(|legacy| *legacy != target && dir_has_plugin(legacy))
    .collect()
}

/// Returns `true` if `dir` holds at least one plugin (a subdirectory containing a
/// `manifest.toml`).
fn dir_has_plugin(dir: &Path) -> bool {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return false;
    };
    entries
        .flatten()
        .any(|entry| entry.path().join("manifest.toml").exists())
}

/// Detect if an executable path lives under a macOS Homebrew prefix and return
/// the Homebrew-managed config directory.
///
/// Homebrew can execute ZeroClaw from `<prefix>/Cellar/zeroclaw/<version>/bin/`,
/// `<prefix>/bin/`, or `<prefix>/opt/zeroclaw/bin/`.
async fn try_resolve_macos_homebrew_config_dir(exe: &Path) -> Option<PathBuf> {
    let parts = exe.iter().collect::<Vec<_>>();
    let prefix = match parts.as_slice() {
        [prefix @ .., cellar, formula, _version, bin, exe_name]
            if *cellar == std::ffi::OsStr::new("Cellar")
                && *formula == std::ffi::OsStr::new("zeroclaw")
                && *bin == std::ffi::OsStr::new("bin")
                && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
        {
            prefix.iter().collect::<PathBuf>()
        }
        [prefix @ .., opt, formula, bin, exe_name]
            if *opt == std::ffi::OsStr::new("opt")
                && *formula == std::ffi::OsStr::new("zeroclaw")
                && *bin == std::ffi::OsStr::new("bin")
                && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
        {
            let prefix = prefix.iter().collect::<PathBuf>();
            if !prefix.as_os_str().is_empty()
                && fs::metadata(prefix.join("Cellar"))
                    .await
                    .is_ok_and(|metadata| metadata.is_dir())
            {
                prefix
            } else {
                return None;
            }
        }
        [prefix @ .., bin, exe_name]
            if *bin == std::ffi::OsStr::new("bin")
                && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
        {
            let prefix = prefix.iter().collect::<PathBuf>();
            if !prefix.as_os_str().is_empty()
                && fs::metadata(prefix.join("Cellar"))
                    .await
                    .is_ok_and(|metadata| metadata.is_dir())
            {
                prefix
            } else {
                return None;
            }
        }
        _ => return None,
    };
    Some(prefix.join("var").join("zeroclaw"))
}

async fn resolve_runtime_config_dirs(
    default_zeroclaw_dir: &Path,
    default_data_dir: &Path,
) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
    if let Ok(custom_config_dir) = std::env::var("ZEROCLAW_CONFIG_DIR") {
        let custom_config_dir = custom_config_dir.trim();
        if !custom_config_dir.is_empty() {
            // If the operator ALSO set ZEROCLAW_DATA_DIR or
            // ZEROCLAW_WORKSPACE, CONFIG_DIR wins; surface the
            // collision so they know which one took effect.
            if std::env::var("ZEROCLAW_DATA_DIR")
                .ok()
                .filter(|v| !v.trim().is_empty())
                .is_some()
            {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_DATA_DIR is ignored \
                     (CONFIG_DIR pins both the config directory and the data \
                     directory under it)."
                );
            }
            if std::env::var("ZEROCLAW_WORKSPACE")
                .ok()
                .filter(|v| !v.is_empty())
                .is_some()
            {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_WORKSPACE (deprecated) \
                     is ignored. ZEROCLAW_WORKSPACE will be removed in a future \
                     release; switch any remaining references to ZEROCLAW_DATA_DIR."
                );
            }
            let zeroclaw_dir = expand_tilde_path(custom_config_dir);
            return Ok((
                zeroclaw_dir.clone(),
                zeroclaw_dir.join("data"),
                ConfigResolutionSource::EnvConfigDir,
            ));
        }
    }

    if let Ok(custom_data) = std::env::var("ZEROCLAW_DATA_DIR")
        && !custom_data.trim().is_empty()
    {
        if std::env::var("ZEROCLAW_WORKSPACE")
            .ok()
            .filter(|v| !v.is_empty())
            .is_some()
        {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                "ZEROCLAW_DATA_DIR and ZEROCLAW_WORKSPACE are both set; \
                 ZEROCLAW_WORKSPACE (deprecated) is ignored. \
                 ZEROCLAW_WORKSPACE will be removed in a future release."
            );
        }
        let expanded = expand_tilde_path(&custom_data);
        let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
        return Ok((zeroclaw_dir, data_dir, ConfigResolutionSource::EnvDataDir));
    }

    if let Ok(custom_workspace) = std::env::var("ZEROCLAW_WORKSPACE")
        && !custom_workspace.is_empty()
    {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "ZEROCLAW_WORKSPACE is deprecated; use ZEROCLAW_DATA_DIR instead. \
             ZEROCLAW_WORKSPACE will be removed in a future release."
        );
        let expanded = expand_tilde_path(&custom_workspace);
        let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
        return Ok((
            zeroclaw_dir,
            data_dir,
            ConfigResolutionSource::EnvWorkspaceLegacy,
        ));
    }

    if cfg!(target_os = "macos")
        && let Ok(exe) = std::env::current_exe()
        && let Some(homebrew_config_dir) = try_resolve_macos_homebrew_config_dir(&exe).await
    {
        return Ok((
            homebrew_config_dir.clone(),
            homebrew_config_dir.join("workspace"),
            ConfigResolutionSource::HomebrewConfigDir,
        ));
    }

    Ok((
        default_zeroclaw_dir.to_path_buf(),
        default_data_dir.to_path_buf(),
        ConfigResolutionSource::DefaultConfigDir,
    ))
}

fn config_dir_creation_error(path: &Path) -> String {
    format!(
        "Failed to create config directory: {}. If running as an OpenRC service, \
         ensure this path is writable by user 'zeroclaw'.",
        path.display()
    )
}

/// Top-level keys that must always appear in the saved config even
/// when their value equals the default. `schema_version` is the
/// migration detector's anchor — dropping it from a freshly-saved
/// config would make the next load mis-detect the file as V1 (no
/// version key = V1).
const SAVE_PRESERVE_KEYS: &[&str] = &["schema_version"];

/// Insert a blank line before every `[section]` header that doesn't
/// already have one, so the serialized TOML reads as discrete blocks
/// instead of running every section header directly after the
/// previous line (`toml::to_string_pretty` doesn't gap between a
/// trailing scalar and the next section header).
fn ensure_blank_line_before_sections(toml: &str) -> String {
    let mut out = String::with_capacity(toml.len() + 64);
    let mut prev_line_blank = true; // start of file counts as blank
    for line in toml.lines() {
        let is_section_header = line.starts_with('[');
        if is_section_header && !prev_line_blank {
            out.push('\n');
        }
        out.push_str(line);
        out.push('\n');
        prev_line_blank = line.trim().is_empty();
    }
    out
}

/// Walk `actual` and drop every key whose value matches the same
/// key's value in `defaults`. Tables recurse; the recursion drops a
/// sub-table when every one of its keys was itself dropped (i.e. the
/// sub-table contained only defaults). Keys that don't appear in
/// `defaults` are operator-added and always survive.
///
/// HashMap-keyed sub-trees (e.g. `agents`, `providers.models.<family>`)
/// are not in the typed default tree, so their operator-added aliases
/// pass through this filter unchanged.
fn prune_default_values(actual: &mut toml::Table, defaults: &toml::Table) {
    let keys: Vec<String> = actual.keys().cloned().collect();
    for key in keys {
        if SAVE_PRESERVE_KEYS.contains(&key.as_str()) {
            continue;
        }
        let Some(default_value) = defaults.get(&key) else {
            // Operator added this key; not in the typed default tree.
            // Always keep — recursing in would either be a no-op or
            // strip operator content.
            continue;
        };
        let Some(child) = actual.remove(&key) else {
            continue;
        };
        let pruned = match (child, default_value) {
            (toml::Value::Table(mut child_table), toml::Value::Table(default_subtable)) => {
                prune_default_values(&mut child_table, default_subtable);
                if child_table.is_empty() {
                    None
                } else {
                    Some(toml::Value::Table(child_table))
                }
            }
            (child, default_value) => {
                if &child == default_value {
                    None
                } else {
                    Some(child)
                }
            }
        };
        if let Some(value) = pruned {
            actual.insert(key, value);
        }
    }
}

fn is_local_ollama_endpoint(api_url: Option<&str>) -> bool {
    let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
        return true;
    };

    reqwest::Url::parse(raw)
        .ok()
        .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
        .is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1" | "0.0.0.0"))
}

fn is_official_ollama_cloud_endpoint(api_url: Option<&str>) -> bool {
    let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
        return false;
    };

    reqwest::Url::parse(raw)
        .ok()
        .and_then(|url| {
            url.host_str().map(|host| {
                host.eq_ignore_ascii_case("ollama.com")
                    || host.eq_ignore_ascii_case("api.ollama.com")
            })
        })
        .unwrap_or(false)
}

fn has_ollama_cloud_credential(config_api_key: Option<&str>) -> bool {
    config_api_key
        .map(str::trim)
        .is_some_and(|value| !value.is_empty())
}

/// Ensure that essential bootstrap files exist in the workspace directory.
///
/// When the workspace is created outside of Quickstart (e.g., non-tty
/// daemon/cron sessions), these files would otherwise be missing. This function
/// creates sensible defaults that allow the agent to operate with a basic identity.
pub async fn ensure_bootstrap_files(workspace_dir: &Path) -> Result<()> {
    let defaults: &[(&str, &str)] = &[
        (
            "IDENTITY.md",
            "# IDENTITY.md — Who Am I?\n\n\
             I am ZeroClaw, an autonomous AI agent.\n\n\
             ## Traits\n\
             - Helpful, precise, and safety-conscious\n\
             - I prioritize clarity and correctness\n",
        ),
        (
            "SOUL.md",
            "# SOUL.md — Who You Are\n\n\
             You are ZeroClaw, an autonomous AI agent.\n\n\
             ## Core Principles\n\
             - Be helpful and accurate\n\
             - Respect user intent and boundaries\n\
             - Ask before taking destructive actions\n\
             - Prefer safe, reversible operations\n",
        ),
    ];

    for (filename, content) in defaults {
        let path = workspace_dir.join(filename);
        if !path.exists() {
            fs::write(&path, content)
                .await
                .with_context(|| format!("Failed to create default {filename} in workspace"))?;
        }
    }

    Ok(())
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct ExtraNestedModelProviderTable {
    family: String,
    alias: String,
    nested: String,
}

impl Config {
    /// External-peer usernames authorized on `<channel_type>.<alias>`.
    ///
    /// A `[peer_groups.<name>]` contributes when its `channel` field either
    /// matches `channel_type` (type-wide group, applies to every alias of
    /// that type) or matches the full dotted `"<channel_type>.<alias>"`
    /// (instance-scoped group, applies to that one alias only).
    pub fn channel_external_peers(&self, channel_type: &str, alias: &str) -> Vec<String> {
        let mut out: Vec<String> = Vec::new();
        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
        for group in self.peer_groups.values() {
            let group_matches = match group.channel.split_once('.') {
                Some((ty, al)) => ty == channel_type && al == alias,
                None => group.channel == channel_type,
            };
            if !group_matches {
                continue;
            }
            for peer in &group.external_peers {
                let username = peer.as_str().to_string();
                if seen.insert(username.clone()) {
                    out.push(username);
                }
            }
        }
        out
    }

    /// Voice-peer usernames for `<channel_type>.<alias>` that should always
    /// receive TTS voice replies.
    ///
    /// A `[peer_groups.<name>]` contributes when its `channel` field matches
    /// (type-wide or dotted) **and** `output_modality = "voice"`.
    ///
    /// This is the live-resolve counterpart of `channel_external_peers`,
    /// filtered to voice-only peer groups. No cache — single source of truth
    /// is `self.peer_groups`.
    pub fn channel_voice_peers(&self, channel_type: &str, alias: &str) -> Vec<String> {
        use crate::multi_agent::OutputModality;

        let mut out: Vec<String> = Vec::new();
        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
        for group in self.peer_groups.values() {
            if group.output_modality != OutputModality::Voice {
                continue;
            }
            let group_matches = match group.channel.split_once('.') {
                Some((ty, al)) => ty == channel_type && al == alias,
                None => group.channel == channel_type,
            };
            if !group_matches {
                continue;
            }
            for peer in &group.external_peers {
                let username = peer.as_str().to_string();
                if seen.insert(username.clone()) {
                    out.push(username);
                }
            }
        }
        out
    }

    /// Sender usernames authorized to issue `/model --agent <model>` on
    /// `<channel_type>.<alias>` for agent `agent_alias`. A
    /// `[peer_groups.<name>]` contributes when its `channel` matches
    /// (type-wide or dotted), its `admin_for_agent_scope = true`, and its
    /// `agents` list (if non-empty) contains `agent_alias`. Returns
    /// deduped and sorted. Live-resolve against the `Config` this method
    /// is called on — no internal cache. Edits to `peer_groups` take
    /// effect at the next `Config::channel_agent_scope_admins` call; the
    /// orchestrator gate reads through a snapshot of this `Config`, so
    /// operator edits become visible after the runtime context is rebuilt
    /// (daemon restart).
    pub fn channel_agent_scope_admins(
        &self,
        channel_type: &str,
        alias: &str,
        agent_alias: &str,
    ) -> Vec<String> {
        let mut out: Vec<String> = Vec::new();
        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
        for group in self.peer_groups.values() {
            if !group.admin_for_agent_scope {
                continue;
            }
            let group_matches = match group.channel.split_once('.') {
                Some((ty, al)) => ty == channel_type && al == alias,
                None => group.channel == channel_type,
            };
            if !group_matches {
                continue;
            }
            // Agent-bound: when the group's `agents` list is non-empty,
            // only grant the privilege if `agent_alias` appears in it.
            // An empty `agents` list is interpreted as "all agents"
            // (backward-compatible channel-wide default).
            if !group.agents.is_empty() && !group.agents.iter().any(|a| a.as_str() == agent_alias) {
                continue;
            }
            for peer in &group.external_peers {
                let username = peer.as_str().to_string();
                if seen.insert(username.clone()) {
                    out.push(username);
                }
            }
        }
        out.sort();
        out
    }

    /// Collect the `IntegrationDescriptor` from every nested config that
    /// declares one via `#[integration(...)]`. Adding a new toggleable
    /// integration is one struct-level attribute on the new config + one
    /// row in this method. The integrations registry consumes the result
    /// without per-vendor branches.
    pub fn integration_descriptors(&self) -> Vec<crate::config::IntegrationDescriptor> {
        // BrowserConfig and GoogleWorkspaceConfig carry
        // `#[integration(...)]` annotations on V3, so the macro emits
        // `integration_descriptor()` on each. Cron has been flattened
        // to `HashMap<String, CronJobDecl>` with no enable toggle, so
        // it gets a hand-crafted descriptor whose `active` reflects
        // whether any job is configured. Display copy lives next to
        // the field so the registry never branches on a category name.
        vec![
            self.browser.integration_descriptor(),
            self.google_workspace.integration_descriptor(),
            crate::config::IntegrationDescriptor {
                display_name: "Cron",
                description: "Scheduled tasks",
                category: "ToolsAutomation",
                active: !self.cron.is_empty(),
            },
        ]
    }

    /// Return top-level TOML keys in `raw_toml` that Config does not recognise.
    ///
    /// Keys present in `Config::default()` serialization pass immediately.
    /// Remaining keys are probed: the key is deserialized in isolation and
    /// the result compared to the default — a changed output means serde
    /// consumed it (covers `Option<T>` fields and `#[serde(alias)]` names).
    /// V1 legacy keys (consumed by migration) are also accepted.
    pub fn unknown_keys(raw_toml: &str) -> Vec<String> {
        let raw: toml::Table = match raw_toml.parse() {
            Ok(t) => t,
            Err(_) => return Vec::new(),
        };
        static DEFAULTS: OnceLock<toml::Table> = OnceLock::new();
        let defaults = DEFAULTS.get_or_init(|| {
            toml::to_string(&Config::default())
                .ok()
                .and_then(|s| s.parse().ok())
                .unwrap_or_default()
        });
        raw.keys()
            .filter(|key| {
                if defaults.contains_key(key.as_str()) {
                    return false;
                }
                if crate::migration::V1_LEGACY_KEYS.contains(&key.as_str()) {
                    return false;
                }
                let mut t = toml::Table::new();
                t.insert((*key).clone(), raw[key.as_str()].clone());
                let consumed = toml::to_string(&t)
                    .ok()
                    .and_then(|s| toml::from_str::<Config>(&s).ok())
                    .and_then(|c| toml::to_string(&c).ok())
                    .and_then(|s| s.parse::<toml::Table>().ok())
                    .is_some_and(|t| t != *defaults);
                !consumed
            })
            .cloned()
            .collect()
    }

    /// Return `<kind>.<family>` entries under `[providers]` in `raw_toml`
    /// whose family is not a known typed slot (kinds: models, tts,
    /// transcription). Serde silently drops these sections at deserialize
    /// time, so a typo'd family (`[providers.models.antropic.x]`) or one
    /// from a newer binary vanishes on reload: the alias "works" in the
    /// session that created it, then disappears after restart.
    pub fn unknown_provider_families(raw_toml: &str) -> Vec<String> {
        let raw: toml::Table = match raw_toml.parse() {
            Ok(t) => t,
            Err(_) => return Vec::new(),
        };
        let Some(kinds) = raw.get("providers").and_then(toml::Value::as_table) else {
            return Vec::new();
        };
        let kind_slots: &[(&str, &[&str])] = &[
            ("models", crate::providers::ModelProviders::slot_names()),
            ("tts", crate::providers::TtsProviders::slot_names()),
            (
                "transcription",
                crate::providers::TranscriptionProviders::slot_names(),
            ),
        ];
        let mut out = Vec::new();
        for (kind, slots) in kind_slots {
            let Some(families) = kinds.get(*kind).and_then(toml::Value::as_table) else {
                continue;
            };
            out.extend(
                families
                    .keys()
                    .filter(|k| !slots.contains(&k.as_str()))
                    .map(|k| format!("{kind}.{k}")),
            );
        }
        out
    }

    /// Return extra-nested model-provider alias tables that look like a
    /// misplaced provider config, e.g.
    /// `[providers.models.zai.default.default]`.
    ///
    /// Serde treats the first `default` as the alias and silently ignores the
    /// second `default` child table. When that child table carries provider
    /// fields such as `model`, `api_key`, or family-specific fields, the
    /// operator's settings are dropped while the empty alias still validates as
    /// an in-progress provider. This raw-TOML detector runs before that shape
    /// is erased by typed deserialization.
    fn extra_nested_model_provider_tables(raw_toml: &str) -> Vec<ExtraNestedModelProviderTable> {
        let raw: toml::Table = match raw_toml.parse() {
            Ok(t) => t,
            Err(_) => return Vec::new(),
        };
        let Some(models) = raw
            .get("providers")
            .and_then(toml::Value::as_table)
            .and_then(|providers| providers.get("models"))
            .and_then(toml::Value::as_table)
        else {
            return Vec::new();
        };

        let mut out = Vec::new();
        for (family, aliases_value) in models {
            if !crate::providers::ModelProviders::slot_names().contains(&family.as_str()) {
                continue;
            }
            let Some(aliases) = aliases_value.as_table() else {
                continue;
            };
            for (alias, alias_value) in aliases {
                let Some(alias_table) = alias_value.as_table() else {
                    continue;
                };
                for (nested_key, nested_value) in alias_table {
                    let Some(nested_table) = nested_value.as_table() else {
                        continue;
                    };
                    if nested_table.is_empty()
                        || Self::is_model_provider_table_valued_field(nested_key)
                    {
                        continue;
                    }
                    out.push(ExtraNestedModelProviderTable {
                        family: family.clone(),
                        alias: alias.clone(),
                        nested: nested_key.clone(),
                    });
                }
            }
        }
        out.sort();
        out.dedup();
        out
    }

    fn is_model_provider_table_valued_field(key: &str) -> bool {
        // These shared model-provider fields legitimately deserialize from
        // nested TOML tables under an alias. Every other table-valued child of
        // `[providers.models.<family>.<alias>]` is an ignored extra section,
        // regardless of whether its contents are shared or family-specific
        // provider fields.
        matches!(
            key,
            "chat_template_kwargs" | "extra_headers" | "pricing" | "provider_extra"
        )
    }

    /// Returns `true` if `path` was populated by a `ZEROCLAW_*` env-var
    /// override at load time. O(1) HashSet lookup; safe to call per row in
    /// list-rendering paths (`config list`, dashboard, quickstart).
    pub fn prop_is_env_overridden(&self, path: &str) -> bool {
        self.env_overridden_paths.contains(path)
    }

    pub async fn load_or_init() -> Result<Self> {
        let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;

        // Resolve env overrides FIRST so the migration runs against
        // the install root the operator actually uses. Running the
        // migration against `default_zeroclaw_dir` would silently skip
        // any install reached via `ZEROCLAW_CONFIG_DIR` or
        // `ZEROCLAW_WORKSPACE`.
        let (zeroclaw_dir, _legacy_workspace_dir, resolution_source) =
            resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;

        // One-time, V<3 → V3 ONLY move of `<install>/workspace/` into
        // `<install>/agents/default/workspace/`. The "default" alias is
        // the migration bridge — it must NEVER appear on a fresh install
        // or on a V3 install that already declared its own aliases.
        //
        // Gate strictly on the on-disk config's `schema_version`:
        // - missing config.toml → fresh install, skip.
        // - schema_version >= 3 → already V3, skip.
        // - schema_version 1 or 2 → upgrade in progress, run.
        // Anything else (parse failure, weird value) is treated as
        // "don't touch the filesystem"; the TOML migrator will surface
        // the real error.
        let config_toml_path = zeroclaw_dir.join("config.toml");
        let needs_fs_migration = config_toml_path.is_file()
            && matches!(
                std::fs::read_to_string(&config_toml_path)
                    .ok()
                    .and_then(|raw| toml::from_str::<toml::Value>(&raw).ok())
                    .and_then(|v| crate::migration::detect_version(&v).ok()),
                Some(v) if v < crate::migration::CURRENT_SCHEMA_VERSION
            );
        if needs_fs_migration
            && let Err(e) = crate::schema::v2::migrate_v2_to_v3_install_filesystem(&zeroclaw_dir)
        {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({
                        "install": zeroclaw_dir.display().to_string(),
                        "error": format!("{}", e),
                    })),
                "[system] filesystem migration failed; continuing with legacy layout"
            );
        } else if !needs_fs_migration
            && let Err(e) =
                crate::schema::v2::relocate_default_agent_skills_to_shared(&zeroclaw_dir)
        {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({
                        "install": zeroclaw_dir.display().to_string(),
                        "error": format!("{}", e),
                    })),
                "[system] skills relocation to shared workspace failed; continuing"
            );
        }

        let config_path = zeroclaw_dir.join("config.toml");

        // The install dir is the only directory `load_or_init` creates
        // unconditionally. Per-agent workspaces (`agents/<alias>/workspace/`)
        // are seeded lazily at agent-loop entry by
        // `Agent::from_config_with_session_cwd_and_mcp`, which runs
        // `ensure_bootstrap_files` for the agent it is starting. There
        // is no fresh-install "default" agent and therefore no
        // `agents/default/workspace/` synthesized at boot; the only
        // legitimate origin for that directory is the V1/V2→V3
        // legacy-workspace migration above, which fires only when a
        // pre-multi-agent install's `<install>/workspace/` is present
        // and needs to be moved into the new layout.
        //
        // `config.data_dir` resolves to `<install>/data/` — the shared
        // instance data directory holding databases (memory, sessions,
        // cost records) and hygiene/state files. Per-agent identity
        // and markdown (MEMORY.md, IDENTITY.md, SOUL.md) lives at
        // `Config::agent_workspace_dir(alias)` instead.
        let data_dir = zeroclaw_dir.join("data");
        fs::create_dir_all(&data_dir).await.with_context(|| {
            format!(
                "Failed to create data directory: {}",
                data_dir.display().to_string()
            )
        })?;
        // Legacy alias retained for clarity in the struct initializer
        // and existing field assignments below.
        let workspace_dir = data_dir;

        // `<install>/shared/` — root workspace shared across every agent
        // on the host. Holds skills, skill bundles, and other content
        // not scoped to a single agent. Per-agent state still lives at
        // `<install>/agents/<alias>/workspace/`.
        let shared_dir = zeroclaw_dir.join("shared");
        fs::create_dir_all(&shared_dir).await.with_context(|| {
            format!(
                "Failed to create shared workspace directory: {}",
                shared_dir.display()
            )
        })?;

        fs::create_dir_all(&zeroclaw_dir)
            .await
            .with_context(|| config_dir_creation_error(&zeroclaw_dir))?;

        if config_path.exists() {
            // Warn if config file is world-readable (may contain API keys)
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                if let Ok(meta) = fs::metadata(&config_path).await
                    && meta.permissions().mode() & 0o004 != 0
                {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                        &format!(
                            "Config file {:?} is world-readable (mode {:o}). \
                             Consider restricting with: chmod 600 {:?}",
                            config_path,
                            meta.permissions().mode() & 0o777,
                            config_path
                        )
                    );
                }
            }

            let contents = fs::read_to_string(&config_path)
                .await
                .context("Failed to read config file")?;

            // Deserialize the config with the standard TOML parser.
            //
            // Previously this used `serde_ignored::deserialize` for both
            // deserialization and unknown-key detection. However,
            // `serde_ignored` silently drops field values inside nested
            // structs that carry `#[serde(default)]` (e.g. the entire
            // `[autonomy]` table), causing user-supplied values to be
            // replaced by defaults.
            //
            // We now deserialize with `toml::from_str` (which is correct)
            // and run `serde_ignored` separately just for diagnostics.
            //
            // `migrate_to_current` parses the TOML, detects the schema
            // version, runs the typed V1→V2→V3 chain via `V1Config::migrate`
            // / `V2Config::migrate`, and deserializes the result into the
            // current `Config` shape.
            //
            // Detect the on-disk version up-front so we can emit one WARN
            // line when the daemon auto-migrates an older config in memory:
            // the disk file is left untouched and the user is advised to lock
            // the migration in with `zeroclaw config migrate`.
            let stale_version = toml::from_str::<toml::Value>(&contents)
                .ok()
                .as_ref()
                .and_then(|v| crate::migration::detect_version(v).ok())
                .filter(|n| *n != crate::migration::CURRENT_SCHEMA_VERSION);
            // Daemon load must never hard-fail on a malformed config — the
            // operator needs the process up to repair it. The resilient path
            // degrades (dropping invalid blocks to defaults); security-critical
            // drops are recorded on `degraded_security` for exposure gating.
            // Strict validation lives in `zeroclaw config migrate`.
            let salvage = crate::migration::migrate_to_current_salvaged(&contents);
            let mut config: Config = salvage.config;
            config.degraded_security = salvage.dropped_security;
            config.degraded_sections = salvage.dropped;
            if let Some(from_version) = stale_version {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    &format!(
                        "Config at {} is schema_version {from_version}; auto-migrated to {} in memory. \
                     Run `zeroclaw config migrate` to commit the migration to disk. \
                     V0.8.0 also replaced the env-var override grammar; see \
                     https://github.com/zeroclaw-labs/zeroclaw/blob/master/docs/book/src/reference/env-vars.md \
                     for the migration recipes.",
                        config_path.display().to_string(),
                        crate::migration::CURRENT_SCHEMA_VERSION
                    )
                );
            }

            // Ensure the built-in default auto_approve entries are always
            // present on a `risk_profiles.default` entry that already
            // exists (typically post-V1/V2→V3 migration). When a user
            // specifies `auto_approve` in their TOML (e.g. to add a
            // custom tool), serde replaces the default list instead of
            // merging — this re-adds the framework defaults so safe
            // tools like `weather` and `calculator` keep their
            // auto-approve status.
            //
            // Users who want to require approval for a default tool can
            // add it to `always_ask`, which takes precedence over
            // `auto_approve` in the approval decision (see approval/mod.rs).
            //
            // Skipped when the loaded config has no `risk_profiles.default`
            // entry: we will not synthesize a `default` alias here.
            // `default` is a migration artifact (V1/V2→V3
            // single-instance bridge); a config that arrives without it
            // is a legitimate multi-aliased shape and must not have one
            // injected at load time.
            if let Some(default_profile) = config.risk_profiles.get_mut("default") {
                default_profile.ensure_default_auto_approve();
            }

            // Detect unknown top-level config keys by comparing the raw
            // TOML table keys against what Config actually deserializes.
            // This replaces the previous serde_ignored-based approach which
            // had false-positive issues with #[serde(default)] nested structs.
            for key in Self::unknown_keys(&contents) {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({"key": key})),
                    "Unknown config key ignored: \"\". Check config.toml for typos or deprecated options."
                );
            }
            // Unknown provider families are dropped by serde, so an alias
            // created under a typo'd or unsupported family silently vanishes
            // on reload while agents.*.model_provider still references it.
            for entry in Self::unknown_provider_families(&contents) {
                let (kind, family) = entry.split_once('.').unwrap_or(("models", entry.as_str()));
                let reference = if kind == "models" {
                    "any agents.*.model_provider referencing them will fail to resolve; \
                     run `zeroclaw providers` for valid family names"
                } else {
                    "references to its aliases will fail to resolve"
                };
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({"kind": kind, "family": family})),
                    &format!(
                        "[providers.{kind}.{family}] section dropped: not a known {kind} \
                        provider family. Its aliases will not load and {reference}."
                    )
                );
            }
            // Extra child tables under a known model-provider alias are also
            // erased by serde. If the child carries provider-shaped fields,
            // call out the likely mistaken nesting instead of letting the
            // parent alias look like an ordinary unfinished provider.
            for entry in Self::extra_nested_model_provider_tables(&contents) {
                let family = entry.family.as_str();
                let alias = entry.alias.as_str();
                let nested = entry.nested.as_str();
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({
                            "family": family,
                            "alias": alias,
                            "nested": nested,
                        })),
                    &format!(
                        "[providers.models.{}.{}.{}] section ignored: \
                         model-provider fields must live directly under \
                         [providers.models.{}.{}]. Move the nested keys up one level.",
                        family, alias, nested, family, alias
                    )
                );
            }
            // Set computed paths that are skipped during serialization
            config.config_path = config_path.clone();
            config.data_dir = workspace_dir;

            // Ensure each configured skill-bundle's resolved directory
            // exists on disk so the bundle has somewhere for skills to
            // land immediately. Idempotent.
            let install_root = config.install_root_dir();
            for alias in config.skill_bundles.keys().cloned().collect::<Vec<_>>() {
                if let Ok(dir) =
                    crate::skill_bundles::resolve_directory(&config, &install_root, &alias)
                    && let Err(e) = std::fs::create_dir_all(&dir)
                {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                        &format!(
                            "skill-bundle '{alias}' directory creation failed at {}: {e}",
                            dir.display().to_string()
                        )
                    );
                }
            }

            let store = crate::secrets::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
            config.onepassword_reference_snapshots =
                collect_onepassword_reference_snapshots(&config);
            // Decrypt all #[secret]-annotated fields via Configurable derive
            config = tokio::task::spawn_blocking(move || {
                config.decrypt_secrets(&store)?;
                Ok::<_, anyhow::Error>(config)
            })
            .await
            .context("Config secret decryption task failed")??;

            // Apply ZEROCLAW_<lowercase_path> env-var overrides. Hard-errors
            // on any unresolvable path — no silent ignores. Tracks overridden
            // paths and per-path pre-override snapshots so save() can mask
            // env-injected values back to the original on-disk state.
            let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
            config.env_overridden_paths = applied.paths;
            config.pre_override_snapshots = applied.snapshots;

            // Validation must NOT prevent the daemon from booting. If
            // it did, a single broken agent reference would lock the
            // operator out of `/config` — the only place they can fix
            // it. Demote to a startup warning; the gateway and dashboard
            // still come up so the user can navigate to the bad section
            // and repair it.
            if let Err(e) = config.validate() {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
                    "[system] config has validation errors — booting anyway so you \
                     can fix them via /config or `zeroclaw config set`"
                );
            }
            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"path": config.config_path.display().to_string(), "workspace": config.data_dir.display().to_string(), "source": resolution_source.as_str(), "initialized": true})), "Config loaded");
            Ok(config)
        } else {
            let mut config = Config {
                config_path: config_path.clone(),
                data_dir: workspace_dir,
                ..Config::default()
            };
            // Save defaults FIRST so env-injected values never reach the
            // freshly-created config file. Env overrides apply post-save to
            // populate the in-memory Config for the running process.
            config.save().await?;

            // Restrict permissions on newly created config file (may contain API keys)
            #[cfg(unix)]
            {
                use std::{fs::Permissions, os::unix::fs::PermissionsExt};
                let _ = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await;
            }

            let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
            config.env_overridden_paths = applied.paths;
            config.pre_override_snapshots = applied.snapshots;

            // Same boot-resilience as the load-existing branch above:
            // a fresh-init config can't realistically fail validation,
            // but if it does we still want the daemon up.
            if let Err(e) = config.validate() {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
                    "[system] freshly-initialized config has validation errors — \
                     booting anyway so you can fix them via /config"
                );
            }
            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"path": config.config_path.display().to_string(), "workspace": config.data_dir.display().to_string(), "source": resolution_source.as_str(), "initialized": true})), "Config loaded");
            Ok(config)
        }
    }

    /// Collect non-fatal validation warnings — config that loads and
    /// validates successfully (`validate()` returns `Ok(())`) but will fail
    /// at runtime because of a logical inconsistency the schema cannot
    /// enforce structurally.
    ///
    /// Called by `validate()` (which emits each warning via `tracing::warn!`
    /// for log visibility) and by the gateway HTTP API (which returns the
    /// structured list in `PropResponse` / `PatchResponse` so dashboard
    /// callers see the same signal the CLI sees on stderr).
    ///
    /// Adding a new warning: append a check here, pick a stable `code`,
    /// and document the code in `validation_warnings.rs`.
    pub fn collect_warnings(&self) -> Vec<crate::validation_warnings::ValidationWarning> {
        let mut warnings = Vec::new();
        self.collect_fallback_warnings(&mut warnings);
        self.collect_cross_provider_summary_model_warnings(&mut warnings);
        self.collect_a2a_exposed_skills_warnings(&mut warnings);
        self.collect_memory_semantic_search_warnings(&mut warnings);
        // Must run after `collect_cross_provider_summary_model_warnings`: it
        // scans `warnings` to suppress its generic inert `summary_model`
        // warning when the more specific cross-provider diagnostic already
        // covers the same path.
        self.collect_context_compression_ignored_warnings(&mut warnings);
        warnings.extend(validate_memory_semantics(&self.memory));
        for (alias, wa) in &self.channels.whatsapp {
            warnings.extend(validate_whatsapp_semantics(alias, wa));
        }
        // `wire_api` is only honored by bring-your-own-endpoint families; on a
        // branded family with a fixed wire protocol it is silently ignored.
        // Surface that so an operator who sets it on, e.g., `mistral` learns it
        // has no effect instead of debugging unexpected runtime behavior.
        for (family, alias, entry) in self.providers.models.iter_entries() {
            if entry.wire_api.is_some() && !crate::provider_aliases::family_honors_wire_api(family)
            {
                warnings.push(crate::validation_warnings::ValidationWarning::new(
                    "wire_api_not_supported_for_family",
                    format!(
                        "wire_api is set on `{family}.{alias}` but the `{family}` family has a \
                         fixed wire protocol and ignores it. wire_api only takes effect on the \
                         openai, llamacpp, and custom (openai-compatible) families."
                    ),
                    format!("providers.models.{family}.{alias}.wire_api"),
                ));
            }
        }
        warnings
    }

    /// Surface sqlite semantic/hybrid search with no effective embedder. The
    /// runtime treats `NoopEmbedding` as dimensions 0 and silently skips the
    /// vector path, so derive this from the canonical memory settings instead
    /// of storing another effective-mode field.
    fn collect_memory_semantic_search_warnings(
        &self,
        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
    ) {
        if !Self::memory_backend_is_sqlite(&self.memory.backend) {
            return;
        }
        if !matches!(
            &self.memory.search_mode,
            SearchMode::Embedding | SearchMode::Hybrid
        ) {
            return;
        }
        if self.memory_has_effective_embedder() {
            return;
        }

        let search_mode = match &self.memory.search_mode {
            SearchMode::Bm25 => "bm25",
            SearchMode::Embedding => "embedding",
            SearchMode::Hybrid => "hybrid",
        };
        warnings.push(crate::validation_warnings::ValidationWarning::new(
            "memory_semantic_search_without_embedder",
            format!(
                "memory.search_mode is {search_mode:?} on sqlite memory, but no effective \
                 embedding provider is configured; vector search is skipped and \
                 recall falls back to keyword-only behavior. Configure \
                 memory.embedding_provider or a valid memory.embedding_model \
                 hint route, or set memory.search_mode = \"bm25\"."
            ),
            "memory.search_mode",
        ));
    }

    fn memory_backend_is_sqlite(backend: &str) -> bool {
        let trimmed = backend.trim();
        let family = trimmed
            .split_once('.')
            .map_or(trimmed, |(family, _)| family)
            .trim();
        family.eq_ignore_ascii_case("sqlite")
    }

    fn memory_has_effective_embedder(&self) -> bool {
        let fallback = || Self::memory_embedding_settings_have_effective_embedder(&self.memory);
        let Some(hint) = self
            .memory
            .embedding_model
            .strip_prefix("hint:")
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            return fallback();
        };

        let Some(route) = self
            .embedding_routes
            .iter()
            .find(|route| route.hint.trim() == hint)
        else {
            return fallback();
        };

        if Self::embedding_route_has_effective_embedder(route, self.memory.embedding_dimensions) {
            return true;
        }
        fallback()
    }

    fn memory_embedding_settings_have_effective_embedder(memory: &MemoryConfig) -> bool {
        let provider = memory.embedding_provider.trim();
        !provider.is_empty()
            && !provider.eq_ignore_ascii_case("none")
            && memory.embedding_dimensions > 0
    }

    fn embedding_route_has_effective_embedder(
        route: &EmbeddingRouteConfig,
        fallback_dimensions: usize,
    ) -> bool {
        let provider = route.model_provider.trim();
        let model = route.model.trim();
        let dimensions = route.dimensions.unwrap_or(fallback_dimensions);
        !provider.is_empty()
            && !provider.eq_ignore_ascii_case("none")
            && !model.is_empty()
            && dimensions > 0
    }

    /// Surface the A2A `exposed_skills` filter that can never resolve a skill.
    /// `exposed_skills` is a narrowing filter over the agent's resolved skill
    /// set, which comes from `skill_bundles`. An agent that lists
    /// `a2a.exposed_skills` but declares no `skill_bundles` advertises an empty
    /// `skills: []` array on its agent card with no error, because every wanted
    /// id is dropped for belonging to a bundle the agent does not declare. The
    /// same silent emptiness happens when a wanted id names no skill in any
    /// declared bundle, or when the owning bundle's include/exclude filter
    /// excludes it; those need disk resolution, so this offline check covers the
    /// common structural case: exposed skills with zero declared bundles.
    fn collect_a2a_exposed_skills_warnings(
        &self,
        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
    ) {
        for (agent_alias, agent) in &self.agents {
            if agent.a2a.exposed_skills.is_empty() {
                continue;
            }
            if !agent.skill_bundles.is_empty() {
                continue;
            }
            warnings.push(crate::validation_warnings::ValidationWarning::new(
                "a2a_exposed_skills_without_bundles",
                format!(
                    "agent `{agent_alias}` lists a2a.exposed_skills but declares no \
                     skill_bundles, so its agent card advertises no skills (skills: []). \
                     exposed_skills is a filter over the agent's resolved skill set; add \
                     the owning bundle(s) to agents.{agent_alias}.skill_bundles so the \
                     listed skills resolve."
                ),
                format!("agents.{agent_alias}.a2a.exposed_skills"),
            ));
        }
    }

    /// Surface cross-provider ambiguity in a legacy config while reporting
    /// the current contract: context compression has no runtime consumer, so
    /// this knob is inert like every other `context_compression` field (see
    /// `collect_context_compression_ignored_warnings`). This diagnostic adds
    /// config-shape detail as a more specific companion for the same line. The
    /// deprecated
    /// `runtime_profiles.<p>.context_compression.summary_model` is a bare
    /// model id that names no provider of its own — it would need to be
    /// resolved onto each consuming agent's OWN provider were the field ever
    /// read again — so when a single profile is shared by agents resolving
    /// to MORE THAN ONE distinct provider, that one bare id is ambiguous for
    /// at least one of them. A `summary_provider` supplies provider identity
    /// for this narrower diagnostic and excludes the corresponding value from
    /// the ambiguity count; it does not make context compression functional.
    ///
    /// The diagnostic is offline and deterministic: no schema bump, no
    /// network, and no model catalog. It names the profile, the affected
    /// agents, and their differing providers, then recommends removing the
    /// unsupported setting or waiting for an accepted compression design.
    fn collect_cross_provider_summary_model_warnings(
        &self,
        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
    ) {
        for (profile_alias, profile) in &self.runtime_profiles {
            // Only the deprecated bare summary_model lacks provider identity.
            // A profile-level summary_provider excludes this narrower
            // ambiguity shape, but context compression remains inert.
            if !profile
                .context_compression
                .summary_provider
                .trim()
                .is_empty()
            {
                continue;
            }
            let Some(summary_model) = profile.context_compression.summary_model.as_deref() else {
                continue;
            };
            if summary_model.trim().is_empty() {
                continue;
            }

            // Gather agents that reference this profile and have no agent-level
            // summary_provider identity. An override excludes that agent from
            // this ambiguity diagnostic but does not make compression
            // functional. Resolve the provider that would be paired with the
            // bare model if a future implementation consumed this config.
            let mut affected: Vec<(String, String)> = Vec::new();
            for (agent_alias, agent) in &self.agents {
                if agent.runtime_profile.trim() != profile_alias {
                    continue;
                }
                if !agent.summary_provider.trim().is_empty() {
                    continue;
                }
                let provider_label = self.canonical_provider_label(agent.model_provider.trim());
                affected.push((agent_alias.clone(), provider_label));
            }

            // Cross-provider ambiguity requires distinct providers. A
            // same-provider bare id is still inert, but the generic
            // context-compression warning reports that fact without this
            // additional ambiguity detail.
            let distinct: std::collections::BTreeSet<&str> =
                affected.iter().map(|(_, p)| p.as_str()).collect();
            if distinct.len() < 2 {
                continue;
            }

            let mut agents_sorted: Vec<&(String, String)> = affected.iter().collect();
            agents_sorted.sort_by(|a, b| a.0.cmp(&b.0));
            let detail = agents_sorted
                .iter()
                .map(|(name, provider)| format!("{name} -> {provider}"))
                .collect::<Vec<_>>()
                .join(", ");

            warnings.push(crate::validation_warnings::ValidationWarning::new(
                "cross_provider_summary_model",
                format!(
                    "runtime_profiles.{profile_alias}.context_compression.summary_model \
                     ({summary_model:?}) is set, but context compression is not currently \
                     implemented in the runtime; this setting has no effect. It is also a \
                     bare model id reused by agents resolving to different providers \
                     ({detail}), which names no provider of its own and would be ambiguous \
                     for at least one of them if compression were read again. Remove the \
                     unsupported context_compression setting (every context_compression \
                     field, including summary_provider, is currently inert), or wait for a \
                     separately accepted compression design before configuring it."
                ),
                format!("runtime_profiles.{profile_alias}.context_compression.summary_model"),
            ));
        }
    }

    /// Surface every non-default `context_compression` knob as inert: the
    /// runtime context compressor was removed and nothing in the
    /// workspace reads `context_compression` at runtime anymore, so the whole
    /// struct — `enabled`, thresholds, protected counts, summarizer limits,
    /// provider selection, tool-result retrimming — has no effect. Mirrors
    /// `validate_memory_semantics`: one warning per non-default field, so a
    /// user sees exactly which of their authored knobs are dead. A field
    /// explicitly written at its default value is indistinguishable from an
    /// omitted one post-deserialization and stays silent (same limitation as
    /// `validate_memory_semantics`).
    ///
    /// Only `[runtime_profiles.<alias>.context_compression]` is checked —
    /// `AliasedAgentConfig` (`[agents.<alias>]`) has no `context_compression`
    /// field of its own, and the legacy pre-V3 `[agent.context_compression]`
    /// top-level table (folded into `[runtime_profiles.default]` by the V2→V3
    /// migration, see `schema/v2.rs`) has already collapsed into this same
    /// surface by the time `Config` exists, so a single pass over
    /// `runtime_profiles` covers both the historical and current authored
    /// forms without double-warning.
    fn collect_context_compression_ignored_warnings(
        &self,
        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
    ) {
        let defaults = crate::scattered_types::ContextCompressionConfig::default();
        for (alias, profile) in &self.runtime_profiles {
            let cc = &profile.context_compression;

            // `enabled = true` gets its own message: it is the master switch
            // users flip expecting compression to happen at all.
            if cc.enabled {
                warnings.push(crate::validation_warnings::ValidationWarning::new(
                    "context_compression_unsupported",
                    format!(
                        "runtime_profiles.{alias}.context_compression.enabled is set but context \
                         compression is not currently implemented in the runtime (the compressor \
                         was removed in #8196); this setting has no effect."
                    ),
                    format!("runtime_profiles.{alias}.context_compression.enabled"),
                ));
            }

            // Every other knob: flag any value that differs from the default.
            let mut inert: Vec<&'static str> = Vec::new();
            if (cc.threshold_ratio - defaults.threshold_ratio).abs() > f64::EPSILON {
                inert.push("threshold_ratio");
            }
            if cc.protect_first_n != defaults.protect_first_n {
                inert.push("protect_first_n");
            }
            if cc.protect_last_n != defaults.protect_last_n {
                inert.push("protect_last_n");
            }
            if cc.max_passes != defaults.max_passes {
                inert.push("max_passes");
            }
            if cc.summary_max_chars != defaults.summary_max_chars {
                inert.push("summary_max_chars");
            }
            if cc.source_max_chars != defaults.source_max_chars {
                inert.push("source_max_chars");
            }
            if cc.timeout_secs != defaults.timeout_secs {
                inert.push("timeout_secs");
            }
            if cc.summary_provider != defaults.summary_provider {
                inert.push("summary_provider");
            }
            if cc.summary_model != defaults.summary_model {
                // Ordering dependency: `collect_warnings()` runs
                // `collect_cross_provider_summary_model_warnings` before this
                // helper, so a cross-provider `summary_model` diagnostic for
                // this same path is already in `warnings`. That diagnostic
                // reports the same "has no effect" fact plus the specific
                // cross-provider agents affected, so it wins and the generic
                // inert warning is skipped to avoid printing two warnings
                // for the same config line. All other `summary_model`
                // shapes (single-provider, unshared) still get the inert
                // warning; no other diagnostic covers them.
                let summary_model_path =
                    format!("runtime_profiles.{alias}.context_compression.summary_model");
                if !warnings.iter().any(|w| {
                    w.code == "cross_provider_summary_model" && w.path == summary_model_path
                }) {
                    inert.push("summary_model");
                }
            }
            if cc.identifier_policy != defaults.identifier_policy {
                inert.push("identifier_policy");
            }
            if cc.tool_result_retrim_chars != defaults.tool_result_retrim_chars {
                inert.push("tool_result_retrim_chars");
            }
            if cc.tool_result_trim_exempt != defaults.tool_result_trim_exempt {
                inert.push("tool_result_trim_exempt");
            }

            for field in inert {
                warnings.push(crate::validation_warnings::ValidationWarning::new(
                    "context_compression_unsupported",
                    format!(
                        "runtime_profiles.{alias}.context_compression.{field} is set to a \
                         non-default value but context compression is not currently implemented \
                         in the runtime (the compressor was removed in #8196); this setting has \
                         no effect."
                    ),
                    format!("runtime_profiles.{alias}.context_compression.{field}"),
                ));
            }
        }
    }

    /// Canonical label for an agent's resolved model provider, used to decide
    /// whether two agents sit on distinct providers. A non-empty ref that
    /// resolves through `[providers.models]` collapses to its canonical
    /// `<family>.<alias>` so equivalent spellings (bare vs dotted) compare
    /// equal; an empty or unresolved ref keeps its raw form (empty becomes a
    /// stable sentinel) so it still participates as a distinct bucket.
    fn canonical_provider_label(&self, provider_ref: &str) -> String {
        if provider_ref.is_empty() {
            return "<agent default provider>".to_string();
        }
        match self.providers.models.find_by_name(provider_ref) {
            Some((family, alias, _)) => format!("{family}.{alias}"),
            None => provider_ref.to_string(),
        }
    }

    /// Surface non-fatal issues in per-alias `fallback` chains: dangling refs
    /// (a fallback naming an alias that is not configured) and cycles (a
    /// fallback path that loops back onto itself). Both are warn-and-skip — the
    /// chain still loads and runs with the offending edge pruned at build time.
    fn collect_fallback_warnings(
        &self,
        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
    ) {
        for (family, alias, cfg) in self.providers.models.iter_entries() {
            self.collect_fallback_model_warnings(family, alias, cfg, warnings);
            if cfg.fallback.is_empty() {
                continue;
            }
            let root = format!("{family}.{alias}");
            let mut visited: Vec<String> = vec![root.clone()];
            self.walk_fallback(&root, &cfg.fallback, &mut visited, 1, warnings);
        }
    }

    /// Surface `fallback_models` entries the build path silently skips: blank
    /// entries and entries that duplicate the alias's primary `model`. The skip
    /// itself is safe, but without a warning an operator never learns that a
    /// listed fallback model is doing nothing.
    fn collect_fallback_model_warnings(
        &self,
        family: &str,
        alias: &str,
        cfg: &ModelProviderConfig,
        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
    ) {
        let Some(primary) = cfg.model.as_deref() else {
            return;
        };
        for (i, model) in cfg.fallback_models.iter().enumerate() {
            let path = format!("providers.models.{family}.{alias}.fallback_models[{i}]");
            if model.trim().is_empty() {
                warnings.push(crate::validation_warnings::ValidationWarning::new(
                    "empty_fallback_model",
                    format!(
                        "fallback_models entry {i} on {family}.{alias} is empty; \
                         it is skipped at runtime"
                    ),
                    path,
                ));
            } else if model == primary {
                warnings.push(crate::validation_warnings::ValidationWarning::new(
                    "fallback_model_duplicates_primary",
                    format!(
                        "fallback_models entry {model:?} on {family}.{alias} duplicates the \
                         primary model; it is skipped at runtime"
                    ),
                    path,
                ));
            }
        }
    }

    fn walk_fallback(
        &self,
        from: &str,
        refs: &[crate::providers::ModelProviderRef],
        visited: &mut Vec<String>,
        depth: usize,
        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
    ) {
        if depth > crate::providers::MAX_FALLBACK_DEPTH {
            warnings.push(crate::validation_warnings::ValidationWarning::new(
                "max_fallback_depth_exceeded",
                format!(
                    "fallback chain from {from} exceeds the maximum depth of {}; \
                     deeper links are pruned at runtime",
                    crate::providers::MAX_FALLBACK_DEPTH
                ),
                format!("providers.models.{from}.fallback"),
            ));
            return;
        }
        for (i, fallback_ref) in refs.iter().enumerate() {
            let raw = fallback_ref.as_str().trim();
            if raw.is_empty() {
                continue;
            }
            let path = format!("providers.models.{from}.fallback[{i}]");
            let Some((family, alias, cfg)) = self.providers.models.find_by_name(raw) else {
                warnings.push(crate::validation_warnings::ValidationWarning::new(
                    "dangling_fallback_ref",
                    format!(
                        "fallback {raw:?} on {from} does not resolve to a configured \
                         providers.models entry; this fallback link is skipped at runtime"
                    ),
                    path,
                ));
                continue;
            };
            let resolved = format!("{family}.{alias}");
            if visited.iter().any(|v| v == &resolved) {
                warnings.push(crate::validation_warnings::ValidationWarning::new(
                    "fallback_cycle",
                    format!(
                        "fallback {raw:?} on {from} closes a cycle \
                         ({} -> {resolved}); the cycle edge is pruned at runtime",
                        visited.join(" -> ")
                    ),
                    path,
                ));
                continue;
            }
            visited.push(resolved.clone());
            self.walk_fallback(&resolved, &cfg.fallback, visited, depth + 1, warnings);
            visited.pop();
        }
    }

    /// Walk every channel HashMap that carries reply pacing fields and yield
    /// `(dotted-path-prefix, &dyn HasReplyPacing)` pairs. Used by validation
    /// and by anywhere that wants a single source of truth for which channel
    /// types participate in pacing.
    pub fn reply_pacing_entries(&self) -> Vec<(String, &dyn HasReplyPacing)> {
        let c = &self.channels;
        fn rows<'a, C: HasReplyPacing>(
            ch_type: &'static str,
            map: &'a std::collections::HashMap<String, C>,
        ) -> impl Iterator<Item = (String, &'a dyn HasReplyPacing)> + 'a {
            map.iter().map(move |(alias, cfg)| {
                (
                    format!("channels.{ch_type}.{alias}"),
                    cfg as &dyn HasReplyPacing,
                )
            })
        }
        rows("telegram", &c.telegram)
            .chain(rows("discord", &c.discord))
            .chain(rows("slack", &c.slack))
            .chain(rows("mattermost", &c.mattermost))
            .chain(rows("webhook", &c.webhook))
            .chain(rows("imessage", &c.imessage))
            .chain(rows("matrix", &c.matrix))
            .chain(rows("signal", &c.signal))
            .chain(rows("whatsapp", &c.whatsapp))
            .collect()
    }

    /// Validate configuration values that would cause runtime failures.
    ///
    /// Called after TOML deserialization and env-override application to catch
    /// obviously invalid values early instead of failing at arbitrary runtime points.
    pub fn validate(&self) -> Result<()> {
        validate_memory_rerank_config(&self.memory)?;

        // Tunnel — OpenVPN
        if self.tunnel.tunnel_provider.trim() == "openvpn" {
            let openvpn = self.tunnel.openvpn.as_ref().ok_or_else(|| {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
                    "tunnel.tunnel_provider='openvpn' rejected: [tunnel.openvpn] block missing"
                );
                anyhow::Error::msg("tunnel.tunnel_provider='openvpn' requires [tunnel.openvpn]")
            })?;

            if openvpn.config_file.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    "tunnel.openvpn.config_file",
                    "tunnel.openvpn.config_file must not be empty"
                );
            }
            if openvpn.connect_timeout_secs == 0 {
                validation_bail!(
                    InvalidNumericRange,
                    "tunnel.openvpn.connect_timeout_secs",
                    "tunnel.openvpn.connect_timeout_secs must be greater than 0"
                );
            }
        }

        let mut lucid_aliases: Vec<&String> = self.storage.lucid.keys().collect();
        lucid_aliases.sort();
        for alias in lucid_aliases {
            let lucid = &self.storage.lucid[alias];
            if lucid
                .binary_path
                .as_ref()
                .is_some_and(|path| path.trim().is_empty())
            {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("storage.lucid.{alias}.binary_path"),
                    "storage.lucid.{alias}.binary_path must not be empty"
                );
            }
            if matches!(lucid.recall_timeout_ms, Some(0)) {
                validation_bail!(
                    InvalidNumericRange,
                    format!("storage.lucid.{alias}.recall_timeout_ms"),
                    "storage.lucid.{alias}.recall_timeout_ms must be greater than 0"
                );
            }
            if matches!(lucid.store_timeout_ms, Some(0)) {
                validation_bail!(
                    InvalidNumericRange,
                    format!("storage.lucid.{alias}.store_timeout_ms"),
                    "storage.lucid.{alias}.store_timeout_ms must be greater than 0"
                );
            }
        }

        // Reply-pacing bounds — both `reply_min_interval_secs` and
        // `reply_queue_depth_max` walk through one entry list so adding
        // a new paced channel only requires extending `reply_pacing_entries`.
        for (path_prefix, cfg) in self.reply_pacing_entries() {
            let secs = cfg.reply_min_interval_secs();
            if secs > REPLY_MIN_INTERVAL_MAX_SECS {
                let path = format!("{path_prefix}.reply_min_interval_secs");
                validation_bail!(
                    InvalidNumericRange,
                    path,
                    "{path} = {secs} is out of range; must be 0..={REPLY_MIN_INTERVAL_MAX_SECS}"
                );
            }
            let depth = cfg.reply_queue_depth_max();
            if depth > REPLY_QUEUE_DEPTH_CEILING {
                let path = format!("{path_prefix}.reply_queue_depth_max");
                validation_bail!(
                    InvalidNumericRange,
                    path,
                    "{path} = {depth} is out of range; must be 0..={REPLY_QUEUE_DEPTH_CEILING}"
                );
            }
        }

        for (alias, tg) in &self.channels.telegram {
            tg.validate_bot_token(alias)?;
            validate_http_base_url(
                &format!("channels.telegram.{alias}.api_base_url"),
                &tg.api_base_url,
            )?;
        }

        for (alias, dc) in &self.channels.discord {
            dc.validate_bot_token(alias)?;
        }

        // Signal and Voice Call: like Telegram/Discord's bot_token, these
        // channels have unambiguous required plain-String credentials. An
        // enabled channel built with an empty credential never connects and
        // its per-channel supervisor restarts it forever (crashloop), so
        // reject at config-load time instead.
        for (alias, sig) in &self.channels.signal {
            sig.validate_required(alias)?;
        }

        for (alias, vc) in &self.channels.voice_call {
            let enabled_path = format!("channels.voice_call.{alias}.enabled");
            validate_required_field(
                &format!("channels.voice_call.{alias}.account_id"),
                &enabled_path,
                vc.enabled,
                &vc.account_id,
            )?;
            validate_required_field(
                &format!("channels.voice_call.{alias}.auth_token"),
                &enabled_path,
                vc.enabled,
                &vc.auth_token,
            )?;
            validate_required_field(
                &format!("channels.voice_call.{alias}.from_number"),
                &enabled_path,
                vc.enabled,
                &vc.from_number,
            )?;
        }

        // Git forge channel: a PAT-backed provider must name its API origin
        // explicitly. There is deliberately no default host - every request
        // carries `access_token` as a bearer credential, so guessing an
        // endpoint (e.g. gitea.com) would disclose the token to a host the
        // operator never configured. Only enabled aliases are checked so a
        // half-filled disabled block doesn't fail the whole config.
        for (alias, git) in &self.channels.git {
            let provider = git.provider.trim().to_ascii_lowercase();
            if git.enabled && matches!(provider.as_str(), "gitea" | "forgejo") {
                let path = format!("channels.git.{alias}.api_base_url");
                match git.api_base_url.as_deref() {
                    None => validation_bail!(
                        RequiredFieldEmpty,
                        path,
                        "{path} is required when provider = \"{provider}\": set the \
                         instance's API base URL including /api/v1 (e.g. \
                         https://git.example.org/api/v1); no default host is assumed \
                         because API requests carry the access token"
                    ),
                    Some(url) => validate_http_base_url(&path, url)?,
                }
            }
        }

        for name in self.http_request.secrets.keys() {
            if name.is_empty()
                || name.len() > 64
                || !name
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
            {
                validation_bail!(
                    InvalidFormat,
                    format!("http_request.secrets.{name}"),
                    "http_request.secrets key {name:?} must contain 1..=64 ASCII letters, numbers, underscores, or hyphens"
                );
            }
        }

        // Gateway
        if self.gateway.host.trim().is_empty() {
            validation_bail!(
                RequiredFieldEmpty,
                "gateway.host",
                "gateway.host must not be empty"
            );
        }
        if self.nodes.mdns.max_peers == 0 {
            validation_bail!(
                InvalidNumericRange,
                "nodes.mdns.max_peers",
                "nodes.mdns.max_peers must be greater than 0"
            );
        }
        if self.nodes.mdns.announce_interval_secs == 0 {
            validation_bail!(
                InvalidNumericRange,
                "nodes.mdns.announce_interval_secs",
                "nodes.mdns.announce_interval_secs must be greater than 0"
            );
        }
        if self.nodes.mdns.peer_ttl_secs == 0 {
            validation_bail!(
                InvalidNumericRange,
                "nodes.mdns.peer_ttl_secs",
                "nodes.mdns.peer_ttl_secs must be greater than 0"
            );
        }
        if self.nodes.mdns.peer_ttl_secs <= self.nodes.mdns.announce_interval_secs {
            validation_bail!(
                InvalidNumericRange,
                "nodes.mdns.peer_ttl_secs",
                "nodes.mdns.peer_ttl_secs must be greater than nodes.mdns.announce_interval_secs"
            );
        }
        if matches!(self.transcription.max_audio_bytes, Some(0)) {
            validation_bail!(
                InvalidNumericRange,
                "transcription.max_audio_bytes",
                "transcription.max_audio_bytes must be greater than zero"
            );
        }
        if self.channels.max_concurrent_per_channel == 0 {
            validation_bail!(
                InvalidNumericRange,
                "channels.max_concurrent_per_channel",
                "channels.max_concurrent_per_channel must be greater than 0"
            );
        }
        // Typed-memory producers are a SQLite-only slice: the enabled write
        // path stores through `store_with_options_and_agent`, and only the
        // SQLite backend persists the full typed StoreOptions surface. Any
        // other backend would hit the trait default's explicit failure deep
        // inside spawned background consolidation, so reject the
        // configuration at load instead.
        if self.memory.types.enabled || self.memory.consolidation_extract_facts {
            let flag_path = if self.memory.types.enabled {
                "memory.types.enabled"
            } else {
                "memory.consolidation_extract_facts"
            };
            // Mirror the runtime's backend classification
            // (`backend_kind_from_dotted`): trim, take the prefix before the
            // first dot, compare case-insensitively.
            let backend_kind = self
                .memory
                .backend
                .trim()
                .split('.')
                .next()
                .unwrap_or_default()
                .to_ascii_lowercase();
            if backend_kind != "sqlite" {
                validation_bail!(
                    InvalidFormat,
                    flag_path,
                    "{flag_path} = true requires memory.backend = \"sqlite\" (typed memory storage is SQLite-only), but memory.backend = {:?}",
                    self.memory.backend
                );
            }
            for (alias, agent) in &self.agents {
                if agent.memory.backend != crate::multi_agent::MemoryBackendKind::Sqlite {
                    let agent_backend = agent.memory.backend;
                    validation_bail!(
                        InvalidFormat,
                        format!("agents.{alias}.memory.backend"),
                        "{flag_path} = true requires every agent on the sqlite memory backend (typed memory storage is SQLite-only), but agents.{alias}.memory.backend = {agent_backend:?}",
                    );
                }
            }
        }
        for (alias, agent) in &self.agents {
            if agent.precheck.timeout_secs == 0 {
                validation_bail!(
                    InvalidNumericRange,
                    format!("agents.{alias}.precheck.timeout_secs"),
                    "agents.{alias}.precheck.timeout_secs must be greater than 0"
                );
            }
        }
        // Heartbeat agent: when heartbeat is enabled, the agent field
        // must name a configured agent.
        if self.heartbeat.enabled {
            let hb_agent = self.heartbeat.agent.trim();
            if hb_agent.is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    "heartbeat.agent",
                    "heartbeat.agent must reference a configured agent when heartbeat.enabled = true"
                );
            }
            if !self.agents.contains_key(hb_agent) {
                validation_bail!(
                    DanglingReference,
                    "heartbeat.agent",
                    "heartbeat.agent = {hb_agent:?} but no [agents.{hb_agent}] entry is configured"
                );
            }
        }
        if let Some(ref prefix) = self.gateway.path_prefix {
            // Validate the raw value — no silent trimming so the stored
            // value is exactly what was validated.
            if !prefix.is_empty() {
                if !prefix.starts_with('/') {
                    validation_bail!(
                        InvalidFormat,
                        "gateway.path_prefix",
                        "gateway.path_prefix must start with '/'"
                    );
                }
                if prefix.ends_with('/') {
                    validation_bail!(
                        InvalidFormat,
                        "gateway.path_prefix",
                        "gateway.path_prefix must not end with '/' (including bare '/')"
                    );
                }
                // Reject characters unsafe for URL paths or HTML/JS injection.
                // Whitespace is intentionally excluded from the allowed set.
                if let Some(bad) = prefix.chars().find(|c| {
                    !matches!(c, '/' | '-' | '_' | '.' | '~'
                        | 'a'..='z' | 'A'..='Z' | '0'..='9'
                        | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
                        | ':' | '@')
                }) {
                    anyhow::bail!(
                        "gateway.path_prefix contains invalid character '{bad}'; \
                         only unreserved and sub-delim URI characters are allowed"
                    );
                }
            }
        }

        // Skill bundles — directories must stay inside `<install>/shared/`
        // and no two bundles may resolve to the same directory. Default
        // directory and the rules themselves live in
        // [`crate::skill_bundles`] so the runtime SkillsService and this
        // validator share one implementation.
        if !self.skill_bundles.is_empty() {
            let install_root = self.install_root_dir();
            for alias in self.skill_bundles.keys() {
                let dir = crate::skill_bundles::resolve_directory(self, &install_root, alias)
                    .map_err(|e| {
                        ::zeroclaw_log::record!(
                            WARN,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Reject
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({
                                "skill_bundle": alias,
                                "error": format!("{}", e),
                            })),
                            "skill_bundles.<alias>.directory could not be resolved"
                        );
                        anyhow::Error::msg(e.to_string())
                    })?;
                if let Err(e) = crate::skill_bundles::validate_directory(&dir, &install_root) {
                    validation_bail!(
                        InvalidFormat,
                        format!("skill-bundles.{alias}.directory"),
                        "{e}"
                    );
                }
            }
            if let Err(e) = crate::skill_bundles::validate_uniqueness(self, &install_root) {
                validation_bail!(InvalidFormat, "skill_bundles", "{e}");
            }
        }

        // MCP bundles: resolution is secure by default, so a dangling
        // reference fails closed (the agent is granted fewer servers, never
        // more). Surface the misconfiguration as a warning so an operator is
        // not left wondering why an agent's MCP tools vanished, without
        // turning a typo into a hard startup failure.
        {
            // Operator UX: secure-by-default means an agent with no
            // `mcp_bundles` grant connects to ZERO MCP servers. When MCP is
            // enabled and `[[mcp.servers]]` is non-empty but no
            // `[mcp_bundles.*]` exists at all, every agent silently gets
            // nothing. That is the inverse of the original silent
            // no-op and surprises operators upgrading from <0.8.3. Warn
            // once at startup so this surfaces via `doctor` and the
            // standard startup warning stream.
            if self.mcp.enabled && !self.mcp.servers.is_empty() && self.mcp_bundles.is_empty() {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note,)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({
                            "mcp_servers_configured": self.mcp.servers.len(),
                        })),
                    "[[mcp.servers]] is configured but no [mcp_bundles.*] bundles exist; no agent will receive any MCP tools. Add a [mcp_bundles.<alias>] entry and reference it from agents.<alias>.mcp_bundles to grant access."
                );
            }

            let known_servers: std::collections::HashSet<&str> =
                self.mcp.servers.iter().map(|s| s.name.as_str()).collect();
            for (bundle_alias, bundle) in &self.mcp_bundles {
                for server in bundle.servers.iter().chain(bundle.exclude.iter()) {
                    if !known_servers.contains(server.as_str()) {
                        ::zeroclaw_log::record!(
                            WARN,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Note
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                            .with_attrs(::serde_json::json!({
                                "mcp_bundle": bundle_alias,
                                "server": server,
                            })),
                            "mcp_bundles.<alias> references an MCP server name not in [mcp.servers]; it grants nothing"
                        );
                    }
                }
            }
            for agent_alias in self.agents.keys() {
                let Some(agent) = self.resolved_agent_config(agent_alias) else {
                    continue;
                };
                for bundle_alias in &agent.mcp_bundles {
                    if !self.mcp_bundles.contains_key(bundle_alias) {
                        ::zeroclaw_log::record!(
                            WARN,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Note
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                            .with_attrs(::serde_json::json!({
                                "agent": agent_alias,
                                "mcp_bundle": bundle_alias,
                            })),
                            "agents.<alias>.mcp_bundles references an undefined [mcp_bundles.<alias>]; the agent is granted no servers from it"
                        );
                    }
                }
            }
        }

        // Validate every configured risk profile. Each profile stands on
        // its own — there is no "active" or "default" risk profile concept;
        // an agent's `risk_profile` field names exactly which one applies.
        let mut profile_aliases: Vec<&String> = self.risk_profiles.keys().collect();
        profile_aliases.sort();
        for profile_alias in profile_aliases {
            let profile = &self.risk_profiles[profile_alias];
            for (i, env_name) in profile.shell_env_passthrough.iter().enumerate() {
                if !is_valid_env_var_name(env_name) {
                    anyhow::bail!(
                        "risk_profiles.{profile_alias}.shell_env_passthrough[{i}] is invalid ({env_name}); expected [A-Za-z_][A-Za-z0-9_]*"
                    );
                }
            }
        }

        // Security OTP / estop
        if self.security.otp.challenge_max_attempts == 0 {
            validation_bail!(
                InvalidNumericRange,
                "security.otp.challenge_max_attempts",
                "security.otp.challenge_max_attempts must be greater than 0"
            );
        }
        if self.security.otp.token_ttl_secs == 0 {
            validation_bail!(
                InvalidNumericRange,
                "security.otp.token_ttl_secs",
                "security.otp.token_ttl_secs must be greater than 0"
            );
        }
        if self.security.otp.cache_valid_secs == 0 {
            validation_bail!(
                InvalidNumericRange,
                "security.otp.cache_valid_secs",
                "security.otp.cache_valid_secs must be greater than 0"
            );
        }
        if self.security.otp.cache_valid_secs < self.security.otp.token_ttl_secs {
            anyhow::bail!(
                "security.otp.cache_valid_secs must be greater than or equal to security.otp.token_ttl_secs"
            );
        }
        if self.security.otp.challenge_max_attempts == 0 {
            validation_bail!(
                InvalidNumericRange,
                "security.otp.challenge_max_attempts",
                "security.otp.challenge_max_attempts must be greater than 0"
            );
        }
        for (i, action) in self.security.otp.gated_actions.iter().enumerate() {
            let normalized = action.trim();
            if normalized.is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("security.otp.gated_actions[{i}]"),
                    "security.otp.gated_actions[{i}] must not be empty"
                );
            }
            if !normalized
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
            {
                anyhow::bail!(
                    "security.otp.gated_actions[{i}] contains invalid characters: {normalized}"
                );
            }
            if !default_otp_gated_actions()
                .iter()
                .any(|known| known == normalized)
            {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({
                            "action": normalized,
                            "known_actions": default_otp_gated_actions(),
                        })),
                    "security.otp.gated_actions entry does not match a known gated action and will not be enforced: "
                );
            }
        }
        DomainMatcher::new(
            &self.security.otp.gated_domains,
            &self.security.otp.gated_domain_categories,
        )
        .with_context(
            || "Invalid security.otp.gated_domains or security.otp.gated_domain_categories",
        )?;
        if self.security.estop.state_file.trim().is_empty() {
            validation_bail!(
                RequiredFieldEmpty,
                "security.estop.state_file",
                "security.estop.state_file must not be empty"
            );
        }
        if !(0.0..=1.0).contains(&self.security.leak_detection.sensitivity) {
            validation_bail!(
                InvalidNumericRange,
                "security.leak_detection.sensitivity",
                "security.leak_detection.sensitivity must be between 0.0 and 1.0"
            );
        }

        // Scheduler
        if self.scheduler.max_concurrent == 0 {
            validation_bail!(
                InvalidNumericRange,
                "scheduler.max_concurrent",
                "scheduler.max_concurrent must be greater than 0"
            );
        }
        if self.scheduler.max_tasks == 0 {
            validation_bail!(
                InvalidNumericRange,
                "scheduler.max_tasks",
                "scheduler.max_tasks must be greater than 0"
            );
        }

        // Model routes
        for (i, route) in self.model_routes.iter().enumerate() {
            if route.hint.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("model_routes[{i}].hint"),
                    "model_routes[{i}].hint must not be empty"
                );
            }
            let mp = route.model_provider.trim();
            if mp.is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("model_routes[{i}].model_provider"),
                    "model_routes[{i}].model_provider must not be empty"
                );
            }
            // Route refs are dotted `<type>.<alias>` and must resolve to a
            // configured `[providers.models.<type>.<alias>]` entry. Unresolved
            // routes are dropped at runtime construction; rejecting them here
            // keeps that drift visible at config-load time.
            match mp.split_once('.') {
                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
                    if self.providers.models.find(ty, inner).is_none() {
                        validation_bail!(
                            DanglingReference,
                            format!("model_routes[{i}].model_provider"),
                            "model_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
                        );
                    }
                }
                _ => validation_bail!(
                    InvalidFormat,
                    format!("model_routes[{i}].model_provider"),
                    "model_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
                ),
            }
            if route.model.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("model_routes[{i}].model"),
                    "model_routes[{i}].model must not be empty"
                );
            }
        }

        // Embedding routes
        for (i, route) in self.embedding_routes.iter().enumerate() {
            if route.hint.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("embedding_routes[{i}].hint"),
                    "embedding_routes[{i}].hint must not be empty"
                );
            }
            let mp = route.model_provider.trim();
            if mp.is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("embedding_routes[{i}].model_provider"),
                    "embedding_routes[{i}].model_provider must not be empty"
                );
            }
            // Embedding routes resolve against the same model-provider map;
            // there is no separate `providers.embeddings` typed section.
            match mp.split_once('.') {
                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
                    if self.providers.models.find(ty, inner).is_none() {
                        validation_bail!(
                            DanglingReference,
                            format!("embedding_routes[{i}].model_provider"),
                            "embedding_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
                        );
                    }
                }
                _ => validation_bail!(
                    InvalidFormat,
                    format!("embedding_routes[{i}].model_provider"),
                    "embedding_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
                ),
            }
            if route.model.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("embedding_routes[{i}].model"),
                    "embedding_routes[{i}].model must not be empty"
                );
            }
        }

        for (type_key, alias_key, profile) in self.providers.models.iter_entries() {
            let profile_name = format!("{type_key}.{alias_key}");

            let has_uri = profile
                .uri
                .as_deref()
                .map(str::trim)
                .is_some_and(|value| !value.is_empty());

            // Entries created by migration from top-level fields use the
            // model_provider type+alias as the map key and may not have
            // explicit `uri` (the model_provider factory resolves the
            // family's default endpoint via `ModelEndpoint`). An entry
            // with no identifying information at all is almost always an
            // in-progress quickstart state — the user picked the model
            // provider but hasn't filled anything in yet. Warn but don't
            // bail; the runtime falls back to family-default endpoint at
            // use time, and a chat against the unconfigured model
            // provider fails with a clear error then.
            let has_api_key = profile
                .api_key
                .as_deref()
                .is_some_and(|v| !v.trim().is_empty());
            let has_model = profile
                .model
                .as_deref()
                .is_some_and(|v| !v.trim().is_empty());
            if !has_uri && !has_api_key && !has_model {
                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"model_provider": profile_name, "profile_name": profile_name})), "providers.models. is empty (no uri / api_key / model). \
                     Skipping at runtime; run `zeroclaw quickstart` (or use the dashboard) \
                     to make this model_provider usable.");
                continue;
            }

            if let Some(uri) = profile.uri.as_deref().map(str::trim)
                && !uri.is_empty()
            {
                let parsed = reqwest::Url::parse(uri).with_context(|| {
                    format!("providers.models.{profile_name}.uri is not a valid URL")
                })?;
                if !matches!(parsed.scheme(), "http" | "https") {
                    anyhow::bail!("providers.models.{profile_name}.uri must use http/https");
                }
            }

            if let Some(temp) = profile.temperature {
                validate_temperature(temp).map_err(|e| {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({
                                "profile": profile_name,
                                "temperature": temp,
                                "error": format!("{}", e),
                            })),
                        "providers.models.<alias>.temperature rejected"
                    );
                    anyhow::Error::msg(format!("providers.models.{profile_name}.temperature: {e}"))
                })?;
            }

            for (key, value) in &profile.pricing {
                if value.is_nan() {
                    anyhow::bail!(
                        "providers.models.{profile_name}.pricing.{key}: value must not be NaN"
                    );
                }
                if *value < 0.0 {
                    anyhow::bail!(
                        "providers.models.{profile_name}.pricing.{key}: value must be >= 0.0 (got {value})"
                    );
                }
            }
        }

        // Non-fatal validation warnings: surfaced both via tracing (CLI sees
        // on stderr) and via Config::collect_warnings (gateway HTTP returns
        // structured to dashboard callers). Single source of truth lives in
        // collect_warnings; emit each one to tracing here so the existing
        // log behavior is preserved.
        for w in self.collect_warnings() {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({"path": w.path, "code": w.code})),
                &format!("{}", w.message)
            );
        }

        // Ollama cloud-routing safety checks
        for (alias, cfg) in &self.providers.models.ollama {
            let entry = &cfg.base;
            if !entry
                .model
                .as_deref()
                .is_some_and(|model| model.trim().ends_with(":cloud"))
            {
                continue;
            }

            if is_local_ollama_endpoint(entry.uri.as_deref()) {
                anyhow::bail!(
                    "providers.models.ollama.{alias}.model uses ':cloud', but uri is local or unset. Set uri to a remote Ollama endpoint (for example https://ollama.com)."
                );
            }
            if is_official_ollama_cloud_endpoint(entry.uri.as_deref())
                && !has_ollama_cloud_credential(entry.api_key.as_deref())
            {
                anyhow::bail!(
                    "providers.models.ollama.{alias}.model uses ':cloud', but no API key is configured. Set api_key on [providers.models.ollama.{alias}] (or via the schema-mirror grammar: ZEROCLAW_providers__models__ollama__{alias}__api_key=<value>)."
                );
            }
        }

        // Microsoft 365
        if self.microsoft365.enabled {
            let tenant = self
                .microsoft365
                .tenant_id
                .as_deref()
                .map(str::trim)
                .filter(|s| !s.is_empty());
            if tenant.is_none() {
                anyhow::bail!(
                    "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
                );
            }
            let client = self
                .microsoft365
                .client_id
                .as_deref()
                .map(str::trim)
                .filter(|s| !s.is_empty());
            if client.is_none() {
                anyhow::bail!(
                    "microsoft365.client_id must not be empty when microsoft365 is enabled"
                );
            }
            let flow = self.microsoft365.auth_flow.trim();
            if flow != "client_credentials" && flow != "device_code" {
                anyhow::bail!(
                    "microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
                );
            }
            if flow == "client_credentials"
                && self
                    .microsoft365
                    .client_secret
                    .as_deref()
                    .is_none_or(|s| s.trim().is_empty())
            {
                anyhow::bail!(
                    "microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
                );
            }
        }

        // Microsoft 365
        if self.microsoft365.enabled {
            let tenant = self
                .microsoft365
                .tenant_id
                .as_deref()
                .map(str::trim)
                .filter(|s| !s.is_empty());
            if tenant.is_none() {
                anyhow::bail!(
                    "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
                );
            }
            let client = self
                .microsoft365
                .client_id
                .as_deref()
                .map(str::trim)
                .filter(|s| !s.is_empty());
            if client.is_none() {
                anyhow::bail!(
                    "microsoft365.client_id must not be empty when microsoft365 is enabled"
                );
            }
            let flow = self.microsoft365.auth_flow.trim();
            if flow != "client_credentials" && flow != "device_code" {
                anyhow::bail!("microsoft365.auth_flow must be client_credentials or device_code");
            }
            if flow == "client_credentials"
                && self
                    .microsoft365
                    .client_secret
                    .as_deref()
                    .is_none_or(|s| s.trim().is_empty())
            {
                anyhow::bail!(
                    "microsoft365.client_secret must not be empty when auth_flow is client_credentials"
                );
            }
        }

        // MCP
        if self.mcp.enabled {
            validate_mcp_config(&self.mcp)?;
        }

        // Knowledge graph
        if self.knowledge.enabled {
            if self.knowledge.max_nodes == 0 {
                validation_bail!(
                    InvalidNumericRange,
                    "knowledge.max_nodes",
                    "knowledge.max_nodes must be greater than 0"
                );
            }
            if self.knowledge.db_path.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    "knowledge.db_path",
                    "knowledge.db_path must not be empty"
                );
            }
        }

        // Google Workspace allowed_services validation
        let mut seen_gws_services = std::collections::HashSet::new();
        for (i, service) in self.google_workspace.allowed_services.iter().enumerate() {
            let normalized = service.trim();
            if normalized.is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("google_workspace.allowed_services[{i}]"),
                    "google_workspace.allowed_services[{i}] must not be empty"
                );
            }
            if !normalized
                .chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
            {
                anyhow::bail!(
                    "google_workspace.allowed_services[{i}] contains invalid characters: {normalized}"
                );
            }
            if !seen_gws_services.insert(normalized.to_string()) {
                anyhow::bail!(
                    "google_workspace.allowed_services contains duplicate entry: {normalized}"
                );
            }
        }

        // Build the effective allowed-services set for cross-validation.
        // When the operator leaves allowed_services empty the tool falls back to
        // DEFAULT_GWS_SERVICES; use the same constant here so validation is
        // consistent in both cases.
        let effective_services: std::collections::HashSet<&str> =
            if self.google_workspace.allowed_services.is_empty() {
                DEFAULT_GWS_SERVICES.iter().copied().collect()
            } else {
                self.google_workspace
                    .allowed_services
                    .iter()
                    .map(|s| s.trim())
                    .collect()
            };

        let mut seen_gws_operations = std::collections::HashSet::new();
        for (i, operation) in self.google_workspace.allowed_operations.iter().enumerate() {
            let service = operation.service.trim();
            let resource = operation.resource.trim();

            if service.is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("google_workspace.allowed_operations[{i}].service"),
                    "google_workspace.allowed_operations[{i}].service must not be empty"
                );
            }
            if resource.is_empty() {
                anyhow::bail!(
                    "google_workspace.allowed_operations[{i}].resource must not be empty"
                );
            }

            if !effective_services.contains(service) {
                anyhow::bail!(
                    "google_workspace.allowed_operations[{i}].service '{service}' is not in the \
                     effective allowed_services; this entry can never match at runtime"
                );
            }
            if !service
                .chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
            {
                anyhow::bail!(
                    "google_workspace.allowed_operations[{i}].service contains invalid characters: {service}"
                );
            }
            if !resource
                .chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
            {
                anyhow::bail!(
                    "google_workspace.allowed_operations[{i}].resource contains invalid characters: {resource}"
                );
            }

            if let Some(ref sub_resource) = operation.sub_resource {
                let sub = sub_resource.trim();
                if sub.is_empty() {
                    anyhow::bail!(
                        "google_workspace.allowed_operations[{i}].sub_resource must not be empty when present"
                    );
                }
                if !sub
                    .chars()
                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
                {
                    anyhow::bail!(
                        "google_workspace.allowed_operations[{i}].sub_resource contains invalid characters: {sub}"
                    );
                }
            }

            if operation.methods.is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("google_workspace.allowed_operations[{i}].methods"),
                    "google_workspace.allowed_operations[{i}].methods must not be empty"
                );
            }

            let mut seen_methods = std::collections::HashSet::new();
            for (j, method) in operation.methods.iter().enumerate() {
                let normalized = method.trim();
                if normalized.is_empty() {
                    anyhow::bail!(
                        "google_workspace.allowed_operations[{i}].methods[{j}] must not be empty"
                    );
                }
                if !normalized
                    .chars()
                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
                {
                    anyhow::bail!(
                        "google_workspace.allowed_operations[{i}].methods[{j}] contains invalid characters: {normalized}"
                    );
                }
                if !seen_methods.insert(normalized.to_string()) {
                    anyhow::bail!(
                        "google_workspace.allowed_operations[{i}].methods contains duplicate entry: {normalized}"
                    );
                }
            }

            let sub_key = operation
                .sub_resource
                .as_deref()
                .map(str::trim)
                .unwrap_or("");
            let operation_key = format!("{service}:{resource}:{sub_key}");
            if !seen_gws_operations.insert(operation_key.clone()) {
                anyhow::bail!(
                    "google_workspace.allowed_operations contains duplicate service/resource/sub_resource entry: {operation_key}"
                );
            }
        }

        // Project intelligence
        if self.project_intel.enabled {
            let lang = &self.project_intel.default_language;
            if !["en", "de", "fr", "it"].contains(&lang.as_str()) {
                anyhow::bail!(
                    "project_intel.default_language must be one of: en, de, fr, it (got '{lang}')"
                );
            }
            let sens = &self.project_intel.risk_sensitivity;
            if !["low", "medium", "high"].contains(&sens.as_str()) {
                anyhow::bail!(
                    "project_intel.risk_sensitivity must be one of: low, medium, high (got '{sens}')"
                );
            }
            if let Some(ref tpl_dir) = self.project_intel.templates_dir
                && !std::path::Path::new(tpl_dir).exists()
            {
                anyhow::bail!("project_intel.templates_dir path does not exist: {tpl_dir}");
            }
        }

        // Proxy (delegate to existing validation)
        self.proxy.validate()?;
        self.cloud_ops.validate()?;

        // Skills — extra registries
        {
            let mut seen = std::collections::HashSet::new();
            for (i, reg) in self.skills.extra_registries.iter().enumerate() {
                if reg.name.trim().is_empty() {
                    anyhow::bail!("skills.extra_registries[{i}].name must not be empty");
                }
                if !ExternalRegistry::is_valid_name(&reg.name) {
                    anyhow::bail!(
                        "skills.extra_registries[{i}].name '{}' is invalid; use only lowercase ASCII letters, numbers, '-' or '_' so it can be addressed as registry:<name>/<skill>",
                        reg.name
                    );
                }
                if !seen.insert(reg.name.as_str()) {
                    anyhow::bail!("skills.extra_registries has duplicate name '{}'", reg.name);
                }
                if reg.url.trim().is_empty() {
                    anyhow::bail!(
                        "skills.extra_registries[{}].url must not be empty",
                        reg.name
                    );
                }
                if reg.kind != ExternalRegistryKind::Git {
                    anyhow::bail!(
                        "skills.extra_registries[{}].kind must be 'git' (got '{}'); other protocols are not yet supported",
                        reg.name,
                        reg.kind
                    );
                }
                match reqwest::Url::parse(&reg.url) {
                    Ok(u) if matches!(u.scheme(), "http" | "https" | "file") => {}
                    Ok(u) => anyhow::bail!(
                        "skills.extra_registries[{}].url scheme '{}' is unsupported (use http, https, or file)",
                        reg.name,
                        u.scheme()
                    ),
                    Err(e) => anyhow::bail!(
                        "skills.extra_registries[{}].url is not a valid URL: {e}",
                        reg.name
                    ),
                }
            }
        }

        // Notion
        if self.notion.enabled {
            if self.notion.database_id.trim().is_empty() {
                anyhow::bail!("notion.database_id must not be empty when notion.enabled = true");
            }
            if self.notion.poll_interval_secs == 0 {
                validation_bail!(
                    InvalidNumericRange,
                    "notion.poll_interval_secs",
                    "notion.poll_interval_secs must be greater than 0"
                );
            }
            if self.notion.max_concurrent == 0 {
                validation_bail!(
                    InvalidNumericRange,
                    "notion.max_concurrent",
                    "notion.max_concurrent must be greater than 0"
                );
            }
            if self.notion.status_property.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    "notion.status_property",
                    "notion.status_property must not be empty"
                );
            }
            if self.notion.input_property.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    "notion.input_property",
                    "notion.input_property must not be empty"
                );
            }
            if self.notion.result_property.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    "notion.result_property",
                    "notion.result_property must not be empty"
                );
            }
        }

        // Pinggy tunnel region — validate allowed values (case-insensitive, auto-lowercased at runtime).
        if let Some(ref pinggy) = self.tunnel.pinggy
            && let Some(ref region) = pinggy.region
        {
            let r = region.trim().to_ascii_lowercase();
            if !r.is_empty() && !matches!(r.as_str(), "us" | "eu" | "ap" | "br" | "au") {
                anyhow::bail!(
                    "tunnel.pinggy.region must be one of: us, eu, ap, br, au (or omitted for auto)"
                );
            }
        }

        // Jira
        if self.jira.enabled {
            if self.jira.base_url.trim().is_empty() {
                anyhow::bail!("jira.base_url must not be empty when jira.enabled = true");
            }
            if self.jira.api_token.trim().is_empty()
                && std::env::var("JIRA_API_TOKEN")
                    .unwrap_or_default()
                    .trim()
                    .is_empty()
            {
                anyhow::bail!(
                    "jira.api_token must be set (or JIRA_API_TOKEN env var) when jira.enabled = true"
                );
            }
            let valid_actions = [
                "get_ticket",
                "search_tickets",
                "comment_ticket",
                "list_projects",
                "myself",
                "list_transitions",
                "transition_ticket",
                "create_ticket",
            ];
            for action in &self.jira.allowed_actions {
                if !valid_actions.contains(&action.as_str()) {
                    anyhow::bail!(
                        "jira.allowed_actions contains unknown action: '{}'. \
                         Valid: get_ticket, search_tickets, comment_ticket, list_projects, myself, list_transitions, transition_ticket, create_ticket",
                        action
                    );
                }
            }
        }

        // Nevis IAM — delegate to NevisConfig::validate() for field-level checks
        if let Err(msg) = self.security.nevis.validate() {
            anyhow::bail!("security.nevis: {msg}");
        }

        // Delegate tool global defaults
        if self.delegate.timeout_secs == 0 {
            validation_bail!(
                InvalidNumericRange,
                "delegate.timeout_secs",
                "delegate.timeout_secs must be greater than 0"
            );
        }
        if self.delegate.agentic_timeout_secs == 0 {
            validation_bail!(
                InvalidNumericRange,
                "delegate.agentic_timeout_secs",
                "delegate.agentic_timeout_secs must be greater than 0"
            );
        }

        // Per-profile validation: the context-compression summarizer provider
        // ref must resolve to a configured `[providers.models.*]` alias.
        // Empty = inherit (valid). A shared profile fails loud at config time
        // instead of only when some agent using it compresses.
        let mut profile_aliases: Vec<&String> = self.runtime_profiles.keys().collect();
        profile_aliases.sort();
        for palias in profile_aliases {
            let value = self.runtime_profiles[palias]
                .context_compression
                .summary_provider
                .trim();
            if value.is_empty() {
                continue;
            }
            match value.split_once('.') {
                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
                    let exists = self
                        .get_map_keys(&format!("providers.models.{ty}"))
                        .is_some_and(|keys| keys.iter().any(|k| k == inner));
                    if !exists {
                        validation_bail!(
                            DanglingReference,
                            format!(
                                "runtime_profiles.{palias}.context_compression.summary_provider"
                            ),
                            "runtime_profiles.{palias}.context_compression.summary_provider = {value:?} but providers.models.{ty}.{inner} is not configured",
                        );
                    }
                }
                _ => validation_bail!(
                    InvalidFormat,
                    format!("runtime_profiles.{palias}.context_compression.summary_provider"),
                    "runtime_profiles.{palias}.context_compression.summary_provider must be dotted form `<type>.<alias>` (got {value:?})",
                ),
            }
        }

        // Per-agent validation. Mandatory + alias-existence checks live
        // here so the gateway PATCH path returns structured per-field
        // errors and the frontend never owns this rule. Sorted iteration
        // keeps error ordering stable across runs.
        let mut agent_aliases: Vec<&String> = self.agents.keys().collect();
        agent_aliases.sort();
        for alias in agent_aliases {
            let agent = &self.agents[alias];

            // model_provider: mandatory, dotted `<type>.<inner>` ref into
            // model_providers.<type>.<inner>.
            let mp = agent.model_provider.trim();
            if mp.is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("agents.{alias}.model_provider"),
                    "agents.{alias}.model_provider must reference a configured model model_provider (e.g. \"anthropic.default\")",
                );
            }
            match mp.split_once('.') {
                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
                    if !crate::providers::ModelProviders::slot_names().contains(&ty) {
                        validation_bail!(
                            DanglingReference,
                            format!("agents.{alias}.model_provider"),
                            "agents.{alias}.model_provider = {mp:?} but {ty:?} is not a known provider family; check [providers.models.<family>.<alias>] in config.toml (valid families: `zeroclaw providers`)",
                        );
                    }
                    let exists = self
                        .get_map_keys(&format!("providers.models.{ty}"))
                        .is_some_and(|keys| keys.iter().any(|k| k == inner));
                    if !exists {
                        validation_bail!(
                            DanglingReference,
                            format!("agents.{alias}.model_provider"),
                            "agents.{alias}.model_provider = {mp:?} but [providers.models.{ty}.{inner}] is not configured",
                        );
                    }
                }
                _ => validation_bail!(
                    InvalidFormat,
                    format!("agents.{alias}.model_provider"),
                    "agents.{alias}.model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
                ),
            }

            // channels: each entry is a dotted `<type>.<inner>` ref into
            // channels.<type>.<inner>. Empty list is valid (delegate-only agent).
            // Uses the schema-derived `get_map_keys` so new channel types
            // surface here automatically — no per-type match arm.
            for (i, ch) in agent.channels.iter().enumerate() {
                let trimmed = ch.trim();
                match trimmed.split_once('.') {
                    Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
                        // `get_map_keys` stores section names using the raw
                        // field ident (snake), the same dotted form the
                        // operator sees in TOML (`gmail_push`, `voice_call`,
                        // `nextcloud_talk`). Look up verbatim.
                        let exists = self
                            .get_map_keys(&format!("channels.{ty}"))
                            .is_some_and(|keys| keys.iter().any(|k| k == inner));
                        if !exists {
                            validation_bail!(
                                DanglingReference,
                                format!("agents.{alias}.channels[{i}]"),
                                "agents.{alias}.channels[{i}] = {trimmed:?} but channels.{ty}.{inner} is not configured",
                            );
                        }
                    }
                    _ => validation_bail!(
                        InvalidFormat,
                        format!("agents.{alias}.channels[{i}]"),
                        "agents.{alias}.channels[{i}] must be dotted form `<type>.<alias>` (got {trimmed:?})",
                    ),
                }
            }

            // Per-agent provider refs that resolve into the typed provider
            // sections. Empty = no preference for that category (no TTS / no
            // STT for this agent), which is valid. Non-empty values must
            // match a configured `[providers.<category>.<type>.<alias>]`
            // entry, fail loud with the dangling ref otherwise.
            // there is no global default-X-provider concept — every consumer
            // either picks a configured alias or opts out entirely.
            let typed_provider_refs: &[(&str, &str, &str)] = &[
                ("providers.tts", "tts_provider", agent.tts_provider.trim()),
                (
                    "providers.transcription",
                    "transcription_provider",
                    agent.transcription_provider.trim(),
                ),
                // New field:
                (
                    "providers.models",
                    "classifier_provider",
                    agent.classifier_provider.trim(),
                ),
                // Agent-level context-compression summarizer override.
                (
                    "providers.models",
                    "summary_provider",
                    agent.summary_provider.trim(),
                ),
            ];
            for (section_prefix, field, value) in typed_provider_refs {
                if value.is_empty() {
                    continue;
                }
                match value.split_once('.') {
                    Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
                        let exists = self
                            .get_map_keys(&format!("{section_prefix}.{ty}"))
                            .is_some_and(|keys| keys.iter().any(|k| k == inner));
                        if !exists {
                            validation_bail!(
                                DanglingReference,
                                format!("agents.{alias}.{field}"),
                                "agents.{alias}.{field} = {value:?} but {section_prefix}.{ty}.{inner} is not configured",
                            );
                        }
                    }
                    _ => validation_bail!(
                        InvalidFormat,
                        format!("agents.{alias}.{field}"),
                        "agents.{alias}.{field} must be dotted form `<type>.<alias>` (got {value:?})",
                    ),
                }
            }

            // Bare-alias bundle refs. Tuple is (kebab section path, kebab
            // agent field name, value list). Both names use the schema's
            // kebab form: section name matches what `get_map_keys` expects
            // (macro converts snake→kebab via `snake_to_kebab` per
            // crates/zeroclaw-macros/src/lib.rs:1056); field name matches
            // what `prop_fields()` emits, so DanglingReference paths bind
            // directly to the right inline error in the dashboard form.
            let bare_multi: &[(&str, &str, &[String])] = &[
                ("skill_bundles", "skill_bundles", &agent.skill_bundles),
                (
                    "knowledge_bundles",
                    "knowledge_bundles",
                    &agent.knowledge_bundles,
                ),
                ("mcp_bundles", "mcp_bundles", &agent.mcp_bundles),
            ];
            for (section, field, values) in bare_multi {
                for (i, key) in values.iter().enumerate() {
                    let trimmed = key.trim();
                    if trimmed.is_empty() {
                        continue;
                    }
                    let exists = self
                        .get_map_keys(section)
                        .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
                    if !exists {
                        validation_bail!(
                            DanglingReference,
                            format!("agents.{alias}.{field}[{i}]"),
                            "agents.{alias}.{field}[{i}] = {trimmed:?} but {section}.{trimmed} is not configured",
                        );
                    }
                }
            }
            let bare_single: &[(&str, &str, &str)] = &[
                ("risk_profiles", "risk_profile", agent.risk_profile.as_str()),
                (
                    "runtime_profiles",
                    "runtime_profile",
                    agent.runtime_profile.as_str(),
                ),
            ];
            for (section, field, raw) in bare_single {
                let trimmed = raw.trim();
                if trimmed.is_empty() {
                    continue;
                }
                let exists = self
                    .get_map_keys(section)
                    .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
                if !exists {
                    validation_bail!(
                        DanglingReference,
                        format!("agents.{alias}.{field}"),
                        "agents.{alias}.{field} = {trimmed:?} but {section}.{trimmed} is not configured",
                    );
                }
            }

            // risk_profile is mandatory for enabled agents — there is no
            // global fallback, so an enabled agent with no profile can't
            // gate its actions. Run this check last so the more specific
            // dangling/format errors above surface first.
            if agent.enabled && agent.risk_profile.trim().is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("agents.{alias}.risk_profile"),
                    "agents.{alias}.risk_profile must reference a configured [risk_profiles.<alias>] entry",
                );
            }

            // delegates: explicit roster entries must point at OTHER
            // configured agents, never self. Cross-profile targets are
            // permitted (they run under the target's own policy), so no
            // profile match is required here.
            let mut seen_delegates: std::collections::BTreeSet<&str> =
                std::collections::BTreeSet::new();
            for (i, target) in agent.delegates.iter().enumerate() {
                let target_str = target.agent().trim();
                if target_str.is_empty() {
                    validation_bail!(
                        RequiredFieldEmpty,
                        format!("agents.{alias}.delegates[{i}].agent"),
                        "agents.{alias}.delegates[{i}].agent is empty; remove it or name a configured agent",
                    );
                }
                if target_str == alias.as_str() {
                    validation_bail!(
                        InvalidFormat,
                        format!("agents.{alias}.delegates[{i}].agent"),
                        "agents.{alias}.delegates[{i}].agent = {target_str:?} names this agent itself; an agent cannot delegate to itself",
                    );
                }
                if !self.agents.contains_key(target_str) {
                    validation_bail!(
                        DanglingReference,
                        format!("agents.{alias}.delegates[{i}].agent"),
                        "agents.{alias}.delegates[{i}].agent = {target_str:?} but agents.{target_str} is not configured",
                    );
                }
                if !seen_delegates.insert(target_str) {
                    validation_bail!(
                        InvalidFormat,
                        format!("agents.{alias}.delegates[{i}].agent"),
                        "agents.{alias}.delegates[{i}].agent = {target_str:?} duplicates an earlier delegate target",
                    );
                }
            }

            // workspace.access: keys must point at OTHER agents, never
            // self, and every target must be a configured agent.
            for (target, mode) in &agent.workspace.access {
                let target_str = target.as_str();
                if target_str == alias.as_str() {
                    validation_bail!(
                        InvalidFormat,
                        format!("agents.{alias}.workspace.access.{target_str}"),
                        "agents.{alias}.workspace.access.{target_str} = {mode:?} but {target_str} is this agent itself; an agent always has full access to its own workspace, so self-references in the cross-agent allowlist are not permitted",
                    );
                }
                if !self.agents.contains_key(target_str) {
                    validation_bail!(
                        DanglingReference,
                        format!("agents.{alias}.workspace.access.{target_str}"),
                        "agents.{alias}.workspace.access.{target_str} = {mode:?} but agents.{target_str} is not configured",
                    );
                }
            }

            // workspace.read_memory_from: every alias must exist as a
            // configured agent and must use the same MemoryBackendKind
            // as the declaring agent. Mismatched backends fail at
            // config load rather than producing a runtime error when
            // the per-agent memory plumbing consumes the allowlist.
            let agent_backend = agent.memory.backend;
            for (i, target) in agent.workspace.read_memory_from.iter().enumerate() {
                let target_str = target.as_str();
                if target_str == alias.as_str() {
                    validation_bail!(
                        InvalidFormat,
                        format!("agents.{alias}.workspace.read_memory_from[{i}]"),
                        "agents.{alias}.workspace.read_memory_from[{i}] = {target_str:?} but {target_str} is this agent itself; an agent always sees its own memory rows, so self-references in the cross-agent allowlist are not permitted",
                    );
                }
                let Some(target_agent) = self.agents.get(target_str) else {
                    validation_bail!(
                        DanglingReference,
                        format!("agents.{alias}.workspace.read_memory_from[{i}]"),
                        "agents.{alias}.workspace.read_memory_from[{i}] = {target_str:?} but agents.{target_str} is not configured",
                    );
                };
                if target_agent.memory.backend != agent_backend {
                    let target_backend = target_agent.memory.backend;
                    validation_bail!(
                        InvalidFormat,
                        format!("agents.{alias}.workspace.read_memory_from[{i}]"),
                        "agents.{alias}.workspace.read_memory_from[{i}] points at agents.{target_str} which uses memory backend {target_backend:?}, but agents.{alias} uses {agent_backend:?}; the allowlist must point at same-backend siblings only",
                    );
                }
            }
        }

        // Peer groups: every member alias must exist as a configured
        // agent, and the group's channel must be in each member's
        // channels list. Mutual opt-in resolution happens at runtime;
        // this cross-reference check keeps misconfigured group
        // members from looking like real peer relationships at load
        // time.
        let mut peer_group_names: Vec<&String> = self.peer_groups.keys().collect();
        peer_group_names.sort();
        for group_name in peer_group_names {
            let group = &self.peer_groups[group_name];
            let group_channel = group.channel.trim();
            if group_channel.is_empty() {
                validation_bail!(
                    RequiredFieldEmpty,
                    format!("peer_groups.{group_name}.channel"),
                    "peer_groups.{group_name}.channel must name a channel type (e.g. \"discord\") or dotted alias (e.g. \"discord.work\")",
                );
            }
            // `get_map_keys` stores section names using the raw field ident
            // (snake); look up the channel type verbatim.
            let (group_channel_type, group_channel_alias) = match group_channel.split_once('.') {
                Some((ty, al)) => (ty, Some(al)),
                None => (group_channel, None),
            };
            let channel_aliases = self.get_map_keys(&format!("channels.{group_channel_type}"));
            if channel_aliases.is_none() {
                validation_bail!(
                    DanglingReference,
                    format!("peer_groups.{group_name}.channel"),
                    "peer_groups.{group_name}.channel = {group_channel:?} but no [channels.{group_channel_type}.*] block is configured",
                );
            }
            if let Some(alias) = group_channel_alias {
                let exists = channel_aliases
                    .as_ref()
                    .is_some_and(|keys| keys.iter().any(|k| k == alias));
                if !exists {
                    validation_bail!(
                        DanglingReference,
                        format!("peer_groups.{group_name}.channel"),
                        "peer_groups.{group_name}.channel = {group_channel:?} but [channels.{group_channel_type}.{alias}] is not configured",
                    );
                }
            }
            for (i, member) in group.agents.iter().enumerate() {
                let member_str = member.as_str();
                let Some(member_agent) = self.agents.get(member_str) else {
                    validation_bail!(
                        DanglingReference,
                        format!("peer_groups.{group_name}.agents[{i}]"),
                        "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str} is not configured",
                    );
                };
                let has_channel_match = member_agent.channels.iter().any(|ch| {
                    let ch_str = ch.as_str();
                    match group_channel_alias {
                        Some(alias) => ch_str == format!("{group_channel_type}.{alias}"),
                        None => ch_str.starts_with(&format!("{group_channel_type}.")),
                    }
                });
                if !has_channel_match {
                    let needs_msg = match group_channel_alias {
                        Some(alias) => format!("entry for {group_channel_type}.{alias}"),
                        None => format!("entry of type {group_channel_type:?}"),
                    };
                    validation_bail!(
                        InvalidFormat,
                        format!("peer_groups.{group_name}.agents[{i}]"),
                        "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str}.channels has no {needs_msg}",
                    );
                }
            }
        }

        if self.plugins.limits.call_fuel == 0 {
            validation_bail!(
                InvalidNumericRange,
                "plugins.limits.call_fuel",
                "plugins.limits.call_fuel must be greater than 0; a zero budget traps every plugin call before it runs"
            );
        }
        if self.plugins.limits.max_memory_mb == 0 {
            validation_bail!(
                InvalidNumericRange,
                "plugins.limits.max_memory_mb",
                "plugins.limits.max_memory_mb must be greater than 0; a zero cap rejects every plugin at instantiation"
            );
        }
        if self.plugins.limits.max_table_elements == 0 {
            validation_bail!(
                InvalidNumericRange,
                "plugins.limits.max_table_elements",
                "plugins.limits.max_table_elements must be greater than 0; a zero ceiling rejects every plugin that allocates a table"
            );
        }
        if self.plugins.limits.max_instances == 0 {
            validation_bail!(
                InvalidNumericRange,
                "plugins.limits.max_instances",
                "plugins.limits.max_instances must be greater than 0; a zero ceiling rejects every plugin at instantiation"
            );
        }

        Ok(())
    }

    pub fn mark_dirty(&mut self, path: &str) {
        self.dirty_paths.insert(path.to_string());
    }

    /// Auto-create the parent map key for a dotted set-prop `path` when it does
    /// not yet exist, so a `set_prop` on a brand-new alias's field materializes
    /// the entry first.
    ///
    /// Returns `true` iff it REFUSED to create the reserved `default` agent: a
    /// set-prop surface should then surface a reserved-name error rather than the
    /// generic "not configured" that the still-missing entry would otherwise
    /// produce. Returns `false` in every other case (created, already existed, or
    /// the path is not a map-keyed entry). The bool is advisory; statement-callers
    /// that do not distinguish the reserved case may ignore it.
    ///
    /// `#[resource_key]` sections are excluded. Their keys are values drawn from
    /// another domain (model id, voice, tool name) and may themselves contain
    /// dots, so the first-dot split below would read
    /// `cost.rates.providers.models.openai.gpt-4.1.input_per_mtok` as the bogus
    /// alias `gpt-4` plus a nonsense tail, and `create_map_key` skips
    /// `validate_alias_key` for those sections so nothing else rejects it.
    /// Rate rows are created explicitly through `POST /api/config/map-key`
    /// instead.
    pub fn ensure_map_key_for_path(&mut self, path: &str) -> bool {
        use crate::traits::MapKeyKind;
        let mut best: Option<&'static str> = None;
        for s in Self::map_key_sections()
            .iter()
            .filter(|s| s.kind == MapKeyKind::Map)
            .filter(|s| !s.resource_key)
        {
            let prefix = format!("{}.", s.path);
            if path.starts_with(&prefix)
                && path.len() > prefix.len()
                && best.is_none_or(|b| s.path.len() > b.len())
            {
                best = Some(s.path);
            }
        }
        let Some(section) = best else {
            return false;
        };
        let rest = &path[section.len() + 1..];
        let Some(alias) = rest.split('.').next().filter(|a| !a.is_empty()) else {
            return false;
        };
        if self
            .get_map_keys(section)
            .is_some_and(|keys| keys.iter().any(|k| k == alias))
        {
            return false;
        }
        // Never auto-vivify the reserved `default` agent from a set-prop path: a
        // prop write under a nonexistent `agents.default` must not materialize the
        // reserved runtime fallback (the operator would get an agent the rename
        // guard then traps). This is the set-prop analogue of the create guard in
        // `create_map_key_checked`. The migration that legitimately synthesizes
        // `agents.default` calls `create_map_key` directly, not this path, and an
        // already-present `default` is left untouched by the existence check above.
        if section == "agents" && crate::alias_refs::is_reserved_agent_alias(alias) {
            // Make the refusal observable: callers that check the return surface a
            // reserved-name error, and this WARN with a stable `error_key` lets an
            // operator see why a set-prop on a fresh `agents.default` was dropped.
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({
                        "error_key": "config.reserved_agent_vivify_refused",
                        "alias": alias,
                        "path": path,
                    })),
                "refused to auto-create the reserved `default` agent from a set-prop path"
            );
            return true;
        }
        let _ = self.create_map_key(section, alias);
        false
    }

    pub fn clear_dirty(&mut self) {
        self.dirty_paths.clear();
    }

    pub fn set_prop_persistent(&mut self, name: &str, value_str: &str) -> Result<()> {
        self.set_prop(name, value_str)?;
        self.mark_dirty(name);
        Ok(())
    }

    pub fn set_secret_persistent(&mut self, name: &str, value: String) -> Result<()> {
        self.set_secret(name, value)?;
        self.mark_dirty(name);
        Ok(())
    }

    async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
        if self
            .config_path
            .parent()
            .is_some_and(|parent| !parent.as_os_str().is_empty())
        {
            return Ok(self.config_path.clone());
        }

        let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;
        let (zeroclaw_dir, _workspace_dir, source) =
            resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
        let file_name = self
            .config_path
            .file_name()
            .filter(|name| !name.is_empty())
            .unwrap_or_else(|| std::ffi::OsStr::new("config.toml"));
        let resolved = zeroclaw_dir.join(file_name);
        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"path": self.config_path.display().to_string(), "resolved": resolved.display().to_string(), "source": source.as_str()})), "Config path missing parent directory; resolving from runtime environment");
        Ok(resolved)
    }

    pub async fn save(&self) -> Result<()> {
        // Encrypt secrets before serialization
        let mut config_to_save = self.clone();
        // Stamp the current schema version on every write. The in-memory
        // config is always at `CURRENT_SCHEMA_VERSION` (load-time migration
        // brings it forward), but pin it explicitly so a full save can never
        // emit a body-newer-than-label file. See `save_dirty` and.
        config_to_save.schema_version = crate::migration::CURRENT_SCHEMA_VERSION;
        let config_path = self.resolve_config_path_for_save().await?;
        let zeroclaw_dir = config_path
            .parent()
            .context("Config path must have a parent directory")?;
        let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);

        // Restore env-overridden paths to their pre-override snapshots before
        // encryption, so values supplied via `ZEROCLAW_*` env vars never reach
        // disk. Snapshots were captured at apply time from the post-decrypt
        // in-memory state, so secrets carry the original plaintext that
        // `encrypt_secrets()` will re-encrypt to fresh ciphertext that
        // decrypts back to the same value.
        if !self.pre_override_snapshots.is_empty() {
            crate::env_overrides::mask_env_overrides_for_save(
                &mut config_to_save,
                &self.pre_override_snapshots,
            )?;
        }
        restore_onepassword_references_for_save(
            &mut config_to_save,
            &self.onepassword_reference_snapshots,
            &self.dirty_paths,
        )?;

        // Encrypt all #[secret]-annotated fields via Configurable derive
        config_to_save.encrypt_secrets(&store)?;

        // Serialize, then prune fields whose values match
        // `Config::default()` so the on-disk config carries only the
        // operator's actual choices (no hundreds of lines of struct
        // defaults the operator never touched). The schema's
        // `#[serde(default = "...")]` annotations re-supply the
        // defaults on load, so the pruned file round-trips identically.
        let mut new_table: toml::Table = toml::Value::try_from(&config_to_save)
            .context("Failed to serialize config to TOML value")?
            .try_into()
            .context("Serialized config is not a TOML table")?;
        let default_table: toml::Table = toml::Value::try_from(Config::default())
            .ok()
            .and_then(|v| v.try_into().ok())
            .unwrap_or_default();
        prune_default_values(&mut new_table, &default_table);
        let new_toml = ensure_blank_line_before_sections(
            &toml::to_string_pretty(&new_table).context("Failed to serialize pruned config")?,
        );

        // If an existing config file is present, sync the new values onto it
        // to preserve comments and formatting. Otherwise, use the fresh serialization.
        let toml_str = if config_path.exists() {
            let existing = fs::read_to_string(&config_path).await.unwrap_or_default();
            if existing.is_empty() {
                new_toml
            } else {
                let mut doc: toml_edit::DocumentMut = existing
                    .parse()
                    .context("Failed to parse existing config for comment preservation")?;
                crate::migration::sync_table(doc.as_table_mut(), &new_table);
                // sync_table preserves existing decor verbatim, so newly
                // inserted sections lack the blank-line gap before their
                // header until the post-processor runs.
                ensure_blank_line_before_sections(&doc.to_string())
            }
        } else {
            new_toml
        };

        write_config_atomically(&config_path, &toml_str).await
    }

    /// Incremental save: only the paths in `self.dirty_paths` are written
    /// against the existing on-disk file. Non-dirty entries (including
    /// secret ciphertext) are left untouched; dirty paths whose value
    /// equals the schema default are removed from the doc instead of
    /// written. Falls back to a full `save()` when the file doesn't
    /// exist yet. Clears the dirty set on success.
    pub async fn save_dirty(&mut self) -> Result<()> {
        if self.dirty_paths.is_empty() {
            return Ok(());
        }

        let config_path = self.resolve_config_path_for_save().await?;
        if !config_path.exists() {
            let result = self.save().await;
            if result.is_ok() {
                self.clear_dirty();
            }
            return result;
        }

        let mut config_to_save = self.clone();
        let zeroclaw_dir = config_path
            .parent()
            .context("Config path must have a parent directory")?;
        let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);

        if !self.pre_override_snapshots.is_empty() {
            crate::env_overrides::mask_env_overrides_for_save(
                &mut config_to_save,
                &self.pre_override_snapshots,
            )?;
        }
        restore_onepassword_references_for_save(
            &mut config_to_save,
            &self.onepassword_reference_snapshots,
            &self.dirty_paths,
        )?;
        config_to_save.encrypt_secrets(&store)?;

        let full_table: toml::Table = toml::Value::try_from(&config_to_save)
            .context("Failed to serialize config to TOML value")?
            .try_into()
            .context("Serialized config is not a TOML table")?;
        let default_table: toml::Table = toml::Value::try_from(Config::default())
            .ok()
            .and_then(|v| v.try_into().ok())
            .unwrap_or_default();

        let existing = fs::read_to_string(&config_path).await.with_context(|| {
            format!(
                "Failed to read existing config for incremental save: {}",
                config_path.display()
            )
        })?;
        let mut doc: toml_edit::DocumentMut = existing
            .parse()
            .context("Failed to parse existing config for incremental save")?;

        for path in &self.dirty_paths {
            apply_dirty_path(doc.as_table_mut(), path, &full_table, &default_table)?;
        }

        // Stamp the current schema version. An incremental save writes
        // current-schema-shaped sections (e.g. the dashboard saving a single
        // `agents.<name>.model_provider`) but `schema_version` is never a
        // dirty path, so without this it keeps whatever an older binary first
        // wrote on disk. The resulting body-newer-than-label file then crashes
        // older binaries with an opaque `missing field ...` serde error.
        // `insert` updates the existing key in place (preserving its
        // position at the top of the file) or appends it when absent.
        doc.as_table_mut().insert(
            "schema_version",
            toml_edit::value(i64::from(crate::migration::CURRENT_SCHEMA_VERSION)),
        );

        let toml_str = ensure_blank_line_before_sections(&doc.to_string());

        write_config_atomically(&config_path, &toml_str).await?;
        self.clear_dirty();
        Ok(())
    }
}

fn collect_onepassword_reference_snapshots(
    config: &Config,
) -> std::collections::HashMap<String, String> {
    config
        .prop_fields()
        .into_iter()
        .filter(|field| field.is_secret)
        .filter_map(|field| {
            let value = crate::env_overrides::raw_value_for_path(config, &field.name)?;
            crate::secrets::SecretStore::is_onepassword_ref(&value).then_some((field.name, value))
        })
        .collect()
}

fn restore_onepassword_references_for_save(
    config_to_save: &mut Config,
    snapshots: &std::collections::HashMap<String, String>,
    dirty_paths: &std::collections::HashSet<String>,
) -> Result<()> {
    for (path, value) in snapshots {
        if dirty_paths.contains(path) {
            continue;
        }
        if let Err(err) = config_to_save.set_prop(path, value) {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({"path": path, "error": format!("{}", err)})),
                "1Password reference save-restore failed; field retains resolved value"
            );
        }
    }
    Ok(())
}

/// Atomic write shared by `save()` and `save_dirty()`.
async fn write_config_atomically(config_path: &Path, toml_str: &str) -> Result<()> {
    let parent_dir = config_path
        .parent()
        .context("Config path must have a parent directory")?;

    fs::create_dir_all(parent_dir).await.with_context(|| {
        format!(
            "Failed to create config directory: {}",
            parent_dir.display()
        )
    })?;

    let file_name = config_path
        .file_name()
        .and_then(|v| v.to_str())
        .unwrap_or("config.toml");
    let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
    let backup_path = parent_dir.join(format!("{file_name}.bak"));

    let mut temp_file = OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(&temp_path)
        .await
        .with_context(|| {
            format!(
                "Failed to create temporary config file: {}",
                temp_path.display()
            )
        })?;
    temp_file
        .write_all(toml_str.as_bytes())
        .await
        .context("Failed to write temporary config contents")?;
    temp_file
        .sync_all()
        .await
        .context("Failed to fsync temporary config file")?;
    drop(temp_file);

    let had_existing_config = config_path.exists();
    if had_existing_config {
        fs::copy(config_path, &backup_path).await.with_context(|| {
            format!(
                "Failed to create config backup before atomic replace: {}",
                backup_path.display()
            )
        })?;
    }

    if let Err(e) = fs::rename(&temp_path, config_path).await {
        let _ = fs::remove_file(&temp_path).await;
        if had_existing_config && backup_path.exists() {
            fs::copy(&backup_path, config_path)
                .await
                .context("Failed to restore config backup")?;
        }
        anyhow::bail!("Failed to atomically replace config file: {e}");
    }

    #[cfg(unix)]
    {
        use std::{fs::Permissions, os::unix::fs::PermissionsExt};
        if let Err(err) = fs::set_permissions(config_path, Permissions::from_mode(0o600)).await {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                &format!(
                    "Failed to harden config permissions to 0600 at {}: {}",
                    config_path.display().to_string(),
                    err
                )
            );
        }
    }

    sync_directory(parent_dir).await?;

    if had_existing_config {
        let _ = fs::remove_file(&backup_path).await;
    }

    Ok(())
}

/// Write the in-memory value at `dotted` into the doc, or delete the leaf
/// when the value is absent or equals the schema default. Segments are
/// kebab→snake-translated; alias keys never carry hyphens (alias rule).
fn apply_dirty_path(
    root: &mut toml_edit::Table,
    dotted: &str,
    full_table: &toml::Table,
    default_table: &toml::Table,
) -> Result<()> {
    let raw: Vec<&str> = dotted.split('.').collect();
    if raw.is_empty() {
        return Ok(());
    }

    // Natural-key `Vec<T>` sections (`#[natural_key = "<f>"]`, currently
    // `mcp.servers`) serialize as `[[mcp.servers]]` — an
    // `Item::ArrayOfTables`, not a Table. The generic walker below
    // assumes every intermediate node is a Table (`as_table()` /
    // `as_table_mut()`), so without this branch a dirty path like
    // `mcp.servers.fs.command` would resolve to `None` in `full_table`,
    // get misclassified as `should_delete`, then bail at the
    // `ArrayOfTables` segment in `delete_path_in_doc` too. Net effect:
    // the per-field MCP editor's edits go nowhere on disk. See the
    // regression test `save_dirty_persists_mcp_server_field_via_natural_key`.
    if let Some(section) = find_natural_key_section_for_path(dotted) {
        apply_dirty_natural_key_path(root, &section, dotted, full_table, default_table);
        return Ok(());
    }

    // `HashMap<String, T>` sections whose key is a `#[resource_key]`
    // (drawn from another domain — a model id, tool name, …) may
    // themselves contain dots, e.g. `gpt-4.1` in
    // `cost.rates.providers.models.openai.gpt-4.1.input_per_mtok`. The
    // blind `raw.split('.')` above and the generic walker below both
    // assume one segment == one dot-delimited token, so a dotted key
    // fragments into bogus segments (`gpt-4`, `1`), the lookup below
    // finds nothing, and the write silently turns into a no-op delete.
    // Longest-match the key against the section's live keys instead —
    // same precedent as the natural-key branch above.
    if let Some(section) = find_map_key_section_for_path(dotted) {
        return apply_dirty_map_key_path(root, &section, dotted, full_table, default_table);
    }

    // Resolve each segment against the in-memory table: struct fields
    // serialize as snake_case (so `input-per-mtok` → `input_per_mtok`), but
    // HashMap keys are preserved verbatim and may legitimately carry hyphens
    // (`claude-opus-4-7`, `tts-1-hd`). Blind `s.replace('-', "_")` mangles
    // those keys and lookup returns None, which apply_dirty_path treats as
    // "delete this path" — silently dropping every cost.rates save.
    let segments: Vec<String> = resolve_dirty_segments(full_table, &raw);
    let segs: Vec<&str> = segments.iter().map(String::as_str).collect();
    write_or_delete_leaf(root, full_table, default_table, &segs);
    Ok(())
}

/// Resolved metadata for a `#[natural_key]` Vec section that matches a
/// given dirty path. `section_path` is the dotted section (e.g.
/// `"mcp.servers"`); `natural_key_field` is the snake_case field name
/// on the inner type that holds each element's alias (e.g. `"name"`).
struct NaturalKeySection {
    section_path: &'static str,
    natural_key_field: &'static str,
}

/// Return the longest natural-key `Kind::List` section whose path is a
/// strict prefix of `dotted` (i.e. `dotted` starts with `<path>.`).
/// Strict-prefix wins so a nested natural-key section would always
/// outscore its parent; today there's only `mcp.servers`, so the lookup
/// is effectively a single-entry match, but the longest-match rule
/// keeps the writer correct when a future `#[natural_key]` Vec lands
/// inside another section.
///
/// Returns the `<section_path>` (e.g. `"mcp.servers"`) and the
/// `<natural_key_field>` (e.g. `"name"`) the writer needs to locate
/// the matching `[[<section_path>]]` element on disk.
///
/// Section paths are exact-matched against the leading segments of
/// `dotted`; the function does **not** apply the kebab→snake fallback
/// that the inner segment resolver (`resolve_dirty_segments`) uses.
/// That is deliberate and mirrors the in-memory routing macro's
/// `section_path == expected` check in
/// `crates/zeroclaw-macros/src/lib.rs` (the `get_map_keys` /
/// `route_vec_path` arms) — making either side dash-tolerant in
/// isolation would let the dispatcher and the writer disagree on
/// which call participates in this branch, which is exactly the
/// memory-vs-disk divergence this PR exists to eliminate. If a
/// future natural-key section needs an underscore in its path AND
/// must be addressable via a kebab wire path, change **both** sides
/// together (the macro arm and this match).
///
/// Plain `Kind::List` Vec sections without `#[natural_key]` (their
/// `natural_key` is `None`) are intentionally excluded — those use the
/// legacy whole-array `ObjectArray` save path and don't need the
/// array-of-tables walker.
fn find_natural_key_section_for_path(dotted: &str) -> Option<NaturalKeySection> {
    use crate::traits::MapKeyKind;

    let mut best: Option<NaturalKeySection> = None;
    for section in Config::map_key_sections().iter() {
        if section.kind != MapKeyKind::List {
            continue;
        }
        let Some(natural_key_field) = section.natural_key else {
            continue;
        };
        // strip_prefix avoids the allocation `format!("{}.", ...)`
        // would do per section per dirty path, and makes the "must
        // have *something* after the dot" guard a natural fallout of
        // the second `strip_prefix('.')` rather than a length check.
        let Some(after) = dotted
            .strip_prefix(section.path)
            .and_then(|s| s.strip_prefix('.'))
        else {
            continue;
        };
        // Empty alias segment ("mcp.servers." with nothing after) can't
        // be addressed by name; skip to avoid matching every otherwise-
        // unmatched path via the empty prefix. Same guard the in-memory
        // `route_vec_path` applies to empty natural keys.
        if after.is_empty() {
            continue;
        }
        let better = best
            .as_ref()
            .is_none_or(|b| section.path.len() > b.section_path.len());
        if better {
            best = Some(NaturalKeySection {
                section_path: section.path,
                natural_key_field,
            });
        }
    }
    best
}

/// Apply a dirty path of the form
/// `<section_path>.<alias>[.<inner_suffix>...]` against an
/// `[[<section_path>]]` array of tables.
///
/// Three shapes show up in practice:
///
///   1. `<section>.<alias>.<inner>` — per-field edit (the dashboard /
///      TUI editor). Locate the in-memory element whose natural-key
///      field equals `<alias>` and reconcile its `<inner>` sub-path
///      against the doc's matching `[[<section>]]` table.
///
///   2. `<section>.<alias>` with the element still in memory — the
///      whole-element write the rename path emits for the *new* alias.
///      Reseed the matching doc entry from the in-memory serialization
///      so a rename `fs` → `filesystem` updates the entire
///      `[[mcp.servers]]` table (in particular its `name` field).
///
///   3. `<section>.<alias>` with the element gone from memory — the
///      delete path the rename emits for the *old* alias, and what
///      `delete_map_key` plus an explicit `mark_dirty` would produce.
///      Drop the matching doc entry.
///
/// All three are handled by walking the doc through the section's
/// non-array parents, locating the `ArrayOfTables` at the section's
/// last path segment, then either editing or removing the entry whose
/// natural-key field matches `<alias>`. The `<inner>` recursion reuses
/// the existing Table-shaped `set_path_in_doc` / `delete_path_in_doc`
/// helpers against the located entry's table.
///
/// **Order-independence invariant.** `Config::dirty_paths` is a
/// `HashSet<String>`, so the three shapes above can apply in any
/// interleaving within a single `save_dirty` batch — in particular,
/// a rename emits both the old and new aliases dirty without an
/// ordering guarantee, and a create-then-edit sequence may walk the
/// per-field path before the whole-element write. Convergence under
/// every ordering is load-bearing here and works only because every
/// branch derives its write from the current in-memory state and the
/// case-1 delete fires only on mem-absence (not on default
/// equivalence). Future edits — e.g. an early-exit that assumes
/// whole-element writes run before per-field ones, or a "skip if
/// already written" optimization keyed on the doc shape — would
/// break this. Preserve commutativity across the three shapes for
/// any single (section, alias) tuple.
fn apply_dirty_natural_key_path(
    root: &mut toml_edit::Table,
    section: &NaturalKeySection,
    dotted: &str,
    full_table: &toml::Table,
    _default_table: &toml::Table,
) {
    // `dotted` is known to start with `<section_path>.` thanks to
    // `find_natural_key_section_for_path`; split the alias and inner
    // suffix off the tail.
    let rest = match dotted
        .strip_prefix(section.section_path)
        .and_then(|s| s.strip_prefix('.'))
    {
        Some(r) => r,
        None => return,
    };
    let (alias, inner_suffix) = match rest.split_once('.') {
        Some((a, i)) => (a, Some(i)),
        None => (rest, None),
    };
    if alias.is_empty() {
        return;
    }

    // Walk the in-memory serialized array at `section_path`. Each
    // element is a `toml::Value::Table`; the alias lives in the
    // `natural_key_field` column.
    let section_segs: Vec<&str> = section.section_path.split('.').collect();
    let mem_array = lookup_path_in_table(full_table, &section_segs).and_then(|v| v.as_array());
    let mem_entry = mem_array.and_then(|arr| {
        arr.iter().find_map(|item| {
            let table = item.as_table()?;
            let alias_value = table.get(section.natural_key_field)?.as_str()?;
            (alias_value == alias).then_some(table)
        })
    });

    // The doc cursor walks the section's parent Tables (e.g. `mcp`) and
    // arrives at the array node (`servers`). The natural-key Vec is
    // always at the deepest segment of `section_path`; the leading
    // segments are plain struct fields and therefore Tables on disk.
    let Some((array_key, parent_segs)) = section_segs.split_last() else {
        return;
    };

    // Inner-suffix path: per-field edit (case 1). Reconcile the inner
    // sub-path on the matched doc table the same way the generic
    // Table-shaped walker does — mem-vs-default comparison decides
    // delete vs write — and bail out before touching the array spine
    // if the entry doesn't exist in either memory or the doc.
    if let Some(inner) = inner_suffix {
        // Look up the same inner sub-path on the default-shaped element.
        // `Config::default()` always has an empty `mcp.servers` (and no
        // default natural-key Vec entry is non-empty), so we synthesize
        // the default by going through the inner type's own
        // `Default` impl via the existing `default_table` plus an
        // alias-keyed lookup. The Vec being empty in defaults means
        // `default_array_entry` is always `None`, which mirrors the
        // existing HashMap behaviour: `default_val` for an alias's
        // child is None too, because the alias key doesn't exist in
        // `Config::default()`. Keep the comparison shape identical so
        // the delete-on-default rule degrades the same way the HashMap
        // path already does (i.e. it doesn't catch "field is set to
        // its serde default"; that limitation is shared and not a
        // regression from this fix). The `_default_table` parameter
        // is intentionally unused for this reason — kept on the
        // signature so the call site in `save_dirty` stays uniform
        // with `apply_dirty_path`, which does consume it.

        let inner_raw: Vec<&str> = inner.split('.').collect();
        // Same dash-aware segment resolution the top-level Table-shaped
        // walker uses (`apply_dirty_path` → `resolve_dirty_segments`), but
        // rooted at the natural-key entry's own serialized table so kebab
        // inner segments like `mcp.servers.fs.tool-timeout-secs` resolve
        // to the snake `tool_timeout_secs` field on disk. Sharing the
        // helper instead of forking is load-bearing: this PR exists
        // because two parallel walks (in-memory vs on-disk) drifted apart;
        // a forked copy here would recreate exactly that hazard, so any
        // future fix to dash handling lands in both code paths at once.
        let inner_segments: Vec<String> = match mem_entry {
            Some(table) => resolve_dirty_segments(table, &inner_raw),
            None => inner_raw.iter().map(|s| (*s).to_string()).collect(),
        };
        let inner_segs: Vec<&str> = inner_segments.iter().map(String::as_str).collect();

        let mem_inner_val = mem_entry.and_then(|t| lookup_path_in_table(t, &inner_segs));

        // mem missing → delete. mem present → write.
        match mem_inner_val {
            Some(value) => {
                let entry_table = ensure_array_of_tables_entry(
                    root,
                    parent_segs,
                    array_key,
                    section.natural_key_field,
                    alias,
                );
                let Some(entry_table) = entry_table else {
                    return;
                };
                let mut pruned = value.clone();
                prune_empty_leaves(&mut pruned);
                set_path_in_doc(entry_table, &inner_segs, &pruned);
            }
            None => {
                // Only walk the doc if we'd actually find something to
                // delete; otherwise an "ambient" delete pass on a fresh
                // alias would scribble an empty `[[mcp.servers]]` entry
                // via `ensure_array_of_tables_entry`'s create path.
                let Some(entry_table) = find_array_of_tables_entry_mut(
                    root,
                    parent_segs,
                    array_key,
                    section.natural_key_field,
                    alias,
                ) else {
                    return;
                };
                delete_path_in_doc(entry_table, &inner_segs);
            }
        }
        return;
    }

    // Bare-alias path (cases 2 and 3). No inner suffix → reconcile the
    // whole element.
    match mem_entry {
        Some(entry) => {
            // Case 2: rewrite the entire array entry from the in-memory
            // serialization. Prunes empties so default fields don't
            // sprawl, matching the per-field path's `prune_empty_leaves`
            // pass.
            let mut value = toml::Value::Table(entry.clone());
            prune_empty_leaves(&mut value);
            let entry_table = ensure_array_of_tables_entry(
                root,
                parent_segs,
                array_key,
                section.natural_key_field,
                alias,
            );
            let Some(entry_table) = entry_table else {
                return;
            };
            // Replace the table's contents wholesale so removed inner
            // fields actually disappear from disk.
            entry_table.clear();
            if let toml::Value::Table(t) = value {
                for (k, v) in t.into_iter() {
                    entry_table.insert(&k, crate::migration::toml_value_to_edit_item(&v));
                }
            }
        }
        None => {
            // Case 3: alias no longer in memory → drop the matching
            // doc entry. If no doc entry matches, no-op.
            delete_array_of_tables_entry(
                root,
                parent_segs,
                array_key,
                section.natural_key_field,
                alias,
            );
        }
    }
}

/// Return the longest `MapKeyKind::Map` section whose path is a strict
/// prefix of `dotted` (i.e. `dotted` starts with `<path>.`). Longest-match
/// mirrors `find_natural_key_section_for_path`: a nested `HashMap` field's
/// own section always outscores a shorter ancestor section.
fn find_map_key_section_for_path(dotted: &str) -> Option<crate::traits::MapKeySection> {
    use crate::traits::MapKeyKind;

    let mut best: Option<crate::traits::MapKeySection> = None;
    for section in Config::map_key_sections() {
        if section.kind != MapKeyKind::Map {
            continue;
        }
        let Some(after) = dotted
            .strip_prefix(section.path)
            .and_then(|s| s.strip_prefix('.'))
        else {
            continue;
        };
        if after.is_empty() {
            continue;
        }
        let better = best
            .as_ref()
            .is_none_or(|b| section.path.len() > b.path.len());
        if better {
            best = Some(section);
        }
    }
    best
}

/// Apply a dirty path of the form `<section_path>.<key>[.<inner_suffix>...]`
/// against a `HashMap<String, T>` section. `<key>` is the literal map key
/// and, for `#[resource_key]` sections (a model id, tool name, … rather
/// than an operator-chosen alias), may itself contain dots — e.g. `gpt-4.1`
/// in `cost.rates.providers.models.openai.gpt-4.1.input_per_mtok`.
///
/// Longest-match `<key>` against the union of keys live in `full_table`
/// (the in-memory state) and in the doc (`root`), so a key that only
/// survives on one side — the delete half of a rename, or a fresh
/// alias not yet flushed — still resolves. This mirrors
/// `apply_dirty_natural_key_path`'s two-sided lookup for `mcp.servers`.
///
/// A whole-entry write (no inner suffix — `<section_path>.<key>` addresses
/// the map value itself, e.g. the create-map-key path) is the case where
/// `<key>` consumes the entire remainder; that always wins over any
/// shorter prefix-plus-dot match into the same key space, so it's checked
/// first.
///
/// Once `<key>` is resolved, the rest of the path reuses the exact
/// mem-vs-default comparison and doc write/delete the generic Table-shaped
/// walker in `apply_dirty_path` uses — the dotted key is just folded into
/// one opaque segment instead of being split on '.' with everything else.
fn apply_dirty_map_key_path(
    root: &mut toml_edit::Table,
    section: &crate::traits::MapKeySection,
    dotted: &str,
    full_table: &toml::Table,
    default_table: &toml::Table,
) -> Result<()> {
    let section_segs: Vec<&str> = section.path.split('.').collect();
    let remainder = &dotted[section.path.len() + 1..];

    let mem_table = lookup_path_in_table(full_table, &section_segs).and_then(|v| v.as_table());
    let doc_table = lookup_table_in_doc(root, &section_segs);

    let keys: Vec<&str> = mem_table
        .into_iter()
        .flat_map(|t| t.keys().map(String::as_str))
        .chain(doc_table.into_iter().flat_map(|t| t.iter().map(|(k, _)| k)))
        .collect();

    // Exact match first: a whole-entry path for key `gpt-4.1` must not be
    // swallowed by `route_hashmap_path` matching the shorter key `gpt-4`
    // plus a bogus inner suffix `1...`. The cost is the inverse shadow: a
    // pathological key literally named `<other-key>.<field>` wins over a
    // field write addressed to `<other-key>` — acceptable because the
    // exact key is the more specific interpretation of the same bytes.
    let key_match: Option<(&str, Option<String>)> = if keys.contains(&remainder) {
        Some((remainder, None))
    } else {
        crate::helpers::route_hashmap_path(dotted, "", section.path, "", keys.iter().copied())
            .map(|(key, inner)| (key, Some(inner)))
    };

    let Some((key, inner)) = key_match else {
        anyhow::bail!(
            "save_dirty: dirty path `{dotted}` addresses map-key section `{}` but its key \
             resolves in neither the in-memory config nor the on-disk file; refusing to \
             silently drop the write",
            section.path
        );
    };

    let mut segments: Vec<String> = section_segs.iter().map(|s| (*s).to_string()).collect();
    segments.push(key.to_string());

    if let Some(inner) = inner {
        // Same dash-aware segment resolution `apply_dirty_natural_key_path`
        // uses for its inner suffix, rooted at the matched key's own
        // serialized table so kebab inner segments (`tool-timeout-secs`)
        // resolve to the snake struct field on disk.
        let key_table = mem_table
            .and_then(|t| t.get(key))
            .and_then(|v| v.as_table());
        let inner_raw: Vec<&str> = inner.split('.').collect();
        let inner_segments: Vec<String> = match key_table {
            Some(t) => resolve_dirty_segments(t, &inner_raw),
            None => inner_raw.iter().map(|s| (*s).to_string()).collect(),
        };
        segments.extend(inner_segments);
    }

    let segs: Vec<&str> = segments.iter().map(String::as_str).collect();
    write_or_delete_leaf(root, full_table, default_table, &segs);
    Ok(())
}

/// Shared tail of the generic and map-key dirty-path walkers: reconcile
/// the resolved `segs` against the doc — delete the leaf when the
/// in-memory value is absent or equals the schema default, otherwise
/// write the pruned in-memory value. One copy so a future fix to the
/// delete-vs-write rule cannot land in one walker only.
fn write_or_delete_leaf(
    root: &mut toml_edit::Table,
    full_table: &toml::Table,
    default_table: &toml::Table,
    segs: &[&str],
) {
    let mem_val = lookup_path_in_table(full_table, segs);
    let default_val = lookup_path_in_table(default_table, segs);

    let should_delete = match (mem_val, default_val) {
        (None, _) => true,
        (Some(m), Some(d)) if m == d => true,
        _ => false,
    };

    if should_delete {
        delete_path_in_doc(root, segs);
    } else if let Some(value) = mem_val {
        let mut pruned = value.clone();
        prune_empty_leaves(&mut pruned);
        set_path_in_doc(root, segs, &pruned);
    }
}

/// Emit a single `WARN` event when a natural-key writer (ensure /
/// find / delete) bails because the on-disk node has the wrong shape
/// — for example an operator hand-edited `mcp.servers = "foo"`, or
/// wrote `servers = [{ name = "fs", ... }]` (an inline array of
/// tables, which `toml_edit` parses as `Item::Value(Value::Array)`
/// rather than the `Item::ArrayOfTables` this writer produces).
///
/// Without this, `config/set` returns success, the in-memory mutation
/// and the dashboard/TUI both reflect the new value, and the on-disk
/// file silently stays stale — the exact symptom the underlying
/// natural-key persistence bug ( fix) exists to eliminate. The
/// `WARN` shape mirrors the 0600-permissions fallback below: same
/// `Action::Note` / `EventOutcome::Unknown` / module path, so log
/// scrapers that already understand the schema warnings will surface
/// these the same way.
///
/// `op` is `"ensure"`, `"find"`, or `"delete"` — which writer bailed.
/// `kind` is `"parent_segment"` or `"array_key"` — which node was
/// wrong-shaped. `which` is the specific seg/key name involved.
fn warn_natural_key_doc_kind_mismatch(
    parent_segs: &[&str],
    array_key: &str,
    alias: &str,
    kind: &str,
    which: &str,
    op: &str,
) {
    let section_path = if parent_segs.is_empty() {
        array_key.to_string()
    } else {
        format!("{}.{array_key}", parent_segs.join("."))
    };
    ::zeroclaw_log::record!(
        WARN,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
            .with_attrs(::serde_json::json!({
                "section_path": section_path,
                "alias": alias,
                "node_kind": kind,
                "node_name": which,
                "operation": op,
            })),
        "natural-key writer: refusing to clobber hand-edited config.toml node; \
         on-disk file may diverge from in-memory state"
    );
}

/// Locate or create the `[[<section_path>]]` entry whose natural-key
/// field equals `alias`, returning a mutable reference to its inner
/// `toml_edit::Table`.
///
/// Three shapes are reconciled here:
///
///   * Parent Tables along `parent_segs` are auto-created if missing
///     (mirrors `set_path_in_doc`).
///   * The `array_key` slot may not yet exist; insert an
///     `ArrayOfTables` if so.
///   * The array may not yet contain an entry whose natural-key field
///     matches `alias`; push a new table with `<natural_key_field> =
///     "<alias>"` seeded.
///
/// Returns `None` only when an existing doc node at `parent_segs` or
/// `array_key` has the wrong kind (e.g. an operator hand-edited
/// `mcp.servers = "foo"`, or wrote an inline `servers = [{ ... }]`
/// array-of-tables that parses as `Item::Value(Value::Array)` rather
/// than the `Item::ArrayOfTables` shape this writer expects); in that
/// case we refuse to clobber the hand-edit and let the save bail
/// rather than data-loss the user's file. A `WARN`-level event is
/// emitted on every wrong-kind bail so the divergence between memory
/// and disk is observable in logs — silent bails were what let the
/// underlying bug ship.
fn ensure_array_of_tables_entry<'a>(
    root: &'a mut toml_edit::Table,
    parent_segs: &[&str],
    array_key: &str,
    natural_key_field: &str,
    alias: &str,
) -> Option<&'a mut toml_edit::Table> {
    let mut cursor: &mut toml_edit::Table = root;
    for seg in parent_segs {
        if !cursor.contains_key(seg) {
            cursor.insert(seg, toml_edit::Item::Table(toml_edit::Table::new()));
        }
        let next = cursor.get_mut(seg).and_then(|i| i.as_table_mut());
        if next.is_none() {
            warn_natural_key_doc_kind_mismatch(
                parent_segs,
                array_key,
                alias,
                "parent_segment",
                seg,
                "ensure",
            );
            return None;
        }
        cursor = next?;
    }
    if !cursor.contains_key(array_key) {
        cursor.insert(
            array_key,
            toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()),
        );
    }
    let aot = cursor
        .get_mut(array_key)
        .and_then(|i| i.as_array_of_tables_mut());
    let Some(aot) = aot else {
        warn_natural_key_doc_kind_mismatch(
            parent_segs,
            array_key,
            alias,
            "array_key",
            array_key,
            "ensure",
        );
        return None;
    };

    let pos = aot
        .iter()
        .position(|t| t.get(natural_key_field).and_then(|i| i.as_str()) == Some(alias));
    let idx = match pos {
        Some(i) => i,
        None => {
            let mut new_table = toml_edit::Table::new();
            new_table.insert(natural_key_field, toml_edit::value(alias.to_string()));
            aot.push(new_table);
            aot.len() - 1
        }
    };
    aot.get_mut(idx)
}

/// Read-only counterpart to [`ensure_array_of_tables_entry`] used by
/// the delete path: never creates a missing array or entry, just
/// locates one if present. Returning `None` on a miss lets the caller
/// skip the doc-mutation step entirely. Distinguishes "node doesn't
/// exist" (silent no-op — the legitimate fresh-alias case) from "node
/// exists but has the wrong kind" (a `WARN`-logged bail — the
/// hand-edited inline-array hazard).
fn find_array_of_tables_entry_mut<'a>(
    root: &'a mut toml_edit::Table,
    parent_segs: &[&str],
    array_key: &str,
    natural_key_field: &str,
    alias: &str,
) -> Option<&'a mut toml_edit::Table> {
    let mut cursor: &mut toml_edit::Table = root;
    for seg in parent_segs {
        let item = cursor.get_mut(seg)?;
        let Some(next) = item.as_table_mut() else {
            warn_natural_key_doc_kind_mismatch(
                parent_segs,
                array_key,
                alias,
                "parent_segment",
                seg,
                "find",
            );
            return None;
        };
        cursor = next;
    }
    let item = cursor.get_mut(array_key)?;
    let Some(aot) = item.as_array_of_tables_mut() else {
        warn_natural_key_doc_kind_mismatch(
            parent_segs,
            array_key,
            alias,
            "array_key",
            array_key,
            "find",
        );
        return None;
    };
    let pos = aot
        .iter()
        .position(|t| t.get(natural_key_field).and_then(|i| i.as_str()) == Some(alias))?;
    aot.get_mut(pos)
}

/// Remove the `[[<section_path>]]` entry whose natural-key field
/// equals `alias`. No-op if the array or entry isn't present. When the
/// removal empties the `ArrayOfTables`, drop the array slot too so the
/// on-disk file doesn't carry a vestigial `[[mcp.servers]]`-shaped
/// blank section header.
fn delete_array_of_tables_entry(
    root: &mut toml_edit::Table,
    parent_segs: &[&str],
    array_key: &str,
    natural_key_field: &str,
    alias: &str,
) {
    let mut cursor: &mut toml_edit::Table = root;
    for seg in parent_segs {
        let Some(item) = cursor.get_mut(seg) else {
            return;
        };
        match item.as_table_mut() {
            Some(next) => cursor = next,
            None => {
                warn_natural_key_doc_kind_mismatch(
                    parent_segs,
                    array_key,
                    alias,
                    "parent_segment",
                    seg,
                    "delete",
                );
                return;
            }
        }
    }
    let Some(item) = cursor.get_mut(array_key) else {
        return;
    };
    let aot = match item.as_array_of_tables_mut() {
        Some(a) => a,
        None => {
            warn_natural_key_doc_kind_mismatch(
                parent_segs,
                array_key,
                alias,
                "array_key",
                array_key,
                "delete",
            );
            return;
        }
    };
    let pos = aot
        .iter()
        .position(|t| t.get(natural_key_field).and_then(|i| i.as_str()) == Some(alias));
    if let Some(idx) = pos {
        aot.remove(idx);
    }
    if aot.is_empty() {
        cursor.remove(array_key);
    }
}

/// Drop empty arrays / tables / strings from a value before writing it
/// to the doc. HashMap entries serialize every default field (no
/// `skip_serializing_if` on individual `Vec<String>` fields), so without
/// this pass an `mcp_bundles.<alias>` write produces `servers = []`,
/// `exclude = []`, etc. The pruned form round-trips identically because
/// each dropped field's serde default IS the dropped value.
fn prune_empty_leaves(value: &mut toml::Value) {
    match value {
        toml::Value::Table(t) => {
            let keys: Vec<String> = t.keys().cloned().collect();
            for key in keys {
                if let Some(inner) = t.get_mut(&key) {
                    prune_empty_leaves(inner);
                }
                let drop = match t.get(&key) {
                    Some(toml::Value::Array(arr)) => arr.is_empty(),
                    Some(toml::Value::Table(inner)) => inner.is_empty(),
                    Some(toml::Value::String(s)) => s.is_empty(),
                    _ => false,
                };
                if drop {
                    t.remove(&key);
                }
            }
        }
        toml::Value::Array(arr) => {
            for item in arr.iter_mut() {
                prune_empty_leaves(item);
            }
        }
        _ => {}
    }
}

fn resolve_dirty_segments(root: &toml::Table, raw: &[&str]) -> Vec<String> {
    let mut out: Vec<String> = Vec::with_capacity(raw.len());
    let mut current: Option<&toml::Value> = None;
    for seg in raw {
        let table_opt: Option<&toml::Table> = if out.is_empty() {
            Some(root)
        } else {
            current.and_then(|v| v.as_table())
        };
        let resolved = match table_opt {
            Some(t) if t.contains_key(*seg) => (*seg).to_string(),
            Some(t) => {
                let snake = seg.replace('-', "_");
                if t.contains_key(&snake) {
                    snake
                } else {
                    (*seg).to_string()
                }
            }
            None => (*seg).to_string(),
        };
        current = table_opt.and_then(|t| t.get(&resolved));
        out.push(resolved);
    }
    out
}

fn lookup_path_in_table<'a>(root: &'a toml::Table, segs: &[&str]) -> Option<&'a toml::Value> {
    let mut current: Option<&toml::Value> = None;
    for (i, seg) in segs.iter().enumerate() {
        let table = if i == 0 { root } else { current?.as_table()? };
        current = table.get(*seg);
    }
    current
}

/// Read-only walk to the table-like node at `segs`, or `None` if any
/// segment is missing or not table-shaped on disk. Used to read a map-key
/// section's live on-disk keys without mutating the doc. `TableLike`
/// rather than `Table` because ZeroClaw loads (though never writes)
/// hand-edited inline tables — `openai = { "gpt-4.1" = { ... } }` parses
/// as `Item::Value(Value::InlineTable)`, which `as_table()` rejects; a
/// key living only in such a section must still resolve here or the
/// unresolvable-key bail in `apply_dirty_map_key_path` aborts the whole
/// `save_dirty` batch.
fn lookup_table_in_doc<'a>(
    root: &'a toml_edit::Table,
    segs: &[&str],
) -> Option<&'a dyn toml_edit::TableLike> {
    let mut cursor: &dyn toml_edit::TableLike = root;
    for seg in segs {
        cursor = cursor.get(seg)?.as_table_like()?;
    }
    Some(cursor)
}

/// `TableLike` rather than `Table` for the traversal cursor — same reason
/// as `lookup_table_in_doc` above: a map-key section may live inside a
/// hand-edited inline table (`openai = { "gpt-4.1" = { ... } }`), which
/// parses as `Item::Value(Value::InlineTable)`. `as_table_mut()` returns
/// `None` for that shape, so a `Table`-only cursor would silently return
/// without removing anything — `save_dirty` reports success while the key
/// stays on disk. `as_table_like_mut()` descends into both `Table` and
/// `InlineTable`, and `TableLike::remove` deletes from either.
fn delete_path_in_doc(root: &mut toml_edit::Table, segs: &[&str]) {
    let Some((last, parents)) = segs.split_last() else {
        return;
    };
    let mut cursor: &mut dyn toml_edit::TableLike = root;
    for seg in parents {
        cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_like_mut()) {
            Some(t) => t,
            None => return,
        };
    }
    cursor.remove(last);
}

/// Same `TableLike` traversal as `delete_path_in_doc`, for the same
/// reason: a write into a key that already lives inside a hand-edited
/// inline table must land in that inline table rather than silently
/// no-op-ing because the cursor only understood standard `Table` nodes.
fn set_path_in_doc(root: &mut toml_edit::Table, segs: &[&str], value: &toml::Value) {
    let Some((last, parents)) = segs.split_last() else {
        return;
    };
    let mut cursor: &mut dyn toml_edit::TableLike = root;
    for seg in parents {
        if !cursor.contains_key(seg) {
            cursor.insert(seg, toml_edit::Item::Table(toml_edit::Table::new()));
        }
        cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_like_mut()) {
            Some(t) => t,
            None => return,
        };
    }
    let new_item = crate::migration::toml_value_to_edit_item(value);
    cursor.insert(last, new_item);
}

#[allow(clippy::unused_async)] // async needed on unix for tokio File I/O; no-op on other platforms
async fn sync_directory(path: &Path) -> Result<()> {
    #[cfg(unix)]
    {
        let dir = File::open(path).await.with_context(|| {
            format!(
                "Failed to open directory for fsync: {}",
                path.display().to_string()
            )
        })?;
        dir.sync_all().await.with_context(|| {
            format!(
                "Failed to fsync directory metadata: {}",
                path.display().to_string()
            )
        })?;
        Ok(())
    }

    #[cfg(windows)]
    {
        use std::os::windows::fs::OpenOptionsExt;
        const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000;
        let dir = std::fs::OpenOptions::new()
            .read(true)
            .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
            .open(path)
            .with_context(|| {
                format!(
                    "Failed to open directory for fsync: {}",
                    path.display().to_string()
                )
            })?;
        // FlushFileBuffers on directory handles returns ERROR_ACCESS_DENIED on
        // Windows (OS Error 5). This is expected — NTFS does not support
        // flushing directory metadata the same way Unix does. The individual
        // files have already been synced, so it is safe to ignore this error.
        if let Err(e) = dir.sync_all() {
            if e.raw_os_error() == Some(5) {
                ::zeroclaw_log::record!(
                    TRACE,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
                    &format!(
                        "Ignoring expected ACCESS_DENIED when fsyncing directory on Windows: {}",
                        path.display().to_string()
                    )
                );
            } else {
                return Err(e).with_context(|| {
                    format!(
                        "Failed to fsync directory metadata: {}",
                        path.display().to_string()
                    )
                });
            }
        }
        Ok(())
    }

    #[cfg(not(any(unix, windows)))]
    {
        let _ = path;
        Ok(())
    }
}

// ── SOP engine configuration ───────────────────────────────────

/// Standard Operating Procedures engine configuration (`[sop]`).
///
/// The `default_execution_mode` field uses the `SopExecutionMode` type from
/// `sop::types` (re-exported via `sop::SopExecutionMode`). To avoid circular
/// module references, config stores it using the same enum definition.
#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "sop"]
pub struct SopConfig {
    /// Directory containing SOP definitions (subdirs with SOP.toml + SOP.md).
    /// Optional override. When omitted, the runtime and CLI both resolve the
    /// default `<workspace>/sops`; SOPs load from there whenever it exists.
    #[serde(default)]
    pub sops_dir: Option<String>,

    /// Default execution mode for SOPs that omit `execution_mode`.
    /// Values: `auto`, `supervised` (default), `step_by_step`,
    /// `priority_based`, `deterministic`.
    #[serde(default = "default_sop_execution_mode")]
    pub default_execution_mode: String,

    /// Maximum total concurrent SOP runs across all SOPs.
    #[serde(default = "default_sop_max_concurrent_total")]
    pub max_concurrent_total: usize,

    /// Approval timeout in seconds. When a run waits for approval longer than
    /// this, the configured `approval_timeout_action` is applied (default
    /// `escalate`: re-surface the gate to the out-of-band approver and never
    /// self-approve). Set to 0 to disable the timeout sweep.
    #[serde(default = "default_sop_approval_timeout_secs")]
    pub approval_timeout_secs: u64,

    /// Maximum number of finished runs kept in memory for status queries.
    /// Oldest runs are evicted when over capacity. 0 = unlimited.
    #[serde(default = "default_sop_max_finished_runs")]
    pub max_finished_runs: usize,

    /// How often (seconds) the daemon runs the SOP maintenance tick: fire
    /// fail-closed approval timeouts (per `approval_timeout_secs` /
    /// `approval_timeout_action`), reap expired concurrency-claim leases, and
    /// prune terminal runs past `max_finished_runs`. Default `60`; set to `0` to
    /// disable the tick entirely. The tick itself self-approves nothing - timeout
    /// handling is governed by `approval_timeout_action` (default `escalate`).
    #[serde(default = "default_sop_maintenance_interval_secs")]
    pub maintenance_interval_secs: u64,

    /// Persist run state durably across restarts. Default `true`: `build_sop_engine`
    /// selects the configured backend (`sqlite`) and in-flight runs - including runs
    /// parked at a HITL approval - survive a restart. This is the durable substrate
    /// the HITL admission model relies on so a pending approval is not lost when the
    /// daemon restarts. Set to `false` to opt back into ephemeral in-memory state.
    #[serde(default = "default_sop_persist_runs")]
    pub persist_runs: bool,

    /// Durable run-state backend when `persist_runs` is true: `sqlite` (default,
    /// durable) or `memory` (explicitly non-durable, for tests/degraded).
    #[serde(default)]
    pub run_store_backend: SopRunStoreBackend,

    /// Directory for the durable run store (created mode-0700). When omitted,
    /// `<data_dir>/sop`. Never OS-temp.
    #[serde(default)]
    pub run_state_dir: Option<String>,

    /// WHO may clear a SOP approval gate. Layered with execution_mode / priority /
    /// requires_confirmation (those still apply). Default `both` keeps today's
    /// behavior (the agent tool OR an out-of-band principal can clear a gate).
    #[serde(default)]
    pub approval_mode: ApprovalMode,

    /// What happens to a SOP gate that times out (after `approval_timeout_secs`).
    /// Default `escalate` is fail-closed: re-surface to the out-of-band approver and
    /// keep waiting, never self-approve. (The SOP-gate analog of the channel
    /// approval-routing fail-closed default; reconcile with that model if both land.)
    #[serde(default)]
    pub approval_timeout_action: ApprovalTimeoutAction,

    /// Approval broker policy config (`[sop.approval]`): named approver groups and
    /// per-name approval policies (required group + quorum + escalation route) the
    /// approval broker consumes for group-membership and quorum checks. Members are
    /// channel-provided identities (a gateway user, a forge login), so this is a
    /// permanent identity source, not a stopgap; a future auth system adds another
    /// resolver alongside it rather than replacing it. An empty block means no broker
    /// policy applies (`approval_mode` alone governs a gate, unchanged behavior).
    #[serde(default)]
    #[nested]
    pub approval: SopApprovalConfig,

    /// Enforce per-step tool scope. Default false keeps `tools:` advisory.
    #[serde(default)]
    pub step_scope_enforce: bool,

    /// Tool names that remain available while step scope is enforced.
    #[serde(default = "default_sop_step_mandatory_tools")]
    pub step_mandatory_tools: Vec<String>,

    /// Enforce per-step input/output schemas when a step declares them.
    #[serde(default = "default_sop_step_schema_enforce")]
    pub step_schema_enforce: bool,

    /// Maximum times a routed SOP run can visit one step.
    #[serde(default = "default_sop_max_step_visits")]
    pub max_step_visits: u32,

    /// Maximum retries allowed by a step failure policy.
    #[serde(default = "default_sop_max_step_retries")]
    pub max_step_retries: u32,

    /// Maximum bytes accepted from untrusted SOP trigger payload/topic content
    /// before char-boundary truncation. 0 disables the cap.
    #[serde(default = "default_sop_untrusted_payload_max_bytes")]
    pub untrusted_payload_max_bytes: usize,

    /// Prompt-guard action for untrusted SOP trigger input: warn, block, or sanitize.
    #[serde(default = "default_sop_untrusted_input_guard")]
    pub untrusted_input_guard: String,

    /// Prompt-guard and outbound-redaction sensitivity for untrusted SOP content.
    #[serde(default = "default_sop_untrusted_guard_sensitivity")]
    pub untrusted_guard_sensitivity: f64,

    /// Include the explanatory warning text inside untrusted-content frames.
    /// Boundary framing itself is always on once wired.
    #[serde(default = "default_sop_untrusted_frame_warning")]
    pub untrusted_frame_warning: bool,

    /// Redact outbound SOP content before persistence/audit consumers write it.
    /// Intended to converge with the shared B/F redaction switch once those consumers land.
    #[serde(default = "default_sop_untrusted_outbound_redact")]
    pub untrusted_outbound_redact: bool,

    /// Enable SOP procedural-memory proposal tooling. Default false keeps
    /// self-modifying SOP write-back opt-in while the SOP subsystem is
    /// Experimental.
    #[serde(default)]
    pub procedural_memory_enabled: bool,
}

fn default_sop_execution_mode() -> String {
    "supervised".to_string()
}

/// Durable SOP run-state backend selector. A closed, compile-time-known set, so it
/// is a serde enum rather than free text (mirrors `SandboxBackend` /
/// `ObservabilityBackend` in this file); unknown values are rejected at parse time.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum SopRunStoreBackend {
    /// Durable WAL-mode SQLite store (default).
    #[default]
    Sqlite,
    /// Ephemeral in-memory store: explicitly non-durable, for tests / degraded boot.
    Memory,
}

/// WHO may clear a SOP approval gate. Layered with execution_mode / priority /
/// requires_confirmation (all of which still apply). Closed set => serde enum.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ApprovalMode {
    /// Agent `sop_approve` OR an out-of-band principal both clear. Backward-compat default.
    #[default]
    Both,
    /// Only out-of-band principals (CLI / WS / HTTP / timeout) clear; `sop_approve`
    /// becomes a no-op. Lets an operator require a separate approver without bricking runs.
    OutOfBandRequired,
    /// Only the agent tool clears; out-of-band surfaces are read-only pending lists. Niche.
    AgentTool,
}

/// What happens to a SOP approval gate when it times out. Default is fail-closed:
/// the gate never self-approves. Closed set => serde enum.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, zeroclaw_macros::ConfigEnum,
)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ApprovalTimeoutAction {
    /// Re-surface to the out-of-band approver and keep waiting; never self-approve.
    /// Default, fail-closed.
    #[default]
    Escalate,
    /// Terminate the run (fail-safe cancel). Strict mode.
    Cancel,
    /// LEGACY: auto-approve on timeout. The ONLY path to the old fail-open behavior;
    /// opt-in only.
    AutoApprove,
}

/// `[sop.approval]` - approval broker policy config. A permanent identity source
/// for channel-provided approvers (not a stopgap): the approval broker consumes it
/// for group-membership and quorum checks. Empty = no broker policy applies.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "sop_approval"]
pub struct SopApprovalConfig {
    /// Named approver groups: `group name -> members`. A member is matched against
    /// the transport-derived (channel-authenticated) `ApprovalPrincipal` identity.
    /// A member may be source-qualified (`<source>:<identity>`, e.g. `http:ZeroClawOperator`,
    /// `ws:<subject>`, `agent:<alias>`) to grant rights on one transport only, or a
    /// bare identity (`alice`) to grant from any non-channel source. Channel members
    /// must include the channel namespace (`channel:<channel-key>:<sender>`) so sender
    /// ids from different channel aliases cannot collide. A future auth system adds a
    /// second resolver alongside this one; it does not replace channel identities.
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    #[nested]
    pub groups: std::collections::HashMap<String, ApprovalGroupConfig>,
    /// Named approval policies a SOP step may reference by name.
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    #[nested]
    pub policies: std::collections::HashMap<String, ApprovalPolicyConfig>,
}

/// A named approver group (`[sop.approval.groups.<name>]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "sop_approval_group"]
pub struct ApprovalGroupConfig {
    /// Identity labels that belong to this group.
    #[serde(default)]
    pub members: Vec<String>,
}

/// A named approval policy (`[sop.approval.policies.<name>]`) the broker enforces.
#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[prefix = "sop_approval_policy"]
pub struct ApprovalPolicyConfig {
    /// Group whose members may satisfy this policy's gate. `None`/empty = any
    /// principal permitted by `approval_mode` (back-compat, no membership gate).
    #[serde(default)]
    pub required_group: Option<String>,
    /// Distinct approvers required before the gate clears. `0`/`1` both mean a
    /// single approval; `>= 2` requires a quorum of distinct approver identities.
    #[serde(default)]
    pub quorum: u32,
    /// Channel to deliver the INITIAL approval request to when a run parks at a
    /// gate this policy governs, formatted `channel:recipient` (e.g.
    /// `discord.ops:123456789012345678`). The `channel` names a configured channel
    /// (the `<channel>.<alias>` / bare-`<channel>` key from the channel map); the
    /// `recipient` is that channel's addressee (a Discord channel id, a chat id,
    /// etc.). Delivery is best-effort - the gate is the source of truth and a
    /// delivery failure never blocks or clears it; approvals still come back
    /// through the normal HTTP/WS/tool surfaces. `None`/empty = no out-of-band
    /// request notice (today's behavior: only the originating surface is notified).
    /// This is a DISTINCT lifecycle event from `escalation_route`: the request goes
    /// out when the run parks; the escalation goes out only if it later times out.
    /// When configured, the route must use the `channel:recipient` format.
    #[serde(default)]
    pub request_route: Option<String>,
    /// Route to escalate to on timeout (the distinct "second route"). `None`/empty
    /// re-surfaces to the same route (today's `Escalate` behavior). When configured,
    /// the route must use the `channel:recipient` format.
    #[serde(default)]
    pub escalation_route: Option<String>,
}

fn default_sop_max_concurrent_total() -> usize {
    4
}

fn default_sop_approval_timeout_secs() -> u64 {
    300
}

fn default_sop_max_finished_runs() -> usize {
    100
}

fn default_sop_persist_runs() -> bool {
    true
}

fn default_sop_step_mandatory_tools() -> Vec<String> {
    ["sop_advance", "sop_approve", "sop_status"]
        .into_iter()
        .map(String::from)
        .collect()
}

fn default_sop_step_schema_enforce() -> bool {
    true
}

fn default_sop_max_step_visits() -> u32 {
    256
}

fn default_sop_max_step_retries() -> u32 {
    2
}

fn default_sop_untrusted_payload_max_bytes() -> usize {
    8192
}

fn default_sop_untrusted_input_guard() -> String {
    "warn".to_string()
}

fn default_sop_untrusted_guard_sensitivity() -> f64 {
    0.7
}

fn default_sop_untrusted_frame_warning() -> bool {
    true
}

fn default_sop_untrusted_outbound_redact() -> bool {
    true
}

fn default_sop_maintenance_interval_secs() -> u64 {
    60
}

impl Default for SopConfig {
    fn default() -> Self {
        Self {
            sops_dir: None,
            default_execution_mode: default_sop_execution_mode(),
            max_concurrent_total: default_sop_max_concurrent_total(),
            approval_timeout_secs: default_sop_approval_timeout_secs(),
            max_finished_runs: default_sop_max_finished_runs(),
            maintenance_interval_secs: default_sop_maintenance_interval_secs(),
            persist_runs: default_sop_persist_runs(),
            run_store_backend: SopRunStoreBackend::Sqlite,
            run_state_dir: None,
            approval_mode: ApprovalMode::Both,
            approval_timeout_action: ApprovalTimeoutAction::Escalate,
            approval: SopApprovalConfig::default(),
            step_scope_enforce: false,
            step_mandatory_tools: default_sop_step_mandatory_tools(),
            step_schema_enforce: default_sop_step_schema_enforce(),
            max_step_visits: default_sop_max_step_visits(),
            max_step_retries: default_sop_max_step_retries(),
            untrusted_payload_max_bytes: default_sop_untrusted_payload_max_bytes(),
            untrusted_input_guard: default_sop_untrusted_input_guard(),
            untrusted_guard_sensitivity: default_sop_untrusted_guard_sensitivity(),
            untrusted_frame_warning: default_sop_untrusted_frame_warning(),
            untrusted_outbound_redact: default_sop_untrusted_outbound_redact(),
            procedural_memory_enabled: false,
        }
    }
}

// Config enums self-declare via `#[derive(ConfigEnum)]` at their definition
// site. `DelegationPolicy` is a struct whose `mode` leaf surfaces as the enum,
// so it carries the impl directly.
impl HasPropKind for crate::autonomy::DelegationPolicy {
    const PROP_KIND: PropKind = PropKind::Enum;
}

impl HasPropKind for serde_json::Value {
    // `serde_json::Value` is an arbitrary JSON document, not an enum.
    // Classifying it as `Enum` previously made `enum_variants_for::<Value>()`
    // hand back the literal placeholder `"(unknown variants)"`, and the
    // dashboard form rendered fields like `model_providers.<key>.provider_extra`
    // as a single-option dropdown. `String` is the closest scalar kind —
    // the form renders a text input where the user pastes raw JSON.
    // Round-trip via `set_prop` stays correct: serde deserializes the TOML
    // string back into `Value::String(...)`. Power users editing complex
    // objects still use `zeroclaw config set --json` or hand-edit the
    // `config.toml`.
    const PROP_KIND: PropKind = PropKind::String;
}

#[cfg(test)]
mod tests {

    // ── Nextcloud Talk: one normalized bot secret for both directions ──
    //
    // Nextcloud installs ONE secret per bot and uses it to verify inbound webhook
    // signatures AND to sign outbound bot-API requests. Before this, outbound used a
    // raw `bot_token.or_else(webhook_secret)` (no trim, no non-empty check) while
    // inbound read only a trimmed non-empty `webhook_secret`, so the two directions
    // could resolve different secrets -- or outbound could sign with whitespace.

    fn nc_cfg(webhook_secret: Option<&str>, bot_token: Option<&str>) -> NextcloudTalkConfig {
        NextcloudTalkConfig {
            webhook_secret: webhook_secret.map(ToOwned::to_owned),
            bot_token: bot_token.map(ToOwned::to_owned),
            ..NextcloudTalkConfig::default()
        }
    }

    /// Alias-only configuration must resolve, so a `bot_token`-only install keeps
    /// working through the migration window -- and, critically, now resolves for the
    /// INBOUND direction too, where it previously produced no secret at all.
    #[::core::prelude::v1::test]
    fn nextcloud_alias_only_configuration_resolves_the_secret() {
        let cfg = nc_cfg(None, Some("shared-bot-secret"));
        assert_eq!(
            cfg.resolve_bot_secret().unwrap(),
            Some("shared-bot-secret".to_string())
        );
    }

    /// The canonical field wins when both carry the SAME value, and agreeing
    /// duplicates are not treated as a conflict.
    #[::core::prelude::v1::test]
    fn nextcloud_agreeing_alias_and_canonical_resolve_to_one_secret() {
        let cfg = nc_cfg(Some("same"), Some("same"));
        assert_eq!(cfg.resolve_bot_secret().unwrap(), Some("same".to_string()));
    }

    /// Two conflicting non-empty values must be REJECTED rather than silently split
    /// into two authorities: whichever direction loses cannot match the installed bot.
    #[::core::prelude::v1::test]
    fn nextcloud_conflicting_alias_is_rejected() {
        let cfg = nc_cfg(Some("canonical"), Some("different"));
        assert!(
            cfg.resolve_bot_secret().is_err(),
            "conflicting non-empty secrets must not silently pick one"
        );
    }

    /// A blank or whitespace alias must not mask a real secret. Previously the raw
    /// `bot_token.or_else(...)` would hand whitespace to the outbound signer.
    #[::core::prelude::v1::test]
    fn nextcloud_blank_alias_does_not_mask_the_real_secret() {
        for blank in ["", "   ", "\t", "\n  "] {
            let cfg = nc_cfg(Some("real-secret"), Some(blank));
            assert_eq!(
                cfg.resolve_bot_secret().unwrap(),
                Some("real-secret".to_string()),
                "blank alias {blank:?} must not mask the canonical secret"
            );
        }
        // ...and a blank canonical falls back to a real alias rather than resolving blank.
        let cfg = nc_cfg(Some("  "), Some("real-secret"));
        assert_eq!(
            cfg.resolve_bot_secret().unwrap(),
            Some("real-secret".to_string())
        );
    }

    /// Values are trimmed, so trailing whitespace in config cannot produce a secret
    /// that differs between the two directions.
    #[::core::prelude::v1::test]
    fn nextcloud_secret_is_trimmed() {
        let cfg = nc_cfg(Some("  padded  "), None);
        assert_eq!(
            cfg.resolve_bot_secret().unwrap(),
            Some("padded".to_string())
        );
    }

    /// With nothing configured the secret is unresolved. Callers must fail closed:
    /// no outbound request is signed, and inbound verification is NOT skipped.
    /// (The gateway rejects with 401 in that case; see the inbound handler.)
    #[::core::prelude::v1::test]
    fn nextcloud_unresolved_secret_yields_none_so_callers_fail_closed() {
        assert_eq!(nc_cfg(None, None).resolve_bot_secret().unwrap(), None);
        assert_eq!(
            nc_cfg(Some(""), Some("   ")).resolve_bot_secret().unwrap(),
            None
        );
    }

    #[::core::prelude::v1::test]
    fn todotracker_config_defaults() {
        let cfg = super::TodoTrackerConfig::default();
        assert!(cfg.enabled);
        assert!(!cfg.enabled_at_start);
        assert_eq!(cfg.location, super::TodoTrackerLocation::Right);
        assert_eq!(cfg.width, 32);
        assert_eq!(cfg.max_height, 5);
    }

    #[::core::prelude::v1::test]
    fn todotracker_config_parses_from_toml() {
        let toml = r#"
enabled = true
enabled_at_start = true
location = "bottom"
width = 40
max_height = 8
"#;
        let cfg: super::TodoTrackerConfig = toml::from_str(toml).unwrap();
        assert!(cfg.enabled_at_start);
        assert_eq!(cfg.location, super::TodoTrackerLocation::Bottom);
        assert_eq!(cfg.width, 40);
        assert_eq!(cfg.max_height, 8);
    }

    #[::core::prelude::v1::test]
    fn mcp_server_config_pinned_resources_defaults_empty_and_round_trips() {
        // Absent field defaults to empty.
        let cfg: McpServerConfig = serde_json::from_str(r#"{"name":"s","command":"x"}"#).unwrap();
        assert!(cfg.pinned_resources.is_empty());

        // Present field round-trips.
        let cfg: McpServerConfig = serde_json::from_str(
            r#"{"name":"s","command":"x","pinned_resources":["file:///a","file:///b"]}"#,
        )
        .unwrap();
        assert_eq!(cfg.pinned_resources, vec!["file:///a", "file:///b"]);
    }

    #[::core::prelude::v1::test]
    fn tool_filter_group_legacy_filter_builtins_key_still_parses() {
        // `filter_builtins` was declared-but-never-read and is removed.
        // `ToolFilterGroup` has no `deny_unknown_fields`, so configs
        // still carrying the key must keep deserializing (silently ignored).
        let group: super::ToolFilterGroup = toml::from_str(
            r#"
            mode = "always"
            tools = ["filesystem__*"]
            filter_builtins = true
            "#,
        )
        .expect("legacy filter_builtins key must not break deserialization");
        assert!(matches!(group.mode, super::ToolFilterGroupMode::Always));
        assert_eq!(group.tools, vec!["filesystem__*".to_string()]);
    }

    #[::core::prelude::v1::test]
    fn memory_config_rerank_stage_defaults() {
        // An empty [memory] block resolves the rerank-stage keys to their
        // inert defaults (stage off, "none" strategy).
        let cfg: super::MemoryConfig = serde_json::from_str("{}").unwrap();
        assert!(!cfg.rerank_enabled);
        assert_eq!(cfg.candidate_multiplier, 4);
        assert_eq!(cfg.rerank_threshold, 5);
        assert_eq!(cfg.rerank_strategy, "none");
        assert!((cfg.mmr_lambda - 0.7).abs() < f64::EPSILON);
        assert!((cfg.importance_weight - 0.2).abs() < f64::EPSILON);
        assert!((cfg.recency_weight - 0.1).abs() < f64::EPSILON);

        // The Default impl agrees with the serde defaults.
        let def = super::MemoryConfig::default();
        assert_eq!(def.candidate_multiplier, cfg.candidate_multiplier);
        assert_eq!(def.rerank_strategy, cfg.rerank_strategy);
        assert!((def.mmr_lambda - cfg.mmr_lambda).abs() < f64::EPSILON);
        assert!((def.importance_weight - cfg.importance_weight).abs() < f64::EPSILON);
        assert!((def.recency_weight - cfg.recency_weight).abs() < f64::EPSILON);
    }

    #[::core::prelude::v1::test]
    fn config_validate_rejects_invalid_memory_rerank_values() {
        // A NaN in any of the blend/floor floats must be rejected outright: it
        // survives `clamp` and would silently drop valid memories downstream.
        let reject_nan = |field: &str| {
            let mut config = super::Config::default();
            match field {
                "memory.min_relevance_score" => config.memory.min_relevance_score = f64::NAN,
                "memory.mmr_lambda" => config.memory.mmr_lambda = f64::NAN,
                "memory.importance_weight" => config.memory.importance_weight = f64::NAN,
                "memory.recency_weight" => config.memory.recency_weight = f64::NAN,
                other => panic!("unhandled field {other}"),
            }
            let err = config
                .validate()
                .expect_err("non-finite value must fail validation");
            assert!(
                err.to_string().contains(field),
                "expected {field}, got {err}"
            );
        };
        reject_nan("memory.min_relevance_score");
        reject_nan("memory.mmr_lambda");
        reject_nan("memory.importance_weight");
        reject_nan("memory.recency_weight");

        for multiplier in [0, super::MAX_MEMORY_RERANK_CANDIDATE_MULTIPLIER + 1] {
            let mut config = super::Config::default();
            config.memory.candidate_multiplier = multiplier;
            let err = config
                .validate()
                .expect_err("out-of-range multiplier must fail validation");
            assert!(
                err.to_string().contains("memory.candidate_multiplier"),
                "unexpected error: {err}"
            );
        }

        let mut config = super::Config::default();
        config.memory.mmr_lambda = 1.1;
        let err = config
            .validate()
            .expect_err("out-of-range MMR lambda must fail validation");
        assert!(err.to_string().contains("memory.mmr_lambda"));
    }

    #[::core::prelude::v1::test]
    fn skill_bundle_admits_skill_honors_include_and_exclude() {
        let mut bundle = super::SkillBundleConfig::default();
        assert!(bundle.admits_skill("anything"));

        bundle.include = vec!["widget".into()];
        assert!(bundle.admits_skill("widget"));
        assert!(!bundle.admits_skill("gadget"));

        bundle.exclude = vec!["widget".into()];
        assert!(!bundle.admits_skill("widget"));
    }

    #[::core::prelude::v1::test]
    fn provider_cost_categories_match_rate_struct_fields() {
        let json = serde_json::to_value(super::ProviderCostRates::default()).unwrap();
        let mut fields: Vec<String> = json
            .as_object()
            .expect("ProviderCostRates serializes to a map")
            .keys()
            .cloned()
            .collect();
        fields.sort();
        let mut registry: Vec<String> = super::PROVIDER_COST_CATEGORIES
            .iter()
            .map(|s| s.to_string())
            .collect();
        registry.sort();
        assert_eq!(
            fields, registry,
            "PROVIDER_COST_CATEGORIES must list exactly the ProviderCostRates rate-sheet fields"
        );
    }

    #[::core::prelude::v1::test]
    fn cost_category_resolves_only_rate_bearing_sections() {
        assert_eq!(
            super::cost_category_for_provider_section("providers.models"),
            Some("models")
        );
        assert_eq!(
            super::cost_category_for_provider_section("providers.tts"),
            Some("tts")
        );
        assert_eq!(
            super::cost_category_for_provider_section("providers.transcription"),
            Some("transcription")
        );
        assert_eq!(super::cost_category_for_provider_section("channels"), None);
        assert_eq!(
            super::cost_category_for_provider_section("providers.unknown"),
            None
        );
        assert_eq!(super::cost_category_for_provider_section("models"), None);
    }

    #[test]
    async fn plugin_entry_config_resolves_own_section_and_isolates_others() {
        let mut plugins = super::PluginsConfig::default();
        plugins.entries.push(super::PluginEntryConfig {
            name: "image_gen_fal".into(),
            config: std::collections::HashMap::from([("api_key".into(), "secret-a".into())]),
        });
        plugins.entries.push(super::PluginEntryConfig {
            name: "sd_webui".into(),
            config: std::collections::HashMap::from([("base_url".into(), "http://host".into())]),
        });

        let fal = plugins.entry_config("image_gen_fal").unwrap();
        assert_eq!(fal.get("api_key").map(String::as_str), Some("secret-a"));
        assert!(fal.get("base_url").is_none());

        let sd = plugins.entry_config("sd_webui").unwrap();
        assert_eq!(sd.get("base_url").map(String::as_str), Some("http://host"));
        assert!(sd.get("api_key").is_none());

        assert!(plugins.entry_config("unknown").is_none());
    }

    #[test]
    async fn git_events_routing_table_parses_dotted_keys_and_defaults() {
        let cfg: GitConfig = ::toml::from_str(
            r#"
            enabled = true
            app_id = 12345
            events_backbone = true

            [events]
            "pull_request.opened" = { sop = "pr-triage" }
            "issues.opened" = { sop = "issue-triage" }
            "issue_comment.created" = { message = true }
            "workflow_run.failed" = { sop = "ci-failure" }
            "#,
        )
        .unwrap();
        // Provider defaults to github when the field is omitted.
        assert_eq!(cfg.provider, "github");
        assert!(cfg.events_backbone);
        assert_eq!(cfg.events.len(), 4);
        let pr = &cfg.events["pull_request.opened"];
        assert_eq!(pr.sop.as_deref(), Some("pr-triage"));
        assert!(!pr.message);
        assert!(cfg.events["issue_comment.created"].message);
        assert!(cfg.events["issue_comment.created"].sop.is_none());

        // An explicit provider round-trips.
        let gitlab: GitConfig = ::toml::from_str("enabled = true\nprovider = \"gitlab\"").unwrap();
        assert_eq!(gitlab.provider, "gitlab");

        // Absent table: empty map, backbone off — the conversational
        // defaults live in the channel's router, not here.
        let bare: GitConfig = ::toml::from_str("enabled = true").unwrap();
        assert!(bare.events.is_empty());
        assert!(!bare.events_backbone);
    }

    #[test]
    async fn git_gitea_provider_fields_parse() {
        let cfg: GitConfig = ::toml::from_str(
            r#"
            enabled = true
            provider = "forgejo"
            api_base_url = "https://git.example.org/api/v1"
            access_token = "token-value"
            repos = ["team/project"]
            "#,
        )
        .unwrap();
        assert_eq!(cfg.provider, "forgejo");
        assert_eq!(
            cfg.api_base_url.as_deref(),
            Some("https://git.example.org/api/v1")
        );
        assert_eq!(cfg.access_token, "token-value");
        assert_eq!(cfg.repos, vec!["team/project"]);
    }

    #[test]
    async fn git_gitea_access_token_is_secret() {
        assert!(GitConfig::prop_is_secret("channels.git.access_token"));

        let cfg = GitConfig {
            access_token: "token-value".to_string(),
            ..Default::default()
        };
        let fields = cfg.secret_fields();
        assert!(
            fields
                .iter()
                .any(|field| field.name == "channels.git.access_token" && field.is_set),
            "Gitea/Forgejo access_token must be secret-classified and set"
        );
    }

    #[test]
    async fn amqp_validate_requires_paired_client_cert_and_key() {
        let base = AmqpConfig {
            enabled: true,
            amqp_url: "amqps://broker.example.org:5671/%2Fpublic".into(),
            exchange: "amq.topic".into(),
            routing_keys: vec!["org.example.release".into()],
            ca_cert: Some(std::path::PathBuf::from("/etc/ssl/ca.pem")),
            ..AmqpConfig::default()
        };

        // Both absent: server-auth only, valid.
        assert!(base.validate().is_ok());

        // Cert without key: invalid.
        let cert_only = AmqpConfig {
            client_cert: Some(std::path::PathBuf::from("/etc/ssl/client.pem")),
            ..base.clone()
        };
        assert!(cert_only.validate().is_err());

        // Key without cert: invalid.
        let key_only = AmqpConfig {
            client_key: Some(std::path::PathBuf::from("/etc/ssl/client.key")),
            ..base.clone()
        };
        assert!(key_only.validate().is_err());

        // Both present: valid.
        let both = AmqpConfig {
            client_cert: Some(std::path::PathBuf::from("/etc/ssl/client.pem")),
            client_key: Some(std::path::PathBuf::from("/etc/ssl/client.key")),
            ..base
        };
        assert!(both.validate().is_ok());
    }

    #[test]
    async fn amqp_dispatch_defaults_to_agent_loop() {
        assert_eq!(AmqpConfig::default().dispatch, SopDispatch::AgentLoop);
    }

    #[test]
    async fn filesystem_validate_requires_path() {
        let cfg = FilesystemConfig {
            enabled: true,
            ..FilesystemConfig::default()
        };
        let err = cfg.validate().unwrap_err();
        assert!(err.to_string().contains("at least one path"));
    }

    #[test]
    async fn filesystem_validate_rejects_broad_root_by_default() {
        for root in ["/etc", "/proc", "/sys", "/dev", "/tmp"] {
            let cfg = FilesystemConfig {
                enabled: true,
                paths: vec![root.into()],
                ..FilesystemConfig::default()
            };
            let err = cfg.validate().unwrap_err();
            assert!(
                err.to_string().contains("broad system root"),
                "root {root} must be rejected by default"
            );
        }
    }

    #[test]
    async fn filesystem_validate_allows_broad_root_with_escape_hatch() {
        let cfg = FilesystemConfig {
            enabled: true,
            paths: vec!["/var".into()],
            allow_broad_roots: true,
            ..FilesystemConfig::default()
        };
        assert!(cfg.validate().is_ok());
    }

    #[test]
    async fn filesystem_validate_rejects_unknown_event() {
        let cfg = FilesystemConfig {
            enabled: true,
            paths: vec!["/srv/inbox".into()],
            events: vec!["created".into(), "exploded".into()],
            ..FilesystemConfig::default()
        };
        let err = cfg.validate().unwrap_err();
        assert!(err.to_string().contains("exploded"));
    }

    #[test]
    async fn filesystem_validate_accepts_scoped_path() {
        let cfg = FilesystemConfig {
            enabled: true,
            paths: vec!["/srv/inbox".into()],
            ..FilesystemConfig::default()
        };
        assert!(cfg.validate().is_ok());
    }

    #[test]
    async fn filesystem_defaults_are_safe() {
        let cfg = FilesystemConfig::default();
        assert!(!cfg.enabled);
        assert!(cfg.recursive);
        assert!(!cfg.read_content);
        assert!(!cfg.follow_symlinks);
        assert!(!cfg.allow_broad_roots);
        assert_eq!(cfg.debounce_ms, 500);
        assert_eq!(cfg.settle_ms, 250);
        assert_eq!(cfg.max_content_bytes, Some(65536));
        assert_eq!(cfg.events.len(), 4);
    }
    use super::*;
    use std::ffi::OsString;
    #[cfg(unix)]
    use std::os::unix::fs::PermissionsExt;
    #[cfg(unix)]
    use std::path::Path;
    use std::path::PathBuf;
    use tempfile::TempDir;
    use tokio::sync::MutexGuard;
    use tokio::test;

    struct EnvValueGuard {
        key: &'static str,
        previous: Option<OsString>,
    }

    impl EnvValueGuard {
        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
            let previous = std::env::var_os(key);
            // SAFETY: tests that mutate env vars serialize on env_override_lock().
            unsafe { std::env::set_var(key, value) };
            Self { key, previous }
        }

        fn remove(key: &'static str) -> Self {
            let previous = std::env::var_os(key);
            // SAFETY: tests that mutate env vars serialize on env_override_lock().
            unsafe { std::env::remove_var(key) };
            Self { key, previous }
        }
    }

    impl Drop for EnvValueGuard {
        fn drop(&mut self) {
            // SAFETY: tests that mutate env vars serialize on env_override_lock().
            unsafe {
                if let Some(previous) = &self.previous {
                    std::env::set_var(self.key, previous);
                } else {
                    std::env::remove_var(self.key);
                }
            }
        }
    }

    #[cfg(unix)]
    fn write_fake_op(bin_dir: &Path, script: &str) -> PathBuf {
        let op_path = bin_dir.join("op");
        std::fs::write(&op_path, script).expect("write fake op");
        let mut perms = std::fs::metadata(&op_path).unwrap().permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&op_path, perms).unwrap();
        op_path
    }

    #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
    #[prefix = "test.object_array.entries"]
    struct ObjectArraySecretEntry {
        pub name: String,
        #[secret]
        pub token: Option<String>,
        #[secret]
        pub headers: HashMap<String, String>,
    }

    impl crate::config::HasPropKind for Vec<ObjectArraySecretEntry> {
        const PROP_KIND: crate::config::PropKind = crate::config::PropKind::ObjectArray;

        fn display_secret_terminals() -> Vec<&'static str> {
            ObjectArraySecretEntry::secret_field_terminals()
        }
    }

    #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
    #[prefix = "test.object_array"]
    struct ObjectArraySecretFixture {
        pub entries: Vec<ObjectArraySecretEntry>,
    }

    // ── Tilde expansion ───────────────────────────────────────

    #[test]
    async fn expand_tilde_path_handles_absolute_path() {
        let path = expand_tilde_path("/absolute/path");
        assert_eq!(path, PathBuf::from("/absolute/path"));
    }

    #[test]
    async fn expand_tilde_path_handles_relative_path() {
        let path = expand_tilde_path("relative/path");
        assert_eq!(path, PathBuf::from("relative/path"));
    }

    #[test]
    async fn expand_tilde_path_expands_tilde_when_home_set() {
        // This test verifies that tilde expansion works when HOME is set.
        // In normal environments, HOME is set, so ~ should expand.
        let path = expand_tilde_path("~/.zeroclaw");
        // The path should not literally start with '~' if HOME is set
        // (it should be expanded to the actual home directory)
        if std::env::var("HOME").is_ok() {
            assert!(
                !path.to_string_lossy().starts_with('~'),
                "Tilde should be expanded when HOME is set"
            );
        }
    }

    // ── Plugins dir resolution ────────────────────────────────

    #[test]
    async fn resolved_plugins_dir_passes_absolute_path_through() {
        let cfg = PluginsConfig {
            plugins_dir: "/srv/plugins".to_string(),
            ..PluginsConfig::default()
        };
        assert_eq!(cfg.resolved_plugins_dir(), PathBuf::from("/srv/plugins"));
    }

    #[test]
    async fn resolved_plugins_dir_expands_leading_tilde() {
        let cfg = PluginsConfig {
            plugins_dir: "~/.zeroclaw/plugins".to_string(),
            ..PluginsConfig::default()
        };
        let resolved = cfg.resolved_plugins_dir();
        if std::env::var("HOME").is_ok() {
            assert!(!resolved.to_string_lossy().starts_with('~'));
            assert!(resolved.ends_with(".zeroclaw/plugins"));
        }
    }

    /// Build a `Config` whose data dir, install root, and configured plugins dir
    /// live under `root`, and create a plugin at `<parent>/<name>/manifest.toml`.
    fn config_with_dirs(root: &Path) -> Config {
        Config {
            data_dir: root.join("data"),
            config_path: root.join("install").join("config.toml"),
            plugins: PluginsConfig {
                plugins_dir: root.join("plugins").to_string_lossy().into_owned(),
                ..Default::default()
            },
            ..Default::default()
        }
    }

    fn write_plugin(parent: &Path, name: &str) {
        std::fs::create_dir_all(parent.join(name)).unwrap();
        std::fs::write(parent.join(name).join("manifest.toml"), "name = \"x\"\n").unwrap();
    }

    #[test]
    async fn legacy_plugin_dirs_detects_data_and_workspace_locations() {
        let tmp = TempDir::new().unwrap();
        let config = config_with_dirs(tmp.path());
        write_plugin(&config.data_dir.join("plugins"), "fromdata");
        write_plugin(
            &config.install_root_dir().join("workspace").join("plugins"),
            "fromworkspace",
        );

        let dirs = legacy_plugin_dirs_with_entries(&config);
        assert_eq!(dirs.len(), 2, "both legacy locations should be reported");
        assert!(dirs.contains(&config.data_dir.join("plugins")));
        assert!(dirs.contains(&config.install_root_dir().join("workspace").join("plugins")));
    }

    #[test]
    async fn legacy_plugin_dirs_empty_when_no_legacy_plugins() {
        let tmp = TempDir::new().unwrap();
        let config = config_with_dirs(tmp.path());
        // Plugin lives in the configured dir, not a legacy one.
        write_plugin(&config.plugins.resolved_plugins_dir(), "current");

        assert!(legacy_plugin_dirs_with_entries(&config).is_empty());
    }

    #[test]
    async fn legacy_plugin_dirs_skips_dir_equal_to_target() {
        let tmp = TempDir::new().unwrap();
        let mut config = config_with_dirs(tmp.path());
        // Point the configured plugins dir AT the legacy data dir.
        let data_plugins = config.data_dir.join("plugins");
        config.plugins.plugins_dir = data_plugins.to_string_lossy().into_owned();
        write_plugin(&data_plugins, "same");

        // The data-dir candidate now equals the target → not a "legacy" dir.
        assert!(legacy_plugin_dirs_with_entries(&config).is_empty());
    }

    // ── Defaults ─────────────────────────────────────────────

    fn has_test_table(raw: &str, table: &str) -> bool {
        let exact = format!("[{table}]");
        let nested = format!("[{table}.");
        raw.lines()
            .map(str::trim)
            .any(|line| line == exact || line.starts_with(&nested))
    }

    fn mcp_server(name: &str) -> McpServerConfig {
        McpServerConfig {
            name: name.to_string(),
            ..McpServerConfig::default()
        }
    }

    /// A config with three configured servers (`a`, `b`, `c`) plus the given
    /// `[mcp_bundles.<alias>]` entries. Uses `push`/`insert` (not field
    /// assignment) to avoid `clippy::field_reassign_with_default`.
    fn config_with_mcp_bundles(bundles: Vec<(&str, McpBundleConfig)>) -> Config {
        let mut config = Config::default();
        config.mcp.servers.push(mcp_server("a"));
        config.mcp.servers.push(mcp_server("b"));
        config.mcp.servers.push(mcp_server("c"));
        for (alias, bundle) in bundles {
            config.mcp_bundles.insert(alias.to_string(), bundle);
        }
        config
    }

    #[test]
    async fn sop_untrusted_payload_defaults_are_back_compat() {
        let config: SopConfig = toml::from_str("").expect("empty SOP config should deserialize");

        assert_eq!(config.untrusted_payload_max_bytes, 8192);
        assert_eq!(config.untrusted_input_guard, "warn");
        assert_eq!(config.untrusted_guard_sensitivity, 0.7);
        assert!(config.untrusted_frame_warning);
        assert!(config.untrusted_outbound_redact);
    }

    #[test]
    async fn sop_untrusted_payload_config_overrides_deserialize() {
        let config: SopConfig = toml::from_str(
            r#"
untrusted_payload_max_bytes = 4096
untrusted_input_guard = "block"
untrusted_guard_sensitivity = 0.9
untrusted_frame_warning = false
untrusted_outbound_redact = false
"#,
        )
        .expect("SOP untrusted payload overrides should deserialize");

        assert_eq!(config.untrusted_payload_max_bytes, 4096);
        assert_eq!(config.untrusted_input_guard, "block");
        assert_eq!(config.untrusted_guard_sensitivity, 0.9);
        assert!(!config.untrusted_frame_warning);
        assert!(!config.untrusted_outbound_redact);
    }

    #[test]
    async fn mcp_bundles_empty_grants_no_servers() {
        // Secure by default: omission is not a grant.
        let config = config_with_mcp_bundles(vec![]);
        assert!(config.mcp_servers_for_bundles(&[]).is_empty());
    }

    #[test]
    async fn mcp_bundles_union_resolves_and_dedups() {
        let config = config_with_mcp_bundles(vec![
            (
                "x",
                McpBundleConfig {
                    servers: vec!["a".into(), "b".into()],
                    exclude: vec![],
                },
            ),
            (
                "y",
                McpBundleConfig {
                    servers: vec!["b".into(), "c".into()],
                    exclude: vec![],
                },
            ),
        ]);
        let names: Vec<String> = config
            .mcp_servers_for_bundles(&["x".to_string(), "y".to_string()])
            .into_iter()
            .map(|s| s.name)
            .collect();
        assert_eq!(
            names,
            vec!["a", "b", "c"],
            "union across bundles, first-seen order, deduplicated"
        );
    }

    #[test]
    async fn mcp_bundles_exclude_wins_across_bundles() {
        // `b` is included by bundle `x` but excluded by bundle `y`; deny wins.
        let config = config_with_mcp_bundles(vec![
            (
                "x",
                McpBundleConfig {
                    servers: vec!["a".into(), "b".into()],
                    exclude: vec![],
                },
            ),
            (
                "y",
                McpBundleConfig {
                    servers: vec!["c".into()],
                    exclude: vec!["b".into()],
                },
            ),
        ]);
        let names: Vec<String> = config
            .mcp_servers_for_bundles(&["x".to_string(), "y".to_string()])
            .into_iter()
            .map(|s| s.name)
            .collect();
        assert_eq!(
            names,
            vec!["a", "c"],
            "an excluded server is denied even when another referenced bundle includes it"
        );
    }

    #[test]
    async fn mcp_bundles_unknown_bundle_and_dangling_name_grant_nothing() {
        // An unknown bundle alias and a server name with no `[mcp.servers]`
        // entry both fail closed (grant nothing).
        let config = config_with_mcp_bundles(vec![(
            "x",
            McpBundleConfig {
                servers: vec!["a".into(), "ghost".into()],
                exclude: vec![],
            },
        )]);
        let names: Vec<String> = config
            .mcp_servers_for_bundles(&["x".to_string(), "missing".to_string()])
            .into_iter()
            .map(|s| s.name)
            .collect();
        assert_eq!(
            names,
            vec!["a"],
            "a dangling server name and an unknown bundle alias grant nothing"
        );
    }

    #[test]
    async fn mcp_servers_for_agent_grants_only_via_agent_bundles() {
        let mut config = config_with_mcp_bundles(vec![(
            "aa",
            McpBundleConfig {
                servers: vec!["a".into()],
                exclude: vec![],
            },
        )]);
        config.agents.insert(
            "aaatools".to_string(),
            AliasedAgentConfig {
                mcp_bundles: vec!["aa".to_string()],
                ..AliasedAgentConfig::default()
            },
        );
        config
            .agents
            .insert("defzc".to_string(), AliasedAgentConfig::default());

        let granted: Vec<String> = config
            .mcp_servers_for_agent("aaatools")
            .into_iter()
            .map(|s| s.name)
            .collect();
        assert_eq!(granted, vec!["a"], "agent is granted its bundle's servers");
        assert!(
            config.mcp_servers_for_agent("defzc").is_empty(),
            "an agent with no mcp_bundles is granted no MCP servers (omission is not a grant)"
        );
        assert!(
            config.mcp_servers_for_agent("ghost").is_empty(),
            "an unknown agent is granted no MCP servers"
        );
    }

    /// Regression test for the operator-UX warning added alongside:
    /// when MCP is enabled and `[[mcp.servers]]` is non-empty but no
    /// `[mcp_bundles.*]` exists, validate() must still succeed (warnings
    /// are non-fatal) AND every agent must resolve to zero servers
    /// (proving the secure-by-default semantics that motivate the warning
    /// are still in force).
    #[test]
    async fn validate_warns_when_servers_configured_but_no_bundles() {
        use crate::schema::{McpServerConfig, McpTransport};
        let mut config = Config::default();
        config.mcp.enabled = true;
        config.mcp.servers = vec![McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            ..Default::default()
        }];
        assert!(
            config.mcp_bundles.is_empty(),
            "test precondition: no bundles configured"
        );

        // validate() must succeed (warnings are non-fatal).
        assert!(config.validate().is_ok());

        // Behavioral assertion that motivates the warning: every agent
        // resolves to zero servers under these conditions.
        for alias in config.agents.keys() {
            assert!(
                config.mcp_servers_for_agent(alias).is_empty(),
                "every agent must get zero servers when no bundles exist"
            );
        }
    }

    /// Counterpart to `validate_warns_when_servers_configured_but_no_bundles`:
    /// once at least one `[mcp_bundles.*]` exists, the warning's
    /// precondition no longer holds. validate() still succeeds and the
    /// granted agent resolves to its bundled server.
    #[test]
    async fn validate_does_not_warn_when_a_bundle_exists() {
        use crate::schema::{McpBundleConfig, McpServerConfig, McpTransport};
        let mut config = Config::default();
        config.mcp.enabled = true;
        config.mcp.servers = vec![McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            ..Default::default()
        }];
        config.mcp_bundles.insert(
            "default".into(),
            McpBundleConfig {
                servers: vec!["fs".into()],
                exclude: vec![],
            },
        );

        assert!(config.validate().is_ok());
        // Precondition check: the warning's trigger condition is now false.
        assert!(!config.mcp_bundles.is_empty());
    }

    fn parse_test_config(raw: &str) -> Config {
        let mut merged = raw.trim().to_string();
        for table in [
            "data_retention",
            "cloud_ops",
            "conversational_ai",
            "security",
            "security_ops",
        ] {
            if has_test_table(&merged, table) {
                continue;
            }
            if !merged.is_empty() {
                merged.push_str("\n\n");
            }
            merged.push('[');
            merged.push_str(table);
            merged.push(']');
        }
        merged.push('\n');
        // Schema-deserialization helper: parses TOML directly into Config
        // WITHOUT running migration transforms. Tests that need migration
        // behavior should use `migrate_to_current` directly. This helper
        // exists so V2-shaped inputs (e.g. flat `[autonomy]` blocks) can
        // be exercised against the typed deserializer without losing
        // sections that V2→V3 strips.
        let mut config: Config = toml::from_str(&merged).unwrap();
        config
            .risk_profiles
            .entry("default".to_string())
            .or_default()
            .ensure_default_auto_approve();
        config
    }

    #[test]
    async fn http_request_config_default_has_correct_values() {
        let cfg = HttpRequestConfig::default();
        assert_eq!(cfg.timeout_secs, 30);
        assert_eq!(cfg.max_response_size, 1_000_000);
        assert!(cfg.enabled);
        assert_eq!(cfg.allowed_domains, vec!["*".to_string()]);
        assert!(!cfg.allow_private_hosts);
        assert!(cfg.allowed_private_hosts.is_empty());
        assert!(cfg.secrets.is_empty());
    }

    #[test]
    async fn http_request_config_deserializes_allowed_private_hosts() {
        let c = parse_test_config(
            r#"
[http_request]
allowed_domains = ["example.com"]
allowed_private_hosts = ["localhost", "10.0.0.1"]
"#,
        );

        assert_eq!(
            c.http_request.allowed_private_hosts,
            vec!["localhost".to_string(), "10.0.0.1".to_string()]
        );
    }

    #[test]
    async fn http_request_config_deserializes_auth_secrets() {
        let c = parse_test_config(
            r#"
[http_request.secrets]
api_token = "Bearer test-token"
"#,
        );

        assert_eq!(
            c.http_request.secrets.get("api_token").map(String::as_str),
            Some("Bearer test-token")
        );
    }

    #[test]
    async fn http_request_auth_secret_names_are_validated() {
        let mut config = Config::default();
        config
            .http_request
            .secrets
            .insert("bad.name".to_string(), "Bearer test-token".to_string());

        let err = config.validate().expect_err("invalid secret name");
        assert!(
            err.to_string().contains("http_request.secrets.bad.name"),
            "validation error must name the bad auth secret path: {err}"
        );
    }

    #[test]
    async fn config_default_has_sane_values() {
        let c = Config::default();
        // No model_provider configured by default — set during Quickstart.
        assert!(c.providers.models.is_empty());
        assert!(c.providers.models.iter_entries().next().is_none());
        assert!(!c.skills.open_skills_enabled);
        assert!(!c.skills.allow_scripts);
        assert!(!c.skills.install_suggestions.enabled);
        assert_eq!(
            c.skills.prompt_injection_mode,
            SkillsPromptInjectionMode::Full
        );
        assert!(c.data_dir.to_string_lossy().contains("data"));
        assert!(c.config_path.to_string_lossy().contains("config.toml"));
    }

    #[test]
    async fn runtime_profile_prompt_injection_mode_overrides_global() {
        let mut config = Config::default();
        config.skills.prompt_injection_mode = SkillsPromptInjectionMode::Full;
        // A runtime profile that pins compact, and an agent pointing at it.
        config.runtime_profiles.insert(
            "compact_profile".to_string(),
            RuntimeProfileConfig {
                prompt_injection_mode: Some(SkillsPromptInjectionMode::Compact),
                ..RuntimeProfileConfig::default()
            },
        );
        // A runtime profile that leaves the mode unset (inherits global).
        config
            .runtime_profiles
            .insert("unset_profile".to_string(), RuntimeProfileConfig::default());
        config.agents.insert(
            "override".to_string(),
            AliasedAgentConfig {
                runtime_profile: "compact_profile".into(),
                ..AliasedAgentConfig::default()
            },
        );
        config.agents.insert(
            "unset".to_string(),
            AliasedAgentConfig {
                runtime_profile: "unset_profile".into(),
                ..AliasedAgentConfig::default()
            },
        );
        // An agent with no runtime profile inherits the global.
        config
            .agents
            .insert("inherit".to_string(), AliasedAgentConfig::default());

        // Profile override beats the global value.
        assert_eq!(
            config.effective_skills_prompt_mode("override"),
            SkillsPromptInjectionMode::Compact
        );
        // Profile present but mode unset → inherit the global value.
        assert_eq!(
            config.effective_skills_prompt_mode("unset"),
            SkillsPromptInjectionMode::Full
        );
        // No runtime profile → inherit the global value.
        assert_eq!(
            config.effective_skills_prompt_mode("inherit"),
            SkillsPromptInjectionMode::Full
        );
        // Unknown alias also falls back to the global value.
        assert_eq!(
            config.effective_skills_prompt_mode("missing"),
            SkillsPromptInjectionMode::Full
        );

        // Flipping the global moves only the inheriting/unset/unknown agents;
        // the profile override is unaffected.
        config.skills.prompt_injection_mode = SkillsPromptInjectionMode::Compact;
        assert_eq!(
            config.effective_skills_prompt_mode("unset"),
            SkillsPromptInjectionMode::Compact
        );
        assert_eq!(
            config.effective_skills_prompt_mode("inherit"),
            SkillsPromptInjectionMode::Compact
        );
        assert_eq!(
            config.effective_skills_prompt_mode("missing"),
            SkillsPromptInjectionMode::Compact
        );
        assert_eq!(
            config.effective_skills_prompt_mode("override"),
            SkillsPromptInjectionMode::Compact
        );
    }

    #[test]
    async fn runtime_profile_prompt_injection_mode_deserializes() {
        // Absent → None, so a profile that omits the key inherits the global
        // mode (no migration for existing global-only configs).
        let inherited: RuntimeProfileConfig = toml::from_str("").unwrap();
        assert_eq!(inherited.prompt_injection_mode, None);

        // Explicit wire spellings parse to their variants.
        let compact: RuntimeProfileConfig =
            toml::from_str("prompt_injection_mode = \"compact\"").unwrap();
        assert_eq!(
            compact.prompt_injection_mode,
            Some(SkillsPromptInjectionMode::Compact)
        );
        let full: RuntimeProfileConfig =
            toml::from_str("prompt_injection_mode = \"full\"").unwrap();
        assert_eq!(
            full.prompt_injection_mode,
            Some(SkillsPromptInjectionMode::Full)
        );
    }

    #[test]
    async fn resolved_agent_config_bakes_prompt_injection_mode_from_profile() {
        // The documented invariant: a runtime_profile knob must be threaded
        // through `resolved_agent_config` into `ResolvedRuntime`, consistent
        // with the `effective_*` helper.
        let raw = r#"
[skills]
prompt_injection_mode = "full"

[runtime_profiles.fast]
prompt_injection_mode = "compact"

[agents.default]
runtime_profile = "fast"

[agents.plain]
"#;
        let parsed = parse_test_config(raw);

        // Profiled agent: resolved value + effective helper both see compact.
        let resolved = parsed
            .resolved_agent_config("default")
            .expect("agent default resolves");
        assert_eq!(
            resolved.resolved.prompt_injection_mode,
            SkillsPromptInjectionMode::Compact
        );
        assert_eq!(
            parsed.effective_skills_prompt_mode("default"),
            SkillsPromptInjectionMode::Compact
        );

        // Profile-less agent: resolved value falls back to the global default.
        let plain = parsed
            .resolved_agent_config("plain")
            .expect("agent plain resolves");
        assert_eq!(
            plain.resolved.prompt_injection_mode,
            SkillsPromptInjectionMode::Full
        );
    }

    #[test]
    async fn skills_install_suggestions_config_deserializes_enabled() {
        let c = parse_test_config(
            r#"
[skills.install_suggestions]
enabled = true
"#,
        );

        assert!(c.skills.install_suggestions.enabled);
    }

    #[test]
    async fn skills_install_suggestions_config_accepts_hyphen_alias() {
        let c = parse_test_config(
            r#"
[skills.install-suggestions]
enabled = true
"#,
        );

        assert!(c.skills.install_suggestions.enabled);
    }

    fn capture_log_events() -> tokio::sync::broadcast::Receiver<serde_json::Value> {
        ::zeroclaw_log::try_install_capture_subscriber();
        ::zeroclaw_log::subscribe_or_install()
    }

    fn drain_captured(rx: &mut tokio::sync::broadcast::Receiver<serde_json::Value>) -> String {
        let mut buf = String::new();
        while let Ok(value) = rx.try_recv() {
            buf.push_str(&serde_json::to_string(&value).unwrap_or_default());
            buf.push('\n');
        }
        buf
    }

    #[test]
    async fn config_dir_creation_error_mentions_openrc_and_path() {
        let msg = config_dir_creation_error(Path::new("/etc/zeroclaw"));
        assert!(msg.contains("/etc/zeroclaw"));
        assert!(msg.contains("OpenRC"));
        assert!(msg.contains("zeroclaw"));
    }

    #[test]
    async fn config_schema_export_contains_expected_contract_shape() {
        #[cfg(feature = "schema-export")]
        let schema = schemars::schema_for!(Config);
        let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");

        assert_eq!(
            schema_json
                .get("$schema")
                .and_then(serde_json::Value::as_str),
            Some("https://json-schema.org/draft/2020-12/schema")
        );

        let properties = schema_json
            .get("properties")
            .and_then(serde_json::Value::as_object)
            .expect("schema should expose top-level properties");

        assert!(properties.contains_key("providers"));
        assert!(properties.contains_key("skills"));
        assert!(properties.contains_key("gateway"));
        assert!(properties.contains_key("channels"));
        assert!(!properties.contains_key("workspace_dir"));
        assert!(!properties.contains_key("config_path"));
        assert!(!properties.contains_key("model_providers"));
        assert!(!properties.contains_key("tts_providers"));
        assert!(!properties.contains_key("transcription_providers"));
        // These fields are now #[serde(skip)] cache fields, not in schema.
        assert!(!properties.contains_key("default_model_provider"));
        assert!(!properties.contains_key("api_key"));
        assert!(!properties.contains_key("default_model"));

        assert!(
            schema_json
                .get("$defs")
                .and_then(serde_json::Value::as_object)
                .is_some(),
            "schema should include reusable type definitions"
        );
    }

    #[cfg(unix)]
    #[test]
    async fn save_sets_config_permissions_on_new_file() {
        let temp = TempDir::new().expect("temp dir");
        let config_path = temp.path().join("config.toml");
        let workspace_dir = temp.path().join("workspace");

        let config = Config {
            config_path: config_path.clone(),
            data_dir: workspace_dir,
            ..Default::default()
        };

        config.save().await.expect("save config");

        let mode = std::fs::metadata(&config_path)
            .expect("config metadata")
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600);
    }

    #[test]
    async fn validate_rejects_reply_min_interval_above_upper_bound() {
        let mut config = Config::default();
        let mut tg = TelegramConfig {
            bot_token: "tok".into(),
            ..Default::default()
        };
        tg.reply_min_interval_secs = REPLY_MIN_INTERVAL_MAX_SECS + 1;
        config.channels.telegram.insert("default".to_string(), tg);
        let err = config.validate().expect_err("over-bound must be rejected");
        let msg = err.to_string();
        assert!(
            msg.contains("channels.telegram.default.reply_min_interval_secs"),
            "error must name the offending path; got: {msg}"
        );
    }

    #[test]
    async fn validate_rejects_zero_plugin_call_fuel() {
        let mut config = Config::default();
        config.plugins.limits.call_fuel = 0;
        let err = config
            .validate()
            .expect_err("zero call_fuel must be rejected");
        assert!(
            err.to_string().contains("plugins.limits.call_fuel"),
            "error must name the offending path; got: {err}"
        );
    }

    #[test]
    async fn validate_rejects_zero_plugin_max_memory() {
        let mut config = Config::default();
        config.plugins.limits.max_memory_mb = 0;
        let err = config
            .validate()
            .expect_err("zero max_memory_mb must be rejected");
        assert!(
            err.to_string().contains("plugins.limits.max_memory_mb"),
            "error must name the offending path; got: {err}"
        );
    }

    #[test]
    async fn validate_rejects_zero_plugin_max_table_elements() {
        let mut config = Config::default();
        config.plugins.limits.max_table_elements = 0;
        let err = config
            .validate()
            .expect_err("zero max_table_elements must be rejected");
        assert!(
            err.to_string()
                .contains("plugins.limits.max_table_elements"),
            "error must name the offending path; got: {err}"
        );
    }

    #[test]
    async fn validate_rejects_zero_plugin_max_instances() {
        let mut config = Config::default();
        config.plugins.limits.max_instances = 0;
        let err = config
            .validate()
            .expect_err("zero max_instances must be rejected");
        assert!(
            err.to_string().contains("plugins.limits.max_instances"),
            "error must name the offending path; got: {err}"
        );
    }

    fn ext_reg(name: &str, url: &str, kind: &str) -> ExternalRegistry {
        ExternalRegistry {
            name: name.to_string(),
            url: url.to_string(),
            kind: kind.to_string().into(),
            enabled: true,
        }
    }

    #[test]
    async fn validate_accepts_git_extra_registry() {
        let mut config = Config::default();
        config.skills.extra_registries =
            vec![ext_reg("team", "https://github.com/acme/skills", "git")];
        assert!(config.validate().is_ok(), "valid git registry must pass");
    }

    #[test]
    async fn validate_rejects_extra_registry_non_git_kind() {
        let mut config = Config::default();
        config.skills.extra_registries =
            vec![ext_reg("team", "https://github.com/acme/skills", "zip-api")];
        let err = config
            .validate()
            .expect_err("non-git kind must be rejected");
        assert!(err.to_string().contains("kind must be 'git'"), "got: {err}");
    }

    #[test]
    async fn validate_rejects_extra_registry_duplicate_names() {
        let mut config = Config::default();
        config.skills.extra_registries = vec![
            ext_reg("team", "https://github.com/acme/a", "git"),
            ext_reg("team", "https://github.com/acme/b", "git"),
        ];
        let err = config
            .validate()
            .expect_err("duplicate names must be rejected");
        assert!(
            err.to_string().contains("duplicate name 'team'"),
            "got: {err}"
        );
    }

    #[test]
    async fn validate_rejects_extra_registry_unaddressable_names() {
        for name in [
            "team.prod",
            "team prod",
            "team/prod",
            "..",
            " team",
            "team ",
            "Team",
            "teamProd",
        ] {
            let mut config = Config::default();
            config.skills.extra_registries =
                vec![ext_reg(name, "https://github.com/acme/skills", "git")];
            let err = config
                .validate()
                .expect_err("unaddressable extra-registry name must be rejected");
            assert!(
                err.to_string().contains("registry:<name>/<skill>"),
                "name {name:?} produced unexpected error: {err}"
            );
        }
    }

    #[test]
    async fn validate_rejects_extra_registry_empty_name_or_url() {
        let mut config = Config::default();
        config.skills.extra_registries = vec![ext_reg("", "https://github.com/acme/a", "git")];
        assert!(config.validate().is_err(), "empty name must be rejected");

        let mut config = Config::default();
        config.skills.extra_registries = vec![ext_reg("team", "   ", "git")];
        assert!(config.validate().is_err(), "empty url must be rejected");
    }

    #[test]
    async fn validate_rejects_extra_registry_bad_url_scheme() {
        let mut config = Config::default();
        config.skills.extra_registries = vec![ext_reg("team", "ftp://example.com/x", "git")];
        let err = config
            .validate()
            .expect_err("non-http(s)/file scheme must be rejected");
        assert!(err.to_string().contains("scheme"), "got: {err}");
    }

    #[test]
    async fn validate_accepts_reply_min_interval_at_upper_bound() {
        let mut config = Config::default();
        let mut tg = TelegramConfig {
            bot_token: "tok".into(),
            ..Default::default()
        };
        tg.reply_min_interval_secs = REPLY_MIN_INTERVAL_MAX_SECS;
        config.channels.telegram.insert("default".to_string(), tg);
        config.validate().expect("documented upper bound must pass");
    }

    #[test]
    async fn validate_rejects_reply_queue_depth_above_ceiling() {
        let mut config = Config::default();
        let mut tg = TelegramConfig {
            bot_token: "tok".into(),
            ..Default::default()
        };
        tg.reply_min_interval_secs = 1;
        tg.reply_queue_depth_max = REPLY_QUEUE_DEPTH_CEILING + 1;
        config.channels.telegram.insert("default".to_string(), tg);
        let err = config
            .validate()
            .expect_err("over-ceiling depth must be rejected");
        let msg = err.to_string();
        assert!(
            msg.contains("channels.telegram.default.reply_queue_depth_max"),
            "error must name the offending path; got: {msg}"
        );
    }

    #[test]
    async fn validate_accepts_reply_queue_depth_at_ceiling() {
        let mut config = Config::default();
        let mut tg = TelegramConfig {
            bot_token: "tok".into(),
            ..Default::default()
        };
        tg.reply_min_interval_secs = 1;
        tg.reply_queue_depth_max = REPLY_QUEUE_DEPTH_CEILING;
        config.channels.telegram.insert("default".to_string(), tg);
        config.validate().expect("documented ceiling must pass");
    }

    #[test]
    async fn validate_accepts_reply_queue_depth_zero_meaning_default() {
        // depth=0 means "fall back to DEFAULT_REPLY_QUEUE_DEPTH at the
        // pacing-wrapper construction site." Validator must accept it.
        let mut config = Config::default();
        let mut tg = TelegramConfig {
            bot_token: "tok".into(),
            ..Default::default()
        };
        tg.reply_min_interval_secs = 1;
        tg.reply_queue_depth_max = 0;
        config.channels.telegram.insert("default".to_string(), tg);
        config
            .validate()
            .expect("zero depth means default; must pass");
    }

    #[test]
    async fn telegram_api_base_url_default_uses_official_endpoint() {
        assert_eq!(
            TelegramConfig::default().api_base_url,
            "https://api.telegram.org"
        );
    }

    #[test]
    async fn telegram_api_base_url_missing_toml_defaults_to_official_endpoint() {
        let parsed: TelegramConfig = toml::from_str(r#"bot_token = "tok""#).unwrap();

        assert_eq!(parsed.api_base_url, "https://api.telegram.org");
    }

    #[test]
    async fn telegram_api_base_url_parses_custom_endpoint() {
        let parsed: TelegramConfig = toml::from_str(
            r#"
bot_token = "tok"
api_base_url = "http://127.0.0.1:8081"
"#,
        )
        .unwrap();

        assert_eq!(parsed.api_base_url, "http://127.0.0.1:8081");
    }

    #[test]
    async fn validate_rejects_empty_telegram_api_base_url() {
        let mut config = Config::default();
        config.channels.telegram.insert(
            "default".to_string(),
            TelegramConfig {
                bot_token: "tok".into(),
                api_base_url: "   ".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("empty Telegram API base URL must be rejected");
        let msg = err.to_string();
        assert!(msg.contains("channels.telegram.default.api_base_url"));
    }

    #[test]
    async fn validate_rejects_malformed_telegram_api_base_url() {
        let mut config = Config::default();
        config.channels.telegram.insert(
            "default".to_string(),
            TelegramConfig {
                bot_token: "tok".into(),
                api_base_url: "not a url".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("malformed Telegram API base URL must be rejected");
        let msg = err.to_string();
        assert!(msg.contains("channels.telegram.default.api_base_url"));
    }

    #[test]
    async fn validate_rejects_enabled_telegram_without_bot_token() {
        let mut config = Config::default();
        config.channels.telegram.insert(
            "telegram".to_string(),
            TelegramConfig {
                enabled: true,
                bot_token: "   ".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("enabled Telegram channel must require a bot token");
        assert!(
            err.to_string()
                .contains("channels.telegram.telegram.bot_token")
        );
    }

    #[test]
    async fn validate_allows_disabled_telegram_without_bot_token() {
        let mut config = Config::default();
        config.channels.telegram.insert(
            "telegram".to_string(),
            TelegramConfig {
                enabled: false,
                bot_token: "   ".into(),
                ..Default::default()
            },
        );

        config
            .validate()
            .expect("disabled Telegram channel may be staged without a bot token");
    }

    #[test]
    async fn validate_rejects_enabled_telegram_with_unset_display_token() {
        let mut config = Config::default();
        config.channels.telegram.insert(
            "telegram".to_string(),
            TelegramConfig {
                enabled: true,
                bot_token: crate::traits::UNSET_DISPLAY.into(),
                ..Default::default()
            },
        );

        config
            .validate()
            .expect_err("enabled Telegram channel must reject the display sentinel");
    }

    #[test]
    async fn validate_rejects_disabled_telegram_with_unset_display_token() {
        let mut config = Config::default();
        config.channels.telegram.insert(
            "telegram".to_string(),
            TelegramConfig {
                enabled: false,
                bot_token: crate::traits::UNSET_DISPLAY.into(),
                ..Default::default()
            },
        );

        config
            .validate()
            .expect_err("the unset display sentinel must never become persisted config");
    }

    #[test]
    async fn validate_rejects_enabled_discord_without_bot_token() {
        let mut config = Config::default();
        config.channels.discord.insert(
            "discord".to_string(),
            DiscordConfig {
                enabled: true,
                bot_token: "   ".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("enabled Discord channel must require a bot token");
        assert!(
            err.to_string()
                .contains("channels.discord.discord.bot_token")
        );
    }

    #[test]
    async fn validate_allows_disabled_discord_without_bot_token() {
        let mut config = Config::default();
        config.channels.discord.insert(
            "discord".to_string(),
            DiscordConfig {
                enabled: false,
                bot_token: "   ".into(),
                ..Default::default()
            },
        );

        config
            .validate()
            .expect("disabled Discord channel may be staged without a bot token");
    }

    #[test]
    async fn validate_rejects_enabled_discord_with_unset_display_token() {
        let mut config = Config::default();
        config.channels.discord.insert(
            "discord".to_string(),
            DiscordConfig {
                enabled: true,
                bot_token: crate::traits::UNSET_DISPLAY.into(),
                ..Default::default()
            },
        );

        config
            .validate()
            .expect_err("enabled Discord channel must reject the display sentinel");
    }

    #[test]
    async fn validate_rejects_disabled_discord_with_unset_display_token() {
        let mut config = Config::default();
        config.channels.discord.insert(
            "discord".to_string(),
            DiscordConfig {
                enabled: false,
                bot_token: crate::traits::UNSET_DISPLAY.into(),
                ..Default::default()
            },
        );

        config
            .validate()
            .expect_err("the unset display sentinel must never become persisted config");
    }

    // Regression: an enabled Signal or Voice Call channel with empty
    // required credentials was built anyway, then its listener failed to
    // connect and the per-channel supervisor restarted it forever
    // (crashloop). Reject at config-load time instead, mirroring how
    // Telegram/Discord require `bot_token`.
    #[test]
    async fn validate_rejects_enabled_signal_without_http_url() {
        let mut config = Config::default();
        config.channels.signal.insert(
            "signal".to_string(),
            SignalConfig {
                enabled: true,
                http_url: "   ".into(),
                account: "+15551234567".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("enabled Signal channel must require an http_url");
        assert!(err.to_string().contains("channels.signal.signal.http_url"));
    }

    #[test]
    async fn validate_rejects_enabled_signal_without_account() {
        let mut config = Config::default();
        config.channels.signal.insert(
            "signal".to_string(),
            SignalConfig {
                enabled: true,
                http_url: "http://127.0.0.1:8686".into(),
                account: "   ".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("enabled Signal channel must require an account");
        assert!(err.to_string().contains("channels.signal.signal.account"));
    }

    #[test]
    async fn validate_allows_disabled_signal_without_creds() {
        let mut config = Config::default();
        config.channels.signal.insert(
            "signal".to_string(),
            SignalConfig {
                enabled: false,
                http_url: "   ".into(),
                account: "   ".into(),
                ..Default::default()
            },
        );

        config
            .validate()
            .expect("disabled Signal channel may be staged without credentials");
    }

    #[test]
    async fn validate_rejects_disabled_signal_with_unset_sentinel() {
        let mut config = Config::default();
        config.channels.signal.insert(
            "signal".to_string(),
            SignalConfig {
                enabled: false,
                http_url: crate::traits::UNSET_DISPLAY.to_string(),
                account: "+15551234567".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("the unset display sentinel must never become persisted config, even for a disabled channel");
        assert!(err.to_string().contains("channels.signal.signal.http_url"));
    }

    #[test]
    async fn signal_has_required_credentials_true_when_both_set() {
        let sig = SignalConfig {
            http_url: "http://127.0.0.1:8686".into(),
            account: "+15551234567".into(),
            ..Default::default()
        };
        assert!(sig.has_required_credentials());
    }

    #[test]
    async fn signal_has_required_credentials_false_when_either_blank() {
        let missing_http_url = SignalConfig {
            http_url: "   ".into(),
            account: "+15551234567".into(),
            ..Default::default()
        };
        assert!(!missing_http_url.has_required_credentials());

        let missing_account = SignalConfig {
            http_url: "http://127.0.0.1:8686".into(),
            account: "   ".into(),
            ..Default::default()
        };
        assert!(!missing_account.has_required_credentials());

        assert!(!SignalConfig::default().has_required_credentials());
    }

    #[test]
    async fn validate_rejects_enabled_voice_call_without_account_id() {
        let mut config = Config::default();
        config.channels.voice_call.insert(
            "voice".to_string(),
            crate::scattered_types::VoiceCallConfig {
                enabled: true,
                account_id: "   ".into(),
                auth_token: "tok".into(),
                from_number: "+15551234567".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("enabled Voice Call channel must require an account_id");
        assert!(
            err.to_string()
                .contains("channels.voice_call.voice.account_id")
        );
    }

    #[test]
    async fn validate_rejects_enabled_voice_call_without_auth_token() {
        let mut config = Config::default();
        config.channels.voice_call.insert(
            "voice".to_string(),
            crate::scattered_types::VoiceCallConfig {
                enabled: true,
                account_id: "AC123".into(),
                auth_token: "   ".into(),
                from_number: "+15551234567".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("enabled Voice Call channel must require an auth_token");
        assert!(
            err.to_string()
                .contains("channels.voice_call.voice.auth_token")
        );
    }

    #[test]
    async fn validate_rejects_enabled_voice_call_without_from_number() {
        let mut config = Config::default();
        config.channels.voice_call.insert(
            "voice".to_string(),
            crate::scattered_types::VoiceCallConfig {
                enabled: true,
                account_id: "AC123".into(),
                auth_token: "tok".into(),
                from_number: "   ".into(),
                ..Default::default()
            },
        );

        let err = config
            .validate()
            .expect_err("enabled Voice Call channel must require a from_number");
        assert!(
            err.to_string()
                .contains("channels.voice_call.voice.from_number")
        );
    }

    #[test]
    async fn validate_allows_disabled_voice_call_without_creds() {
        let mut config = Config::default();
        config.channels.voice_call.insert(
            "voice".to_string(),
            crate::scattered_types::VoiceCallConfig {
                enabled: false,
                account_id: "   ".into(),
                auth_token: "   ".into(),
                from_number: "   ".into(),
                ..Default::default()
            },
        );

        config
            .validate()
            .expect("disabled Voice Call channel may be staged without credentials");
    }

    // Regression (fail closed, both PAT-backed forge providers): a Gitea or
    // Forgejo alias with an access token but no api_base_url must be rejected
    // at config-validation time. The old behavior silently defaulted to
    // https://gitea.com/api/v1 and sent the configured token there.
    #[test]
    async fn validate_rejects_gitea_forgejo_without_api_base_url() {
        for provider in ["gitea", "forgejo"] {
            let mut config = Config::default();
            config.channels.git.insert(
                "default".to_string(),
                GitConfig {
                    enabled: true,
                    provider: provider.to_string(),
                    access_token: "tok".into(),
                    ..Default::default()
                },
            );

            let err = config
                .validate()
                .expect_err("a PAT-backed forge provider without api_base_url must be rejected");
            let msg = err.to_string();
            assert!(msg.contains("channels.git.default.api_base_url"), "{msg}");
            assert!(msg.contains(provider), "{msg}");
        }
    }

    #[test]
    async fn validate_rejects_blank_or_malformed_gitea_api_base_url() {
        for bad in ["   ", "not a url", "ftp://git.example.org/api/v1"] {
            let mut config = Config::default();
            config.channels.git.insert(
                "default".to_string(),
                GitConfig {
                    enabled: true,
                    provider: "gitea".to_string(),
                    access_token: "tok".into(),
                    api_base_url: Some(bad.to_string()),
                    ..Default::default()
                },
            );

            let err = config
                .validate()
                .expect_err("blank or malformed Gitea API base URL must be rejected");
            let msg = err.to_string();
            assert!(msg.contains("channels.git.default.api_base_url"), "{msg}");
        }
    }

    // The requirement is scoped to enabled PAT-backed providers: the GitHub
    // provider has no api_base_url, and a disabled half-filled Gitea block
    // must not fail the whole config.
    #[test]
    async fn validate_git_api_base_url_scope() {
        let mut config = Config::default();
        config.channels.git.insert(
            "default".to_string(),
            GitConfig {
                enabled: true,
                ..Default::default()
            },
        );
        config.channels.git.insert(
            "disabled_gitea".to_string(),
            GitConfig {
                enabled: false,
                provider: "gitea".to_string(),
                access_token: "tok".into(),
                ..Default::default()
            },
        );
        config
            .validate()
            .expect("github provider and disabled gitea alias must validate");

        let mut config = Config::default();
        config.channels.git.insert(
            "default".to_string(),
            GitConfig {
                enabled: true,
                provider: "forgejo".to_string(),
                access_token: "tok".into(),
                api_base_url: Some("https://git.example.org/api/v1".to_string()),
                ..Default::default()
            },
        );
        config
            .validate()
            .expect("forgejo with an explicit api_base_url must validate");
    }

    #[test]
    async fn observability_enums_deserialize_legacy_string_values() {
        // Backward compat: TOML configs written before the enum conversion
        // stored these as bare strings. They must still parse.
        let toml = r#"
backend = "otel"
log_persistence = "full"
log_tool_io = "off"
"#;
        let o: ObservabilityConfig = toml::from_str(toml).unwrap();
        assert_eq!(o.backend, ObservabilityBackend::Otel);
        assert_eq!(o.log_persistence, LogPersistence::Full);
        assert_eq!(o.log_tool_io, LogToolIo::Off);

        // Legacy alias key `runtime_trace_mode` still maps to log_persistence.
        let aliased: ObservabilityConfig = toml::from_str("runtime_trace_mode = \"none\"").unwrap();
        assert_eq!(aliased.log_persistence, LogPersistence::None);

        // Round-trip: serialize back to the same wire strings the runtime
        // boundary (`to_log_config`) and downstream `from_raw` expect.
        assert_eq!(ObservabilityBackend::Otel.as_wire(), "otel");
        assert_eq!(LogPersistence::Full.as_wire(), "full");
        assert_eq!(LogPersistence::Rotating.as_wire(), "rotating");
        assert_eq!(LogToolIo::Off.as_wire(), "off");

        // The `rotating` mode and its knobs parse from a TOML config.
        let rot: ObservabilityConfig = toml::from_str(
            "log_persistence = \"rotating\"\nlog_persistence_max_bytes = 1048576\nlog_persistence_rotate_daily = false\nlog_persistence_retention_max_files = 3\nlog_persistence_retention_max_age_days = 14\n",
        )
        .unwrap();
        assert_eq!(rot.log_persistence, LogPersistence::Rotating);
        assert_eq!(rot.log_persistence_max_bytes, 1_048_576);
        assert!(!rot.log_persistence_rotate_daily);
        assert_eq!(rot.log_persistence_retention_max_files, 3);
        assert_eq!(rot.log_persistence_retention_max_age_days, 14);

        // Rotation knobs are serde-defaulted, so existing configs that omit them
        // still parse and pick up the documented defaults.
        let defaults: ObservabilityConfig = toml::from_str("backend = \"none\"").unwrap();
        assert_eq!(defaults.log_persistence_max_bytes, 0);
        assert!(defaults.log_persistence_rotate_daily);
        assert_eq!(defaults.log_persistence_retention_max_files, 7);
        assert_eq!(defaults.log_persistence_retention_max_age_days, 0);

        // log_llm_request_payload parses leniently and round-trips its wire form.
        let payload: ObservabilityConfig =
            toml::from_str("log_llm_request_payload = \"full\"").unwrap();
        assert_eq!(payload.log_llm_request_payload, LogLlmRequestPayload::Full);
        assert_eq!(LogLlmRequestPayload::Off.as_wire(), "off");
    }

    #[test]
    async fn observability_backend_unknown_falls_back_to_default() {
        let parsed: ObservabilityConfig = toml::from_str("backend = \"bogus\"").unwrap();
        assert_eq!(parsed.backend, ObservabilityBackend::None);
        let noop: ObservabilityConfig = toml::from_str("backend = \"noop\"").unwrap();
        assert_eq!(noop.backend, ObservabilityBackend::None);
        let otlp: ObservabilityConfig = toml::from_str("backend = \"otlp\"").unwrap();
        assert_eq!(otlp.backend, ObservabilityBackend::Otel);
        let otel_alias: ObservabilityConfig =
            toml::from_str("backend = \"opentelemetry\"").unwrap();
        assert_eq!(otel_alias.backend, ObservabilityBackend::Otel);
    }

    #[test]
    async fn runtime_kind_unknown_falls_back_to_default() {
        let docker: RuntimeConfig = toml::from_str("kind = \"docker\"").unwrap();
        assert_eq!(docker.kind, RuntimeKind::Docker);
        let cf: RuntimeConfig = toml::from_str("kind = \"cloudflare\"").unwrap();
        assert_eq!(cf.kind, RuntimeKind::Cloudflare);
        let bogus: RuntimeConfig = toml::from_str("kind = \"bogus\"").unwrap();
        assert_eq!(bogus.kind, RuntimeKind::Native);
        let empty: RuntimeConfig = toml::from_str("kind = \"\"").unwrap();
        assert_eq!(empty.kind, RuntimeKind::Native);
        assert_eq!(RuntimeKind::default(), RuntimeKind::Native);
    }

    #[test]
    async fn observability_config_default() {
        let o = ObservabilityConfig::default();
        assert_eq!(o.backend, ObservabilityBackend::None);
        assert_eq!(o.log_persistence, LogPersistence::Rolling);
        assert_eq!(o.log_persistence_path, "state/runtime-trace.jsonl");
        assert_eq!(o.log_persistence_max_entries, 200);
        assert_eq!(o.log_tool_io, LogToolIo::Redacted);
        assert_eq!(o.log_tool_io_truncate_bytes, 40960);
        assert!(o.log_tool_io_denylist.is_empty());
        assert_eq!(o.log_llm_request_payload, LogLlmRequestPayload::Off);
    }

    #[test]
    async fn risk_profile_default_mirrors_v2_autonomy_safety_defaults() {
        let a = RiskProfileConfig::default();
        assert_eq!(a.level, AutonomyLevel::Supervised);
        assert!(a.workspace_only);
        assert!(a.allowed_commands.contains(&"git".to_string()));
        assert!(a.allowed_commands.contains(&"cargo".to_string()));
        assert!(
            !a.forbidden_paths.is_empty(),
            "default forbidden_paths must not be empty"
        );
        #[cfg(not(target_os = "windows"))]
        assert!(
            a.forbidden_paths.iter().any(|p| p == "/etc"),
            "Default forbidden_paths must include /etc on Unix"
        );
        #[cfg(target_os = "windows")]
        assert!(
            a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
            "Default forbidden_paths must include C:\\Windows on Windows"
        );
        assert!(
            a.forbidden_paths.contains(&"~/.ssh".to_string()),
            "Default forbidden_paths must include ~/.ssh"
        );
        assert!(a.require_approval_for_medium_risk);
        assert!(a.block_high_risk_commands);
        assert!(a.shell_env_passthrough.is_empty());
        assert!(a.allowed_tools.is_empty());
    }

    #[test]
    async fn runtime_config_default() {
        let r = RuntimeConfig::default();
        assert_eq!(r.kind, RuntimeKind::Native);
        assert_eq!(r.docker.image, "alpine:3.20");
        assert_eq!(r.docker.network, "none");
        assert_eq!(r.docker.memory_limit_mb, Some(512));
        assert_eq!(r.docker.cpu_limit, Some(1.0));
        assert!(r.docker.read_only_rootfs);
        assert!(r.docker.mount_workspace);
    }

    #[test]
    async fn heartbeat_config_default() {
        let h = HeartbeatConfig::default();
        // Heartbeat defaults to disabled. Enabling requires the user to
        // also bind it to a configured agent — there is no default agent
        // for heartbeat to fall through to.
        assert!(!h.enabled);
        assert!(h.agent.is_empty());
        assert_eq!(h.interval_minutes, 30);
        assert!(h.message.is_none());
        assert!(h.target.is_none());
        assert!(h.to.is_none());
    }

    #[test]
    async fn heartbeat_config_parses_delivery_aliases() {
        let raw = r#"
enabled = true
interval_minutes = 10
message = "Ping"
channel = "telegram"
recipient = "42"
"#;
        let parsed: HeartbeatConfig = toml::from_str(raw).unwrap();
        assert!(parsed.enabled);
        assert_eq!(parsed.interval_minutes, 10);
        assert_eq!(parsed.message.as_deref(), Some("Ping"));
        assert_eq!(parsed.target.as_deref(), Some("telegram"));
        assert_eq!(parsed.to.as_deref(), Some("42"));
    }

    #[test]
    async fn scheduler_config_default() {
        let s = SchedulerConfig::default();
        assert!(s.enabled);
        assert!(s.catch_up_on_startup);
        assert_eq!(s.max_run_history, 50);
    }

    #[test]
    async fn scheduler_config_serde_roundtrip() {
        let s = SchedulerConfig {
            enabled: false,
            max_tasks: 16,
            max_concurrent: 2,
            catch_up_on_startup: false,
            max_run_history: 100,
        };
        let json = serde_json::to_string(&s).unwrap();
        let parsed: SchedulerConfig = serde_json::from_str(&json).unwrap();
        assert!(!parsed.enabled);
        assert!(!parsed.catch_up_on_startup);
        assert_eq!(parsed.max_run_history, 100);
    }

    #[test]
    async fn config_defaults_scheduler_when_section_missing() {
        let toml_str = r#"
workspace_dir = "/tmp/workspace"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;

        let parsed = parse_test_config(toml_str);
        assert!(parsed.scheduler.enabled);
        assert!(parsed.scheduler.catch_up_on_startup);
        assert_eq!(parsed.scheduler.max_run_history, 50);
        assert!(parsed.cron.is_empty());
    }

    #[test]
    async fn memory_config_default_hygiene_settings() {
        let m = MemoryConfig::default();
        assert_eq!(m.backend, "sqlite");
        assert!(m.auto_save);
        assert!(m.hygiene_enabled);
        assert_eq!(m.archive_after_days, 7);
        assert_eq!(m.purge_after_days, 30);
        assert_eq!(m.conversation_retention_days, 30);
        assert_eq!(m.search_mode, SearchMode::Hybrid);
    }

    #[test]
    async fn memory_types_and_extract_facts_default_off() {
        let m = MemoryConfig::default();
        assert!(!m.consolidation_extract_facts);
        assert!(!m.types.enabled);
    }

    #[test]
    async fn memory_config_without_types_keys_deserializes_off() {
        // Back-compat: configs written before [memory.types] and
        // consolidation_extract_facts existed must still parse, with both off.
        let toml_str = r#"
workspace_dir = "/tmp/workspace"
config_path = "/tmp/config.toml"
default_temperature = 0.7

[memory]
backend = "sqlite"
auto_save = true
"#;
        let parsed = parse_test_config(toml_str);
        assert!(!parsed.memory.consolidation_extract_facts);
        assert!(!parsed.memory.types.enabled);
    }

    #[test]
    async fn memory_types_keys_parse_when_set() {
        let toml_str = r#"
workspace_dir = "/tmp/workspace"
config_path = "/tmp/config.toml"
default_temperature = 0.7

[memory]
backend = "sqlite"
consolidation_extract_facts = true

[memory.types]
enabled = true
"#;
        let parsed = parse_test_config(toml_str);
        assert!(parsed.memory.consolidation_extract_facts);
        assert!(parsed.memory.types.enabled);
    }

    #[test]
    async fn search_mode_config_deserialization() {
        let toml_str = r#"
workspace_dir = "/tmp/workspace"
config_path = "/tmp/config.toml"
default_temperature = 0.7

[memory]
backend = "sqlite"
auto_save = true
search_mode = "bm25"
"#;
        let parsed = parse_test_config(toml_str);
        assert_eq!(parsed.memory.search_mode, SearchMode::Bm25);

        let toml_str_embedding = r#"
workspace_dir = "/tmp/workspace"
config_path = "/tmp/config.toml"
default_temperature = 0.7

[memory]
backend = "sqlite"
auto_save = true
search_mode = "embedding"
"#;
        let parsed = parse_test_config(toml_str_embedding);
        assert_eq!(parsed.memory.search_mode, SearchMode::Embedding);

        let toml_str_hybrid = r#"
workspace_dir = "/tmp/workspace"
config_path = "/tmp/config.toml"
default_temperature = 0.7

[memory]
backend = "sqlite"
auto_save = true
search_mode = "hybrid"
"#;
        let parsed = parse_test_config(toml_str_hybrid);
        assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
    }

    #[test]
    async fn search_mode_defaults_to_hybrid_when_omitted() {
        let toml_str = r#"
workspace_dir = "/tmp/workspace"
config_path = "/tmp/config.toml"
default_temperature = 0.7

[memory]
backend = "sqlite"
auto_save = true
"#;
        let parsed = parse_test_config(toml_str);
        assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
    }

    #[test]
    async fn search_mode_serde_roundtrip() {
        let json_bm25 = serde_json::to_string(&SearchMode::Bm25).unwrap();
        assert_eq!(json_bm25, "\"bm25\"");
        let parsed: SearchMode = serde_json::from_str(&json_bm25).unwrap();
        assert_eq!(parsed, SearchMode::Bm25);

        let json_embedding = serde_json::to_string(&SearchMode::Embedding).unwrap();
        assert_eq!(json_embedding, "\"embedding\"");
        let parsed: SearchMode = serde_json::from_str(&json_embedding).unwrap();
        assert_eq!(parsed, SearchMode::Embedding);

        let json_hybrid = serde_json::to_string(&SearchMode::Hybrid).unwrap();
        assert_eq!(json_hybrid, "\"hybrid\"");
        let parsed: SearchMode = serde_json::from_str(&json_hybrid).unwrap();
        assert_eq!(parsed, SearchMode::Hybrid);
    }

    #[test]
    async fn storage_two_tier_defaults_empty() {
        let storage = StorageConfig::default();
        assert!(storage.sqlite.is_empty());
        assert!(storage.postgres.is_empty());
        assert!(storage.qdrant.is_empty());
        assert!(storage.markdown.is_empty());
        assert!(storage.lucid.is_empty());
    }

    #[test]
    async fn storage_postgres_alias_pgvector_roundtrip() {
        let toml = r#"
            [postgres.default]
            db_url = "postgres://user:pw@host/db"
            vector_enabled = true
            vector_dimensions = 768
        "#;
        let parsed: StorageConfig = toml::from_str(toml).unwrap();
        let pg = parsed.postgres.get("default").expect("alias present");
        assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
        assert!(pg.vector_enabled);
        assert_eq!(pg.vector_dimensions, 768);
    }

    #[test]
    async fn storage_postgres_pgvector_defaults_when_omitted() {
        let toml = r#"
            [postgres.default]
        "#;
        let parsed: StorageConfig = toml::from_str(toml).unwrap();
        let pg = parsed.postgres.get("default").expect("alias present");
        assert!(!pg.vector_enabled);
        assert_eq!(pg.vector_dimensions, 1536);
        assert_eq!(pg.schema, "public");
        assert_eq!(pg.table, "memories");
    }

    #[test]
    async fn ollama_alias_tuning_fields_roundtrip() {
        // Ollama-specific tuning lives on `OllamaModelProviderConfig`,
        // not on the generic `ModelProviderConfig` base. These knobs
        // ride alongside the flattened `base` so a TOML alias like
        // `[providers.models.ollama.local]` accepts them at the same
        // level as `model`, `api_key`, etc.
        let toml = r#"
            num_ctx = 16384
            num_predict = 4096
            temperature_override = 0.5
        "#;
        let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
        assert_eq!(parsed.num_ctx, Some(16384));
        assert_eq!(parsed.num_predict, Some(4096));
        assert_eq!(parsed.temperature_override, Some(0.5));

        let serialized = toml::to_string(&parsed).unwrap();
        let reparsed: OllamaModelProviderConfig = toml::from_str(&serialized).unwrap();
        assert_eq!(reparsed.num_ctx, Some(16384));
        assert_eq!(reparsed.num_predict, Some(4096));
        assert_eq!(reparsed.temperature_override, Some(0.5));
    }

    #[test]
    async fn ollama_alias_tuning_fields_default_to_none() {
        let toml = r#"
            api_key = "sk-test"
        "#;
        let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
        assert!(parsed.num_ctx.is_none());
        assert!(parsed.num_predict.is_none());
        assert!(parsed.temperature_override.is_none());
    }

    #[test]
    async fn channels_default() {
        let c = ChannelsConfig::default();
        assert!(c.cli);
        assert!(c.telegram.is_empty());
        assert!(c.discord.is_empty());
        assert!(c.wecom_ws.is_empty());
        assert!(!c.show_tool_calls);
        assert_eq!(
            c.max_concurrent_per_channel,
            default_channel_max_concurrent_per_channel()
        );
    }

    #[test]
    async fn channels_max_concurrent_per_channel_defaults_and_round_trips() {
        let parsed: ChannelsConfig = toml::from_str("cli = true").unwrap();
        assert_eq!(
            parsed.max_concurrent_per_channel,
            default_channel_max_concurrent_per_channel()
        );

        let parsed: ChannelsConfig =
            toml::from_str("cli = true\nmax_concurrent_per_channel = 2").unwrap();
        assert_eq!(parsed.max_concurrent_per_channel, 2);

        let toml_str = toml::to_string_pretty(&parsed).unwrap();
        let reparsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(reparsed.max_concurrent_per_channel, 2);
    }

    #[test]
    async fn validate_rejects_zero_channel_max_concurrent_per_channel() {
        let mut config = Config::default();
        config.channels.max_concurrent_per_channel = 0;

        let err = config
            .validate()
            .expect_err("zero channel concurrency budget must fail validate");
        assert!(
            err.to_string()
                .contains("channels.max_concurrent_per_channel must be greater than 0"),
            "got: {err}"
        );
    }

    #[test]
    async fn wecom_ws_config_serde_defaults_and_secret_metadata() {
        let toml = r#"
            enabled = true
            bot_id = "bot-123"
            secret = "sk-test"
            allowed_users = ["zeroclaw_user"]
            allowed_groups = ["zeroclaw_group"]
            bot_name = "danya"
            proxy_url = "http://127.0.0.1:7890"
        "#;
        let parsed: WeComWsConfig = toml::from_str(toml).unwrap();

        assert!(parsed.enabled);
        assert_eq!(parsed.bot_id, "bot-123");
        assert_eq!(parsed.secret, "sk-test");
        assert_eq!(parsed.allowed_users, vec!["zeroclaw_user"]);
        assert_eq!(parsed.allowed_groups, vec!["zeroclaw_group"]);
        assert_eq!(parsed.bot_name.as_deref(), Some("danya"));
        assert_eq!(parsed.file_retention_days, 7);
        assert_eq!(parsed.max_file_size_mb, 20);
        assert_eq!(parsed.stream_mode, StreamMode::Partial);
        assert_eq!(parsed.proxy_url.as_deref(), Some("http://127.0.0.1:7890"));
        assert!(parsed.excluded_tools.is_empty());
        assert_eq!(WeComWsConfig::default().file_retention_days, 7);
        assert_eq!(WeComWsConfig::default().max_file_size_mb, 20);
        assert_eq!(WeComWsConfig::default().stream_mode, StreamMode::Partial);
        assert!(WeComWsConfig::default().bot_name.is_none());
        assert!(WeComWsConfig::default().proxy_url.is_none());
        assert!(WeComWsConfig::prop_is_secret("channels.wecom_ws.secret"));
    }

    #[test]
    async fn config_parses_wecom_ws_separate_from_wecom_webhook() {
        let toml = r#"
            [channels.wecom.default]
            enabled = true
            webhook_key = "webhook-key"

            [channels.wecom_ws.default]
            enabled = true
            bot_id = "bot-123"
            secret = "sk-test"
            allowed_users = ["zeroclaw_user"]
        "#;
        let parsed: Config = toml::from_str(toml).unwrap();

        assert_eq!(
            parsed.channels.wecom.get("default").unwrap().webhook_key,
            "webhook-key"
        );
        let ws = parsed.channels.wecom_ws.get("default").unwrap();
        assert_eq!(ws.bot_id, "bot-123");
        assert_eq!(ws.allowed_users, vec!["zeroclaw_user"]);
        assert_eq!(ws.stream_mode, StreamMode::Partial);
    }

    // ── Serde round-trip ─────────────────────────────────────

    #[test]
    async fn config_toml_roundtrip() {
        let config = Config {
            eval: crate::scattered_types::EvalHarnessConfig::default(),
            degraded_security: Vec::new(),
            degraded_sections: Vec::new(),
            schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
            providers: {
                let mut p = crate::providers::Providers::default();
                p.models.openrouter.insert(
                    "default".to_string(),
                    OpenRouterModelProviderConfig {
                        base: ModelProviderConfig {
                            api_key: Some("sk-test-key".into()),
                            model: Some("gpt-4o".into()),
                            temperature: Some(0.5),
                            timeout_secs: Some(120),
                            ..Default::default()
                        },
                    },
                );
                p
            },
            model_routes: Vec::new(),
            embedding_routes: Vec::new(),
            data_dir: PathBuf::from("/tmp/test/workspace"),
            config_path: PathBuf::from("/tmp/test/config.toml"),
            observability: ObservabilityConfig {
                backend: ObservabilityBackend::Log,
                ..ObservabilityConfig::default()
            },
            risk_profiles: {
                let mut m = HashMap::new();
                m.insert(
                    "default".into(),
                    RiskProfileConfig {
                        level: AutonomyLevel::Full,
                        workspace_only: false,
                        allowed_commands: vec!["docker".into()],
                        forbidden_paths: vec!["/secret".into()],
                        require_approval_for_medium_risk: false,
                        block_high_risk_commands: true,
                        shell_env_passthrough: vec!["DATABASE_URL".into()],
                        auto_approve: vec!["file_read".into()],
                        always_ask: vec![],
                        allowed_roots: vec![],
                        allowed_tools: vec![],
                        excluded_tools: vec![],
                        ..RiskProfileConfig::default()
                    },
                );
                m
            },
            trust: crate::scattered_types::TrustConfig::default(),
            backup: BackupConfig::default(),
            data_retention: DataRetentionConfig::default(),
            cloud_ops: CloudOpsConfig::default(),
            conversational_ai: ConversationalAiConfig::default(),
            security: SecurityConfig::default(),
            security_ops: SecurityOpsConfig::default(),
            runtime: RuntimeConfig {
                kind: RuntimeKind::Docker,
                ..RuntimeConfig::default()
            },
            reliability: ReliabilityConfig::default(),
            scheduler: SchedulerConfig::default(),
            skills: SkillsConfig::default(),
            pipeline: PipelineConfig::default(),
            query_classification: QueryClassificationConfig::default(),
            heartbeat: HeartbeatConfig {
                enabled: true,
                interval_minutes: 15,
                two_phase: true,
                message: Some("Check London time".into()),
                target: Some("telegram".into()),
                to: Some("123456".into()),
                ..HeartbeatConfig::default()
            },
            todotracker: TodoTrackerConfig::default(),
            cron: HashMap::new(),
            acp: AcpConfig::default(),
            channels: ChannelsConfig {
                cli: true,
                telegram: HashMap::from([(
                    "default".to_string(),
                    TelegramConfig {
                        enabled: true,
                        bot_token: "123:ABC".into(),
                        api_base_url: default_telegram_api_base_url(),
                        stream_mode: StreamMode::default(),
                        draft_update_interval_ms: default_draft_update_interval_ms(),
                        debounce_ms: None,
                        interrupt_on_new_message: false,
                        mention_only: false,
                        ack_reactions: None,
                        proxy_url: None,
                        approval_timeout_secs: default_telegram_approval_timeout_secs(),
                        excluded_tools: vec![],
                        reply_min_interval_secs: 0,
                        reply_queue_depth_max: 0,
                    },
                )]),
                discord: HashMap::new(),
                slack: HashMap::new(),
                mattermost: HashMap::new(),
                webhook: HashMap::new(),
                imessage: HashMap::new(),
                matrix: HashMap::new(),
                signal: HashMap::new(),
                whatsapp: HashMap::new(),
                linq: HashMap::new(),
                wati: HashMap::new(),
                nextcloud_talk: HashMap::new(),
                email: HashMap::new(),
                gmail_push: HashMap::new(),
                irc: HashMap::new(),
                twitch: HashMap::new(),
                lark: HashMap::new(),
                line: HashMap::new(),
                dingtalk: HashMap::new(),
                wecom: HashMap::new(),
                wecom_ws: HashMap::new(),
                wechat: HashMap::new(),
                qq: HashMap::new(),
                twitter: HashMap::new(),
                mochat: HashMap::new(),
                nostr: HashMap::new(),
                clawdtalk: HashMap::new(),
                reddit: HashMap::new(),
                bluesky: HashMap::new(),
                git: HashMap::new(),
                voice_call: HashMap::new(),
                voice_duplex: HashMap::new(),
                voice_wake: HashMap::new(),
                mqtt: HashMap::new(),
                amqp: HashMap::new(),
                filesystem: HashMap::new(),
                message_timeout_secs: 300,
                max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
                ack_reactions: true,
                show_tool_calls: true,
                session_persistence: true,
                session_backend: default_session_backend(),
                session_ttl_hours: 0,
                debounce_ms: 0,
            },
            memory: MemoryConfig::default(),
            storage: StorageConfig::default(),
            tunnel: TunnelConfig::default(),
            gateway: GatewayConfig::default(),
            a2a: crate::multi_agent::A2aServerSection::default(),
            wss: WssConfig::default(),
            composio: ComposioConfig::default(),
            microsoft365: Microsoft365Config::default(),
            secrets: SecretsConfig::default(),
            browser: BrowserConfig::default(),
            browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
            http_request: HttpRequestConfig::default(),
            multimodal: MultimodalConfig::default(),
            media_pipeline: MediaPipelineConfig::default(),
            web_fetch: WebFetchConfig::default(),
            link_enricher: LinkEnricherConfig::default(),
            text_browser: TextBrowserConfig::default(),
            web_search: WebSearchConfig::default(),
            project_intel: ProjectIntelConfig::default(),
            google_workspace: GoogleWorkspaceConfig::default(),
            proxy: ProxyConfig::default(),
            pacing: PacingConfig::default(),
            cost: CostConfig::default(),
            peripherals: PeripheralsConfig::default(),
            delegate: DelegateToolConfig::default(),
            agents: HashMap::new(),
            runtime_profiles: HashMap::new(),
            skill_bundles: HashMap::new(),
            knowledge_bundles: HashMap::new(),
            mcp_bundles: HashMap::new(),
            peer_groups: HashMap::new(),
            hooks: HooksConfig::default(),
            hardware: HardwareConfig::default(),
            transcription: TranscriptionConfig::default(),
            tts: TtsConfig::default(),
            mcp: McpConfig::default(),
            nodes: NodesConfig::default(),
            onboard_state: OnboardStateConfig::default(),
            notion: NotionConfig::default(),
            jira: JiraConfig::default(),
            node_transport: NodeTransportConfig::default(),
            knowledge: KnowledgeConfig::default(),
            linkedin: LinkedInConfig::default(),
            image_gen: ImageGenConfig::default(),
            file_upload: FileUploadConfig::default(),
            file_upload_bundle: FileUploadBundleConfig::default(),
            file_download: FileDownloadConfig::default(),
            plugins: PluginsConfig::default(),
            locale: None,
            verifiable_intent: VerifiableIntentConfig::default(),
            claude_code: ClaudeCodeConfig::default(),
            claude_code_runner: ClaudeCodeRunnerConfig::default(),
            codex_cli: CodexCliConfig::default(),
            gemini_cli: GeminiCliConfig::default(),
            opencode_cli: OpenCodeCliConfig::default(),
            sop: SopConfig::default(),
            shell_tool: ShellToolConfig::default(),
            escalation: EscalationConfig::default(),
            env_overridden_paths: std::collections::HashSet::new(),
            pre_override_snapshots: std::collections::HashMap::new(),
            onepassword_reference_snapshots: std::collections::HashMap::new(),
            dirty_paths: std::collections::HashSet::new(),
        };
        // ModelProvider fields are now resolved directly — no cache needed.

        let toml_str = toml::to_string_pretty(&config).unwrap();
        let parsed = parse_test_config(&toml_str);

        assert_eq!(parsed.providers.models.len(), config.providers.models.len());
        assert_eq!(parsed.observability.backend, ObservabilityBackend::Log);
        assert_eq!(
            parsed.observability.log_persistence,
            LogPersistence::Rolling
        );
        let default_profile = parsed.risk_profiles.get("default").unwrap();
        assert_eq!(default_profile.level, AutonomyLevel::Full);
        assert!(!default_profile.workspace_only);
        assert_eq!(parsed.runtime.kind, RuntimeKind::Docker);
        assert!(parsed.heartbeat.enabled);
        assert_eq!(parsed.heartbeat.interval_minutes, 15);
        assert_eq!(
            parsed.heartbeat.message.as_deref(),
            Some("Check London time")
        );
        assert_eq!(parsed.heartbeat.target.as_deref(), Some("telegram"));
        assert_eq!(parsed.heartbeat.to.as_deref(), Some("123456"));
        assert!(!parsed.channels.telegram.is_empty());
        assert_eq!(
            parsed.channels.telegram.get("default").unwrap().bot_token,
            "123:ABC"
        );
    }

    #[test]
    async fn config_minimal_toml_uses_defaults() {
        let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
        let parsed = parse_test_config(minimal);
        assert!(
            parsed
                .providers
                .models
                .iter_entries()
                .next()
                .map(|(_, _, e)| e)
                .and_then(|e| e.api_key.as_deref())
                .is_none()
        );
        assert_eq!(parsed.observability.backend, ObservabilityBackend::None);
        assert_eq!(
            parsed.observability.log_persistence,
            LogPersistence::Rolling
        );
        // Migration synthesizes risk_profiles.default from the legacy
        // [autonomy] block; assert against the named entry rather than a
        // global "active" profile (no such concept exists).
        assert_eq!(
            parsed
                .risk_profiles
                .get("default")
                .expect("migration synthesized risk_profiles.default")
                .level,
            AutonomyLevel::Supervised
        );
        assert_eq!(parsed.runtime.kind, RuntimeKind::Native);
        // Heartbeat defaults to disabled.
        assert!(!parsed.heartbeat.enabled);
        assert!(parsed.channels.cli);
        assert!(parsed.memory.hygiene_enabled);
        assert_eq!(parsed.memory.archive_after_days, 7);
        assert_eq!(parsed.memory.purge_after_days, 30);
        assert_eq!(parsed.memory.conversation_retention_days, 30);
        // Temperature migrated onto the primary model_provider entry
        assert!(
            (parsed
                .providers
                .models
                .iter_entries()
                .next()
                .map(|(_, _, e)| e)
                .and_then(|e| e.temperature)
                .unwrap_or(0.7)
                - 0.7)
                .abs()
                < f64::EPSILON
        );
        assert_eq!(
            parsed
                .providers
                .models
                .iter_entries()
                .next()
                .map(|(_, _, e)| e)
                .and_then(|e| e.timeout_secs)
                .unwrap_or(120),
            DEFAULT_DELEGATE_TIMEOUT_SECS
        );
    }

    /// `[autonomy]` migrates onto `[risk_profiles.default]` via the V2→V3
    /// migration. The fields must round-trip without being silently dropped.
    #[test]
    async fn v2_autonomy_section_migrates_onto_risk_profiles_default() {
        let raw = r#"
schema_version = 2
default_temperature = 0.7

[autonomy]
level = "full"
max_actions_per_hour = 99
auto_approve = ["file_read", "memory_recall", "http_request"]
"#;
        let parsed = crate::migration::migrate_to_current(raw).unwrap();
        let profile = parsed
            .risk_profiles
            .get("default")
            .expect("default profile");
        assert_eq!(profile.level, AutonomyLevel::Full);
        assert!(profile.auto_approve.contains(&"http_request".to_string()));
        let runtime = parsed
            .runtime_profiles
            .get("default")
            .expect("default runtime profile");
        assert_eq!(runtime.max_actions_per_hour, 99);
    }

    /// Regression test for: when a user provides a custom auto_approve
    /// list, the built-in defaults must still be present.
    #[test]
    async fn auto_approve_merges_user_entries_with_defaults() {
        let raw = r#"
default_temperature = 0.7

[risk_profiles.default]
auto_approve = ["my_custom_tool", "another_tool"]
"#;
        let parsed = parse_test_config(raw);
        let profile = parsed.risk_profiles.get("default").unwrap();
        assert!(profile.auto_approve.contains(&"my_custom_tool".to_string()));
        assert!(profile.auto_approve.contains(&"another_tool".to_string()));
        for default_tool in &[
            "file_read",
            "memory_recall",
            "weather",
            "calculator",
            "web_fetch",
        ] {
            assert!(
                profile.auto_approve.contains(&String::from(*default_tool)),
                "default tool '{default_tool}' must be present"
            );
        }
    }

    #[test]
    async fn default_auto_approve_includes_tool_search() {
        let defaults = default_auto_approve();
        assert!(defaults.contains(&"tool_search".to_string()));
    }

    /// Regression test: empty auto_approve still gets defaults merged.
    #[test]
    async fn auto_approve_empty_list_gets_defaults() {
        let raw = r#"
default_temperature = 0.7

[risk_profiles.default]
auto_approve = []
"#;
        let parsed = parse_test_config(raw);
        let profile = parsed.risk_profiles.get("default").unwrap();
        for tool in &default_auto_approve() {
            assert!(
                profile.auto_approve.contains(tool),
                "default tool '{tool}' must be present"
            );
        }
    }

    /// When no risk_profiles section is provided, defaults are applied to the
    /// synthesized "default" profile.
    #[test]
    async fn auto_approve_defaults_when_no_risk_profile_section() {
        let raw = r#"
default_temperature = 0.7
"#;
        let parsed = parse_test_config(raw);
        let profile = parsed.risk_profiles.get("default").unwrap();
        for tool in &default_auto_approve() {
            assert!(
                profile.auto_approve.contains(tool),
                "default tool '{tool}' must be present"
            );
        }
    }

    /// Duplicates are not introduced when ensure_default_auto_approve runs
    /// on a list that already contains the defaults.
    #[test]
    async fn auto_approve_no_duplicates() {
        let raw = r#"
default_temperature = 0.7

[risk_profiles.default]
auto_approve = ["weather", "file_read"]
"#;
        let parsed = parse_test_config(raw);
        let profile = parsed.risk_profiles.get("default").unwrap();
        assert_eq!(
            profile
                .auto_approve
                .iter()
                .filter(|t| *t == "weather")
                .count(),
            1
        );
        assert_eq!(
            profile
                .auto_approve
                .iter()
                .filter(|t| *t == "file_read")
                .count(),
            1
        );
    }

    #[test]
    async fn provider_timeout_secs_parses_from_toml() {
        // V1 top-level `provider_timeout_secs` is folded into the
        // synthesized model_provider entry's `timeout_secs`.
        let raw = r#"
default_temperature = 0.7
provider_timeout_secs = 300
"#;
        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
        assert_eq!(
            parsed
                .providers
                .models
                .find("openrouter", "default")
                .and_then(|e| e.timeout_secs)
                .unwrap_or(120),
            300
        );
    }

    #[test]
    async fn extra_headers_parses_from_toml() {
        // V1 top-level `[extra_headers]` is folded into the synthesized
        // default model_provider entry's `extra_headers` map.
        let raw = r#"
default_temperature = 0.7

[extra_headers]
User-Agent = "MyApp/1.0"
X-Title = "zeroclaw"
"#;
        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
        let headers = &parsed
            .providers
            .models
            .find("openrouter", "default")
            .expect("synthesized openrouter.default model_provider")
            .extra_headers;
        assert_eq!(headers.len(), 2);
        assert_eq!(headers.get("User-Agent").unwrap(), "MyApp/1.0");
        assert_eq!(headers.get("X-Title").unwrap(), "zeroclaw");
    }

    #[test]
    async fn extra_headers_defaults_to_empty() {
        let raw = r#"
default_temperature = 0.7
"#;
        let parsed = parse_test_config(raw);
        assert!(
            parsed
                .providers
                .models
                .iter_entries()
                .next()
                .map(|(_, _, e)| e.extra_headers.is_empty())
                .unwrap_or(true)
        );
    }

    #[test]
    async fn storage_postgres_dburl_alias_deserializes() {
        let raw = r#"
default_temperature = 0.7

[storage.postgres.default]
dbURL = "postgres://user:pw@host/db"
schema = "public"
table = "memories"
connect_timeout_secs = 12
"#;

        let parsed = parse_test_config(raw);
        let pg = parsed
            .storage
            .postgres
            .get("default")
            .expect("postgres.default present");
        assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
        assert_eq!(pg.schema, "public");
        assert_eq!(pg.table, "memories");
        assert_eq!(pg.connect_timeout_secs, Some(12));
    }

    #[test]
    async fn storage_lucid_timeout_overrides_deserialize() {
        let raw = r#"
default_temperature = 0.7

[storage.lucid.default]
binary_path = "/opt/lucid/bin/lucid"
recall_timeout_ms = 5000
store_timeout_ms = 4000
"#;

        let parsed = parse_test_config(raw);
        let lucid = parsed
            .storage
            .lucid
            .get("default")
            .expect("lucid.default present");
        assert_eq!(lucid.binary_path.as_deref(), Some("/opt/lucid/bin/lucid"));
        assert_eq!(lucid.recall_timeout_ms, Some(5000));
        assert_eq!(lucid.store_timeout_ms, Some(4000));
    }

    #[test]
    async fn validate_rejects_zero_lucid_timeouts_with_alias_qualified_paths() {
        for field in ["recall_timeout_ms", "store_timeout_ms"] {
            let raw = format!(
                r#"
default_temperature = 0.7

[storage.lucid.edge_arm]
{field} = 0
"#
            );
            let parsed = parse_test_config(&raw);
            let error = parsed
                .validate()
                .expect_err("zero Lucid timeout must fail validation");
            let expected_path = format!("storage.lucid.edge_arm.{field}");
            assert!(
                error.to_string().contains(&expected_path),
                "validation error must name {expected_path}: {error:#}"
            );
        }
    }

    #[test]
    async fn validate_rejects_blank_lucid_binary_with_alias_qualified_path() {
        let raw = r#"
default_temperature = 0.7

[storage.lucid.edge_arm]
binary_path = "   "
"#;
        let parsed = parse_test_config(raw);
        let error = parsed
            .validate()
            .expect_err("blank Lucid binary path must fail validation");
        assert!(
            error
                .to_string()
                .contains("storage.lucid.edge_arm.binary_path"),
            "validation error must name the alias-qualified binary path: {error:#}"
        );
    }

    #[test]
    async fn runtime_reasoning_enabled_deserializes() {
        let raw = r#"
default_temperature = 0.7

[runtime]
reasoning_enabled = false
"#;

        let parsed = parse_test_config(raw);
        assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
    }

    #[test]
    async fn runtime_reasoning_effort_deserializes() {
        let raw = r#"
default_temperature = 0.7

[runtime]
reasoning_effort = "HIGH"
"#;

        let parsed: Config = toml::from_str(raw).unwrap();
        assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
    }

    #[test]
    async fn runtime_reasoning_effort_rejects_invalid_values() {
        let raw = r#"
default_temperature = 0.7

[runtime]
reasoning_effort = "turbo"
"#;

        let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
        assert!(error.to_string().contains("reasoning_effort"));
    }

    #[test]
    async fn agent_config_defaults() {
        let cfg = AliasedAgentConfig::default();
        assert!(cfg.resolved.compact_context);
        assert_eq!(cfg.resolved.max_tool_iterations, 10);
        assert_eq!(cfg.resolved.max_history_messages, 50);
        assert!(!cfg.resolved.parallel_tools);
        assert_eq!(cfg.resolved.tool_dispatcher, "auto");
        assert!(!cfg.resolved.strict_tool_parsing);
        assert!(cfg.precheck.enabled);
        assert_eq!(cfg.precheck.timeout_secs, 5);
    }

    #[test]
    async fn agent_precheck_config_parses_from_agent_block() {
        let raw = r#"
[agents.default]
model_provider = "custom.default"
risk_profile = "default"
runtime_profile = "default"

[agents.default.precheck]
enabled = false
timeout_secs = 12
"#;
        let parsed = parse_test_config(raw);
        let agent = parsed
            .agents
            .get("default")
            .expect("[agents.default] parses into agents map");
        assert!(!agent.precheck.enabled);
        assert_eq!(agent.precheck.timeout_secs, 12);
    }

    #[test]
    async fn validate_rejects_zero_agent_precheck_timeout() {
        let raw = r#"
[agents.default]
model_provider = "custom.default"
risk_profile = "default"
runtime_profile = "default"

[agents.default.precheck]
timeout_secs = 0
"#;
        let parsed = parse_test_config(raw);
        let error = parsed
            .validate()
            .expect_err("zero precheck timeout must be rejected");
        assert!(
            error
                .to_string()
                .contains("agents.default.precheck.timeout_secs")
        );
    }

    #[test]
    async fn agent_level_tunable_keys_are_inert() {
        let raw = r#"
default_temperature = 0.7
[agents.default]
compact_context = true
max_tool_iterations = 20
max_history_messages = 80
parallel_tools = true
tool_dispatcher = "xml"
strict_tool_parsing = true
"#;
        let parsed = parse_test_config(raw);
        let agent = parsed
            .agents
            .get("default")
            .expect("[agents.default] parses into agents map");
        assert_eq!(agent.resolved.max_tool_iterations, 10);
        assert_eq!(agent.resolved.tool_dispatcher, "auto");
        assert!(!agent.resolved.strict_tool_parsing);
    }

    #[test]
    async fn runtime_profile_max_tool_iterations_is_honored() {
        // `[runtime_profiles.*].max_tool_iterations` must actually take
        // effect. It previously had no effect (the value had to be set on
        // `[agents.*]`); now agent-inline is inert and the profile is the
        // authoritative surface, so this guards the resolved value.
        let raw = r#"
[runtime_profiles.fast]
max_tool_iterations = 25

[agents.default]
runtime_profile = "fast"
"#;
        let parsed = parse_test_config(raw);
        assert_eq!(parsed.effective_max_tool_iterations("default"), 25);
    }

    #[test]
    async fn runtime_profile_unset_max_tool_iterations_uses_default() {
        // A profile that does not set max_tool_iterations (sentinel 0) falls
        // back to the global default rather than 0.
        let raw = r#"
[runtime_profiles.fast]
max_history_messages = 80

[agents.default]
runtime_profile = "fast"
"#;
        let parsed = parse_test_config(raw);
        assert_eq!(parsed.effective_max_tool_iterations("default"), 10);
    }

    #[test]
    async fn runtime_profile_structured_history_cap_scales_when_omitted() {
        let raw = r#"
[runtime_profiles.long_turn]
max_tool_iterations = 100

[agents.default]
runtime_profile = "long_turn"
"#;
        let parsed = parse_test_config(raw);
        assert_eq!(parsed.effective_max_history_messages("default"), 50);
        assert_eq!(
            parsed.effective_structured_max_history_messages("default"),
            202
        );
        let agent = parsed.resolved_agent_config("default").unwrap();
        assert_eq!(agent.resolved.max_history_messages, 50);
    }

    #[test]
    async fn runtime_profile_history_cap_explicit_value_remains_authoritative() {
        let raw = r#"
[runtime_profiles.long_turn]
max_tool_iterations = 100
max_history_messages = 80

[agents.default]
runtime_profile = "long_turn"
"#;
        let parsed = parse_test_config(raw);
        assert_eq!(parsed.effective_max_history_messages("default"), 80);
        assert_eq!(
            parsed.effective_structured_max_history_messages("default"),
            80
        );
        let agent = parsed.resolved_agent_config("default").unwrap();
        assert_eq!(agent.resolved.max_history_messages, 80);
    }

    #[test]
    async fn runtime_profile_history_cap_explicit_zero_remains_authoritative() {
        let raw = r#"
[runtime_profiles.long_turn]
max_tool_iterations = 100
max_history_messages = 0

[agents.default]
runtime_profile = "long_turn"
"#;
        let parsed = parse_test_config(raw);
        assert_eq!(parsed.effective_max_history_messages("default"), 0);
        assert_eq!(
            parsed.effective_structured_max_history_messages("default"),
            0
        );
        let agent = parsed.resolved_agent_config("default").unwrap();
        assert_eq!(agent.resolved.max_history_messages, 0);
    }

    #[test]
    async fn runtime_profile_history_cap_saturates_at_usize_max() {
        let mut config = Config::default();
        config.runtime_profiles.insert(
            "long_turn".to_string(),
            RuntimeProfileConfig {
                max_tool_iterations: usize::MAX,
                ..RuntimeProfileConfig::default()
            },
        );
        config.agents.insert(
            "default".to_string(),
            AliasedAgentConfig {
                runtime_profile: "long_turn".into(),
                ..AliasedAgentConfig::default()
            },
        );

        assert_eq!(
            config.effective_structured_max_history_messages("default"),
            usize::MAX
        );
        assert_eq!(config.effective_max_history_messages("default"), 50);
    }

    #[test]
    async fn default_runtime_profile_history_cap_remains_50() {
        let parsed = parse_test_config("");
        assert_eq!(parsed.effective_max_tool_iterations("default"), 10);
        assert_eq!(parsed.effective_max_history_messages("default"), 50);
        assert_eq!(
            parsed.effective_structured_max_history_messages("default"),
            50
        );
    }

    #[test]
    async fn pacing_config_defaults_are_all_none_or_empty() {
        let cfg = PacingConfig::default();
        assert!(cfg.step_timeout_secs.is_none());
        assert!(cfg.loop_detection_min_elapsed_secs.is_none());
        assert!(cfg.loop_ignore_tools.is_empty());
        assert!(cfg.message_timeout_scale_max.is_none());
    }

    #[test]
    async fn pacing_config_deserializes_from_toml() {
        let raw = r#"
default_temperature = 0.7
[pacing]
step_timeout_secs = 120
loop_detection_min_elapsed_secs = 60
loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
message_timeout_scale_max = 8
"#;
        let parsed: Config = toml::from_str(raw).unwrap();
        assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
        assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
        assert_eq!(
            parsed.pacing.loop_ignore_tools,
            vec!["browser_screenshot", "browser_navigate"]
        );
        assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
    }

    #[test]
    async fn pacing_config_absent_preserves_defaults() {
        let raw = r#"
default_temperature = 0.7
"#;
        let parsed: Config = toml::from_str(raw).unwrap();
        assert!(parsed.pacing.step_timeout_secs.is_none());
        assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
        assert!(parsed.pacing.loop_ignore_tools.is_empty());
        assert!(parsed.pacing.message_timeout_scale_max.is_none());
    }

    #[tokio::test]
    async fn sync_directory_handles_existing_directory() {
        let dir = std::env::temp_dir().join(format!(
            "zeroclaw_test_sync_directory_{}",
            uuid::Uuid::new_v4()
        ));
        fs::create_dir_all(&dir).await.unwrap();

        sync_directory(&dir).await.unwrap();

        let _ = fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn config_save_prunes_unchanged_default_blocks() {
        // Fresh-init config without any operator edits should write a
        // tiny config.toml — only `schema_version` and any operator-
        // touched fields. The hundreds of all-default blocks
        // (LinkedIn, memory, observability, etc.) must not appear.
        let dir =
            std::env::temp_dir().join(format!("zeroclaw_save_prune_test_{}", uuid::Uuid::new_v4()));
        fs::create_dir_all(&dir).await.unwrap();
        let config = Config {
            config_path: dir.join("config.toml"),
            data_dir: dir.join("data"),
            ..Default::default()
        };
        config.save().await.unwrap();
        let raw = fs::read_to_string(&config.config_path).await.unwrap();

        // schema_version must always survive (migration detector
        // anchor); without it a re-load would mis-detect as V1.
        assert!(
            raw.contains("schema_version"),
            "schema_version must survive pruning"
        );

        // Defaulted nested struct blocks must NOT appear in a fresh
        // save. Pick representative samples from across the schema:
        for block in [
            "[memory]",
            "[linkedin",
            "[observability]",
            "[gateway]",
            "[cost]",
        ] {
            assert!(
                !raw.contains(block),
                "pruned config.toml must not emit defaulted block {block}; got:\n{raw}",
            );
        }

        // Round-trip: load the pruned config and verify it still
        // deserializes to a `Config` (schema defaults fill the gaps).
        let _reloaded: Config = toml::from_str(&raw).expect("pruned config round-trips");

        let _ = fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn config_save_keeps_operator_set_non_default_fields() {
        let dir =
            std::env::temp_dir().join(format!("zeroclaw_save_keep_test_{}", uuid::Uuid::new_v4()));
        fs::create_dir_all(&dir).await.unwrap();
        let mut config = Config {
            config_path: dir.join("config.toml"),
            data_dir: dir.join("data"),
            ..Default::default()
        };
        // Operator picked a non-default locale + provider entry.
        config.locale = Some("ja-JP".into());
        config.providers.models.anthropic.insert(
            "claude_default".into(),
            AnthropicModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("claude-sonnet-4".into()),
                    ..Default::default()
                },
            },
        );
        config.save().await.unwrap();
        let raw = fs::read_to_string(&config.config_path).await.unwrap();

        assert!(
            raw.contains("ja-JP"),
            "operator-set locale must survive pruning; got:\n{raw}",
        );
        assert!(
            raw.contains("claude_default"),
            "operator-added provider alias must survive pruning; got:\n{raw}",
        );
        assert!(
            raw.contains("claude-sonnet-4"),
            "operator-set model must survive pruning; got:\n{raw}",
        );

        let _ = fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn config_save_and_load_tmpdir() {
        let dir = std::env::temp_dir().join("zeroclaw_test_config");
        let _ = fs::remove_dir_all(&dir).await;
        fs::create_dir_all(&dir).await.unwrap();

        let config_path = dir.join("config.toml");
        let mut providers = crate::providers::Providers::default();
        providers.models.openrouter.insert(
            "default".to_string(),
            OpenRouterModelProviderConfig {
                base: ModelProviderConfig {
                    api_key: Some("sk-roundtrip".into()),
                    model: Some("test-model".into()),
                    temperature: Some(0.9),
                    timeout_secs: Some(120),
                    ..Default::default()
                },
            },
        );
        let config = Config {
            eval: crate::scattered_types::EvalHarnessConfig::default(),
            degraded_security: Vec::new(),
            degraded_sections: Vec::new(),
            schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
            providers,
            model_routes: Vec::new(),
            embedding_routes: Vec::new(),
            data_dir: dir.join("workspace"),
            config_path: config_path.clone(),
            observability: ObservabilityConfig::default(),
            trust: crate::scattered_types::TrustConfig::default(),
            backup: BackupConfig::default(),
            data_retention: DataRetentionConfig::default(),
            cloud_ops: CloudOpsConfig::default(),
            conversational_ai: ConversationalAiConfig::default(),
            security: SecurityConfig::default(),
            security_ops: SecurityOpsConfig::default(),
            runtime: RuntimeConfig::default(),
            reliability: ReliabilityConfig::default(),
            scheduler: SchedulerConfig::default(),
            skills: SkillsConfig::default(),
            pipeline: PipelineConfig::default(),
            query_classification: QueryClassificationConfig::default(),
            heartbeat: HeartbeatConfig::default(),
            todotracker: TodoTrackerConfig::default(),
            cron: HashMap::new(),
            acp: AcpConfig::default(),
            channels: ChannelsConfig::default(),
            memory: MemoryConfig::default(),
            storage: StorageConfig::default(),
            tunnel: TunnelConfig::default(),
            gateway: GatewayConfig::default(),
            a2a: crate::multi_agent::A2aServerSection::default(),
            wss: WssConfig::default(),
            composio: ComposioConfig::default(),
            microsoft365: Microsoft365Config::default(),
            secrets: SecretsConfig::default(),
            browser: BrowserConfig::default(),
            browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
            http_request: HttpRequestConfig::default(),
            multimodal: MultimodalConfig::default(),
            media_pipeline: MediaPipelineConfig::default(),
            web_fetch: WebFetchConfig::default(),
            link_enricher: LinkEnricherConfig::default(),
            text_browser: TextBrowserConfig::default(),
            web_search: WebSearchConfig::default(),
            project_intel: ProjectIntelConfig::default(),
            google_workspace: GoogleWorkspaceConfig::default(),
            proxy: ProxyConfig::default(),
            pacing: PacingConfig::default(),
            cost: CostConfig::default(),
            peripherals: PeripheralsConfig::default(),
            delegate: DelegateToolConfig::default(),
            agents: HashMap::new(),
            risk_profiles: HashMap::new(),
            runtime_profiles: HashMap::new(),
            skill_bundles: HashMap::new(),
            knowledge_bundles: HashMap::new(),
            mcp_bundles: HashMap::new(),
            peer_groups: HashMap::new(),
            hooks: HooksConfig::default(),
            hardware: HardwareConfig::default(),
            transcription: TranscriptionConfig::default(),
            tts: TtsConfig::default(),
            mcp: McpConfig::default(),
            nodes: NodesConfig::default(),
            onboard_state: OnboardStateConfig::default(),
            notion: NotionConfig::default(),
            jira: JiraConfig::default(),
            node_transport: NodeTransportConfig::default(),
            knowledge: KnowledgeConfig::default(),
            linkedin: LinkedInConfig::default(),
            image_gen: ImageGenConfig::default(),
            file_upload: FileUploadConfig::default(),
            file_upload_bundle: FileUploadBundleConfig::default(),
            file_download: FileDownloadConfig::default(),
            plugins: PluginsConfig::default(),
            locale: None,
            verifiable_intent: VerifiableIntentConfig::default(),
            claude_code: ClaudeCodeConfig::default(),
            claude_code_runner: ClaudeCodeRunnerConfig::default(),
            codex_cli: CodexCliConfig::default(),
            gemini_cli: GeminiCliConfig::default(),
            opencode_cli: OpenCodeCliConfig::default(),
            sop: SopConfig::default(),
            shell_tool: ShellToolConfig::default(),
            escalation: EscalationConfig::default(),
            env_overridden_paths: std::collections::HashSet::new(),
            pre_override_snapshots: std::collections::HashMap::new(),
            onepassword_reference_snapshots: std::collections::HashMap::new(),
            dirty_paths: std::collections::HashSet::new(),
        };

        // ModelProvider fields are now resolved directly — no cache needed.
        config.save().await.unwrap();
        assert!(config_path.exists());

        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
        let loaded = crate::migration::migrate_to_current(&contents).unwrap();
        let entry = &loaded
            .providers
            .models
            .find("openrouter", "default")
            .expect("entry exists");
        assert!(
            entry
                .api_key
                .as_deref()
                .is_some_and(crate::secrets::SecretStore::is_encrypted)
        );
        let store = crate::secrets::SecretStore::new(&dir, true);
        let decrypted = store.decrypt(entry.api_key.as_deref().unwrap()).unwrap();
        assert_eq!(decrypted, "sk-roundtrip");
        assert_eq!(entry.model.as_deref(), Some("test-model"));
        assert!(
            entry
                .temperature
                .is_some_and(|t| (t - 0.9).abs() < f64::EPSILON)
        );

        let _ = fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn config_save_encrypts_nested_credentials() {
        let dir = std::env::temp_dir().join(format!(
            "zeroclaw_test_nested_credentials_{}",
            uuid::Uuid::new_v4()
        ));
        fs::create_dir_all(&dir).await.unwrap();

        let mut config = Config {
            data_dir: dir.join("workspace"),
            config_path: dir.join("config.toml"),
            ..Default::default()
        };
        config.providers.models.anthropic.insert(
            "default".to_string(),
            AnthropicModelProviderConfig {
                base: ModelProviderConfig {
                    api_key: Some("root-credential".into()),
                    extra_headers: HashMap::from([(
                        "Authorization".to_string(),
                        "Bearer provider-header-credential".to_string(),
                    )]),
                    ..Default::default()
                },
            },
        );
        // ModelProvider fields are now resolved directly — no cache needed.
        config.composio.api_key = Some("composio-credential".into());
        config.browser.computer_use.api_key = Some("browser-credential".into());
        config.web_search.brave_api_key = Some("brave-credential".into());
        config.web_search.tavily_api_key = Some("tavily-credential".into());
        config.storage.postgres.insert(
            "default".to_string(),
            PostgresStorageConfig {
                db_url: Some("postgres://user:pw@host/db".into()),
                ..PostgresStorageConfig::default()
            },
        );
        config.storage.qdrant.insert(
            "default".to_string(),
            QdrantStorageConfig {
                api_key: Some("qdrant-credential".into()),
                ..QdrantStorageConfig::default()
            },
        );
        config.reliability.api_keys = vec![
            "rotation-credential-a".into(),
            "rotation-credential-b".into(),
        ];
        config.node_transport.shared_secret = "node-shared-credential".into();
        config.nodes.auth_token = Some("nodes-auth-credential".into());
        config.observability.backend = ObservabilityBackend::Otel;
        config.observability.otel_headers = Some(HashMap::from([(
            "Authorization".to_string(),
            "Bearer otel-credential".to_string(),
        )]));
        config.file_upload.headers = HashMap::from([(
            "Authorization".to_string(),
            "Bearer upload-credential".to_string(),
        )]);
        config.http_request.secrets = HashMap::from([(
            "api_token".to_string(),
            "Bearer http-request-credential".to_string(),
        )]);
        config.channels.lark.insert(
            "feishu".to_string(),
            LarkConfig {
                enabled: true,
                app_id: "cli_feishu_123".into(),
                app_secret: "feishu-secret".into(),
                encrypt_key: Some("feishu-encrypt".into()),
                verification_token: Some("feishu-verify".into()),
                mention_only: false,
                use_feishu: true,
                receive_mode: LarkReceiveMode::Websocket,
                port: None,
                proxy_url: None,
                excluded_tools: vec![],
                approval_timeout_secs: 300,
                per_user_session: false,
                ack_reactions: None,
                stream_mode: StreamMode::default(),
                draft_update_interval_ms: default_draft_update_interval_ms(),
            },
        );

        config.providers.models.openrouter.insert(
            "worker".into(),
            crate::schema::OpenRouterModelProviderConfig {
                base: ModelProviderConfig {
                    api_key: Some("agent-credential".into()),
                    model: Some("model-test".into()),
                    ..Default::default()
                },
            },
        );
        config.agents.insert(
            "worker".into(),
            AliasedAgentConfig {
                model_provider: "openrouter.worker".into(),
                ..Default::default()
            },
        );

        // Webhook channel: auth_header carries a Bearer token; must be
        // encrypted alongside the existing webhook `secret` field.
        config.channels.webhook.insert(
            "primary".into(),
            WebhookConfig {
                enabled: true,
                port: 8080,
                auth_header: Some("Bearer webhook-cred".into()),
                secret: Some("webhook-shared-secret".into()),
                ..Default::default()
            },
        );

        // MCP server: HTTP headers map carries an Authorization Bearer
        // token; the new `#[secret]` on `HashMap<String, String>` must
        // encrypt every value (and only every value — keys stay plain).
        config.mcp.servers.push(McpServerConfig {
            name: "primary".into(),
            transport: McpTransport::Sse,
            url: Some("https://mcp.example.invalid/sse".into()),
            env: HashMap::from([("MCP_API_KEY".to_string(), "mcp-env-credential".to_string())]),
            headers: HashMap::from([
                ("Authorization".to_string(), "Bearer mcp-cred".to_string()),
                ("X-Tenant".to_string(), "tenant-42".to_string()),
            ]),
            ..Default::default()
        });

        config.save().await.unwrap();

        let contents = tokio::fs::read_to_string(config.config_path.clone())
            .await
            .unwrap();
        for plaintext in [
            "root-credential",
            "Bearer provider-header-credential",
            "composio-credential",
            "browser-credential",
            "brave-credential",
            "tavily-credential",
            "postgres://user:pw@host/db",
            "qdrant-credential",
            "rotation-credential-a",
            "rotation-credential-b",
            "node-shared-credential",
            "nodes-auth-credential",
            "Bearer otel-credential",
            "Bearer upload-credential",
            "Bearer http-request-credential",
            "mcp-env-credential",
            "Bearer mcp-cred",
            "tenant-42",
        ] {
            assert!(
                !contents.contains(plaintext),
                "saved TOML must not contain plaintext credential `{plaintext}`"
            );
        }
        let stored: Config = crate::migration::migrate_to_current(&contents).unwrap();
        let store = crate::secrets::SecretStore::new(&dir, true);

        let root_encrypted = stored
            .providers
            .models
            .find("anthropic", "default")
            .and_then(|e| e.api_key.as_deref())
            .unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(root_encrypted));
        assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");

        let provider_header = stored
            .providers
            .models
            .find("anthropic", "default")
            .and_then(|e| e.extra_headers.get("Authorization"))
            .unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(provider_header));
        assert_eq!(
            store.decrypt(provider_header).unwrap(),
            "Bearer provider-header-credential"
        );

        let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(
            composio_encrypted
        ));
        assert_eq!(
            store.decrypt(composio_encrypted).unwrap(),
            "composio-credential"
        );

        let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(browser_encrypted));
        assert_eq!(
            store.decrypt(browser_encrypted).unwrap(),
            "browser-credential"
        );

        let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(
            web_search_encrypted
        ));
        assert_eq!(
            store.decrypt(web_search_encrypted).unwrap(),
            "brave-credential"
        );

        let tavily_encrypted = stored.web_search.tavily_api_key.as_deref().unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(tavily_encrypted));
        assert_eq!(
            store.decrypt(tavily_encrypted).unwrap(),
            "tavily-credential"
        );

        let worker_provider = stored
            .providers
            .models
            .find("openrouter", "worker")
            .unwrap();
        let worker_encrypted = worker_provider.api_key.as_deref().unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(worker_encrypted));
        assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");

        let storage_db_url = stored
            .storage
            .postgres
            .get("default")
            .and_then(|p| p.db_url.as_deref())
            .unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(storage_db_url));
        assert_eq!(
            store.decrypt(storage_db_url).unwrap(),
            "postgres://user:pw@host/db"
        );

        let qdrant_key = stored
            .storage
            .qdrant
            .get("default")
            .and_then(|q| q.api_key.as_deref())
            .unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(qdrant_key));
        assert_eq!(store.decrypt(qdrant_key).unwrap(), "qdrant-credential");

        for key in &stored.reliability.api_keys {
            assert!(crate::secrets::SecretStore::is_encrypted(key));
        }
        assert_eq!(
            store.decrypt(&stored.reliability.api_keys[0]).unwrap(),
            "rotation-credential-a"
        );
        assert_eq!(
            store.decrypt(&stored.reliability.api_keys[1]).unwrap(),
            "rotation-credential-b"
        );

        assert!(crate::secrets::SecretStore::is_encrypted(
            &stored.node_transport.shared_secret
        ));
        assert_eq!(
            store.decrypt(&stored.node_transport.shared_secret).unwrap(),
            "node-shared-credential"
        );

        let nodes_auth = stored.nodes.auth_token.as_deref().unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(nodes_auth));
        assert_eq!(store.decrypt(nodes_auth).unwrap(), "nodes-auth-credential");

        let otel_auth = stored
            .observability
            .otel_headers
            .as_ref()
            .and_then(|h| h.get("Authorization"))
            .unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(otel_auth));
        assert_eq!(store.decrypt(otel_auth).unwrap(), "Bearer otel-credential");

        let upload_auth = stored.file_upload.headers.get("Authorization").unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(upload_auth));
        assert_eq!(
            store.decrypt(upload_auth).unwrap(),
            "Bearer upload-credential"
        );

        let http_request_auth = stored.http_request.secrets.get("api_token").unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(http_request_auth));
        assert_eq!(
            store.decrypt(http_request_auth).unwrap(),
            "Bearer http-request-credential"
        );

        let feishu = stored.channels.lark.get("feishu").unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(
            &feishu.app_secret
        ));
        assert_eq!(store.decrypt(&feishu.app_secret).unwrap(), "feishu-secret");
        assert!(
            feishu
                .encrypt_key
                .as_deref()
                .is_some_and(crate::secrets::SecretStore::is_encrypted)
        );
        assert_eq!(
            store
                .decrypt(feishu.encrypt_key.as_deref().unwrap())
                .unwrap(),
            "feishu-encrypt"
        );
        assert!(
            feishu
                .verification_token
                .as_deref()
                .is_some_and(crate::secrets::SecretStore::is_encrypted)
        );
        assert_eq!(
            store
                .decrypt(feishu.verification_token.as_deref().unwrap())
                .unwrap(),
            "feishu-verify"
        );

        // Webhook auth_header — newly tagged `#[secret]`.
        let webhook = stored.channels.webhook.get("primary").unwrap();
        let webhook_auth = webhook.auth_header.as_deref().unwrap();
        assert!(
            crate::secrets::SecretStore::is_encrypted(webhook_auth),
            "webhook auth_header must be encrypted on save"
        );
        assert_eq!(store.decrypt(webhook_auth).unwrap(), "Bearer webhook-cred");
        // The pre-existing webhook `secret` field stays encrypted too —
        // sanity check that the refactor didn't regress it.
        let webhook_secret = webhook.secret.as_deref().unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(webhook_secret));
        assert_eq!(
            store.decrypt(webhook_secret).unwrap(),
            "webhook-shared-secret"
        );

        // MCP server headers — every value must be encrypted; the keys
        // stay plaintext (TOML table headers are not secret).
        let mcp_server = stored
            .mcp
            .servers
            .iter()
            .find(|s| s.name == "primary")
            .expect("mcp server `primary` round-trips through save");
        for (key, value) in &mcp_server.headers {
            assert!(
                crate::secrets::SecretStore::is_encrypted(value),
                "mcp.servers.primary.headers.{key} must be encrypted on save"
            );
        }
        let mcp_env = mcp_server.env.get("MCP_API_KEY").unwrap();
        assert!(
            crate::secrets::SecretStore::is_encrypted(mcp_env),
            "mcp.servers.primary.env.MCP_API_KEY must be encrypted on save"
        );
        let auth = mcp_server.headers.get("Authorization").unwrap();
        let tenant = mcp_server.headers.get("X-Tenant").unwrap();
        assert_eq!(store.decrypt(mcp_env).unwrap(), "mcp-env-credential");
        assert_eq!(store.decrypt(auth).unwrap(), "Bearer mcp-cred");
        assert_eq!(store.decrypt(tenant).unwrap(), "tenant-42");

        let _ = fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn config_save_atomic_cleanup() {
        let dir =
            std::env::temp_dir().join(format!("zeroclaw_test_config_{}", uuid::Uuid::new_v4()));
        fs::create_dir_all(&dir).await.unwrap();

        let config_path = dir.join("config.toml");
        let mut config = Config {
            data_dir: dir.join("workspace"),
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.providers.models.openrouter.insert(
            "default".to_string(),
            OpenRouterModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("model-a".into()),
                    ..Default::default()
                },
            },
        );
        config.save().await.unwrap();
        assert!(config_path.exists());

        config
            .providers
            .models
            .ensure("openrouter", "default")
            .unwrap()
            .model = Some("model-b".into());
        config.save().await.unwrap();

        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
        assert!(contents.contains("model-b"));

        let mut names: Vec<String> = Vec::new();
        let mut read_dir = fs::read_dir(&dir).await.unwrap();
        while let Some(entry) = read_dir.next_entry().await.unwrap() {
            names.push(entry.file_name().to_string_lossy().to_string());
        }
        assert!(!names.iter().any(|name| name.contains(".tmp-")));
        assert!(!names.iter().any(|name| name.ends_with(".bak")));

        let _ = fs::remove_dir_all(&dir).await;
    }

    // ── Telegram / Discord config ────────────────────────────

    #[test]
    async fn telegram_config_serde() {
        let tc = TelegramConfig {
            enabled: true,
            bot_token: "123:XYZ".into(),
            api_base_url: default_telegram_api_base_url(),
            stream_mode: StreamMode::Partial,
            draft_update_interval_ms: 500,
            interrupt_on_new_message: true,
            mention_only: false,
            ack_reactions: None,
            proxy_url: None,
            approval_timeout_secs: 120,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
            debounce_ms: None,
        };
        let json = serde_json::to_string(&tc).unwrap();
        let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.bot_token, "123:XYZ");
        assert_eq!(parsed.stream_mode, StreamMode::Partial);
        assert_eq!(parsed.draft_update_interval_ms, 500);
        assert!(parsed.interrupt_on_new_message);
    }

    #[test]
    async fn telegram_config_defaults_stream_off() {
        let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
        let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
        assert_eq!(parsed.stream_mode, StreamMode::Off);
        assert_eq!(parsed.draft_update_interval_ms, 1000);
        assert!(!parsed.interrupt_on_new_message);
        assert_eq!(parsed.api_base_url, "https://api.telegram.org");
    }

    #[test]
    async fn discord_config_serde() {
        let dc = DiscordConfig {
            enabled: true,
            bot_token: "discord-token".into(),
            guild_ids: vec!["12345".into()],
            channel_ids: vec![],
            archive: false,
            listen_to_bots: false,
            interrupt_on_new_message: false,
            mention_only: false,
            slash_commands: false,
            slash_command_scope: SlashCommandScope::default(),
            proxy_url: None,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1000,
            multi_message_delay_ms: 800,
            stall_timeout_secs: 0,
            intents_mask: None,
            reaction_notifications: DiscordReactionScope::Off,
            approval_timeout_secs: 300,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let json = serde_json::to_string(&dc).unwrap();
        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.bot_token, "discord-token");
        assert_eq!(parsed.guild_ids, vec!["12345".to_string()]);
    }

    #[test]
    async fn discord_config_empty_guild_ids() {
        let dc = DiscordConfig {
            enabled: true,
            bot_token: "tok".into(),
            guild_ids: Vec::new(),
            channel_ids: vec![],
            archive: false,
            listen_to_bots: false,
            interrupt_on_new_message: false,
            mention_only: false,
            slash_commands: false,
            slash_command_scope: SlashCommandScope::default(),
            proxy_url: None,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1000,
            multi_message_delay_ms: 800,
            stall_timeout_secs: 0,
            intents_mask: None,
            reaction_notifications: DiscordReactionScope::Off,
            approval_timeout_secs: 300,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let json = serde_json::to_string(&dc).unwrap();
        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
        assert!(parsed.guild_ids.is_empty());
    }

    // ── iMessage / Matrix config ────────────────────────────

    // iMessage `allowed_contacts` was lifted out of `IMessageConfig` in V3;
    // inbound peer authorization lives in `Config::peer_groups`. The
    // round-trip of contact-list values from a V2 TOML is exercised by
    // `imessage_v2_allowed_contacts_fold_into_peer_groups` below; per-field
    // struct serde for `allowed_contacts` no longer applies.

    #[test]
    async fn imessage_v2_allowed_contacts_fold_into_peer_groups() {
        // V2 TOML with `allowed_contacts` on the channel must be folded
        // into a synthesized `peer_groups.imessage_default` group with
        // each contact as an external peer.
        let raw = r#"
schema_version = 2

[channels.imessage]
enabled = true
allowed_contacts = ["+1234567890", "user@icloud.com"]
"#;
        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
        let group = parsed
            .peer_groups
            .get("imessage_default")
            .expect("V2 imessage.allowed_contacts must fold into peer_groups.imessage_default");
        assert_eq!(group.channel, "imessage");
        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
        assert_eq!(usernames, vec!["+1234567890", "user@icloud.com"]);
    }

    #[test]
    async fn matrix_config_serde() {
        let mc = MatrixConfig {
            enabled: true,
            homeserver: "https://matrix.org".into(),
            access_token: Some("syt_token_abc".into()),
            user_id: Some("@bot:matrix.org".into()),
            device_id: Some("DEVICE123".into()),
            allowed_rooms: vec!["!room123:matrix.org".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let json = serde_json::to_string(&mc).unwrap();
        let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.homeserver, "https://matrix.org");
        assert_eq!(parsed.access_token.as_deref(), Some("syt_token_abc"));
        assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
        assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
        assert_eq!(
            parsed.allowed_rooms.first().map(|s| s.as_str()),
            Some("!room123:matrix.org")
        );
    }

    #[test]
    async fn matrix_config_toml_roundtrip() {
        let mc = MatrixConfig {
            enabled: true,
            homeserver: "https://synapse.local:8448".into(),
            access_token: Some("tok".into()),
            user_id: None,
            device_id: None,
            allowed_rooms: vec!["!abc:synapse.local".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let toml_str = toml::to_string(&mc).unwrap();
        let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(parsed.homeserver, "https://synapse.local:8448");
        assert_eq!(parsed.allowed_rooms.len(), 1);
    }

    #[test]
    async fn matrix_config_backward_compatible_without_session_hints() {
        // room_id in TOML is now migrated by prepare_table at the top level;
        // a bare MatrixConfig parse just ignores unknown keys.
        let toml = r#"
homeserver = "https://matrix.org"
access_token = "tok"
allowed_users = ["@ops:matrix.org"]
allowed_rooms = ["!ops:matrix.org"]
"#;

        let parsed: MatrixConfig = toml::from_str(toml).unwrap();
        assert_eq!(parsed.homeserver, "https://matrix.org");
        assert!(parsed.user_id.is_none());
        assert!(parsed.device_id.is_none());
        assert_eq!(parsed.allowed_rooms, vec!["!ops:matrix.org"]);
    }

    #[test]
    async fn matrix_config_reply_in_thread_defaults_to_true() {
        let toml = r#"
homeserver = "https://matrix.org"
access_token = "tok"
allowed_users = ["@u:matrix.org"]
"#;
        let parsed: MatrixConfig = toml::from_str(toml).unwrap();
        assert!(parsed.reply_in_thread);
    }

    #[test]
    async fn signal_config_serde() {
        let sc = SignalConfig {
            enabled: true,
            http_url: "http://127.0.0.1:8686".into(),
            account: "+1234567890".into(),
            group_ids: vec!["group123".into()],
            dm_only: false,
            ignore_attachments: true,
            ignore_stories: false,
            proxy_url: None,
            approval_timeout_secs: 300,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let json = serde_json::to_string(&sc).unwrap();
        let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
        assert_eq!(parsed.account, "+1234567890");
        assert_eq!(parsed.group_ids, vec!["group123".to_string()]);
        assert!(!parsed.dm_only);
        assert!(parsed.ignore_attachments);
        assert!(!parsed.ignore_stories);
    }

    #[test]
    async fn signal_config_toml_roundtrip() {
        let sc = SignalConfig {
            enabled: true,
            http_url: "http://localhost:8080".into(),
            account: "+9876543210".into(),
            group_ids: Vec::new(),
            dm_only: true,
            ignore_attachments: false,
            ignore_stories: true,
            proxy_url: None,
            approval_timeout_secs: 300,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let toml_str = toml::to_string(&sc).unwrap();
        let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(parsed.http_url, "http://localhost:8080");
        assert_eq!(parsed.account, "+9876543210");
        assert!(parsed.group_ids.is_empty());
        assert!(parsed.dm_only);
        assert!(parsed.ignore_stories);
    }

    #[test]
    async fn signal_config_defaults() {
        let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
        let parsed: SignalConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.group_ids.is_empty());
        assert!(!parsed.dm_only);
        assert!(!parsed.ignore_attachments);
        assert!(!parsed.ignore_stories);
    }

    #[test]
    async fn channels_with_imessage_and_matrix() {
        let c = ChannelsConfig {
            cli: true,
            telegram: HashMap::new(),
            discord: HashMap::new(),
            slack: HashMap::new(),
            mattermost: HashMap::new(),
            webhook: HashMap::new(),
            imessage: HashMap::from([(
                "default".to_string(),
                IMessageConfig {
                    enabled: true,
                    excluded_tools: vec![],
                    reply_min_interval_secs: 0,
                    reply_queue_depth_max: 0,
                },
            )]),
            matrix: HashMap::from([(
                "default".to_string(),
                MatrixConfig {
                    enabled: true,
                    homeserver: "https://m.org".into(),
                    access_token: Some("tok".into()),
                    user_id: None,
                    device_id: None,
                    allowed_rooms: vec!["!r:m".into()],
                    interrupt_on_new_message: false,
                    stream_mode: StreamMode::default(),
                    draft_update_interval_ms: 1500,
                    multi_message_delay_ms: 800,
                    recovery_key: None,
                    mention_only: false,
                    password: None,
                    approval_timeout_secs: 300,
                    reply_in_thread: true,
                    ack_reactions: Some(true),
                    excluded_tools: vec![],
                    reply_min_interval_secs: 0,
                    reply_queue_depth_max: 0,
                },
            )]),
            signal: HashMap::new(),
            whatsapp: HashMap::new(),
            linq: HashMap::new(),
            wati: HashMap::new(),
            nextcloud_talk: HashMap::new(),
            email: HashMap::new(),
            gmail_push: HashMap::new(),
            irc: HashMap::new(),
            twitch: HashMap::new(),
            lark: HashMap::new(),
            line: HashMap::new(),
            dingtalk: HashMap::new(),
            wecom: HashMap::new(),
            wecom_ws: HashMap::new(),
            wechat: HashMap::new(),
            qq: HashMap::new(),
            twitter: HashMap::new(),
            mochat: HashMap::new(),
            nostr: HashMap::new(),
            clawdtalk: HashMap::new(),
            reddit: HashMap::new(),
            bluesky: HashMap::new(),
            git: HashMap::new(),
            voice_call: HashMap::new(),
            voice_duplex: HashMap::new(),
            voice_wake: HashMap::new(),
            mqtt: HashMap::new(),
            amqp: HashMap::new(),
            filesystem: HashMap::new(),
            message_timeout_secs: 300,
            max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
            ack_reactions: true,
            show_tool_calls: true,
            session_persistence: true,
            session_backend: default_session_backend(),
            session_ttl_hours: 0,
            debounce_ms: 0,
        };
        let toml_str = toml::to_string_pretty(&c).unwrap();
        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
        assert!(!parsed.imessage.is_empty());
        assert!(!parsed.matrix.is_empty());
        assert_eq!(
            parsed.matrix.get("default").unwrap().homeserver,
            "https://m.org"
        );
    }

    #[test]
    async fn channels_default_has_no_imessage_matrix() {
        let c = ChannelsConfig::default();
        assert!(c.imessage.is_empty());
        assert!(c.matrix.is_empty());
    }

    // ── Edge cases: serde(default) for non-secret optional fields ─────
    // The legacy `allowed_users` field is no longer carried on channel
    // configs (V3 moved inbound peer authorization into
    // `Config::peer_groups`); V2 TOMLs with `allowed_users` are folded
    // by `migrate_to_current` into `[peer_groups.<type>_<alias>]`. See
    // `discord_v2_allowed_users_fold_into_peer_groups` below.

    #[test]
    async fn discord_v2_allowed_users_fold_into_peer_groups() {
        let raw = r#"
schema_version = 2

[channels.discord]
enabled = true
bot_token = "tok"
guild_id = "123"
allowed_users = ["111", "222"]
"#;
        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
        let group = parsed
            .peer_groups
            .get("discord_default")
            .expect("V2 discord.allowed_users must fold into peer_groups.discord_default");
        assert_eq!(group.channel, "discord");
        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
        assert_eq!(usernames, vec!["111", "222"]);
    }

    #[test]
    async fn slack_v2_allowed_users_fold_into_peer_groups() {
        let raw = r#"
schema_version = 2

[channels.slack]
enabled = true
bot_token = "xoxb-tok"
allowed_users = ["U111"]
"#;
        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
        let group = parsed
            .peer_groups
            .get("slack_default")
            .expect("V2 slack.allowed_users must fold into peer_groups.slack_default");
        assert_eq!(group.channel, "slack");
        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
        assert_eq!(usernames, vec!["U111"]);
    }

    #[test]
    async fn slack_config_deserializes_with_channel_ids() {
        let json = r#"{"bot_token":"xoxb-tok","channel_ids":["C111","D222"]}"#;
        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
        assert_eq!(parsed.channel_ids, vec!["C111", "D222"]);
        assert!(!parsed.interrupt_on_new_message);
        assert_eq!(parsed.thread_replies, None);
        assert!(!parsed.mention_only);
    }

    #[test]
    async fn slack_config_deserializes_with_mention_only() {
        let json = r#"{"bot_token":"xoxb-tok","mention_only":true}"#;
        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.mention_only);
        assert!(!parsed.interrupt_on_new_message);
        assert_eq!(parsed.thread_replies, None);
    }

    #[test]
    async fn slack_config_deserializes_interrupt_on_new_message() {
        let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.interrupt_on_new_message);
        assert_eq!(parsed.thread_replies, None);
        assert!(!parsed.mention_only);
    }

    #[test]
    async fn slack_config_deserializes_thread_replies() {
        let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
        assert_eq!(parsed.thread_replies, Some(false));
        assert!(!parsed.interrupt_on_new_message);
        assert!(!parsed.mention_only);
    }

    #[test]
    async fn discord_config_default_interrupt_on_new_message_is_false() {
        let json = r#"{"bot_token":"tok"}"#;
        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
        assert!(!parsed.interrupt_on_new_message);
    }

    #[test]
    async fn discord_config_deserializes_interrupt_on_new_message_true() {
        let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.interrupt_on_new_message);
    }

    #[test]
    async fn discord_config_toml_backward_compat() {
        let toml_str = r#"
bot_token = "tok"
guild_id = "123"
"#;
        let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(parsed.bot_token, "tok");
    }

    #[test]
    async fn slack_config_toml_with_channel_ids() {
        let toml_str = r#"
bot_token = "xoxb-tok"
channel_ids = ["C123", "D456"]
"#;
        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(parsed.channel_ids, vec!["C123", "D456"]);
        assert!(!parsed.interrupt_on_new_message);
        assert_eq!(parsed.thread_replies, None);
        assert!(!parsed.mention_only);
    }

    #[test]
    async fn slack_config_toml_without_channel_ids_defaults_empty() {
        let toml_str = r#"
bot_token = "xoxb-tok"
"#;
        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
        assert!(parsed.channel_ids.is_empty());
    }

    #[test]
    async fn mattermost_config_default_interrupt_on_new_message_is_false() {
        let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#;
        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
        assert!(!parsed.interrupt_on_new_message);
    }

    #[test]
    async fn mattermost_config_deserializes_interrupt_on_new_message_true() {
        let json =
            r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#;
        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.interrupt_on_new_message);
    }

    #[test]
    async fn whatsapp_config_default_interrupt_on_new_message_is_false() {
        let json = r#"{"session_path":"/tmp/zeroclaw-whatsapp-session.db"}"#;
        let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
        assert!(!parsed.interrupt_on_new_message);
    }

    #[test]
    async fn whatsapp_config_deserializes_interrupt_on_new_message_true() {
        let json = r#"{"session_path":"/tmp/zeroclaw-whatsapp-session.db","interrupt_on_new_message":true}"#;
        let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.interrupt_on_new_message);
    }

    #[test]
    async fn webhook_config_with_secret() {
        let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
        assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
    }

    #[test]
    async fn webhook_config_without_secret() {
        let json = r#"{"port":8080}"#;
        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.secret.is_none());
        assert_eq!(parsed.port, 8080);
    }

    #[test]
    async fn webhook_config_port_defaults_when_omitted() {
        let p: WebhookConfig = serde_json::from_str("{}").unwrap();
        assert_eq!(p.port, 8090);
    }

    #[test]
    async fn webhook_config_retry_fields_default_to_none() {
        let json = r#"{"port":8080}"#;
        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.max_retries.is_none());
        assert!(parsed.retry_base_delay_ms.is_none());
        assert!(parsed.retry_max_delay_ms.is_none());
    }

    #[test]
    async fn webhook_config_retry_fields_roundtrip() {
        let wc = WebhookConfig {
            enabled: true,
            port: 8080,
            listen_path: None,
            send_url: Some("https://example.com/cb".into()),
            send_method: None,
            auth_header: None,
            secret: None,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
            max_retries: Some(5),
            retry_base_delay_ms: Some(250),
            retry_max_delay_ms: Some(10_000),
        };

        let json = serde_json::to_string(&wc).unwrap();
        let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.max_retries, Some(5));
        assert_eq!(parsed.retry_base_delay_ms, Some(250));
        assert_eq!(parsed.retry_max_delay_ms, Some(10_000));

        let toml_str = toml::to_string(&wc).unwrap();
        let parsed: WebhookConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(parsed.max_retries, Some(5));
        assert_eq!(parsed.retry_base_delay_ms, Some(250));
        assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
    }

    // ── WhatsApp config ──────────────────────────────────────

    #[test]
    async fn whatsapp_config_serde() {
        let wc = WhatsAppConfig {
            enabled: true,
            access_token: Some("EAABx...".into()),
            phone_number_id: Some("123456789".into()),
            verify_token: Some("my-verify-token".into()),
            app_secret: None,
            session_path: None,
            pair_phone: None,
            pair_code: None,
            ws_url: None,
            mention_only: false,
            passive_group_context: false,
            interrupt_on_new_message: false,
            mode: WhatsAppWebMode::default(),
            dm_policy: WhatsAppChatPolicy::default(),
            group_policy: WhatsAppChatPolicy::default(),
            self_chat_mode: false,
            dm_mention_patterns: vec![],
            group_mention_patterns: vec![],
            allowed_groups: vec![],
            proxy_url: None,
            approval_timeout_secs: 300,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let json = serde_json::to_string(&wc).unwrap();
        let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.access_token, Some("EAABx...".into()));
        assert_eq!(parsed.phone_number_id, Some("123456789".into()));
        assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
    }

    #[test]
    async fn whatsapp_config_toml_roundtrip() {
        let wc = WhatsAppConfig {
            enabled: true,
            access_token: Some("tok".into()),
            phone_number_id: Some("12345".into()),
            verify_token: Some("verify".into()),
            app_secret: Some("secret123".into()),
            session_path: None,
            pair_phone: None,
            pair_code: None,
            ws_url: None,
            mention_only: false,
            passive_group_context: false,
            interrupt_on_new_message: false,
            mode: WhatsAppWebMode::default(),
            dm_policy: WhatsAppChatPolicy::default(),
            group_policy: WhatsAppChatPolicy::default(),
            self_chat_mode: false,
            dm_mention_patterns: vec![],
            group_mention_patterns: vec![],
            allowed_groups: vec![],
            proxy_url: None,
            approval_timeout_secs: 300,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let toml_str = toml::to_string(&wc).unwrap();
        let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(parsed.phone_number_id, Some("12345".into()));
    }

    #[test]
    async fn whatsapp_config_passive_group_context_defaults_off() {
        let parsed: WhatsAppConfig = serde_json::from_str("{}").unwrap();
        assert!(!parsed.passive_group_context);
    }

    #[test]
    async fn whatsapp_config_passive_group_context_deserializes_true() {
        let parsed: WhatsAppConfig =
            serde_json::from_str(r#"{"passive_group_context":true}"#).unwrap();
        assert!(parsed.passive_group_context);
    }

    #[test]
    async fn whatsapp_v2_allowed_numbers_fold_into_peer_groups() {
        // V2 `allowed_numbers` on a WhatsApp channel migrates to a
        // synthesized `peer_groups.whatsapp_default` group. The wildcard
        // `*` is dropped at synthesis; concrete numbers round-trip.
        let raw = r#"
schema_version = 2

[channels.whatsapp]
enabled = true
access_token = "tok"
phone_number_id = "123"
verify_token = "ver"
allowed_numbers = ["+1", "+2"]
"#;
        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
        let group = parsed
            .peer_groups
            .get("whatsapp_default")
            .expect("V2 whatsapp.allowed_numbers must fold into peer_groups.whatsapp_default");
        assert_eq!(group.channel, "whatsapp");
        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
        assert_eq!(usernames, vec!["+1", "+2"]);
    }

    #[test]
    async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
        let wc = WhatsAppConfig {
            enabled: true,
            access_token: Some("tok".into()),
            phone_number_id: Some("123".into()),
            verify_token: Some("ver".into()),
            app_secret: None,
            session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
            pair_phone: None,
            pair_code: None,
            ws_url: None,
            mention_only: false,
            passive_group_context: false,
            interrupt_on_new_message: false,
            mode: WhatsAppWebMode::default(),
            dm_policy: WhatsAppChatPolicy::default(),
            group_policy: WhatsAppChatPolicy::default(),
            self_chat_mode: false,
            dm_mention_patterns: vec![],
            group_mention_patterns: vec![],
            allowed_groups: vec![],
            proxy_url: None,
            approval_timeout_secs: 300,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        assert!(wc.is_ambiguous_config());
        assert_eq!(wc.backend_type(), "cloud");
    }

    #[test]
    async fn whatsapp_config_backend_type_web() {
        let wc = WhatsAppConfig {
            enabled: true,
            access_token: None,
            phone_number_id: None,
            verify_token: None,
            app_secret: None,
            session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
            pair_phone: None,
            pair_code: None,
            ws_url: None,
            mention_only: false,
            passive_group_context: false,
            interrupt_on_new_message: false,
            mode: WhatsAppWebMode::default(),
            dm_policy: WhatsAppChatPolicy::default(),
            group_policy: WhatsAppChatPolicy::default(),
            self_chat_mode: false,
            dm_mention_patterns: vec![],
            group_mention_patterns: vec![],
            allowed_groups: vec![],
            proxy_url: None,
            approval_timeout_secs: 300,
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        assert!(!wc.is_ambiguous_config());
        assert_eq!(wc.backend_type(), "web");
    }

    #[test]
    async fn whatsapp_config_backend_type_web_from_personal_pairing() {
        let wc = WhatsAppConfig {
            enabled: true,
            mode: WhatsAppWebMode::Personal,
            pair_phone: Some("+10000000000".into()),
            ..Default::default()
        };
        assert_eq!(wc.backend_type(), "web");
        assert!(wc.is_web_config());
        assert!(!wc.is_cloud_config());

        let pair_only = WhatsAppConfig {
            enabled: true,
            pair_phone: Some("+10000000000".into()),
            ..Default::default()
        };
        assert_eq!(pair_only.backend_type(), "web");

        let empty = WhatsAppConfig {
            enabled: true,
            ..Default::default()
        };
        assert_eq!(empty.backend_type(), "cloud");

        let cloud_plus_pairing = WhatsAppConfig {
            enabled: true,
            phone_number_id: Some("123".into()),
            pair_phone: Some("+10000000000".into()),
            ..Default::default()
        };
        assert_eq!(cloud_plus_pairing.backend_type(), "cloud");
        assert!(cloud_plus_pairing.is_ambiguous_config());
    }

    #[test]
    async fn channels_with_whatsapp() {
        let c = ChannelsConfig {
            cli: true,
            telegram: HashMap::new(),
            discord: HashMap::new(),
            slack: HashMap::new(),
            mattermost: HashMap::new(),
            webhook: HashMap::new(),
            imessage: HashMap::new(),
            matrix: HashMap::new(),
            signal: HashMap::new(),
            whatsapp: HashMap::from([(
                "default".to_string(),
                WhatsAppConfig {
                    enabled: true,
                    access_token: Some("tok".into()),
                    phone_number_id: Some("123".into()),
                    verify_token: Some("ver".into()),
                    app_secret: None,
                    session_path: None,
                    pair_phone: None,
                    pair_code: None,
                    ws_url: None,
                    mention_only: false,
                    passive_group_context: false,
                    interrupt_on_new_message: false,
                    mode: WhatsAppWebMode::default(),
                    dm_policy: WhatsAppChatPolicy::default(),
                    group_policy: WhatsAppChatPolicy::default(),
                    self_chat_mode: false,
                    dm_mention_patterns: vec![],
                    group_mention_patterns: vec![],
                    allowed_groups: vec![],
                    proxy_url: None,
                    approval_timeout_secs: 300,
                    excluded_tools: vec![],
                    reply_min_interval_secs: 0,
                    reply_queue_depth_max: 0,
                },
            )]),
            linq: HashMap::new(),
            wati: HashMap::new(),
            nextcloud_talk: HashMap::new(),
            email: HashMap::new(),
            gmail_push: HashMap::new(),
            irc: HashMap::new(),
            twitch: HashMap::new(),
            lark: HashMap::new(),
            line: HashMap::new(),
            dingtalk: HashMap::new(),
            wecom: HashMap::new(),
            wecom_ws: HashMap::new(),
            wechat: HashMap::new(),
            qq: HashMap::new(),
            twitter: HashMap::new(),
            mochat: HashMap::new(),
            nostr: HashMap::new(),
            clawdtalk: HashMap::new(),
            reddit: HashMap::new(),
            bluesky: HashMap::new(),
            git: HashMap::new(),
            voice_call: HashMap::new(),
            voice_duplex: HashMap::new(),
            voice_wake: HashMap::new(),
            mqtt: HashMap::new(),
            amqp: HashMap::new(),
            filesystem: HashMap::new(),
            message_timeout_secs: 300,
            max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
            ack_reactions: true,
            show_tool_calls: true,
            session_persistence: true,
            session_backend: default_session_backend(),
            session_ttl_hours: 0,
            debounce_ms: 0,
        };
        let toml_str = toml::to_string_pretty(&c).unwrap();
        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
        assert!(!parsed.whatsapp.is_empty());
        let wa = parsed.whatsapp.get("default").unwrap();
        assert_eq!(wa.phone_number_id, Some("123".into()));
    }

    #[test]
    async fn channels_default_has_no_whatsapp() {
        let c = ChannelsConfig::default();
        assert!(c.whatsapp.is_empty());
    }

    #[test]
    async fn channels_default_has_no_nextcloud_talk() {
        let c = ChannelsConfig::default();
        assert!(c.nextcloud_talk.is_empty());
    }

    // ══════════════════════════════════════════════════════════
    // SECURITY CHECKLIST TESTS — Gateway config
    // ══════════════════════════════════════════════════════════

    #[test]
    async fn checklist_gateway_default_requires_pairing() {
        let g = GatewayConfig::default();
        assert!(g.require_pairing, "Pairing must be required by default");
    }

    #[test]
    async fn checklist_gateway_default_blocks_public_bind() {
        let g = GatewayConfig::default();
        assert!(
            !g.allow_public_bind,
            "Public bind must be blocked by default"
        );
    }

    #[test]
    async fn checklist_gateway_default_no_tokens() {
        let g = GatewayConfig::default();
        assert!(
            g.paired_tokens.is_empty(),
            "No pre-paired tokens by default"
        );
        assert_eq!(g.pair_rate_limit_per_minute, 10);
        assert_eq!(g.webhook_rate_limit_per_minute, 60);
        assert!(!g.trust_forwarded_headers);
        assert_eq!(g.rate_limit_max_keys, 10_000);
        assert_eq!(g.idempotency_ttl_secs, 300);
        assert_eq!(g.idempotency_max_keys, 10_000);
    }

    #[test]
    async fn checklist_gateway_cli_default_host_is_localhost() {
        // The CLI default for --host is 127.0.0.1 (checked in main.rs)
        // Here we verify the config default matches
        let c = Config::default();
        assert!(
            c.gateway.require_pairing,
            "Config default must require pairing"
        );
        assert!(
            !c.gateway.allow_public_bind,
            "Config default must block public bind"
        );
    }

    #[test]
    async fn checklist_gateway_serde_roundtrip() {
        let g = GatewayConfig {
            port: 42617,
            host: "127.0.0.1".into(),
            require_pairing: true,
            allow_public_bind: false,
            allow_remote_admin: false,
            paired_tokens: vec!["zc_test_token".into()],
            pair_rate_limit_per_minute: 12,
            webhook_rate_limit_per_minute: 80,
            trust_forwarded_headers: true,
            path_prefix: Some("/zeroclaw".into()),
            rate_limit_max_keys: 2048,
            idempotency_ttl_secs: 600,
            idempotency_max_keys: 4096,
            session_persistence: true,
            session_ttl_hours: 0,
            pairing_dashboard: PairingDashboardConfig::default(),
            web_dist_dir: None,
            tls: None,
            request_timeout_secs: 30,
            long_running_request_timeout_secs: 600,
            check_updates: true,
            allow_self_upgrade: false,
        };
        let toml_str = toml::to_string(&g).unwrap();
        let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
        assert!(parsed.require_pairing);
        assert!(parsed.session_persistence);
        assert_eq!(parsed.session_ttl_hours, 0);
        assert!(!parsed.allow_public_bind);
        assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
        assert_eq!(parsed.pair_rate_limit_per_minute, 12);
        assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
        assert!(parsed.trust_forwarded_headers);
        assert_eq!(parsed.path_prefix.as_deref(), Some("/zeroclaw"));
        assert_eq!(parsed.rate_limit_max_keys, 2048);
        assert_eq!(parsed.idempotency_ttl_secs, 600);
        assert_eq!(parsed.idempotency_max_keys, 4096);
        assert!(parsed.check_updates);
        assert!(!parsed.allow_self_upgrade);
    }

    #[test]
    async fn checklist_gateway_backward_compat_no_gateway_section() {
        // Old configs without [gateway] should get secure defaults
        let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
        let parsed = parse_test_config(minimal);
        assert!(
            parsed.gateway.require_pairing,
            "Missing [gateway] must default to require_pairing=true"
        );
        assert!(
            !parsed.gateway.allow_public_bind,
            "Missing [gateway] must default to allow_public_bind=false"
        );
    }

    #[test]
    async fn checklist_risk_profile_default_is_workspace_scoped() {
        let a = RiskProfileConfig::default();
        assert!(a.workspace_only, "Default profile must be workspace_only");
        assert!(
            !a.forbidden_paths.is_empty(),
            "Default forbidden_paths must not be empty"
        );
        #[cfg(not(target_os = "windows"))]
        {
            assert!(
                a.forbidden_paths.iter().any(|p| p == "/etc"),
                "Must block /etc on Unix"
            );
            assert!(
                a.forbidden_paths.iter().any(|p| p == "/proc"),
                "Must block /proc on Unix"
            );
        }
        #[cfg(target_os = "windows")]
        {
            assert!(
                a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
                "Must block C:\\Windows on Windows"
            );
            assert!(
                a.forbidden_paths.iter().any(|p| p == "C:\\Program Files"),
                "Must block C:\\Program Files on Windows"
            );
        }
        assert!(
            a.forbidden_paths.contains(&"~/.ssh".to_string()),
            "Must block ~/.ssh"
        );
    }

    // ══════════════════════════════════════════════════════════
    // COMPOSIO CONFIG TESTS
    // ══════════════════════════════════════════════════════════

    #[test]
    async fn composio_config_default_disabled() {
        let c = ComposioConfig::default();
        assert!(!c.enabled, "Composio must be disabled by default");
        assert!(c.api_key.is_none(), "No API key by default");
        assert_eq!(c.entity_id, "default");
    }

    #[test]
    async fn composio_config_serde_roundtrip() {
        let c = ComposioConfig {
            enabled: true,
            api_key: Some("comp-key-123".into()),
            entity_id: "user42".into(),
        };
        let toml_str = toml::to_string(&c).unwrap();
        let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
        assert!(parsed.enabled);
        assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
        assert_eq!(parsed.entity_id, "user42");
    }

    #[test]
    async fn composio_config_backward_compat_missing_section() {
        let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
        let parsed = parse_test_config(minimal);
        assert!(
            !parsed.composio.enabled,
            "Missing [composio] must default to disabled"
        );
        assert!(parsed.composio.api_key.is_none());
    }

    #[test]
    async fn composio_config_partial_toml() {
        let toml_str = r"
enabled = true
";
        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
        assert!(parsed.enabled);
        assert!(parsed.api_key.is_none());
        assert_eq!(parsed.entity_id, "default");
    }

    #[test]
    async fn composio_config_enable_alias_supported() {
        let toml_str = r"
enable = true
";
        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
        assert!(parsed.enabled);
        assert!(parsed.api_key.is_none());
        assert_eq!(parsed.entity_id, "default");
    }

    // ══════════════════════════════════════════════════════════
    // SECRETS CONFIG TESTS
    // ══════════════════════════════════════════════════════════

    #[test]
    async fn secrets_config_default_encrypts() {
        let s = SecretsConfig::default();
        assert!(s.encrypt, "Encryption must be enabled by default");
    }

    #[test]
    async fn secrets_config_serde_roundtrip() {
        let s = SecretsConfig { encrypt: false };
        let toml_str = toml::to_string(&s).unwrap();
        let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
        assert!(!parsed.encrypt);
    }

    #[test]
    async fn secrets_config_backward_compat_missing_section() {
        let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
        let parsed = parse_test_config(minimal);
        assert!(
            parsed.secrets.encrypt,
            "Missing [secrets] must default to encrypt=true"
        );
    }

    #[test]
    async fn config_default_has_composio_and_secrets() {
        let c = Config::default();
        assert!(!c.composio.enabled);
        assert!(c.composio.api_key.is_none());
        assert!(c.secrets.encrypt);
        assert!(c.browser.enabled);
        assert_eq!(c.browser.allowed_domains, vec!["*".to_string()]);
    }

    #[test]
    async fn browser_config_default_enabled() {
        let b = BrowserConfig::default();
        assert!(b.enabled);
        assert_eq!(b.allowed_domains, vec!["*".to_string()]);
        assert_eq!(b.backend, "agent_browser");
        assert_eq!(b.headed, None);
        assert!(b.native_headless);
        assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
        assert!(b.native_chrome_path.is_none());
        assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
        assert_eq!(b.computer_use.timeout_ms, 15_000);
        assert!(!b.computer_use.allow_remote_endpoint);
        assert!(b.computer_use.window_allowlist.is_empty());
        assert!(b.computer_use.max_coordinate_x.is_none());
        assert!(b.computer_use.max_coordinate_y.is_none());
    }

    #[test]
    async fn browser_config_serde_roundtrip() {
        let b = BrowserConfig {
            enabled: true,
            allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
            session_name: None,
            backend: "auto".into(),
            headed: Some(true),
            native_headless: false,
            native_webdriver_url: "http://localhost:4444".into(),
            native_chrome_path: Some("/usr/bin/chromium".into()),
            computer_use: BrowserComputerUseConfig {
                endpoint: "https://computer-use.example.com/v1/actions".into(),
                api_key: Some("test-token".into()),
                timeout_ms: 8_000,
                allow_remote_endpoint: true,
                window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
                max_coordinate_x: Some(3840),
                max_coordinate_y: Some(2160),
            },
            allowed_private_hosts: vec![],
        };
        let toml_str = toml::to_string(&b).unwrap();
        let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
        assert!(parsed.enabled);
        assert_eq!(parsed.allowed_domains.len(), 2);
        assert_eq!(parsed.allowed_domains[0], "example.com");
        assert_eq!(parsed.backend, "auto");
        assert_eq!(parsed.headed, Some(true));
        assert!(!parsed.native_headless);
        assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
        assert_eq!(
            parsed.native_chrome_path.as_deref(),
            Some("/usr/bin/chromium")
        );
        assert_eq!(
            parsed.computer_use.endpoint,
            "https://computer-use.example.com/v1/actions"
        );
        assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
        assert_eq!(parsed.computer_use.timeout_ms, 8_000);
        assert!(parsed.computer_use.allow_remote_endpoint);
        assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
        assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
        assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
    }

    #[test]
    async fn browser_config_parses_headed_true() {
        let parsed: BrowserConfig = toml::from_str(
            r#"
backend = "agent_browser"
headed = true
"#,
        )
        .unwrap();

        assert_eq!(parsed.backend, "agent_browser");
        assert_eq!(parsed.headed, Some(true));
        assert!(parsed.native_headless);
    }

    #[test]
    async fn browser_config_backward_compat_missing_section() {
        let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
        let parsed = parse_test_config(minimal);
        assert!(parsed.browser.enabled);
        assert_eq!(parsed.browser.allowed_domains, vec!["*".to_string()]);
    }

    async fn env_override_lock() -> MutexGuard<'static, ()> {
        // Delegate to the crate-shared lock so env-mutating tests in this
        // module serialize against `env_overrides::tests` too. Without
        // this, tests across the two modules race on `ZEROCLAW_*` vars.
        crate::env_overrides::env_test_lock().await
    }

    #[test]
    async fn slack_config_deserializes_without_bot_token() {
        // Regression for /: before `bot_token` became
        // `Option<String>` + `#[serde(default)]`, a config that omitted it
        // failed to deserialize with `missing field 'bot_token'`, aborting
        // startup before the env-var fallback could ever run.
        let parsed: SlackConfig = toml::from_str("enabled = true\n")
            .expect("SlackConfig must deserialize without bot_token");
        assert!(parsed.bot_token.is_none());
    }

    #[test]
    async fn slack_config_deserializes_explicit_bot_token() {
        let parsed: SlackConfig =
            toml::from_str("enabled = true\nbot_token = \"xoxb-from-toml\"\n").unwrap();
        assert_eq!(parsed.bot_token.as_deref(), Some("xoxb-from-toml"));
    }

    /// Set (`Some`) or clear (`None`) an env var. Callers must hold
    /// `env_override_lock()`. Used to snapshot-and-restore the Slack token
    /// vars so these tests leave the process environment exactly as found.
    fn set_or_clear_env(key: &str, value: Option<&str>) {
        // SAFETY: callers serialize on env_override_lock().
        unsafe {
            match value {
                Some(v) => std::env::set_var(key, v),
                None => std::env::remove_var(key),
            }
        }
    }

    #[test]
    async fn slack_resolved_bot_token_falls_back_to_env() {
        let _env_guard = env_override_lock().await;
        let prev_bot = std::env::var("SLACK_BOT_TOKEN").ok();
        let prev_zc = std::env::var("ZEROCLAW_SLACK_BOT_TOKEN").ok();
        set_or_clear_env("ZEROCLAW_SLACK_BOT_TOKEN", None);
        set_or_clear_env("SLACK_BOT_TOKEN", Some("xoxb-from-env"));

        let cfg = SlackConfig {
            bot_token: None,
            ..Default::default()
        };
        assert_eq!(cfg.resolved_bot_token().as_deref(), Some("xoxb-from-env"));

        set_or_clear_env("SLACK_BOT_TOKEN", prev_bot.as_deref());
        set_or_clear_env("ZEROCLAW_SLACK_BOT_TOKEN", prev_zc.as_deref());
    }

    #[test]
    async fn slack_resolved_bot_token_prefers_zeroclaw_prefix() {
        let _env_guard = env_override_lock().await;
        let prev_bot = std::env::var("SLACK_BOT_TOKEN").ok();
        let prev_zc = std::env::var("ZEROCLAW_SLACK_BOT_TOKEN").ok();
        set_or_clear_env("SLACK_BOT_TOKEN", Some("xoxb-generic"));
        set_or_clear_env("ZEROCLAW_SLACK_BOT_TOKEN", Some("xoxb-zeroclaw"));

        let cfg = SlackConfig {
            bot_token: None,
            ..Default::default()
        };
        assert_eq!(cfg.resolved_bot_token().as_deref(), Some("xoxb-zeroclaw"));

        set_or_clear_env("SLACK_BOT_TOKEN", prev_bot.as_deref());
        set_or_clear_env("ZEROCLAW_SLACK_BOT_TOKEN", prev_zc.as_deref());
    }

    #[test]
    async fn slack_resolved_bot_token_prefers_config_over_env() {
        let _env_guard = env_override_lock().await;
        let prev_bot = std::env::var("SLACK_BOT_TOKEN").ok();
        set_or_clear_env("SLACK_BOT_TOKEN", Some("xoxb-from-env"));

        let cfg = SlackConfig {
            bot_token: Some("xoxb-from-config".to_string()),
            ..Default::default()
        };
        assert_eq!(
            cfg.resolved_bot_token().as_deref(),
            Some("xoxb-from-config")
        );

        set_or_clear_env("SLACK_BOT_TOKEN", prev_bot.as_deref());
    }

    #[test]
    async fn v1_known_provider_migrates_with_globals_folded_onto_typed_slot() {
        // Top-level `model_provider` + `model` + `default_temperature` flow
        // onto the migrated typed-slot entry. Vendor-canonical names like
        // `openai` map straight to their typed slot; `wire_api` and
        // `requires_openai_auth` survive the move.
        //
        // (Unknown V1 names like `sub2api` are intentionally silent-dropped
        // by the V2→V3 migration — see the `Unknown/passthrough` arm of
        // `normalize_provider_type` in schema/v2.rs.)
        let raw = r#"
default_temperature = 0.7
model_provider = "openai"
model = "gpt-5.3-codex"

[model_providers.openai]
api_key = "sk-test"
uri = "https://api.openai.com/v1"
wire_api = "responses"
requires_openai_auth = true
"#;

        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
        assert!(
            parsed
                .providers
                .models
                .contains_model_provider_type("openai"),
            "vendor-canonical V1 provider should land in its typed slot",
        );
        let profile = parsed
            .providers
            .models
            .find("openai", "default")
            .expect("openai.default entry");
        assert_eq!(profile.api_key.as_deref(), Some("sk-test"));
        assert_eq!(profile.uri.as_deref(), Some("https://api.openai.com/v1"));
        assert_eq!(profile.model.as_deref(), Some("gpt-5.3-codex"));
        assert_eq!(profile.wire_api, Some(WireApi::Responses));
        assert!(profile.requires_openai_auth);
    }

    #[test]
    async fn typed_custom_slot_routes_uri_through_find() {
        let _env_guard = env_override_lock().await;
        let mut config = Config::default();
        config.providers.models.custom.insert(
            "default".to_string(),
            CustomModelProviderConfig {
                base: ModelProviderConfig {
                    uri: Some("https://api.tonsof.blue/v1".to_string()),
                    ..Default::default()
                },
            },
        );

        assert_eq!(
            config
                .providers
                .models
                .find("custom", "default")
                .and_then(|e| e.uri.as_deref()),
            Some("https://api.tonsof.blue/v1")
        );
        assert!(config.providers.models.find("custom", "default").is_some());
    }

    #[test]
    async fn openai_codex_alias_carries_responses_wire_api_and_requires_openai_auth() {
        let _env_guard = env_override_lock().await;
        let mut config = Config::default();
        config.providers.models.openai.insert(
            "codex".to_string(),
            OpenAIModelProviderConfig {
                base: ModelProviderConfig {
                    uri: Some("https://api.tonsof.blue".to_string()),
                    wire_api: Some(WireApi::Responses),
                    requires_openai_auth: true,
                    ..Default::default()
                },
            },
        );

        let entry = config
            .providers
            .models
            .find("openai", "codex")
            .expect("openai.codex entry");
        assert_eq!(entry.uri.as_deref(), Some("https://api.tonsof.blue"));
        assert_eq!(entry.wire_api, Some(WireApi::Responses));
        assert!(entry.requires_openai_auth);
    }

    /// Round-trip test for the config CLI: a TOML file with a typed-family
    /// model entry must deserialize, find via the typed accessor, and
    /// re-serialize without losing any field.
    #[test]
    async fn provider_models_round_trips_through_load_apply_serialize() {
        let _env_guard = env_override_lock().await;
        let toml_in = r#"
schema_version = 3

[providers.models.openrouter.default]
uri = "https://example.invalid/v1"
model = "primary-model"
"#;

        let config: Config = toml::from_str(toml_in).expect("parse toml");

        assert_eq!(
            config
                .providers
                .models
                .find("openrouter", "default")
                .and_then(|e| e.model.as_deref()),
            Some("primary-model"),
        );

        // What `config save` would write back to disk.
        let toml_out = toml::to_string(&config).expect("serialize toml");
        assert!(
            toml_out.contains("primary-model"),
            "serialized config must keep model value; got:\n{toml_out}",
        );
    }

    /// `resolve_default_model` returns the first available `models.*` entry's
    /// model. Returning `None` is reserved for "no model_provider has any model
    /// configured", which callers must surface as a configuration error
    /// rather than silently substituting a vendor default.
    #[test]
    async fn resolve_default_model_picks_first_available() {
        let _env_guard = env_override_lock().await;
        let mut config = Config::default();
        // Empty config: no model anywhere -> None (caller errors loudly).
        assert_eq!(config.resolve_default_model(), None);

        // Add an entry without a model -> still None.
        config
            .providers
            .models
            .anthropic
            .insert("default".into(), AnthropicModelProviderConfig::default());
        assert_eq!(config.resolve_default_model(), None);

        // Add an entry with a model -> first-available wins.
        config.providers.models.together.insert(
            "default".to_string(),
            TogetherModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("tertiary-model".to_string()),
                    ..Default::default()
                },
            },
        );
        assert_eq!(
            config.resolve_default_model().as_deref(),
            Some("tertiary-model"),
        );

        // Add a model_provider with a model — resolve_default_model finds it.
        config.providers.models.openrouter.insert(
            "default".to_string(),
            OpenRouterModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("primary-model".to_string()),
                    ..Default::default()
                },
            },
        );
        // resolve_default_model returns the first non-empty model across all model_providers.
        assert!(config.resolve_default_model().is_some());
    }

    #[test]
    async fn save_repairs_bare_config_filename_using_runtime_resolution() {
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("workspace");
        let resolved_config_path = temp_home.join(".zeroclaw").join("config.toml");

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let mut config = Config {
            data_dir: workspace_dir,
            config_path: PathBuf::from("config.toml"),
            ..Default::default()
        };
        config.providers.models.anthropic.insert(
            "default".to_string(),
            AnthropicModelProviderConfig {
                base: ModelProviderConfig {
                    temperature: Some(0.5),
                    ..Default::default()
                },
            },
        );
        // ModelProvider fields are now resolved directly — no cache needed.
        config.save().await.unwrap();

        assert!(resolved_config_path.exists());
        let saved = tokio::fs::read_to_string(&resolved_config_path)
            .await
            .unwrap();
        let parsed = parse_test_config(&saved);
        assert!(
            (parsed
                .providers
                .models
                .find("anthropic", "default")
                .and_then(|e| e.temperature)
                .unwrap_or(0.7)
                - 0.5)
                .abs()
                < f64::EPSILON
        );

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = tokio::fs::remove_dir_all(temp_home).await;
    }

    #[test]
    async fn validate_ollama_cloud_model_requires_remote_api_url() {
        let _env_guard = env_override_lock().await;
        let mut config = Config::default();
        config.providers.models.ollama.insert(
            "default".to_string(),
            OllamaModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("glm-5:cloud".to_string()),
                    uri: None,
                    api_key: Some("ollama-key".to_string()),
                    ..Default::default()
                },
                ..OllamaModelProviderConfig::default()
            },
        );

        let error = config.validate().expect_err("expected validation to fail");
        assert!(error.to_string().contains(
            "providers.models.ollama.default.model uses ':cloud', but uri is local or unset"
        ));
    }

    #[test]
    async fn validate_ollama_cloud_model_accepts_private_remote_without_api_key() {
        let _env_guard = env_override_lock().await;
        let mut config = Config::default();
        config.providers.models.ollama.insert(
            "default".to_string(),
            OllamaModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("glm-5:cloud".to_string()),
                    uri: Some("http://192.168.1.100:11434".to_string()),
                    api_key: None,
                    ..Default::default()
                },
                ..OllamaModelProviderConfig::default()
            },
        );

        let result = config.validate();
        assert!(result.is_ok(), "expected validation to pass: {result:?}");
    }

    #[test]
    async fn validate_ollama_cloud_model_requires_api_key_for_official_endpoint() {
        let _env_guard = env_override_lock().await;
        let mut config = Config::default();
        config.providers.models.ollama.insert(
            "default".to_string(),
            OllamaModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("glm-5:cloud".to_string()),
                    uri: Some("https://ollama.com/api".to_string()),
                    api_key: None,
                    ..Default::default()
                },
                ..OllamaModelProviderConfig::default()
            },
        );

        let error = config.validate().expect_err("expected validation to fail");
        assert!(error.to_string().contains(
            "providers.models.ollama.default.model uses ':cloud', but no API key is configured"
        ));
    }

    #[test]
    async fn validate_ollama_cloud_model_accepts_remote_endpoint_with_typed_api_key() {
        // V0.8.0: env-var fallback (`OLLAMA_API_KEY`) eradicated.
        // Operators set the credential on the typed alias.
        let _env_guard = env_override_lock().await;
        let mut config = Config::default();
        config.providers.models.ollama.insert(
            "default".to_string(),
            OllamaModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("glm-5:cloud".to_string()),
                    uri: Some("https://ollama.com/api".to_string()),
                    api_key: Some("ollama-typed-key".to_string()),
                    ..Default::default()
                },
                ..OllamaModelProviderConfig::default()
            },
        );

        let result = config.validate();
        assert!(result.is_ok(), "expected validation to pass: {result:?}");
    }

    #[test]
    async fn validate_ollama_cloud_model_checks_each_alias_for_official_key() {
        let _env_guard = env_override_lock().await;
        let mut config = Config::default();
        config.providers.models.ollama.insert(
            "local".to_string(),
            OllamaModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("llama3".to_string()),
                    uri: Some("http://192.168.1.100:11434".to_string()),
                    ..Default::default()
                },
                ..OllamaModelProviderConfig::default()
            },
        );
        config.providers.models.ollama.insert(
            "cloud".to_string(),
            OllamaModelProviderConfig {
                base: ModelProviderConfig {
                    model: Some("glm-5:cloud".to_string()),
                    uri: Some("https://ollama.com/api".to_string()),
                    api_key: None,
                    ..Default::default()
                },
                ..OllamaModelProviderConfig::default()
            },
        );

        let error = config.validate().expect_err("expected validation to fail");
        assert!(error.to_string().contains(
            "providers.models.ollama.cloud.model uses ':cloud', but no API key is configured"
        ));
    }

    #[test]
    async fn deserialize_rejects_unknown_model_provider_wire_api() {
        let toml = r#"
schema_version = 3

[providers.models.openrouter.default]
uri = "https://api.tonsof.blue/v1"
wire_api = "ws"
"#;
        let err = toml::from_str::<Config>(toml).expect_err("expected deserialize failure");
        let msg = err.to_string();
        assert!(
            msg.contains("wire_api") || msg.contains("ws"),
            "error should reference the invalid wire_api value, got: {msg}"
        );
    }

    #[test]
    async fn resolve_runtime_config_dirs_accepts_legacy_zeroclaw_workspace() {
        let _env_guard = env_override_lock().await;
        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
        let default_workspace_dir = default_config_dir.join("workspace");
        let workspace_dir = default_config_dir.join("profile-a");

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
        let (config_dir, resolved_workspace_dir, source) =
            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
                .await
                .unwrap();

        // ZEROCLAW_WORKSPACE is the deprecated alias for ZEROCLAW_DATA_DIR.
        // Resolution treats the path as the config root and derives the data
        // sub-dir from it; the source label reflects the deprecated entry.
        assert_eq!(source, ConfigResolutionSource::EnvWorkspaceLegacy);
        assert_eq!(config_dir, workspace_dir);
        assert_eq!(resolved_workspace_dir, workspace_dir.join("data"));

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        let _ = fs::remove_dir_all(default_config_dir).await;
    }

    #[test]
    async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
        let _env_guard = env_override_lock().await;
        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
        let default_workspace_dir = default_config_dir.join("workspace");
        let explicit_config_dir = default_config_dir.join("explicit-config");

        fs::create_dir_all(&default_config_dir).await.unwrap();

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &explicit_config_dir) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };

        let (config_dir, resolved_workspace_dir, source) =
            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
                .await
                .unwrap();

        assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
        assert_eq!(config_dir, explicit_config_dir);
        assert_eq!(resolved_workspace_dir, explicit_config_dir.join("data"));

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };
        let _ = fs::remove_dir_all(default_config_dir).await;
    }

    #[test]
    async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
        let _env_guard = env_override_lock().await;
        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
        let default_workspace_dir = default_config_dir.join("workspace");

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        let (config_dir, resolved_workspace_dir, source) =
            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
                .await
                .unwrap();

        assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
        assert_eq!(config_dir, default_config_dir);
        assert_eq!(resolved_workspace_dir, default_workspace_dir);

        let _ = fs::remove_dir_all(default_config_dir).await;
    }

    #[test]
    async fn classify_runtime_config_kind_uses_runtime_resolution_source() {
        let _env_guard = env_override_lock().await;
        #[cfg(unix)]
        let fake_home =
            PathBuf::from("/non-temp-zeroclaw-test-home").join(uuid::Uuid::new_v4().to_string());
        #[cfg(not(unix))]
        let fake_home = UserDirs::new()
            .expect("user directories should be available")
            .home_dir()
            .to_path_buf();
        let explicit_config_dir = fake_home.join("explicit-config");

        #[cfg(unix)]
        let _home_guard = EnvValueGuard::set("HOME", &fake_home);
        #[cfg(not(unix))]
        let _home_guard = EnvValueGuard::remove("HOME");
        let _data_guard = EnvValueGuard::remove("ZEROCLAW_DATA_DIR");
        let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");

        assert_eq!(
            classify_runtime_config_kind(&fake_home.join(".zeroclaw").join("config.toml")).await,
            RuntimeConfigKind::Default
        );

        let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", &explicit_config_dir);
        assert_eq!(
            classify_runtime_config_kind(&explicit_config_dir.join("config.toml")).await,
            RuntimeConfigKind::Custom
        );
    }

    #[test]
    async fn classify_runtime_config_kind_reports_temporary_paths() {
        let tmp = tempfile::TempDir::new().unwrap();

        assert_eq!(
            classify_runtime_config_kind(&tmp.path().join("config.toml")).await,
            RuntimeConfigKind::Temporary
        );
    }

    async fn create_homebrew_prefix() -> TempDir {
        let prefix = TempDir::new().expect("homebrew prefix temp dir");
        fs::create_dir_all(prefix.path().join("Cellar"))
            .await
            .expect("create Cellar marker");
        prefix
    }

    #[test]
    async fn try_resolve_macos_homebrew_config_dir_detects_cellar_layout() {
        let prefix = create_homebrew_prefix().await;
        let exe = prefix
            .path()
            .join("Cellar")
            .join("zeroclaw")
            .join("0.7.0")
            .join("bin")
            .join("zeroclaw");

        let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
            .await
            .expect("expected Homebrew layout");

        assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
    }

    #[test]
    async fn try_resolve_macos_homebrew_config_dir_detects_prefix_bin_layout() {
        let prefix = create_homebrew_prefix().await;
        let exe = prefix.path().join("bin").join("zeroclaw");

        let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
            .await
            .expect("expected Homebrew layout");

        assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
    }

    #[test]
    async fn try_resolve_macos_homebrew_config_dir_detects_opt_bin_layout() {
        let prefix = create_homebrew_prefix().await;
        let exe = prefix
            .path()
            .join("opt")
            .join("zeroclaw")
            .join("bin")
            .join("zeroclaw");

        let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
            .await
            .expect("expected Homebrew layout");

        assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
    }

    #[test]
    async fn try_resolve_macos_homebrew_config_dir_rejects_non_homebrew_layout() {
        let prefix = TempDir::new().expect("non-homebrew temp dir");
        let exe = prefix.path().join("bin").join("zeroclaw");

        assert!(try_resolve_macos_homebrew_config_dir(&exe).await.is_none());
    }

    #[test]
    async fn default_path_under_config_dir_respects_zeroclaw_config_dir() {
        let _env_guard = env_override_lock().await;
        let custom_dir = std::env::temp_dir().join("zeroclaw-test-profile");
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &custom_dir) };

        let result = default_path_under_config_dir("knowledge.db");

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };

        assert_eq!(
            result,
            custom_dir.join("knowledge.db").to_string_lossy().as_ref(),
            "expected path under ZEROCLAW_CONFIG_DIR, got: {result}"
        );
    }

    #[test]
    async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("profile-a");

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        // V3 fresh init: `config.data_dir` lives at `<install>/data/`
        // (the shared databases root); the install root holds
        // `config.toml`. No synthesized `agents/default/workspace/` is
        // created at boot — `default` is migration-only, and per-agent
        // workspaces are created lazily at agent-loop entry.
        assert_eq!(config.data_dir, workspace_dir.join("data"));
        assert_eq!(config.config_path, workspace_dir.join("config.toml"));
        assert!(workspace_dir.join("config.toml").exists());
        assert!(
            !workspace_dir.join("agents").exists(),
            "fresh init must not create agents/ tree"
        );

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("workspace");
        let legacy_config_dir = temp_home.join(".zeroclaw");
        let legacy_config_path = legacy_config_dir.join("config.toml");

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        // V3: `config.data_dir` lives at `<install>/data/`. The
        // ZEROCLAW_WORKSPACE env var (deprecated alias) resolved to the
        // legacy config layout where the install root is the parent of
        // the env-var path; data sits at `<install>/data/`.
        assert_eq!(config.data_dir, legacy_config_dir.join("data"));
        assert_eq!(config.config_path, legacy_config_path);
        assert!(config.config_path.exists());

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("custom-workspace");
        let legacy_config_dir = temp_home.join(".zeroclaw");
        let legacy_config_path = legacy_config_dir.join("config.toml");

        fs::create_dir_all(&legacy_config_dir).await.unwrap();
        fs::write(
            &legacy_config_path,
            r#"default_temperature = 0.7
default_model = "legacy-model"
"#,
        )
        .await
        .unwrap();

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        // V3: `config.data_dir` resolves to `<install>/data/` under
        // the install root (the directory holding the existing
        // `config.toml`), regardless of the ZEROCLAW_WORKSPACE
        // (deprecated) override.
        assert_eq!(config.data_dir, legacy_config_dir.join("data"));
        assert_eq!(config.config_path, legacy_config_path);
        assert_eq!(
            config
                .providers
                .models
                .find("openrouter", "default")
                .and_then(|e| e.model.as_deref()),
            Some("legacy-model")
        );

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    async fn load_or_init_decrypts_feishu_channel_secrets() {
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let config_dir = temp_home.join(".zeroclaw");
        let config_path = config_dir.join("config.toml");

        fs::create_dir_all(&config_dir).await.unwrap();

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };

        let mut config = Config {
            config_path: config_path.clone(),
            data_dir: config_dir.join("workspace"),
            ..Default::default()
        };
        config.secrets.encrypt = true;
        config.channels.lark.insert(
            "feishu".to_string(),
            LarkConfig {
                enabled: true,
                app_id: "cli_feishu_123".into(),
                app_secret: "feishu-secret".into(),
                encrypt_key: Some("feishu-encrypt".into()),
                verification_token: Some("feishu-verify".into()),
                mention_only: false,
                use_feishu: true,
                receive_mode: LarkReceiveMode::Websocket,
                port: None,
                proxy_url: None,
                excluded_tools: vec![],
                approval_timeout_secs: 300,
                per_user_session: false,
                ack_reactions: None,
                stream_mode: StreamMode::default(),
                draft_update_interval_ms: default_draft_update_interval_ms(),
            },
        );
        config.save().await.unwrap();

        let loaded = Box::pin(Config::load_or_init()).await.unwrap();
        let feishu = loaded.channels.lark.get("feishu").unwrap();
        assert_eq!(feishu.app_secret, "feishu-secret");
        assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
        assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));

        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    #[allow(clippy::large_futures)]
    async fn load_or_init_logs_existing_config_as_initialized() {
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("profile-a");
        let config_path = workspace_dir.join("config.toml");

        fs::create_dir_all(&workspace_dir).await.unwrap();
        fs::write(
            &config_path,
            r#"default_temperature = 0.7
default_model = "persisted-profile"
"#,
        )
        .await
        .unwrap();

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let mut rx = capture_log_events();

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        let logs = drain_captured(&mut rx);

        // V3: shared databases live at `<install>/data/`, per-agent
        // identity at `<install>/agents/<alias>/workspace/`. The
        // ZEROCLAW_WORKSPACE env var (deprecated alias for
        // ZEROCLAW_DATA_DIR) pinned the install root, so data_dir is
        // `<install>/data/` derived from the resolved root.
        assert_eq!(config.data_dir, workspace_dir.join("data"));
        assert_eq!(config.config_path, config_path);
        assert_eq!(
            config
                .providers
                .models
                .find("openrouter", "default")
                .and_then(|e| e.model.as_deref()),
            Some("persisted-profile")
        );
        assert!(logs.contains("Config loaded"), "{logs}");
        assert!(logs.contains("\"initialized\":true"), "{logs}");
        assert!(!logs.contains("\"initialized\":false"), "{logs}");

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    #[allow(clippy::large_futures)]
    async fn load_or_init_assigns_degraded_security_for_malformed_section() {
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("profile-a");
        let config_path = workspace_dir.join("config.toml");

        fs::create_dir_all(&workspace_dir).await.unwrap();
        // `[security] audit` must be a table; a scalar forces the security
        // section to drop to its default on the resilient daemon path.
        fs::write(
            &config_path,
            r#"schema_version = 3
audit = "should-be-a-table-not-a-string"

[security]
audit = "should-be-a-table-not-a-string"
"#,
        )
        .await
        .unwrap();

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        assert!(
            config.degraded_security.iter().any(|s| s == "security"),
            "load_or_init must surface a dropped [security] section on degraded_security, got {:?}",
            config.degraded_security
        );

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    #[allow(clippy::large_futures)]
    async fn load_or_init_assigns_degraded_sections_for_malformed_channel_alias() {
        // Regression: `doctor` was blind to degraded_sections even though
        // load_or_init already populates it correctly. A [channels.telegram]
        // alias with a type-corrupt `bot_token` (not merely missing - see
        // the scenario where a missing bot_token must survive salvage instead of
        // being dropped) must be pruned (not fatal) and its path recorded on
        // `degraded_sections` so downstream diagnostics (zeroclaw-runtime's
        // check_degraded_sections) can name it.
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("profile-a");
        let config_path = workspace_dir.join("config.toml");

        fs::create_dir_all(&workspace_dir).await.unwrap();
        fs::write(
            &config_path,
            r#"schema_version = 3

[channels.telegram.default]
enabled = true
bot_token = 42
"#,
        )
        .await
        .unwrap();

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        assert!(
            config
                .degraded_sections
                .iter()
                .any(|s| s == "channels.telegram.default"),
            "load_or_init must surface a dropped [channels.telegram.default] alias on \
             degraded_sections, got {:?}",
            config.degraded_sections
        );

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    async fn load_or_init_keeps_partial_channel_alias_out_of_degraded_sections() {
        // End-to-end companion to the salvage-layer tests: through the
        // real load_or_init entry point, a partial (tokenless) telegram alias
        // must load intact and must NOT be reported on degraded_sections.
        // Disabled so Config::validate() stays quiet about the missing token.
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("profile-a");
        let config_path = workspace_dir.join("config.toml");

        fs::create_dir_all(&workspace_dir).await.unwrap();
        fs::write(
            &config_path,
            r#"schema_version = 3

[channels.telegram.default]
enabled = false
"#,
        )
        .await
        .unwrap();

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        assert!(
            config.channels.telegram.contains_key("default"),
            "a partial (tokenless) alias must survive load_or_init, got {:?}",
            config.channels.telegram.keys().collect::<Vec<_>>()
        );
        assert!(
            config.degraded_sections.is_empty(),
            "a partial (tokenless) alias must not be reported as degraded, got {:?}",
            config.degraded_sections
        );

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    async fn salvage_reports_dropped_plugins_section_for_malformed_entries() {
        // `[plugins.entries]` written as a table instead of an array of
        // tables (`[[plugins.entries]]`) drops the whole [plugins] section
        // to defaults on the resilient path. That drop must land on
        // `ResilientLoad::dropped`; load_or_init copies it onto
        // `degraded_sections` so the CLI surfaces it on stderr instead of
        // the operator discovering `enabled = false` by accident.
        let raw = r#"schema_version = 3

[plugins]
enabled = true

[plugins.entries]
name = "weather-tool"
"#;
        let load = crate::migration::migrate_to_current_salvaged(raw);
        assert!(
            load.dropped.iter().any(|s| s == "plugins"),
            "a malformed [plugins] section must be reported on dropped, got {:?}",
            load.dropped
        );
        assert!(
            !load.config.plugins.enabled,
            "the malformed section must have been reset to defaults"
        );
    }

    #[test]
    #[allow(clippy::large_futures)]
    async fn load_or_init_marks_whole_config_degraded_for_unparseable_file() {
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("profile-a");
        let config_path = workspace_dir.join("config.toml");

        fs::create_dir_all(&workspace_dir).await.unwrap();
        // Not valid TOML at all: the whole config defaults, so every
        // security-critical section is lost at once. load_or_init must surface
        // that on degraded_security so the serving gate refuses to start.
        fs::write(&config_path, "this is not valid TOML {{{")
            .await
            .unwrap();

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        assert!(
            !config.degraded_security.is_empty(),
            "load_or_init must surface a whole-config loss on degraded_security, got {:?}",
            config.degraded_security
        );

        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    #[allow(clippy::large_futures)]
    async fn load_or_init_keeps_agents_with_object_form_delegates() {
        // Regression for the observed regression: a current
        // schema config containing an object-form delegate must not degrade and
        // drop the whole `agents` section.
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("profile-a");
        let config_path = workspace_dir.join("config.toml");

        fs::create_dir_all(&workspace_dir).await.unwrap();
        fs::write(
            &config_path,
            r#"schema_version = 3

[providers.models.ollama.default]

[risk_profiles.shared]

[runtime_profiles.default]

[agents.task_orchestrator]
model_provider = "ollama.default"
risk_profile = "shared"
runtime_profile = "default"
delegates = [
  "reviewer",
  { agent = "sysadmin", mode = "independent" },
]

[agents.reviewer]
model_provider = "ollama.default"
risk_profile = "shared"
runtime_profile = "default"

[agents.sysadmin]
model_provider = "ollama.default"
risk_profile = "shared"
runtime_profile = "default"
"#,
        )
        .await
        .unwrap();

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, guarded by env_override_lock.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, guarded by env_override_lock.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        assert!(
            config.degraded_security.is_empty(),
            "{:?}",
            config.degraded_security
        );
        assert!(config.agents.contains_key("task_orchestrator"));
        assert!(config.agents.contains_key("reviewer"));
        assert!(config.agents.contains_key("sysadmin"));
        assert_eq!(
            config.agents["task_orchestrator"].delegates,
            vec![
                DelegateTargetConfig::bounded("reviewer"),
                DelegateTargetConfig {
                    agent: "sysadmin".to_string(),
                    mode: DelegateExecutionMode::Independent,
                },
            ]
        );

        // SAFETY: test-only, guarded by env_override_lock.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, guarded by env_override_lock.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, guarded by env_override_lock.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    #[allow(clippy::large_futures)]
    async fn load_or_init_migrates_agents_with_object_form_delegates() {
        // Same shape as above, but without schema_version so the migration path
        // proves it accepts mixed string/object delegates before validation.
        let _env_guard = env_override_lock().await;
        let temp_home =
            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
        let workspace_dir = temp_home.join("profile-a");
        let config_path = workspace_dir.join("config.toml");

        fs::create_dir_all(&workspace_dir).await.unwrap();
        fs::write(
            &config_path,
            r#"
[providers.models.ollama.default]

[risk_profiles.shared]

[runtime_profiles.default]

[agents.task_orchestrator]
model_provider = "ollama.default"
risk_profile = "shared"
runtime_profile = "default"
delegates = [
  "reviewer",
  { agent = "sysadmin", mode = "independent" },
]

[agents.reviewer]
model_provider = "ollama.default"
risk_profile = "shared"
runtime_profile = "default"

[agents.sysadmin]
model_provider = "ollama.default"
risk_profile = "shared"
runtime_profile = "default"
"#,
        )
        .await
        .unwrap();

        let original_home = std::env::var("HOME").ok();
        // SAFETY: test-only, guarded by env_override_lock.
        unsafe { std::env::set_var("HOME", &temp_home) };
        // SAFETY: test-only, guarded by env_override_lock.
        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };

        let config = Box::pin(Config::load_or_init()).await.unwrap();

        assert!(config.agents.contains_key("task_orchestrator"));
        assert!(config.agents.contains_key("reviewer"));
        assert!(config.agents.contains_key("sysadmin"));
        assert_eq!(
            config.agents["task_orchestrator"].delegates,
            vec![
                DelegateTargetConfig::bounded("reviewer"),
                DelegateTargetConfig {
                    agent: "sysadmin".to_string(),
                    mode: DelegateExecutionMode::Independent,
                },
            ]
        );

        // SAFETY: test-only, guarded by env_override_lock.
        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
        if let Some(home) = original_home {
            // SAFETY: test-only, guarded by env_override_lock.
            unsafe { std::env::set_var("HOME", home) };
        } else {
            // SAFETY: test-only, guarded by env_override_lock.
            unsafe { std::env::remove_var("HOME") };
        }
        let _ = fs::remove_dir_all(temp_home).await;
    }

    #[test]
    async fn validate_rejects_out_of_range_temperature() {
        let mut config = Config::default();
        config.providers.models.openrouter.insert(
            "default".to_string(),
            OpenRouterModelProviderConfig {
                base: ModelProviderConfig {
                    api_key: Some("sk-test".into()),
                    temperature: Some(99.0),
                    ..Default::default()
                },
            },
        );
        let err = config.validate().unwrap_err();
        assert!(
            err.to_string().contains("temperature"),
            "expected temperature validation error, got: {err}"
        );
    }

    #[test]
    async fn validate_rejects_negative_temperature() {
        let mut config = Config::default();
        config.providers.models.openrouter.insert(
            "default".to_string(),
            OpenRouterModelProviderConfig {
                base: ModelProviderConfig {
                    api_key: Some("sk-test".into()),
                    temperature: Some(-0.5),
                    ..Default::default()
                },
            },
        );
        let err = config.validate().unwrap_err();
        assert!(
            err.to_string().contains("temperature"),
            "expected temperature validation error, got: {err}"
        );
    }

    #[test]
    async fn validate_accepts_valid_temperature() {
        let mut config = Config::default();
        config.providers.models.openrouter.insert(
            "default".to_string(),
            OpenRouterModelProviderConfig {
                base: ModelProviderConfig {
                    temperature: Some(0.7),
                    ..Default::default()
                },
            },
        );
        assert!(config.validate().is_ok());
    }

    #[test]
    async fn validate_rejects_unknown_jira_actions() {
        for action in ["delete_ticket", "drop_database", ""] {
            let mut config = Config::default();
            config.jira.enabled = true;
            config.jira.base_url = "https://jira.example.test".into();
            config.jira.api_token = "token".into();
            config.jira.allowed_actions = vec![action.into()];

            let err = config
                .validate()
                .expect_err("unknown Jira action should be rejected")
                .to_string();
            assert!(
                err.contains("jira.allowed_actions contains unknown action"),
                "expected Jira allowed action error for {action:?}, got: {err}"
            );
        }
    }

    #[test]
    async fn validate_accepts_all_published_jira_actions() {
        for action in [
            "get_ticket",
            "search_tickets",
            "comment_ticket",
            "list_projects",
            "myself",
            "list_transitions",
            "transition_ticket",
            "create_ticket",
        ] {
            let mut config = Config::default();
            config.jira.enabled = true;
            config.jira.base_url = "https://jira.example.test".into();
            config.jira.api_token = "token".into();
            config.jira.allowed_actions = vec![action.into()];

            assert!(
                config.validate().is_ok(),
                "published Jira action {action:?} should validate"
            );
        }
    }

    #[test]
    async fn jira_email_empty_string_deserializes_as_none() {
        // Legacy configs round-tripped `email = ""` to disk because the
        // pre-rename `email: String` lacked `skip_serializing_if`. The
        // current `Option<String>` would otherwise deserialize `""` as
        // `Some("")`, and JiraTool would attempt Basic auth with empty
        // username (the dropped email-required validation no longer
        // catches this). Defense-in-depth: empty strings deserialize as
        // None.
        let toml_input = r#"
enabled = true
base_url = "https://jira.example.test"
email = ""
api_token = "tok"
"#;
        let cfg: JiraConfig = toml::from_str(toml_input).expect("parses with empty email");
        assert!(
            cfg.email.is_none(),
            "empty `email = \"\"` must deserialize as None, got {:?}",
            cfg.email
        );
        // Whitespace-only is also normalized to None.
        let toml_input_ws = r#"
enabled = true
base_url = "https://jira.example.test"
email = "   "
api_token = "tok"
"#;
        let cfg_ws: JiraConfig =
            toml::from_str(toml_input_ws).expect("parses with whitespace email");
        assert!(
            cfg_ws.email.is_none(),
            "whitespace-only email must deserialize as None, got {:?}",
            cfg_ws.email
        );
        // A real email still survives.
        let toml_input_real = r#"
enabled = true
base_url = "https://jira.example.test"
email = "ops@example.com"
api_token = "tok"
"#;
        let cfg_real: JiraConfig = toml::from_str(toml_input_real).expect("parses with real email");
        assert_eq!(
            cfg_real.email.as_deref(),
            Some("ops@example.com"),
            "non-empty email must round-trip unchanged"
        );
    }

    #[test]
    async fn proxy_config_scope_services_requires_entries_when_enabled() {
        let proxy = ProxyConfig {
            enabled: true,
            http_proxy: Some("http://127.0.0.1:7890".into()),
            https_proxy: None,
            all_proxy: None,
            no_proxy: Vec::new(),
            scope: ProxyScope::Services,
            services: Vec::new(),
        };

        let error = proxy.validate().unwrap_err().to_string();
        assert!(error.contains("proxy.scope='services'"));
    }

    #[test]
    async fn google_workspace_allowed_operations_require_methods() {
        let mut config = Config::default();
        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
            service: "gmail".into(),
            resource: "users".into(),
            sub_resource: Some("drafts".into()),
            methods: Vec::new(),
        }];

        let err = config.validate().unwrap_err().to_string();
        assert!(err.contains("google_workspace.allowed_operations[0].methods"));
    }

    #[test]
    async fn google_workspace_allowed_operations_reject_duplicate_service_resource_sub_resource_entries()
     {
        let mut config = Config::default();
        config.google_workspace.allowed_operations = vec![
            GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("drafts".into()),
                methods: vec!["create".into()],
            },
            GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("drafts".into()),
                methods: vec!["update".into()],
            },
        ];

        let err = config.validate().unwrap_err().to_string();
        assert!(err.contains("duplicate service/resource/sub_resource entry"));
    }

    #[test]
    async fn google_workspace_allowed_operations_allow_same_resource_different_sub_resource() {
        let mut config = Config::default();
        config.google_workspace.allowed_operations = vec![
            GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("messages".into()),
                methods: vec!["list".into(), "get".into()],
            },
            GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("drafts".into()),
                methods: vec!["create".into(), "update".into()],
            },
        ];

        assert!(config.validate().is_ok());
    }

    #[test]
    async fn google_workspace_allowed_operations_reject_duplicate_methods_within_entry() {
        let mut config = Config::default();
        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
            service: "gmail".into(),
            resource: "users".into(),
            sub_resource: Some("drafts".into()),
            methods: vec!["create".into(), "create".into()],
        }];

        let err = config.validate().unwrap_err().to_string();
        assert!(
            err.contains("duplicate entry"),
            "expected duplicate entry error, got: {err}"
        );
    }

    #[test]
    async fn google_workspace_allowed_operations_accept_valid_entries() {
        let mut config = Config::default();
        config.google_workspace.allowed_operations = vec![
            GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("messages".into()),
                methods: vec!["list".into(), "get".into()],
            },
            GoogleWorkspaceAllowedOperation {
                service: "drive".into(),
                resource: "files".into(),
                sub_resource: None,
                methods: vec!["list".into(), "get".into()],
            },
        ];

        assert!(config.validate().is_ok());
    }

    #[test]
    async fn google_workspace_allowed_operations_reject_invalid_sub_resource_characters() {
        let mut config = Config::default();
        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
            service: "gmail".into(),
            resource: "users".into(),
            sub_resource: Some("bad resource!".into()),
            methods: vec!["list".into()],
        }];

        let err = config.validate().unwrap_err().to_string();
        assert!(err.contains("sub_resource contains invalid characters"));
    }

    fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
        match runtime_proxy_client_cache().read() {
            Ok(guard) => guard.contains_key(cache_key),
            Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
        }
    }

    #[test]
    async fn runtime_proxy_client_cache_reuses_default_profile_key() {
        let service_key = format!(
            "model_provider.cache_test.{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("system clock should be after unix epoch")
                .as_nanos()
        );
        let cache_key = runtime_proxy_cache_key(&service_key, None, None);

        clear_runtime_proxy_client_cache();
        assert!(!runtime_proxy_cache_contains(&cache_key));

        let _ = build_runtime_proxy_client(&service_key);
        assert!(runtime_proxy_cache_contains(&cache_key));

        let _ = build_runtime_proxy_client(&service_key);
        assert!(runtime_proxy_cache_contains(&cache_key));
    }

    #[test]
    async fn proxy_reload_applies_new_config_through_rwlock() {
        set_runtime_proxy_config(ProxyConfig {
            enabled: true,
            http_proxy: Some("http://boot.example:3128".to_string()),
            ..Default::default()
        });
        assert_eq!(
            runtime_proxy_config().http_proxy.as_deref(),
            Some("http://boot.example:3128")
        );

        set_runtime_proxy_config(ProxyConfig {
            enabled: true,
            http_proxy: Some("http://reloaded.example:8080".to_string()),
            ..Default::default()
        });
        assert_eq!(
            runtime_proxy_config().http_proxy.as_deref(),
            Some("http://reloaded.example:8080"),
            "RwLock-backed runtime config must reflect the reloaded value"
        );

        set_runtime_proxy_config(ProxyConfig::default());
    }

    #[test]
    async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
        let service_key = format!(
            "model_provider.cache_timeout_test.{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("system clock should be after unix epoch")
                .as_nanos()
        );
        let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));

        clear_runtime_proxy_client_cache();
        let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
        assert!(runtime_proxy_cache_contains(&cache_key));

        set_runtime_proxy_config(ProxyConfig::default());
        assert!(!runtime_proxy_cache_contains(&cache_key));
    }

    #[test]
    async fn gateway_config_default_values() {
        let g = GatewayConfig::default();
        assert_eq!(g.port, 42617);
        assert_eq!(g.host, "127.0.0.1");
        assert!(g.require_pairing);
        assert!(!g.allow_public_bind);
        assert!(g.paired_tokens.is_empty());
        assert!(!g.trust_forwarded_headers);
        assert_eq!(g.rate_limit_max_keys, 10_000);
        assert_eq!(g.idempotency_max_keys, 10_000);
    }

    // ── Peripherals config ───────────────────────────────────────

    #[test]
    async fn peripherals_config_default_disabled() {
        let p = PeripheralsConfig::default();
        assert!(!p.enabled);
        assert!(p.boards.is_empty());
    }

    #[test]
    async fn peripheral_board_config_defaults() {
        let b = PeripheralBoardConfig::default();
        assert!(b.board.is_empty());
        assert_eq!(b.transport, "serial");
        assert!(b.path.is_none());
        assert_eq!(b.baud, 115_200);
    }

    #[test]
    async fn peripherals_config_toml_roundtrip() {
        let p = PeripheralsConfig {
            enabled: true,
            boards: vec![PeripheralBoardConfig {
                board: "nucleo-f401re".into(),
                transport: "serial".into(),
                path: Some("/dev/ttyACM0".into()),
                baud: 115_200,
            }],
            datasheet_dir: None,
        };
        let toml_str = toml::to_string(&p).unwrap();
        let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
        assert!(parsed.enabled);
        assert_eq!(parsed.boards.len(), 1);
        assert_eq!(parsed.boards[0].board, "nucleo-f401re");
        assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
    }

    #[test]
    async fn lark_config_serde() {
        let lc = LarkConfig {
            enabled: true,
            app_id: "cli_123456".into(),
            app_secret: "secret_abc".into(),
            encrypt_key: Some("encrypt_key".into()),
            verification_token: Some("verify_token".into()),
            mention_only: false,
            use_feishu: true,
            receive_mode: LarkReceiveMode::Websocket,
            port: None,
            proxy_url: None,
            excluded_tools: vec![],
            approval_timeout_secs: 300,
            per_user_session: false,
            ack_reactions: None,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: default_draft_update_interval_ms(),
        };
        let json = serde_json::to_string(&lc).unwrap();
        let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.app_id, "cli_123456");
        assert_eq!(parsed.app_secret, "secret_abc");
        assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
        assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
        assert!(parsed.use_feishu);
    }

    #[test]
    async fn lark_config_toml_roundtrip() {
        let lc = LarkConfig {
            enabled: true,
            app_id: "cli_123456".into(),
            app_secret: "secret_abc".into(),
            encrypt_key: Some("encrypt_key".into()),
            verification_token: Some("verify_token".into()),
            mention_only: false,
            use_feishu: false,
            receive_mode: LarkReceiveMode::Webhook,
            port: Some(9898),
            proxy_url: None,
            excluded_tools: vec![],
            approval_timeout_secs: 300,
            per_user_session: false,
            ack_reactions: None,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: default_draft_update_interval_ms(),
        };
        let toml_str = toml::to_string(&lc).unwrap();
        let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
        assert_eq!(parsed.app_id, "cli_123456");
        assert_eq!(parsed.app_secret, "secret_abc");
        assert!(!parsed.use_feishu);
    }

    #[test]
    async fn lark_config_deserializes_without_optional_fields() {
        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.encrypt_key.is_none());
        assert!(parsed.verification_token.is_none());
        assert!(!parsed.mention_only);
        assert!(!parsed.use_feishu);
    }

    #[test]
    async fn lark_config_defaults_to_lark_endpoint() {
        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
        assert!(
            !parsed.use_feishu,
            "use_feishu should default to false (Lark)"
        );
    }

    #[test]
    async fn lark_v2_allowed_users_fold_into_peer_groups() {
        // V2 `allowed_users` on a Lark channel migrates to a synthesized
        // `peer_groups.lark_default` group. The wildcard `*` is dropped at
        // synthesis (operator-explicit lists only); concrete user IDs
        // round-trip through.
        let raw = r#"
schema_version = 2

[channels.lark]
enabled = true
app_id = "cli_123"
app_secret = "secret"
allowed_users = ["user_alpha", "user_beta"]
"#;
        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
        let group = parsed
            .peer_groups
            .get("lark_default")
            .expect("V2 lark.allowed_users must fold into peer_groups.lark_default");
        assert_eq!(group.channel, "lark");
        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
        assert_eq!(usernames, vec!["user_alpha", "user_beta"]);
    }

    // ── LINE ──────────────────────────────────────────────────

    #[test]
    async fn line_config_toml_roundtrip() {
        // Full [channels.line] TOML block — covers every user-facing field.
        //
        // channel_access_token and channel_secret can be omitted here and
        // supplied via LINE_CHANNEL_ACCESS_TOKEN / LINE_CHANNEL_SECRET env vars
        // instead; both fields default to "" when absent.
        let toml = r#"
[channels_config.line.default]
enabled = true
channel_access_token = "ChannelAccessToken=="
channel_secret = "abc123secret"
dm_policy = "pairing"
group_policy = "mention"
allowed_users = []
webhook_port = 8443
sender_name = "Popcorn"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let ln = config.channels.line.get("default").unwrap();
        assert_eq!(ln.channel_access_token, "ChannelAccessToken==");
        assert_eq!(ln.channel_secret, "abc123secret");
        assert_eq!(ln.dm_policy, LineDmPolicy::Pairing);
        assert_eq!(ln.group_policy, LineGroupPolicy::Mention);
        assert_eq!(ln.webhook_port, 8443);
        assert!(ln.proxy_url.is_none());
        assert_eq!(ln.sender_name.as_deref(), Some("Popcorn"));
    }

    #[test]
    async fn line_config_defaults() {
        // Minimal config — only the required secret fields are provided.
        // All optional fields should resolve to documented defaults.
        let toml = r#"
[channels_config.line.default]
channel_access_token = "tok"
channel_secret = "sec"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let ln = config.channels.line.get("default").unwrap();
        assert_eq!(
            ln.dm_policy,
            LineDmPolicy::Pairing,
            "dm_policy default is pairing"
        );
        assert_eq!(
            ln.group_policy,
            LineGroupPolicy::Mention,
            "group_policy default is mention"
        );
        assert_eq!(ln.webhook_port, 8443, "webhook_port default is 8443");
        assert!(ln.proxy_url.is_none());
        assert!(ln.sender_name.is_none(), "sender_name default is None");
    }

    #[test]
    async fn line_config_allowlist_policy() {
        // dm_policy = allowlist; the user ID list itself now lives on the
        // V3 `peer_groups.line_default` group (synthesized from V2's
        // `allowed_users`), not on the LineConfig struct.
        let toml = r#"
schema_version = 2

[channels.line]
enabled = true
channel_access_token = "tok"
channel_secret = "sec"
dm_policy = "allowlist"
allowed_users = ["Uabc123", "Udef456"]
"#;
        let config = crate::migration::migrate_to_current(toml).expect("migration succeeds");
        let ln = config.channels.line.get("default").unwrap();
        assert_eq!(ln.dm_policy, LineDmPolicy::Allowlist);
        let group = config
            .peer_groups
            .get("line_default")
            .expect("V2 line.allowed_users must fold into peer_groups.line_default");
        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
        assert_eq!(usernames, vec!["Uabc123", "Udef456"]);
    }

    #[test]
    async fn line_config_open_policies() {
        // dm_policy = open + group_policy = open — most permissive combination.
        let toml = r#"
[channels_config.line.default]
channel_access_token = "tok"
channel_secret = "sec"
dm_policy = "open"
group_policy = "open"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let ln = config.channels.line.get("default").unwrap();
        assert_eq!(ln.dm_policy, LineDmPolicy::Open);
        assert_eq!(ln.group_policy, LineGroupPolicy::Open);
    }

    #[test]
    async fn line_config_group_disabled() {
        // group_policy = disabled — bot ignores all group/room messages.
        let toml = r#"
[channels_config.line.default]
channel_access_token = "tok"
channel_secret = "sec"
group_policy = "disabled"
"#;
        let config: Config = toml::from_str(toml).unwrap();
        let ln = config.channels.line.get("default").unwrap();
        assert_eq!(ln.group_policy, LineGroupPolicy::Disabled);
    }

    #[test]
    async fn nextcloud_talk_config_serde() {
        let nc = NextcloudTalkConfig {
            enabled: true,
            base_url: "https://cloud.example.com".into(),
            app_token: None,
            bot_token: None,
            webhook_secret: Some("webhook-secret".into()),
            proxy_url: None,
            bot_name: None,
            excluded_tools: vec![],
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1000,
        };

        let json = serde_json::to_string(&nc).unwrap();
        let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.base_url, "https://cloud.example.com");
        assert!(parsed.app_token.is_none());
        assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
    }

    #[test]
    async fn nextcloud_talk_config_defaults_optional_fields() {
        let json = r#"{"base_url":"https://cloud.example.com"}"#;
        let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
        assert!(parsed.webhook_secret.is_none());
        assert!(parsed.app_token.is_none());
    }

    #[test]
    async fn nextcloud_talk_config_accepts_legacy_app_token_without_using_it() {
        // Pre-existing configs may still set the now-deprecated app_token;
        // it must parse (not fail-closed the whole config) and just be
        // carried as a no-op field.
        let json = r#"{"base_url":"https://cloud.example.com","app_token":"legacy-value"}"#;
        let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
        assert_eq!(parsed.app_token.as_deref(), Some("legacy-value"));
    }

    // ── Config file permission hardening (Unix only) ───────────────

    #[cfg(unix)]
    #[test]
    async fn new_config_file_has_restricted_permissions() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Create a config and save it
        let config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.save().await.unwrap();

        let meta = fs::metadata(&config_path).await.unwrap();
        let mode = meta.permissions().mode() & 0o777;
        assert_eq!(
            mode, 0o600,
            "New config file should be owner-only (0600), got {mode:o}"
        );
    }

    #[cfg(unix)]
    #[test]
    async fn save_restricts_existing_world_readable_config_to_owner_only() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.save().await.unwrap();

        // Simulate the regression state observed in issue.
        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
        let loose_mode = std::fs::metadata(&config_path)
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(
            loose_mode, 0o644,
            "test setup requires world-readable config"
        );

        if let Some(entry) = config.providers.models.ensure("openrouter", "default") {
            entry.temperature = Some(0.6);
        }
        config.save().await.unwrap();

        let hardened_mode = std::fs::metadata(&config_path)
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(
            hardened_mode, 0o600,
            "Saving config should restore owner-only permissions (0600)"
        );
    }

    #[test]
    async fn save_dirty_stamps_current_schema_version_on_stale_label() {
        // Regression for. An incremental save writes current-schema-shaped
        // sections, but `schema_version` is never a dirty path. Without an
        // explicit stamp, a file first written by an older binary keeps its
        // stale `schema_version` label while gaining a current-schema body — a
        // state that crashes older binaries with `missing field ...`.
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Seed an on-disk file labeled with a stale schema version so the
        // incremental path (not the new-file fallback to full `save`) runs.
        std::fs::write(
            &config_path,
            "schema_version = 2\n\n[observability]\nbackend = \"none\"\n",
        )
        .unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.observability.backend = ObservabilityBackend::Otel;
        config.mark_dirty("observability.backend");
        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            written.contains(&format!(
                "schema_version = {}",
                crate::migration::CURRENT_SCHEMA_VERSION
            )),
            "save_dirty must stamp the current schema_version; got:\n{written}"
        );
        assert!(
            !written.contains("schema_version = 2"),
            "stale schema_version label must be overwritten; got:\n{written}"
        );
        // The dirty value still lands, and the stamp sits at the top of the file.
        assert!(
            written.contains("backend = \"otel\""),
            "dirty value must still be written; got:\n{written}"
        );
        assert!(
            written.trim_start().starts_with("schema_version ="),
            "schema_version should remain the first key; got:\n{written}"
        );
    }

    /// Regression for the per-field `[[mcp.servers]]` editor: after
    /// `d06ed25` shipped the natural-key arm, in-memory edits succeed
    /// (the TUI / dashboard show the new value) but `save_dirty` is
    /// silently a no-op because `apply_dirty_path` walks the serialized
    /// TOML as if every segment is a `Table` — `mcp.servers` is an
    /// array of tables, so `lookup_path_in_table` returns `None` at the
    /// natural-key segment, the path is misclassified as
    /// `should_delete`, and `delete_path_in_doc` bails when it hits the
    /// array too. Net effect: the on-disk file keeps its stale value.
    #[test]
    async fn save_dirty_persists_mcp_server_field_via_natural_key() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Seed an on-disk file with a single MCP server so the
        // incremental path (not the new-file fallback to full `save`)
        // runs. Schema version is stamped to current so the writer
        // doesn't have to migrate anything.
        let seed = format!(
            "schema_version = {}\n\n\
             [[mcp.servers]]\n\
             name = \"fs\"\n\
             transport = \"stdio\"\n\
             command = \"/usr/bin/mcp-fs\"\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        // Build the in-memory config to match the seeded file. We
        // don't need to round-trip through deserialization — the bug
        // is purely on the save side, and the on-disk seed gives
        // `save_dirty` an existing file to do an incremental write
        // into (the new-file fallback to full `save` would mask the
        // dirty-path bug because it serializes the whole struct).
        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.mcp.servers.push(McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            ..Default::default()
        });
        assert_eq!(config.mcp.servers[0].command, "/usr/bin/mcp-fs");

        // The same call site the dashboard / TUI use: set_prop_persistent
        // on a natural-key-routed inner path, then flush via save_dirty.
        config
            .set_prop_persistent("mcp.servers.fs.command", "/usr/local/bin/mcp-fs")
            .expect("set_prop_persistent must route through the natural-key arm");
        // The in-memory mutation must land — this is what the UI sees.
        assert_eq!(config.mcp.servers[0].command, "/usr/local/bin/mcp-fs");

        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            written.contains("/usr/local/bin/mcp-fs"),
            "save_dirty must write the new command for `mcp.servers.fs.command`; \
             on-disk file still reads:\n{written}"
        );
        assert!(
            !written.contains("/usr/bin/mcp-fs"),
            "stale command must be overwritten; got:\n{written}"
        );
        // The natural-key field itself must stay on disk — losing
        // `name` would orphan every other field in the [[mcp.servers]]
        // entry and break subsequent loads.
        assert!(
            written.contains("name = \"fs\""),
            "natural-key `name` must survive the incremental save; got:\n{written}"
        );
    }

    /// `cost.rates.providers.models.<type>` is a
    /// `#[resource_key]` `HashMap<String, ModelCostRates>` — its key is a
    /// model id, not an operator-chosen alias, and may contain dots
    /// (`gpt-4.1`). Before the map-key-section branch landed,
    /// `apply_dirty_path` blindly split the dirty path on `.`, fragmenting
    /// the key into `gpt-4` + `1`, finding neither in the in-memory table,
    /// and silently deleting (no-op-ing) the write instead of persisting
    /// it.
    #[test]
    async fn save_dirty_persists_dotted_map_key_field() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let seed = format!(
            "schema_version = {}\n\n\
             [cost.rates.providers.models.openai.\"gpt-4.1\"]\n\
             input_per_mtok = 1.0\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.cost.rates.providers.models.openai.insert(
            "gpt-4.1".to_string(),
            ModelCostRates {
                input_per_mtok: Some(1.0),
                ..Default::default()
            },
        );

        config
            .set_prop_persistent(
                "cost.rates.providers.models.openai.gpt-4.1.input_per_mtok",
                "9.9",
            )
            .expect("set_prop_persistent must route through the dotted resource key");
        assert_eq!(
            config
                .cost
                .rates
                .providers
                .models
                .openai
                .get("gpt-4.1")
                .and_then(|r| r.input_per_mtok),
            Some(9.9)
        );

        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            written.contains("9.9"),
            "save_dirty must write the new input_per_mtok for the dotted model key; \
             on-disk file still reads:\n{written}"
        );
        assert!(
            written.contains("\"gpt-4.1\""),
            "the dotted key must survive as one quoted TOML key, not be split apart; got:\n{written}"
        );

        let reloaded: Config = toml::from_str(&written)
            .unwrap_or_else(|e| panic!("rewritten config must reparse: {e}\n---\n{written}"));
        assert_eq!(
            reloaded
                .cost
                .rates
                .providers
                .models
                .openai
                .get("gpt-4.1")
                .and_then(|r| r.input_per_mtok),
            Some(9.9),
            "reloaded config must see the persisted value; got:\n{written}"
        );
    }

    /// Control for `save_dirty_persists_dotted_map_key_field`: a dot-free
    /// resource key must keep working through the same map-key-section
    /// branch (it's no longer special-cased out — every `HashMap<String,
    /// T>` write now routes through `apply_dirty_map_key_path`).
    #[test]
    async fn save_dirty_persists_dot_free_map_key_field() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let seed = format!(
            "schema_version = {}\n\n\
             [cost.rates.providers.models.openai.gpt-4o]\n\
             input_per_mtok = 1.0\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.cost.rates.providers.models.openai.insert(
            "gpt-4o".to_string(),
            ModelCostRates {
                input_per_mtok: Some(1.0),
                ..Default::default()
            },
        );

        config
            .set_prop_persistent(
                "cost.rates.providers.models.openai.gpt-4o.input_per_mtok",
                "5.5",
            )
            .unwrap();
        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            written.contains("5.5"),
            "dot-free map key writes must still persist; got:\n{written}"
        );
    }

    /// Delete path: removing a dotted map key in memory
    /// (`delete_map_key`) must still drop the matching on-disk table.
    /// Before this fix the on-disk key was never located because the
    /// dirty path (`<section>.<key>`, no inner suffix) is exactly the
    /// shape the naive `raw.split('.')` walker mis-parses.
    #[test]
    async fn save_dirty_removes_dotted_map_key_entry() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let seed = format!(
            "schema_version = {}\n\n\
             [cost.rates.providers.models.openai.\"gpt-4.1\"]\n\
             input_per_mtok = 1.0\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.cost.rates.providers.models.openai.insert(
            "gpt-4.1".to_string(),
            ModelCostRates {
                input_per_mtok: Some(1.0),
                ..Default::default()
            },
        );

        let removed = config
            .delete_map_key("cost.rates.providers.models.openai", "gpt-4.1")
            .expect("delete_map_key must accept the dotted resource key");
        assert!(removed);
        config.mark_dirty("cost.rates.providers.models.openai.gpt-4.1");

        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            !written.contains("gpt-4.1"),
            "deleted dotted map key must be dropped from disk; got:\n{written}"
        );
    }

    /// ZeroClaw never writes inline tables but loads hand-edited ones
    /// fine, so a map-key section shaped `openai = { "gpt-4.1" = { ... } }`
    /// parses as `Item::Value(Value::InlineTable)` — invisible to a
    /// `Table`-only doc walk. Both halves must go through `TableLike`:
    /// resolving the key (read side) so the batch doesn't abort, and
    /// actually removing it from the inline table (write side) so the
    /// deletion isn't reported as successful while the key survives on
    /// disk — a mutable traversal that only understood `Table` would
    /// resolve the key, then silently return without deleting anything.
    #[test]
    async fn save_dirty_resolves_map_key_from_inline_table_on_disk() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let seed = format!(
            "schema_version = {}\n\n\
             [cost.rates.providers.models]\n\
             openai = {{ \"gpt-4.1\" = {{ input_per_mtok = 1.0 }} }}\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        // Key absent from memory (the delete half): resolution can only
        // come from the on-disk doc, i.e. through the inline table.
        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.mark_dirty("cost.rates.providers.models.openai.gpt-4.1");

        config.save_dirty().await.expect(
            "a key living only in an on-disk inline table must resolve, not abort the save",
        );

        let written = std::fs::read_to_string(&config_path).unwrap();
        let doc = written
            .parse::<toml_edit::DocumentMut>()
            .unwrap_or_else(|e| panic!("rewritten config must reparse: {e}\n---\n{written}"));

        // The delete must actually take effect on disk, not just resolve
        // and then no-op: reporting `Ok(())` while the key survives is
        // the silent-persistence failure this section of `save_dirty`
        // exists to eliminate.
        assert!(
            !written.contains("gpt-4.1"),
            "deleted key must not remain anywhere in the rewritten file; got:\n{written}"
        );
        let openai_item = doc
            .get("cost")
            .and_then(|i| i.get("rates"))
            .and_then(|i| i.get("providers"))
            .and_then(|i| i.get("models"))
            .and_then(|i| i.get("openai"))
            .expect("openai entry must survive the delete of its only sub-key");
        let openai_table = openai_item.as_table_like().expect(
            "openai entry must still be table-like (Table or InlineTable) after the delete",
        );
        assert!(
            !openai_table.contains_key("gpt-4.1"),
            "gpt-4.1 must be removed from the on-disk openai inline table; got:\n{written}"
        );
    }

    /// Write half of the inline-table fix: a value living inside a
    /// hand-edited inline table must be updated in place, not just
    /// resolved-and-ignored. Same doc shape as
    /// `save_dirty_resolves_map_key_from_inline_table_on_disk`, but the
    /// key stays in memory with a changed value instead of being dropped.
    #[test]
    async fn save_dirty_persists_write_into_inline_table_on_disk() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let seed = format!(
            "schema_version = {}\n\n\
             [cost.rates.providers.models]\n\
             openai = {{ \"gpt-4.1\" = {{ input_per_mtok = 1.0 }} }}\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.cost.rates.providers.models.openai.insert(
            "gpt-4.1".to_string(),
            ModelCostRates {
                input_per_mtok: Some(1.0),
                ..Default::default()
            },
        );
        config
            .set_prop_persistent(
                "cost.rates.providers.models.openai.gpt-4.1.input_per_mtok",
                "9.9",
            )
            .expect("set_prop_persistent must route through the dotted resource key");

        config.save_dirty().await.expect(
            "a write into a key resolved through an on-disk inline table must not abort the save",
        );

        let written = std::fs::read_to_string(&config_path).unwrap();
        let doc = written
            .parse::<toml_edit::DocumentMut>()
            .unwrap_or_else(|e| panic!("rewritten config must reparse: {e}\n---\n{written}"));

        assert!(
            written.contains("9.9"),
            "save_dirty must write the new input_per_mtok into the inline table; got:\n{written}"
        );
        let rates_item = doc
            .get("cost")
            .and_then(|i| i.get("rates"))
            .and_then(|i| i.get("providers"))
            .and_then(|i| i.get("models"))
            .and_then(|i| i.get("openai"))
            .and_then(|i| i.get("gpt-4.1"))
            .and_then(|i| i.get("input_per_mtok"))
            .expect("input_per_mtok must survive as a leaf inside the on-disk inline table");
        assert_eq!(
            rates_item.as_float(),
            Some(9.9),
            "the on-disk inline-table leaf must reflect the written value; got:\n{written}"
        );
    }

    /// Loud-failure guard: a dirty path that resolves to a
    /// map-key section but whose key exists in neither the in-memory
    /// config nor the on-disk doc must fail `save_dirty` instead of
    /// silently no-op-ing (the original bug's symptom — reported success,
    /// nothing written).
    #[test]
    async fn save_dirty_errors_on_unresolvable_map_key_path() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let seed = format!(
            "schema_version = {}\n\n\
             [observability]\n\
             backend = \"none\"\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        // Neither `full_table` nor the on-disk doc has a
        // `cost.rates.providers.models.openai.ghost-model` entry — mark it
        // dirty directly the way a stale/duplicate `mark_dirty` call
        // (e.g. a bug elsewhere, or a manually crafted RPC) would.
        config.mark_dirty("cost.rates.providers.models.openai.ghost-model.input_per_mtok");

        let err = config
            .save_dirty()
            .await
            .expect_err("an unresolvable map-key dirty path must fail loudly, not no-op");
        let msg = err.to_string();
        assert!(
            msg.contains("cost.rates.providers.models.openai.ghost-model.input_per_mtok"),
            "error must name the offending dirty path; got: {msg}"
        );
    }

    /// `create_map_key("mcp.servers", "new")` followed by per-field
    /// edits must produce a complete `[[mcp.servers]]` table on disk —
    /// including the seeded natural-key field. This is the path the
    /// dashboard's `+ Add MCP server` affordance walks: insert, then
    /// edit `command` / `transport` etc.
    #[test]
    async fn save_dirty_writes_new_mcp_server_added_via_create_map_key() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Seed an unrelated existing entry so we exercise the
        // append-into-existing-array path (not the create-array-from-
        // scratch path). Both matter; the empty-doc case is covered by
        // `save` instead of `save_dirty` (see the `!config_path.exists()`
        // branch at the top of `save_dirty`).
        let seed = format!(
            "schema_version = {}\n\n\
             [[mcp.servers]]\n\
             name = \"fs\"\n\
             transport = \"stdio\"\n\
             command = \"/usr/bin/mcp-fs\"\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.mcp.servers.push(McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            ..Default::default()
        });

        // The handle_config_map_key_create dispatch path runs
        // create_map_key + mark_dirty(`<section>.<key>`). Replicate
        // that here so we're testing the same wire sequence.
        let created = config
            .create_map_key("mcp.servers", "github")
            .expect("create_map_key on a natural-key section must succeed");
        assert!(created);
        config.mark_dirty("mcp.servers.github");

        // Per-field edit on the freshly-added entry.
        config
            .set_prop_persistent("mcp.servers.github.transport", "http")
            .expect("set transport on freshly-added entry must route");
        config
            .set_prop_persistent("mcp.servers.github.url", "https://mcp.example/")
            .expect("set url on freshly-added entry must route");

        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        // Both entries survive, with their distinct fields.
        assert!(
            written.contains("name = \"fs\""),
            "pre-existing entry must survive; got:\n{written}"
        );
        assert!(
            written.contains("name = \"github\""),
            "new entry's natural-key field must land on disk; got:\n{written}"
        );
        assert!(
            written.contains("transport = \"http\""),
            "per-field edit on the new entry must land; got:\n{written}"
        );
        assert!(
            written.contains("url = \"https://mcp.example/\""),
            "second per-field edit on the new entry must land; got:\n{written}"
        );
        // Round-trip: parsing the written file must yield exactly the
        // shape we built up in memory. Catches mis-shaped output (e.g.
        // a nested `mcp.servers.github` inline table sneaking in instead
        // of a second `[[mcp.servers]]`).
        let reparsed: Config = toml::from_str(&written).unwrap();
        assert_eq!(reparsed.mcp.servers.len(), 2);
        let gh = reparsed
            .mcp
            .servers
            .iter()
            .find(|s| s.name == "github")
            .expect("reparse must surface the new entry by natural key");
        assert_eq!(gh.transport, McpTransport::Http);
        assert_eq!(gh.url.as_deref(), Some("https://mcp.example/"));
    }

    /// `rename_map_key("mcp.servers", "fs", "filesystem")` rewrites the
    /// in-memory entry's `name` field in place and marks BOTH aliases
    /// dirty. The incremental writer must update the matching
    /// `[[mcp.servers]]` entry's `name` to the new value without
    /// leaving a stale duplicate behind.
    #[test]
    async fn save_dirty_persists_mcp_server_rename_via_natural_key() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let seed = format!(
            "schema_version = {}\n\n\
             [[mcp.servers]]\n\
             name = \"fs\"\n\
             transport = \"stdio\"\n\
             command = \"/usr/bin/mcp-fs\"\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.mcp.servers.push(McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            ..Default::default()
        });

        // Mirror handle_config_map_key_rename: rename, then mark both
        // the old and new aliases dirty.
        let renamed = config
            .rename_map_key("mcp.servers", "fs", "filesystem")
            .expect("rename of a unique alias must succeed");
        assert!(renamed);
        config.mark_dirty("mcp.servers.fs");
        config.mark_dirty("mcp.servers.filesystem");

        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            written.contains("name = \"filesystem\""),
            "rename target must land on disk; got:\n{written}"
        );
        assert!(
            !written.contains("name = \"fs\""),
            "stale rename source must NOT remain on disk; got:\n{written}"
        );
        // Other fields on the renamed entry are preserved.
        assert!(
            written.contains("command = \"/usr/bin/mcp-fs\""),
            "rename must preserve sibling fields on the entry; got:\n{written}"
        );

        let reparsed: Config = toml::from_str(&written).unwrap();
        assert_eq!(reparsed.mcp.servers.len(), 1);
        assert_eq!(reparsed.mcp.servers[0].name, "filesystem");
        assert_eq!(reparsed.mcp.servers[0].command, "/usr/bin/mcp-fs");
    }

    /// `delete_map_key("mcp.servers", "fs")` removes the in-memory
    /// entry and marks the alias dirty. The incremental writer must
    /// drop the corresponding `[[mcp.servers]]` entry from disk,
    /// dropping the array slot entirely when no entries remain so the
    /// file doesn't carry a dangling `[[mcp.servers]]` section header.
    #[test]
    async fn save_dirty_removes_mcp_server_deleted_via_natural_key() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Two entries on disk; we'll delete one and assert the other
        // survives.
        let seed = format!(
            "schema_version = {}\n\n\
             [[mcp.servers]]\n\
             name = \"fs\"\n\
             transport = \"stdio\"\n\
             command = \"/usr/bin/mcp-fs\"\n\n\
             [[mcp.servers]]\n\
             name = \"github\"\n\
             transport = \"http\"\n\
             url = \"https://mcp.example/\"\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.mcp.servers.push(McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            ..Default::default()
        });
        config.mcp.servers.push(McpServerConfig {
            name: "github".into(),
            transport: McpTransport::Http,
            url: Some("https://mcp.example/".to_string()),
            ..Default::default()
        });

        let deleted = config
            .delete_map_key("mcp.servers", "fs")
            .expect("delete by natural key must resolve");
        assert!(deleted);
        config.mark_dirty("mcp.servers.fs");

        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            !written.contains("name = \"fs\""),
            "deleted entry must not survive incremental save; got:\n{written}"
        );
        assert!(
            written.contains("name = \"github\""),
            "untouched sibling entry must survive; got:\n{written}"
        );

        let reparsed: Config = toml::from_str(&written).unwrap();
        assert_eq!(reparsed.mcp.servers.len(), 1);
        assert_eq!(reparsed.mcp.servers[0].name, "github");
    }

    /// Deleting the last `[[mcp.servers]]` entry drops the array slot
    /// entirely. A vestigial empty `[[mcp.servers]]` header would
    /// reparse as a single default-shaped element with an empty
    /// natural-key field, which the validator then rejects on next
    /// load — actively breaking the file the writer just produced.
    #[test]
    async fn save_dirty_drops_array_header_when_last_mcp_server_removed() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let seed = format!(
            "schema_version = {}\n\n\
             [[mcp.servers]]\n\
             name = \"fs\"\n\
             transport = \"stdio\"\n\
             command = \"/usr/bin/mcp-fs\"\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.mcp.servers.push(McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            ..Default::default()
        });

        config.delete_map_key("mcp.servers", "fs").unwrap();
        config.mark_dirty("mcp.servers.fs");
        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            !written.contains("[[mcp.servers]]"),
            "empty array header must be dropped, otherwise it reparses as a default entry; got:\n{written}"
        );
        let reparsed: Config = toml::from_str(&written).unwrap();
        assert!(reparsed.mcp.servers.is_empty());
    }

    /// `Config::map_key_sections()` must surface the natural-key field
    /// for `mcp.servers`. This is the metadata `apply_dirty_path` reads
    /// to decide whether to take the array-of-tables branch; if the
    /// derive ever stops emitting it for `#[natural_key = "..."]` Vec
    /// fields, the dirty-path writer falls back to the broken
    /// Table-only walker and silently drops MCP edits on the floor
    /// again. Lock the contract here.
    #[test]
    async fn map_key_sections_exposes_natural_key_for_mcp_servers() {
        let sections = Config::map_key_sections();
        let entry = sections
            .iter()
            .find(|s| s.path == "mcp.servers")
            .expect("mcp.servers must be discoverable in map_key_sections()");
        assert_eq!(entry.kind, crate::traits::MapKeyKind::List);
        assert_eq!(
            entry.natural_key,
            Some("name"),
            "natural_key must mirror the `#[natural_key = \"name\"]` attribute \
             on McpConfig::servers; the dirty-path writer keys off this to take \
             the array-of-tables branch"
        );

        // Sanity: a representative HashMap section (alias IS the TOML
        // key) carries `natural_key: None`. The dirty-path writer's
        // branch decision falls through to the generic Table walker
        // for these.
        let anthropic = sections
            .iter()
            .find(|s| s.path == "providers.models.anthropic")
            .expect(
                "providers.models.anthropic must surface as a HashMap-backed map-keyed section",
            );
        assert_eq!(anthropic.kind, crate::traits::MapKeyKind::Map);
        assert_eq!(anthropic.natural_key, None);
    }

    /// `model_routes` and `embedding_routes` are `#[nested]` Vec fields
    /// with `#[natural_key = "hint"]` — they must surface in
    /// `map_key_sections()` as `List` entries so the dashboard and the
    /// incremental TOML writer can address individual route entries.
    #[tokio::test]
    async fn map_key_sections_exposes_natural_key_for_model_routes() {
        let sections = Config::map_key_sections();
        let entry = sections
            .iter()
            .find(|s| s.path == "model_routes")
            .expect("model_routes must be discoverable in map_key_sections()");
        assert_eq!(entry.kind, crate::traits::MapKeyKind::List);
        assert_eq!(
            entry.natural_key,
            Some("hint"),
            "natural_key must mirror the `#[natural_key = \"hint\"]` attribute \
             on Config::model_routes; the dirty-path writer keys off this to take \
             the array-of-tables branch"
        );
    }

    #[tokio::test]
    async fn map_key_sections_exposes_natural_key_for_embedding_routes() {
        let sections = Config::map_key_sections();
        let entry = sections
            .iter()
            .find(|s| s.path == "embedding_routes")
            .expect("embedding_routes must be discoverable in map_key_sections()");
        assert_eq!(entry.kind, crate::traits::MapKeyKind::List);
        assert_eq!(
            entry.natural_key,
            Some("hint"),
            "natural_key must mirror the `#[natural_key = \"hint\"]` attribute \
             on Config::embedding_routes; the dirty-path writer keys off this to take \
             the array-of-tables branch"
        );
    }

    /// A dirty path with a kebab-shaped inner field (e.g.
    /// `mcp.servers.fs.tool-timeout-secs`) must resolve through the
    /// shared `resolve_dirty_segments` helper inside the natural-key
    /// branch the same way the top-level Table walker does — landing
    /// on the snake `tool_timeout_secs` field on disk. This pins the
    /// dash-aware resolution that's load-bearing for any future
    /// natural-key struct field whose snake_case name is multi-word.
    /// Without this, a UI client that emits kebab field names would
    /// recreate the exact symptom the PR fixes (memory updates, disk
    /// stays stale) for any such field.
    #[test]
    async fn save_dirty_persists_mcp_server_kebab_inner_field_via_natural_key() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        let seed = format!(
            "schema_version = {}\n\n\
             [[mcp.servers]]\n\
             name = \"fs\"\n\
             transport = \"stdio\"\n\
             command = \"/usr/bin/mcp-fs\"\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        config.mcp.servers.push(McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            tool_timeout_secs: Some(45),
            ..Default::default()
        });

        // mark_dirty directly with a kebab leaf segment so the
        // dash-aware resolver inside `resolve_dirty_segments` is the
        // only thing that can possibly land this on disk. set_prop /
        // set_prop_persistent route through the macro which has its
        // own snake-only field-name lookup; this test isolates the
        // writer-side resolution. The in-memory mutation above
        // simulates the dispatcher having already routed the
        // set_prop side; what we're testing here is the save side.
        config.mark_dirty("mcp.servers.fs.tool-timeout-secs");
        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            written.contains("tool_timeout_secs = 45"),
            "kebab dirty segment must resolve to the snake on-disk field; \
             got:\n{written}"
        );
        assert!(
            !written.contains("tool-timeout-secs"),
            "kebab field name must never appear on disk; got:\n{written}"
        );
        // Other fields on the entry survive the targeted edit.
        let reparsed: Config = toml::from_str(&written).unwrap();
        assert_eq!(reparsed.mcp.servers.len(), 1);
        assert_eq!(reparsed.mcp.servers[0].name, "fs");
        assert_eq!(reparsed.mcp.servers[0].command, "/usr/bin/mcp-fs");
        assert_eq!(reparsed.mcp.servers[0].tool_timeout_secs, Some(45));
    }

    /// An explicit per-field unset (the in-memory field reverts to
    /// `None`, but the dirty path still names that specific inner
    /// field rather than the whole element) must drive the case-1
    /// `mem missing → delete` branch — removing the field from the
    /// `[[mcp.servers]]` entry on disk without touching its siblings.
    /// The pre-existing whole-element delete test covers a different
    /// path (the rename source / `delete_map_key`); this one pins
    /// the per-field delete branch independently. Without it, a
    /// future refactor that collapses case-1's `None` branch into
    /// the whole-element path would silently break "clear this field"
    /// edits — the field would survive on disk while showing as
    /// unset in the UI.
    #[test]
    async fn save_dirty_unset_per_field_drops_field_from_mcp_server_entry() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Seed an entry with tool_timeout_secs set — this is the
        // field we'll unset via a per-field dirty path.
        let seed = format!(
            "schema_version = {}\n\n\
             [[mcp.servers]]\n\
             name = \"fs\"\n\
             transport = \"stdio\"\n\
             command = \"/usr/bin/mcp-fs\"\n\
             tool_timeout_secs = 45\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        // In-memory mirror without the optional field set — i.e. the
        // UI user just cleared `tool_timeout_secs`.
        config.mcp.servers.push(McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            tool_timeout_secs: None,
            ..Default::default()
        });
        // Per-field dirty path — exactly what a UI "clear this
        // field" affordance emits. The whole-element bare alias
        // `mcp.servers.fs` is intentionally NOT marked: the bare
        // alias is the rename/delete shape; the bug-prone case is
        // the per-field one.
        config.mark_dirty("mcp.servers.fs.tool_timeout_secs");
        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            !written.contains("tool_timeout_secs"),
            "explicit per-field unset must drop the field from disk; got:\n{written}"
        );
        // Siblings survive: the entry isn't deleted, just the one
        // field, and the natural-key field itself must stay so
        // subsequent loads still know which alias this entry is.
        assert!(
            written.contains("name = \"fs\""),
            "natural-key field must survive a per-field unset; got:\n{written}"
        );
        assert!(
            written.contains("command = \"/usr/bin/mcp-fs\""),
            "sibling fields must survive a per-field unset; got:\n{written}"
        );

        let reparsed: Config = toml::from_str(&written).unwrap();
        assert_eq!(reparsed.mcp.servers.len(), 1);
        assert_eq!(reparsed.mcp.servers[0].name, "fs");
        assert_eq!(reparsed.mcp.servers[0].tool_timeout_secs, None);
    }

    /// When the on-disk node at `mcp.servers` exists but has the
    /// wrong kind — e.g. a hand-edited `mcp.servers = "foo"`, or an
    /// inline-array-of-tables literal `servers = [{ ... }]` that
    /// `toml_edit` parses as `Item::Value(Value::Array)` rather than
    /// `Item::ArrayOfTables` — the writer must refuse to clobber
    /// rather than data-loss the user's hand-edit. The bail is
    /// observable (a `WARN`-level log event), but here we just pin
    /// the don't-clobber behavior at the disk level: the original
    /// shape survives the save unchanged. This is the explicit
    /// contract test for the wrong-kind bail surface; without it,
    /// a refactor that "fixes" the bail by overwriting silently
    /// would corrupt every operator who has either of these
    /// (otherwise valid) TOML shapes in their config.
    #[test]
    async fn save_dirty_refuses_to_clobber_wrong_kind_mcp_servers_node() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Hand-edited file: operator wrote `mcp.servers` as a scalar.
        // This is invalid against the schema but is what
        // `Item::Value(Value::String)` looks like in toml_edit, and
        // it's representative of the wrong-kind case (the same bail
        // path also fires for `Item::Value(Value::Array)` inline
        // arrays). Schema validation would reject this on load; the
        // test forces the writer to face the shape regardless.
        let seed = format!(
            "schema_version = {}\n\n\
             [mcp]\n\
             servers = \"hand-edited-scalar\"\n",
            crate::migration::CURRENT_SCHEMA_VERSION
        );
        std::fs::write(&config_path, &seed).unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        // In-memory has a real server; the per-field edit would
        // normally land on `[[mcp.servers]]` on disk.
        config.mcp.servers.push(McpServerConfig {
            name: "fs".into(),
            transport: McpTransport::Stdio,
            command: "/usr/bin/mcp-fs".into(),
            ..Default::default()
        });
        config.mark_dirty("mcp.servers.fs.command");

        // The save itself must succeed — bail is a no-op for the
        // wrong-kind node, not an error.
        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        // The hand-edited scalar shape survives untouched: we'd
        // rather a load-time validation error the operator sees than
        // a silent overwrite of their (possibly intentional) file
        // surgery.
        assert!(
            written.contains("servers = \"hand-edited-scalar\""),
            "wrong-kind `mcp.servers` node must survive the save unchanged; \
             got:\n{written}"
        );
        assert!(
            !written.contains("[[mcp.servers]]"),
            "writer must not synthesize an array-of-tables next to a scalar \
             hand-edit; got:\n{written}"
        );
        assert!(
            !written.contains("/usr/bin/mcp-fs"),
            "in-memory command must not leak past a wrong-kind bail; \
             got:\n{written}"
        );
    }

    #[test]
    async fn collect_warnings_flags_wire_api_on_fixed_protocol_family() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        // mistral has a fixed wire protocol and ignores wire_api.
        config
            .providers
            .models
            .ensure("mistral", "primary")
            .unwrap()
            .wire_api = Some(WireApi::Responses);
        // custom honors wire_api — must NOT warn.
        config
            .providers
            .models
            .ensure("custom", "vllm")
            .unwrap()
            .wire_api = Some(WireApi::Responses);

        let warnings = config.collect_warnings();
        assert_eq!(warnings.len(), 1, "exactly the mistral entry should warn");
        let w = &warnings[0];
        assert_eq!(w.code, "wire_api_not_supported_for_family");
        assert_eq!(w.path, "providers.models.mistral.primary.wire_api");
        assert!(
            !warnings.iter().any(|w| w.path.contains("custom.vllm")),
            "custom honors wire_api and must not warn",
        );
    }

    #[cfg(unix)]
    #[test]
    async fn world_readable_config_is_detectable() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Create a config file with intentionally loose permissions
        std::fs::write(&config_path, "# test config").unwrap();
        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();

        let meta = std::fs::metadata(&config_path).unwrap();
        let mode = meta.permissions().mode();
        assert!(
            mode & 0o004 != 0,
            "Test setup: file should be world-readable (mode {mode:o})"
        );
    }

    #[test]
    async fn transcription_config_defaults() {
        let tc = TranscriptionConfig::default();
        assert!(!tc.enabled);
        assert!(tc.api_url.contains("groq.com"));
        assert_eq!(tc.model, "whisper-large-v3-turbo");
        assert!(tc.language.is_none());
        assert!(tc.max_audio_bytes.is_none());
        assert_eq!(tc.max_duration_secs, 120);
        assert!(!tc.transcribe_non_ptt_audio);
    }

    #[test]
    async fn config_roundtrip_with_transcription() {
        let mut config = Config::default();
        config.transcription.enabled = true;
        config.transcription.language = Some("en".into());

        let toml_str = toml::to_string_pretty(&config).unwrap();
        let parsed = parse_test_config(&toml_str);

        assert!(parsed.transcription.enabled);
        assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
        assert_eq!(parsed.transcription.model, "whisper-large-v3-turbo");
    }

    #[test]
    async fn config_roundtrip_with_transcription_max_audio_bytes() {
        let mut config = Config::default();
        config.transcription.max_audio_bytes = Some(65_536);

        let toml_str = toml::to_string_pretty(&config).unwrap();
        let parsed = parse_test_config(&toml_str);

        assert_eq!(parsed.transcription.max_audio_bytes, Some(65_536));
    }

    #[test]
    async fn transcription_max_audio_bytes_round_trips_through_prop_path() {
        let mut config = Config::default();

        assert_eq!(
            config
                .get_prop("transcription.max_audio_bytes")
                .unwrap()
                .as_str(),
            "<unset>"
        );

        config
            .set_prop("transcription.max_audio_bytes", "65536")
            .unwrap();
        assert_eq!(config.transcription.max_audio_bytes, Some(65_536));
        assert_eq!(
            config.get_prop("transcription.max_audio_bytes").unwrap(),
            "65536"
        );

        config
            .set_prop("transcription.max_audio_bytes", "")
            .unwrap();
        assert!(config.transcription.max_audio_bytes.is_none());
        assert_eq!(
            config.get_prop("transcription.max_audio_bytes").unwrap(),
            "<unset>"
        );
    }

    #[test]
    async fn config_validate_rejects_zero_transcription_max_audio_bytes() {
        let mut config = Config::default();
        config.transcription.max_audio_bytes = Some(0);

        let err = config.validate().unwrap_err();
        assert!(
            err.to_string()
                .contains("transcription.max_audio_bytes must be greater than zero"),
            "got: {err}"
        );
    }

    #[test]
    async fn config_without_transcription_uses_defaults() {
        let toml_str = r#"
            default_model_provider = "openrouter"
            default_model = "test-model"
            default_temperature = 0.7
        "#;
        let parsed = parse_test_config(toml_str);
        assert!(!parsed.transcription.enabled);
        assert_eq!(parsed.transcription.max_duration_secs, 120);
    }

    #[test]
    async fn security_defaults_are_backward_compatible() {
        let parsed = parse_test_config(
            r#"
default_model_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4.6"
default_temperature = 0.7
"#,
        );

        assert!(!parsed.security.otp.enabled);
        assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
        assert!(!parsed.security.estop.enabled);
        assert!(parsed.security.estop.require_otp_to_resume);
        assert!(parsed.security.leak_detection.enabled);
        assert_eq!(parsed.security.leak_detection.sensitivity, 0.7);
        assert!(parsed.security.leak_detection.high_entropy_tokens);
    }

    #[test]
    async fn security_toml_parses_otp_and_estop_sections() {
        let parsed = parse_test_config(
            r#"
default_model_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4.6"
default_temperature = 0.7

[security.otp]
enabled = true
method = "totp"
token_ttl_secs = 30
cache_valid_secs = 120
gated_actions = ["shell", "browser_open"]
gated_domains = ["*.chase.com", "accounts.google.com"]
gated_domain_categories = ["banking"]

[security.estop]
enabled = true
state_file = "~/.zeroclaw/estop-state.json"
require_otp_to_resume = true
"#,
        );

        assert!(parsed.security.otp.enabled);
        assert!(parsed.security.estop.enabled);
        assert_eq!(parsed.security.otp.gated_actions.len(), 2);
        assert_eq!(parsed.security.otp.gated_domains.len(), 2);
        parsed.validate().unwrap();
    }

    #[test]
    async fn security_toml_parses_leak_detection_section() {
        let parsed = parse_test_config(
            r#"
default_model_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4.6"
default_temperature = 0.7

[security.leak_detection]
enabled = false
sensitivity = 0.35
high_entropy_tokens = false
"#,
        );

        assert!(!parsed.security.leak_detection.enabled);
        assert_eq!(parsed.security.leak_detection.sensitivity, 0.35);
        assert!(!parsed.security.leak_detection.high_entropy_tokens);
        parsed.validate().unwrap();
    }

    #[test]
    async fn security_validation_rejects_out_of_range_leak_detection_sensitivity() {
        let mut config = Config::default();
        config.security.leak_detection.sensitivity = 1.5;

        let err = config
            .validate()
            .expect_err("expected invalid leak-detection sensitivity");
        assert!(
            err.to_string()
                .contains("security.leak_detection.sensitivity"),
            "got: {err}"
        );
    }

    #[test]
    async fn security_validation_rejects_invalid_domain_glob() {
        let mut config = Config::default();
        config.security.otp.gated_domains = vec!["bad domain.com".into()];

        let err = config.validate().expect_err("expected invalid domain glob");
        assert!(err.to_string().contains("gated_domains"));
    }

    #[test]
    async fn security_validation_accepts_all_default_gated_actions_without_warning() {
        let mut config = Config::default();
        config.security.otp.gated_actions = default_otp_gated_actions();

        config
            .validate()
            .expect("the canonical default gated actions must validate clean");
    }

    #[test]
    async fn security_validation_accepts_unknown_gated_action_but_does_not_bail() {
        // An unknown but well-formed action name is a silent no-op today
        // (OTP enforcement of gated_actions is not wired through). Config load
        // must not fail on it — the operator's whole config would be rejected
        // for a typo'd gate — but the runtime emits a WARN so the no-op is not
        // silent. This asserts the warn-and-continue contract: load succeeds.
        let mut config = Config::default();
        config.security.otp.gated_actions = vec!["kubectl_write".into()];

        config
            .validate()
            .expect("an unknown gated action must warn, not reject the config");
    }

    #[test]
    async fn security_validation_still_rejects_malformed_gated_action() {
        // The unknown-name warn must not weaken the existing hard checks from
        // the charset/empty validation: a name with invalid characters still
        // bails rather than degrading to a warn.
        let mut config = Config::default();
        config.security.otp.gated_actions = vec!["kubectl write".into()];

        let err = config
            .validate()
            .expect_err("malformed gated action must still be rejected");
        assert!(err.to_string().contains("gated_actions"));
    }

    // The two `validate_*_transcription_default_provider` tests were removed
    // alongside the deleted `TranscriptionConfig.default_transcription_provider`
    // field in. there is no global default-provider concept; the equivalent
    // dangling-reference enforcement now lives on the per-agent
    // `agent.transcription_provider` field (see
    // `Config::validate()` checks for `tts_provider` / `transcription_provider`).

    #[tokio::test]
    async fn channel_secret_telegram_bot_token_roundtrip() {
        let dir = std::env::temp_dir().join(format!(
            "zeroclaw_test_tg_bot_token_{}",
            uuid::Uuid::new_v4()
        ));
        fs::create_dir_all(&dir).await.unwrap();

        let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";

        let mut config = Config {
            data_dir: dir.join("workspace"),
            config_path: dir.join("config.toml"),
            ..Default::default()
        };
        config.channels.telegram.insert(
            "default".to_string(),
            TelegramConfig {
                enabled: true,
                bot_token: plaintext_token.into(),
                api_base_url: default_telegram_api_base_url(),
                stream_mode: StreamMode::default(),
                draft_update_interval_ms: default_draft_update_interval_ms(),
                interrupt_on_new_message: false,
                mention_only: false,
                ack_reactions: None,
                proxy_url: None,
                approval_timeout_secs: default_telegram_approval_timeout_secs(),
                excluded_tools: vec![],
                reply_min_interval_secs: 0,
                reply_queue_depth_max: 0,
                debounce_ms: None,
            },
        );

        // Save (triggers encryption)
        config.save().await.unwrap();

        // Read raw TOML and verify plaintext token is NOT present
        let raw_toml = tokio::fs::read_to_string(&config.config_path)
            .await
            .unwrap();
        assert!(
            !raw_toml.contains(plaintext_token),
            "Saved TOML must not contain the plaintext bot_token"
        );

        // Parse stored TOML and verify the value is encrypted
        let stored: Config = toml::from_str(&raw_toml).unwrap();
        let stored_token = &stored.channels.telegram.get("default").unwrap().bot_token;
        assert!(
            crate::secrets::SecretStore::is_encrypted(stored_token),
            "Stored bot_token must be marked as encrypted"
        );

        // Decrypt and verify it matches the original plaintext
        let store = crate::secrets::SecretStore::new(&dir, true);
        assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);

        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
        loaded.config_path = dir.join("config.toml");
        let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
        loaded.decrypt_secrets(&load_store).unwrap();
        assert_eq!(
            loaded.channels.telegram.get("default").unwrap().bot_token,
            plaintext_token,
            "Loaded bot_token must match the original plaintext after decryption"
        );

        let _ = fs::remove_dir_all(&dir).await;
    }

    #[test]
    async fn security_validation_rejects_unknown_domain_category() {
        let mut config = Config::default();
        config.security.otp.gated_domain_categories = vec!["not_real".into()];

        let err = config
            .validate()
            .expect_err("expected unknown domain category");
        assert!(err.to_string().contains("gated_domain_categories"));
    }

    #[test]
    async fn security_validation_rejects_zero_token_ttl() {
        let mut config = Config::default();
        config.security.otp.token_ttl_secs = 0;

        let err = config
            .validate()
            .expect_err("expected ttl validation failure");
        assert!(err.to_string().contains("token_ttl_secs"));
    }

    // ── MCP config validation ─────────────────────────────────────────────

    fn stdio_server(name: &str, command: &str) -> McpServerConfig {
        McpServerConfig {
            name: name.to_string(),
            transport: McpTransport::Stdio,
            command: command.to_string(),
            ..Default::default()
        }
    }

    fn http_server(name: &str, url: &str) -> McpServerConfig {
        McpServerConfig {
            name: name.to_string(),
            transport: McpTransport::Http,
            url: Some(url.to_string()),
            ..Default::default()
        }
    }

    fn sse_server(name: &str, url: &str) -> McpServerConfig {
        McpServerConfig {
            name: name.to_string(),
            transport: McpTransport::Sse,
            url: Some(url.to_string()),
            ..Default::default()
        }
    }

    #[test]
    async fn validate_mcp_config_empty_servers_ok() {
        let cfg = McpConfig::default();
        assert!(validate_mcp_config(&cfg).is_ok());
    }

    #[test]
    async fn validate_mcp_config_valid_stdio_ok() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![stdio_server("fs", "/usr/bin/mcp-fs")],
            ..Default::default()
        };
        assert!(validate_mcp_config(&cfg).is_ok());
    }

    #[test]
    async fn validate_mcp_config_valid_http_ok() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![http_server("svc", "http://localhost:8080/mcp")],
            ..Default::default()
        };
        assert!(validate_mcp_config(&cfg).is_ok());
    }

    #[test]
    async fn validate_mcp_config_valid_sse_ok() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![sse_server("svc", "https://example.com/events")],
            ..Default::default()
        };
        assert!(validate_mcp_config(&cfg).is_ok());
    }

    #[test]
    async fn validate_mcp_config_rejects_empty_name() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![stdio_server("", "/usr/bin/tool")],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("empty name should fail");
        assert!(
            err.to_string().contains("name must not be empty"),
            "got: {err}"
        );
    }

    #[test]
    async fn validate_mcp_config_rejects_whitespace_name() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![stdio_server("   ", "/usr/bin/tool")],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("whitespace name should fail");
        assert!(
            err.to_string().contains("name must not be empty"),
            "got: {err}"
        );
    }

    #[test]
    async fn validate_mcp_config_rejects_duplicate_names() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![
                stdio_server("fs", "/usr/bin/mcp-a"),
                stdio_server("fs", "/usr/bin/mcp-b"),
            ],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("duplicate name should fail");
        assert!(err.to_string().contains("duplicate name"), "got: {err}");
    }

    #[test]
    async fn validate_mcp_config_rejects_zero_timeout() {
        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
        server.tool_timeout_secs = Some(0);
        let cfg = McpConfig {
            enabled: true,
            servers: vec![server],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("zero timeout should fail");
        assert!(err.to_string().contains("greater than 0"), "got: {err}");
    }

    #[test]
    async fn validate_mcp_config_rejects_timeout_exceeding_max() {
        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS + 1);
        let cfg = McpConfig {
            enabled: true,
            servers: vec![server],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("oversized timeout should fail");
        assert!(err.to_string().contains("exceeds max"), "got: {err}");
    }

    #[test]
    async fn validate_mcp_config_allows_max_timeout_exactly() {
        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS);
        let cfg = McpConfig {
            enabled: true,
            servers: vec![server],
            ..Default::default()
        };
        assert!(validate_mcp_config(&cfg).is_ok());
    }

    #[test]
    async fn validate_mcp_config_rejects_stdio_with_empty_command() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![stdio_server("fs", "")],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("empty command should fail");
        assert!(
            err.to_string().contains("requires non-empty command"),
            "got: {err}"
        );
    }

    #[test]
    async fn validate_mcp_config_rejects_http_without_url() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![McpServerConfig {
                name: "svc".to_string(),
                transport: McpTransport::Http,
                url: None,
                ..Default::default()
            }],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("http without url should fail");
        assert!(err.to_string().contains("requires url"), "got: {err}");
    }

    #[test]
    async fn validate_mcp_config_rejects_sse_without_url() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![McpServerConfig {
                name: "svc".to_string(),
                transport: McpTransport::Sse,
                url: None,
                ..Default::default()
            }],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("sse without url should fail");
        assert!(err.to_string().contains("requires url"), "got: {err}");
    }

    #[test]
    async fn validate_mcp_config_rejects_non_http_scheme() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![http_server("svc", "ftp://example.com/mcp")],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("non-http scheme should fail");
        assert!(err.to_string().contains("http/https"), "got: {err}");
    }

    #[test]
    async fn validate_mcp_config_rejects_invalid_url() {
        let cfg = McpConfig {
            enabled: true,
            servers: vec![http_server("svc", "not a url at all !!!")],
            ..Default::default()
        };
        let err = validate_mcp_config(&cfg).expect_err("invalid url should fail");
        assert!(err.to_string().contains("valid URL"), "got: {err}");
    }

    #[test]
    async fn mcp_transport_required_leaf_is_the_single_source() {
        // The relationship every consumer reads. Wire names must match the
        // `rename_all = "lowercase"` serde representation.
        assert_eq!(McpTransport::Stdio.required_leaf(), "command");
        assert_eq!(McpTransport::Http.required_leaf(), "url");
        assert_eq!(McpTransport::Sse.required_leaf(), "url");
        assert_eq!(McpTransport::Stdio.wire_name(), "stdio");
        assert_eq!(McpTransport::Http.wire_name(), "http");
        assert_eq!(McpTransport::Sse.wire_name(), "sse");
        // The schema-derived enumerator must surface every variant, and
        // `wire_name` must agree with serde for each, or the emitted metadata
        // desyncs from the wire representation the form reads.
        let transports = mcp_transports();
        assert_eq!(
            transports,
            vec![McpTransport::Stdio, McpTransport::Http, McpTransport::Sse]
        );
        for transport in transports {
            let wire = serde_json::to_value(transport).expect("transport serializes");
            assert_eq!(
                wire,
                serde_json::Value::String(transport.wire_name().into())
            );
        }
    }

    #[test]
    async fn validate_mcp_config_enforces_required_leaf_for_every_transport() {
        // Drift guard: whatever `required_leaf` declares, the validator must
        // actually enforce, so the schema metadata and the runtime check can
        // never disagree about which field a transport needs.
        for transport in mcp_transports() {
            let mut server = McpServerConfig {
                name: "svc".to_string(),
                transport,
                ..Default::default()
            };
            // Populate every required leaf except the one under test, leaving
            // the declared `required_leaf` empty, and confirm rejection.
            match transport.required_leaf() {
                "command" => server.command = String::new(),
                "url" => {
                    server.command = "echo".to_string();
                    server.url = None;
                }
                other => panic!("unhandled required leaf {other} for {transport:?}"),
            }
            let cfg = McpConfig {
                enabled: true,
                servers: vec![server],
                ..Default::default()
            };
            validate_mcp_config(&cfg).expect_err(&format!(
                "{transport:?} must reject an empty {}",
                transport.required_leaf()
            ));
        }
    }

    #[test]
    async fn mcp_server_schema_emits_required_by_transport_metadata() {
        // The config form reads `x-required-by-transport` off the
        // `McpServerConfig` element schema; assert it is present and projects
        // exactly `McpTransport::required_leaf` for every variant.
        #[cfg(feature = "schema-export")]
        let schema = schemars::schema_for!(McpServerConfig);
        let schema_json = serde_json::to_value(&schema).expect("schema serializes to json");
        let map = schema_json
            .get("x-required-by-transport")
            .and_then(serde_json::Value::as_object)
            .expect("schema should carry the x-required-by-transport extension");
        let transports = mcp_transports();
        assert_eq!(map.len(), transports.len());
        for transport in transports {
            assert_eq!(
                map.get(transport.wire_name())
                    .and_then(serde_json::Value::as_str),
                Some(transport.required_leaf()),
                "metadata for {transport:?} must match required_leaf",
            );
        }
    }

    #[test]
    async fn full_config_schema_nests_required_by_transport_on_mcp_server_def() {
        // The gateway serves `schema_for!(Config)` to the Operator Console; the
        // extension must survive into that full document (under `$defs`) where
        // the form resolves the `mcp.servers` element type, not just on the
        // standalone struct schema.
        #[cfg(feature = "schema-export")]
        let schema = schemars::schema_for!(Config);
        let schema_json = serde_json::to_value(&schema).expect("schema serializes to json");

        fn find_extension(
            value: &serde_json::Value,
        ) -> Option<&serde_json::Map<String, serde_json::Value>> {
            match value {
                serde_json::Value::Object(obj) => {
                    if let Some(found) = obj
                        .get("x-required-by-transport")
                        .and_then(serde_json::Value::as_object)
                    {
                        return Some(found);
                    }
                    obj.values().find_map(find_extension)
                }
                serde_json::Value::Array(items) => items.iter().find_map(find_extension),
                _ => None,
            }
        }

        let map = find_extension(&schema_json).expect(
            "full Config schema should carry x-required-by-transport on the mcp server def",
        );
        for transport in mcp_transports() {
            assert_eq!(
                map.get(transport.wire_name())
                    .and_then(serde_json::Value::as_str),
                Some(transport.required_leaf()),
            );
        }
    }

    #[test]
    async fn mcp_config_defaults_enabled_eager_loading_with_empty_servers() {
        let cfg = McpConfig::default();
        assert!(cfg.enabled);
        assert!(!cfg.deferred_loading);
        assert!(cfg.servers.is_empty());
    }

    #[test]
    async fn mcp_config_parsed_missing_flags_uses_enabled_eager_defaults() {
        let raw = r#"
[mcp]

[[mcp.servers]]
name = "svc"
transport = "http"
url = "http://localhost:8080/mcp"
"#;
        let parsed = parse_test_config(raw);
        assert!(parsed.mcp.enabled);
        assert!(!parsed.mcp.deferred_loading);
        assert_eq!(parsed.mcp.servers.len(), 1);
    }

    #[test]
    async fn mcp_config_explicit_disable_and_deferred_loading_are_respected() {
        let raw = r#"
[mcp]
enabled = false
deferred_loading = true

[[mcp.servers]]
name = "svc"
transport = "http"
url = "http://localhost:8080/mcp"
"#;
        let parsed = parse_test_config(raw);
        assert!(!parsed.mcp.enabled);
        assert!(parsed.mcp.deferred_loading);
        assert_eq!(parsed.mcp.servers.len(), 1);
    }

    #[test]
    async fn mcp_transport_serde_roundtrip_lowercase() {
        let cases = [
            (McpTransport::Stdio, "\"stdio\""),
            (McpTransport::Http, "\"http\""),
            (McpTransport::Sse, "\"sse\""),
        ];
        for (variant, expected_json) in &cases {
            let serialized = serde_json::to_string(variant).expect("serialize");
            assert_eq!(&serialized, expected_json, "variant: {variant:?}");
            let deserialized: McpTransport =
                serde_json::from_str(expected_json).expect("deserialize");
            assert_eq!(&deserialized, variant);
        }
    }

    #[tokio::test]
    async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
        let dir = std::env::temp_dir().join(format!(
            "zeroclaw_test_nevis_secret_{}",
            uuid::Uuid::new_v4()
        ));
        fs::create_dir_all(&dir).await.unwrap();

        let plaintext_secret = "nevis-test-client-secret-value";

        let mut config = Config {
            data_dir: dir.join("workspace"),
            config_path: dir.join("config.toml"),
            ..Default::default()
        };
        config.security.nevis.client_secret = Some(plaintext_secret.into());

        // Save (triggers encryption)
        config.save().await.unwrap();

        // Read raw TOML and verify plaintext secret is NOT present
        let raw_toml = tokio::fs::read_to_string(&config.config_path)
            .await
            .unwrap();
        assert!(
            !raw_toml.contains(plaintext_secret),
            "Saved TOML must not contain the plaintext client_secret"
        );

        // Parse stored TOML and verify the value is encrypted
        let stored: Config = toml::from_str(&raw_toml).unwrap();
        let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
        assert!(
            crate::secrets::SecretStore::is_encrypted(stored_secret),
            "Stored client_secret must be marked as encrypted"
        );

        // Decrypt and verify it matches the original plaintext
        let store = crate::secrets::SecretStore::new(&dir, true);
        assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);

        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
        loaded.config_path = dir.join("config.toml");
        let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
        loaded.decrypt_secrets(&load_store).unwrap();
        assert_eq!(
            loaded.security.nevis.client_secret.as_deref().unwrap(),
            plaintext_secret,
            "Loaded client_secret must match the original plaintext after decryption"
        );

        let _ = fs::remove_dir_all(&dir).await;
    }

    // ══════════════════════════════════════════════════════════
    // Nevis config validation tests
    // ══════════════════════════════════════════════════════════

    #[test]
    async fn nevis_config_validate_disabled_accepts_empty_fields() {
        let cfg = NevisConfig::default();
        assert!(!cfg.enabled);
        assert!(cfg.validate().is_ok());
    }

    #[test]
    async fn nevis_config_validate_rejects_empty_instance_url() {
        let cfg = NevisConfig {
            enabled: true,
            instance_url: String::new(),
            client_id: "test-client".into(),
            ..NevisConfig::default()
        };
        let err = cfg.validate().unwrap_err();
        assert!(err.contains("instance_url"));
    }

    #[test]
    async fn nevis_config_validate_rejects_empty_client_id() {
        let cfg = NevisConfig {
            enabled: true,
            instance_url: "https://nevis.example.com".into(),
            client_id: String::new(),
            ..NevisConfig::default()
        };
        let err = cfg.validate().unwrap_err();
        assert!(err.contains("client_id"));
    }

    #[test]
    async fn nevis_config_validate_rejects_empty_realm() {
        let cfg = NevisConfig {
            enabled: true,
            instance_url: "https://nevis.example.com".into(),
            client_id: "test-client".into(),
            realm: String::new(),
            ..NevisConfig::default()
        };
        let err = cfg.validate().unwrap_err();
        assert!(err.contains("realm"));
    }

    #[test]
    async fn nevis_config_validate_rejects_local_without_jwks() {
        let cfg = NevisConfig {
            enabled: true,
            instance_url: "https://nevis.example.com".into(),
            client_id: "test-client".into(),
            token_validation: "local".into(),
            jwks_url: None,
            ..NevisConfig::default()
        };
        let err = cfg.validate().unwrap_err();
        assert!(err.contains("jwks_url"));
    }

    #[test]
    async fn nevis_config_validate_rejects_zero_session_timeout() {
        let cfg = NevisConfig {
            enabled: true,
            instance_url: "https://nevis.example.com".into(),
            client_id: "test-client".into(),
            token_validation: "remote".into(),
            session_timeout_secs: 0,
            ..NevisConfig::default()
        };
        let err = cfg.validate().unwrap_err();
        assert!(err.contains("session_timeout_secs"));
    }

    #[test]
    async fn nevis_config_validate_accepts_valid_enabled_config() {
        let cfg = NevisConfig {
            enabled: true,
            instance_url: "https://nevis.example.com".into(),
            realm: "master".into(),
            client_id: "test-client".into(),
            token_validation: "remote".into(),
            session_timeout_secs: 3600,
            ..NevisConfig::default()
        };
        assert!(cfg.validate().is_ok());
    }

    #[test]
    async fn nevis_config_validate_rejects_invalid_token_validation() {
        let cfg = NevisConfig {
            enabled: true,
            instance_url: "https://nevis.example.com".into(),
            realm: "master".into(),
            client_id: "test-client".into(),
            token_validation: "invalid_mode".into(),
            session_timeout_secs: 3600,
            ..NevisConfig::default()
        };
        let err = cfg.validate().unwrap_err();
        assert!(
            err.contains("invalid value 'invalid_mode'"),
            "Expected invalid token_validation error, got: {err}"
        );
    }

    #[test]
    async fn nevis_config_debug_redacts_client_secret() {
        let cfg = NevisConfig {
            client_secret: Some("super-secret".into()),
            ..NevisConfig::default()
        };
        let debug_output = format!("{:?}", cfg);
        assert!(
            !debug_output.contains("super-secret"),
            "Debug output must not contain the raw client_secret"
        );
        assert!(
            debug_output.contains("[REDACTED]"),
            "Debug output must show [REDACTED] for client_secret"
        );
    }

    #[test]
    async fn git_config_debug_redacts_private_key_and_access_token() {
        let cfg = GitConfig {
            private_key: Some(
                "-----BEGIN RSA PRIVATE KEY-----\nSUPERSECRETPEM\n-----END RSA PRIVATE KEY-----"
                    .into(),
            ),
            access_token: "ghp_supersecrettoken".into(),
            ..GitConfig::default()
        };
        let debug_output = format!("{cfg:?}");
        assert!(
            !debug_output.contains("SUPERSECRETPEM"),
            "Debug output must not contain the raw private_key PEM"
        );
        assert!(
            !debug_output.contains("ghp_supersecrettoken"),
            "Debug output must not contain the raw access_token"
        );
        assert!(
            debug_output.contains("***"),
            "Debug output must mask the private_key and access_token"
        );
    }

    #[test]
    async fn telegram_config_ack_reactions_false_deserializes() {
        let toml_str = r#"
            bot_token = "123:ABC"
            allowed_users = ["alice"]
            ack_reactions = false
        "#;
        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.ack_reactions, Some(false));
    }

    #[test]
    async fn telegram_config_ack_reactions_true_deserializes() {
        let toml_str = r#"
            bot_token = "123:ABC"
            allowed_users = ["alice"]
            ack_reactions = true
        "#;
        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.ack_reactions, Some(true));
    }

    #[test]
    async fn telegram_config_ack_reactions_missing_defaults_to_none() {
        let toml_str = r#"
            bot_token = "123:ABC"
            allowed_users = ["alice"]
        "#;
        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.ack_reactions, None);
    }

    #[test]
    async fn telegram_config_ack_reactions_channel_overrides_top_level() {
        let tg_toml = r#"
            bot_token = "123:ABC"
            allowed_users = ["alice"]
            ack_reactions = false
        "#;
        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
        let top_level_ack = true;
        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
        assert!(
            !effective,
            "channel-level false must override top-level true"
        );
    }

    #[test]
    async fn telegram_config_ack_reactions_falls_back_to_top_level() {
        let tg_toml = r#"
            bot_token = "123:ABC"
            allowed_users = ["alice"]
        "#;
        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
        let top_level_ack = false;
        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
        assert!(
            !effective,
            "must fall back to top-level false when channel omits field"
        );
    }

    #[test]
    async fn google_workspace_allowed_operations_deserialize_from_toml() {
        let toml_str = r#"
            enabled = true

            [[allowed_operations]]
            service = "gmail"
            resource = "users"
            sub_resource = "drafts"
            methods = ["create", "update"]
        "#;

        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.allowed_operations.len(), 1);
        assert_eq!(cfg.allowed_operations[0].service, "gmail");
        assert_eq!(cfg.allowed_operations[0].resource, "users");
        assert_eq!(
            cfg.allowed_operations[0].sub_resource.as_deref(),
            Some("drafts")
        );
        assert_eq!(
            cfg.allowed_operations[0].methods,
            vec!["create".to_string(), "update".to_string()]
        );
    }

    #[test]
    async fn google_workspace_allowed_operations_deserialize_without_sub_resource() {
        let toml_str = r#"
            enabled = true

            [[allowed_operations]]
            service = "drive"
            resource = "files"
            methods = ["list", "get"]
        "#;

        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.allowed_operations[0].sub_resource, None);
    }

    #[test]
    async fn config_validate_accepts_google_workspace_allowed_operations() {
        let mut cfg = Config::default();
        cfg.google_workspace.enabled = true;
        cfg.google_workspace.allowed_services = vec!["gmail".into()];
        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
            service: "gmail".into(),
            resource: "users".into(),
            sub_resource: Some("drafts".into()),
            methods: vec!["create".into(), "update".into()],
        }];

        cfg.validate().unwrap();
    }

    #[test]
    async fn config_validate_rejects_duplicate_google_workspace_allowed_operations() {
        let mut cfg = Config::default();
        cfg.google_workspace.enabled = true;
        cfg.google_workspace.allowed_services = vec!["gmail".into()];
        cfg.google_workspace.allowed_operations = vec![
            GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("drafts".into()),
                methods: vec!["create".into()],
            },
            GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("drafts".into()),
                methods: vec!["update".into()],
            },
        ];

        let err = cfg.validate().unwrap_err().to_string();
        assert!(err.contains("duplicate service/resource/sub_resource entry"));
    }

    #[test]
    async fn config_validate_rejects_operation_service_not_in_allowed_services() {
        let mut cfg = Config::default();
        cfg.google_workspace.enabled = true;
        cfg.google_workspace.allowed_services = vec!["gmail".into()];
        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
            service: "drive".into(), // drive is not in allowed_services
            resource: "files".into(),
            sub_resource: None,
            methods: vec!["list".into()],
        }];

        let err = cfg.validate().unwrap_err().to_string();
        assert!(
            err.contains("not in the effective allowed_services"),
            "expected not-in-allowed_services error, got: {err}"
        );
    }

    #[test]
    async fn config_validate_accepts_default_service_when_allowed_services_empty() {
        // When allowed_services is empty the validator uses DEFAULT_GWS_SERVICES.
        // A known default service must pass.
        let mut cfg = Config::default();
        cfg.google_workspace.enabled = true;
        // allowed_services deliberately left empty (falls back to defaults)
        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
            service: "drive".into(),
            resource: "files".into(),
            sub_resource: None,
            methods: vec!["list".into()],
        }];

        assert!(cfg.validate().is_ok());
    }

    #[test]
    async fn config_validate_rejects_unknown_service_when_allowed_services_empty() {
        // Even with allowed_services empty (using defaults), an operation whose
        // service is not in DEFAULT_GWS_SERVICES must fail validation — not silently
        // pass through to be rejected at runtime.
        let mut cfg = Config::default();
        cfg.google_workspace.enabled = true;
        // allowed_services deliberately left empty
        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
            service: "not_a_real_service".into(),
            resource: "files".into(),
            sub_resource: None,
            methods: vec!["list".into()],
        }];

        let err = cfg.validate().unwrap_err().to_string();
        assert!(
            err.contains("not in the effective allowed_services"),
            "expected effective-allowed_services error, got: {err}"
        );
    }

    // ── Bootstrap files ─────────────────────────────────────

    #[tokio::test]
    async fn ensure_bootstrap_files_creates_missing_files() {
        let tmp = tempfile::TempDir::new().unwrap();
        let ws = tmp.path().join("workspace");
        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();

        ensure_bootstrap_files(&ws).await.unwrap();

        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
            .await
            .unwrap();
        assert!(soul.contains("SOUL.md"));
        assert!(identity.contains("IDENTITY.md"));
    }

    #[tokio::test]
    async fn ensure_bootstrap_files_does_not_overwrite_existing() {
        let tmp = tempfile::TempDir::new().unwrap();
        let ws = tmp.path().join("workspace");
        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();

        let custom = "# My custom SOUL";
        let _: () = tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();

        ensure_bootstrap_files(&ws).await.unwrap();

        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
        assert_eq!(
            soul, custom,
            "ensure_bootstrap_files must not overwrite existing files"
        );

        // IDENTITY.md should still be created since it was missing
        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
            .await
            .unwrap();
        assert!(identity.contains("IDENTITY.md"));
    }

    // ── PacingConfig serde defaults ─────────────────────────────

    #[test]
    async fn pacing_config_serde_defaults_match_manual_default() {
        // Deserialise an empty TOML table and verify the loop-detection
        // fields receive the same defaults as `PacingConfig::default()`.
        let from_toml: PacingConfig = toml::from_str("").unwrap();
        let manual = PacingConfig::default();

        assert_eq!(
            from_toml.loop_detection_enabled,
            manual.loop_detection_enabled
        );
        assert_eq!(
            from_toml.loop_detection_window_size,
            manual.loop_detection_window_size
        );
        assert_eq!(
            from_toml.loop_detection_max_repeats,
            manual.loop_detection_max_repeats
        );

        // Verify concrete values so a silent change to the defaults is caught.
        assert!(from_toml.loop_detection_enabled, "default should be true");
        assert_eq!(from_toml.loop_detection_window_size, 20);
        assert_eq!(from_toml.loop_detection_max_repeats, 3);
    }

    // ── Docker baked config template ────────────────────────────

    /// The TOML template baked into Docker images (Dockerfile + Dockerfile.debian).
    /// Kept here so changes to the Dockerfiles can be validated by `cargo test`.
    const DOCKER_CONFIG_TEMPLATE: &str = r#"
schema_version = 3
workspace_dir = "/zeroclaw-data/workspace"
config_path = "/zeroclaw-data/.zeroclaw/config.toml"
api_key = ""
default_model_provider = "openrouter"
default_model = "anthropic/claude-sonnet-4-20250514"
default_temperature = 0.7

[gateway]
port = 42617
host = "[::]"
allow_public_bind = true

[risk_profiles.default]
level = "supervised"
auto_approve = ["file_read", "file_write", "file_edit", "memory_recall", "memory_store", "web_search_tool", "web_fetch", "calculator", "glob_search", "content_search", "image_info", "weather", "git_operations"]
"#;

    #[test]
    async fn docker_config_template_is_parseable() {
        let cfg: Config = toml::from_str(DOCKER_CONFIG_TEMPLATE)
            .expect("Docker baked config.toml must be valid TOML that deserialises into Config");

        let auto = &cfg
            .risk_profiles
            .get("default")
            .expect("Docker config must define [risk_profiles.default]")
            .auto_approve;
        for tool in &[
            "file_read",
            "file_write",
            "file_edit",
            "memory_recall",
            "memory_store",
            "web_search_tool",
            "web_fetch",
            "calculator",
            "glob_search",
            "content_search",
            "image_info",
            "weather",
            "git_operations",
        ] {
            assert!(
                auto.iter().any(|t| t == tool),
                "Docker config risk_profiles.default.auto_approve missing expected tool: {tool}"
            );
        }
    }

    #[test]
    async fn cost_enforcement_config_defaults() {
        let config = CostEnforcementConfig::default();
        assert_eq!(config.mode, "warn");
        assert_eq!(config.route_down_model, None);
        assert_eq!(config.reserve_percent, 10);
    }

    #[test]
    async fn cost_config_includes_enforcement() {
        let config = CostConfig::default();
        assert_eq!(config.enforcement.mode, "warn");
        assert_eq!(config.enforcement.reserve_percent, 10);
    }

    // ── Configurable macro tests ──

    #[test]
    async fn matrix_secret_fields_discovered() {
        let mx = MatrixConfig {
            enabled: true,
            homeserver: "https://m.org".into(),
            access_token: Some("tok".into()),
            user_id: None,
            device_id: None,
            allowed_rooms: vec!["!r:m".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let fields = mx.secret_fields();
        assert_eq!(fields.len(), 3);
        assert_eq!(fields[0].name, "channels.matrix.access_token");
        assert_eq!(fields[0].category, "Channels");
        assert!(fields[0].is_set);
        assert_eq!(fields[1].name, "channels.matrix.recovery_key");
        assert!(!fields[1].is_set);
        assert_eq!(fields[2].name, "channels.matrix.password");
        assert!(!fields[2].is_set);
    }

    #[test]
    async fn matrix_secret_fields_empty_not_set() {
        let mx = MatrixConfig {
            enabled: true,
            homeserver: "https://m.org".into(),
            access_token: None,
            user_id: None,
            device_id: None,
            allowed_rooms: vec!["!r:m".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        let fields = mx.secret_fields();
        assert!(!fields[0].is_set);
    }

    #[test]
    async fn set_secret_updates_field() {
        let mut mx = MatrixConfig {
            enabled: true,
            homeserver: "https://m.org".into(),
            access_token: Some("old".into()),
            user_id: None,
            device_id: None,
            allowed_rooms: vec!["!r:m".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        mx.set_secret("channels.matrix.access_token", "new-token".into())
            .unwrap();
        assert_eq!(mx.access_token.as_deref(), Some("new-token"));
    }

    #[test]
    async fn set_secret_unknown_name_fails() {
        let mut mx = MatrixConfig {
            enabled: true,
            homeserver: "https://m.org".into(),
            access_token: Some("tok".into()),
            user_id: None,
            device_id: None,
            allowed_rooms: vec!["!r:m".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };
        assert!(
            mx.set_secret("channels.matrix.nonexistent", "val".into())
                .is_err()
        );
    }

    #[test]
    async fn config_tree_traversal_discovers_nested_secrets() {
        let mut config = Config::default();
        // Set api_key on first model_provider entry (or create one)
        config
            .providers
            .models
            .ensure("anthropic", "default")
            .expect("anthropic typed slot")
            .api_key = Some("test-key".into());
        config.channels.matrix.insert(
            "default".to_string(),
            MatrixConfig {
                enabled: true,
                homeserver: "https://m.org".into(),
                access_token: Some("mx-tok".into()),
                user_id: None,
                device_id: None,
                allowed_rooms: vec!["!r:m".into()],
                interrupt_on_new_message: false,
                stream_mode: StreamMode::default(),
                draft_update_interval_ms: 1500,
                multi_message_delay_ms: 800,
                recovery_key: None,
                mention_only: false,
                password: None,
                approval_timeout_secs: 300,
                reply_in_thread: true,
                ack_reactions: Some(true),
                excluded_tools: vec![],
                reply_min_interval_secs: 0,
                reply_queue_depth_max: 0,
            },
        );

        let fields = config.secret_fields();
        let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
        assert!(names.contains(&"channels.matrix.access_token"));
        assert!(names.contains(&"channels.matrix.recovery_key"));
        assert!(
            names.contains(&"http_request.secrets"),
            "http_request.secrets must be classified as a secret map"
        );
    }

    #[test]
    async fn config_set_secret_dispatches_to_child() {
        let mut config = Config::default();
        config.channels.matrix.insert(
            "default".to_string(),
            MatrixConfig {
                enabled: true,
                homeserver: "https://m.org".into(),
                access_token: Some("old".into()),
                user_id: None,
                device_id: None,
                allowed_rooms: vec!["!r:m".into()],
                interrupt_on_new_message: false,
                stream_mode: StreamMode::default(),
                draft_update_interval_ms: 1500,
                multi_message_delay_ms: 800,
                recovery_key: None,
                mention_only: false,
                password: None,
                approval_timeout_secs: 300,
                reply_in_thread: true,
                ack_reactions: Some(true),
                excluded_tools: vec![],
                reply_min_interval_secs: 0,
                reply_queue_depth_max: 0,
            },
        );

        config
            .set_secret("channels.matrix.access_token", "new".into())
            .unwrap();
        assert_eq!(
            config
                .channels
                .matrix
                .get("default")
                .unwrap()
                .access_token
                .as_deref(),
            Some("new")
        );
    }

    #[test]
    async fn config_set_secret_dispatches_to_matrix_child() {
        let mut config = Config::default();
        config.channels.matrix.insert(
            "default".to_string(),
            MatrixConfig {
                enabled: true,
                homeserver: "https://m.org".into(),
                access_token: Some("old".into()),
                user_id: None,
                device_id: None,
                allowed_rooms: vec!["!r:m".into()],
                interrupt_on_new_message: false,
                stream_mode: StreamMode::default(),
                draft_update_interval_ms: 1500,
                multi_message_delay_ms: 800,
                mention_only: false,
                recovery_key: None,
                password: None,
                approval_timeout_secs: 300,
                reply_in_thread: true,
                ack_reactions: Some(true),
                excluded_tools: vec![],
                reply_min_interval_secs: 0,
                reply_queue_depth_max: 0,
            },
        );
        config
            .set_secret("channels.matrix.access_token", "sk-test".into())
            .unwrap();
        assert_eq!(
            config
                .channels
                .matrix
                .get("default")
                .unwrap()
                .access_token
                .as_deref(),
            Some("sk-test")
        );
    }

    #[test]
    async fn config_set_secret_unknown_fails() {
        let mut config = Config::default();
        assert!(
            config
                .set_secret("nonexistent.field", "val".into())
                .is_err()
        );
    }

    #[test]
    async fn config_set_http_request_secret_map_key_is_masked_and_encrypted() {
        let dir = TempDir::new().unwrap();
        let config_path = dir.path().join("config.toml");
        tokio::fs::write(&config_path, "schema_version = 1\n")
            .await
            .unwrap();
        let mut config = Config {
            config_path: config_path.clone(),
            data_dir: dir.path().join("workspace"),
            secrets: SecretsConfig { encrypt: true },
            ..Config::default()
        };
        let path = "http_request.secrets.api_token";

        assert!(
            Config::prop_is_secret(path),
            "dynamic http_request secret map entries must be classified as secret before the key exists"
        );
        config
            .set_prop_persistent(path, "Bearer from-config-set")
            .unwrap();

        assert_eq!(
            config
                .http_request
                .secrets
                .get("api_token")
                .map(String::as_str),
            Some("Bearer from-config-set")
        );
        assert_eq!(config.get_prop(path).unwrap(), "****");

        let field = config
            .prop_fields()
            .into_iter()
            .find(|field| field.name == path)
            .expect("dynamic secret map prop field");
        assert!(field.is_secret);
        assert_eq!(field.display_value, "****");
        assert_eq!(
            field.credential_class,
            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
        );

        config.save_dirty().await.unwrap();
        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
        assert!(
            !contents.contains("Bearer from-config-set"),
            "auth secret must not be written in plaintext: {contents}"
        );

        let stored = crate::migration::migrate_to_current(&contents).unwrap();
        let encrypted = stored.http_request.secrets.get("api_token").unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(encrypted));
        let store = crate::secrets::SecretStore::new(dir.path(), true);
        assert_eq!(store.decrypt(encrypted).unwrap(), "Bearer from-config-set");
    }

    #[test]
    async fn encrypt_decrypt_roundtrip_via_macro() {
        let dir = TempDir::new().unwrap();
        let store = crate::secrets::SecretStore::new(dir.path(), true);

        let mut mx = MatrixConfig {
            enabled: true,
            homeserver: "https://m.org".into(),
            access_token: Some("plaintext-token".into()),
            user_id: None,
            device_id: None,
            allowed_rooms: vec!["!r:m".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };

        // Encrypt
        mx.encrypt_secrets(&store).unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(
            mx.access_token.as_deref().unwrap_or_default()
        ));
        assert_ne!(mx.access_token.as_deref(), Some("plaintext-token"));

        // Decrypt
        mx.decrypt_secrets(&store).unwrap();
        assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
    }

    #[test]
    async fn encrypt_skips_already_encrypted() {
        let dir = TempDir::new().unwrap();
        let store = crate::secrets::SecretStore::new(dir.path(), true);

        let mut mx = MatrixConfig {
            enabled: true,
            homeserver: "https://m.org".into(),
            access_token: Some("plaintext-token".into()),
            user_id: None,
            device_id: None,
            allowed_rooms: vec!["!r:m".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };

        mx.encrypt_secrets(&store).unwrap();
        let first_encrypted = mx.access_token.clone();

        // Encrypt again — should be idempotent
        mx.encrypt_secrets(&store).unwrap();
        assert_eq!(mx.access_token, first_encrypted);
    }

    #[test]
    async fn encrypt_no_op_on_disabled_store() {
        let dir = TempDir::new().unwrap();
        let store = crate::secrets::SecretStore::new(dir.path(), false);

        let mut mx = MatrixConfig {
            enabled: true,
            homeserver: "https://m.org".into(),
            access_token: Some("plaintext-token".into()),
            user_id: None,
            device_id: None,
            allowed_rooms: vec!["!r:m".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        };

        mx.encrypt_secrets(&store).unwrap();
        // With encryption disabled, value should stay plaintext
        assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
    }

    // ── Property method tests ──

    fn test_matrix_config() -> MatrixConfig {
        MatrixConfig {
            enabled: true,
            homeserver: "https://m.org".into(),
            access_token: Some("tok".into()),
            user_id: Some("@bot:m.org".into()),
            device_id: None,
            allowed_rooms: vec!["!r:m".into()],
            interrupt_on_new_message: false,
            stream_mode: StreamMode::default(),
            draft_update_interval_ms: 1500,
            multi_message_delay_ms: 800,
            recovery_key: None,
            mention_only: false,
            password: None,
            approval_timeout_secs: 300,
            reply_in_thread: true,
            ack_reactions: Some(true),
            excluded_tools: vec![],
            reply_min_interval_secs: 0,
            reply_queue_depth_max: 0,
        }
    }

    #[test]
    async fn prop_fields_returns_typed_entries() {
        let mx = test_matrix_config();
        let fields = mx.prop_fields();
        let by_name: std::collections::HashMap<&str, &crate::traits::PropFieldInfo> =
            fields.iter().map(|f| (f.name.as_str(), f)).collect();

        // String field
        let homeserver = by_name["channels.matrix.homeserver"];
        assert_eq!(homeserver.type_hint, "String");
        assert_eq!(homeserver.display_value, "https://m.org");

        // Option<String> — set
        let user_id = by_name["channels.matrix.user_id"];
        assert_eq!(user_id.type_hint, "Option<String>");
        assert_eq!(user_id.display_value, "@bot:m.org");

        // Option<String> — unset
        let device_id = by_name["channels.matrix.device_id"];
        assert_eq!(device_id.display_value, "<unset>");

        // u64 field
        let interval = by_name["channels.matrix.draft_update_interval_ms"];
        assert_eq!(interval.type_hint, "u64");
        assert_eq!(interval.display_value, "1500");

        // Enum field
        let stream = by_name["channels.matrix.stream_mode"];
        assert!(stream.is_enum());
        assert!(stream.enum_variants.is_some());

        // Secret field — masked
        let token = by_name["channels.matrix.access_token"];
        assert!(token.is_secret);
        assert_eq!(token.display_value, "****");

        // All fields have correct category
        for field in &fields {
            assert_eq!(field.category, "Channels");
        }
    }

    #[test]
    async fn generated_config_fields_keep_operator_descriptions() {
        fn assert_description(
            fields: &[crate::traits::PropFieldInfo],
            suffix: &str,
            expected: &str,
        ) {
            let matches = fields
                .iter()
                .filter(|field| field.name.ends_with(suffix))
                .collect::<Vec<_>>();
            assert_eq!(
                matches.len(),
                1,
                "expected exactly one configurable field ending in `{suffix}`"
            );
            let field = matches[0];
            assert!(
                field
                    .description
                    .to_ascii_lowercase()
                    .contains(&expected.to_ascii_lowercase()),
                "description for {} must retain `{expected}`: {}",
                field.name,
                field.description,
            );
        }

        let workspace = crate::multi_agent::AgentWorkspaceConfig::default().prop_fields();
        assert_description(&workspace, ".access", "cross-agent workspace allowlist");
        assert_description(
            &workspace,
            ".read_memory_from",
            "Cross-agent memory allowlist",
        );

        let a2a = crate::multi_agent::A2aServerConfig::default().prop_fields();
        assert_description(&a2a, ".public_base_url", "operator-supplied base URL");

        let thinking = crate::scattered_types::ThinkingConfig::default().prop_fields();
        assert_description(&thinking, ".native_thinking", "selected level has a budget");

        let compression = crate::scattered_types::ContextCompressionConfig::default().prop_fields();
        assert_description(&compression, ".summary_provider", "<type>.<alias>");
        assert_description(&compression, ".summary_model", "DEPRECATED bare model id");

        let email = crate::scattered_types::EmailConfig::default().prop_fields();
        assert_description(&email, ".observer_mode", "never modifies any IMAP flag");
    }

    #[cfg(feature = "schema-export")]
    #[test]
    async fn generated_config_types_keep_schema_descriptions() {
        fn assert_schema_description<T: schemars::JsonSchema>(name: &str) {
            let schema =
                serde_json::to_value(schemars::schema_for!(T)).expect("schema serializes to json");
            let description = schema
                .get("description")
                .and_then(serde_json::Value::as_str)
                .unwrap_or_else(|| panic!("{name} schema must have a top-level description"));
            assert!(
                !description.trim().is_empty(),
                "{name} schema description must not be empty",
            );
        }

        use crate::autonomy::{ApprovalRoute, AutonomyLevel, DelegationPolicy};
        use crate::multi_agent::{
            A2aServerConfig, A2aServerSection, AccessMode, AgentA2aConfig, AgentMemoryConfig,
            AgentWorkspaceConfig, MemoryBackendKind, OutputModality,
        };
        use crate::presets::{
            BuilderSubmission, ChannelQuickStart, ModelProviderChoice, SelectorChoice,
        };
        use crate::providers::{ModelProviders, Providers};
        use crate::scattered_types::ChannelPrecheckConfig;
        use crate::sections::{Section, SectionGroup};
        use crate::validation_warnings::ValidationWarning;

        assert_schema_description::<AutonomyLevel>("AutonomyLevel");
        assert_schema_description::<DelegationPolicy>("DelegationPolicy");
        assert_schema_description::<ApprovalRoute>("ApprovalRoute");
        assert_schema_description::<AccessMode>("AccessMode");
        assert_schema_description::<MemoryBackendKind>("MemoryBackendKind");
        assert_schema_description::<AgentWorkspaceConfig>("AgentWorkspaceConfig");
        assert_schema_description::<AgentMemoryConfig>("AgentMemoryConfig");
        assert_schema_description::<OutputModality>("OutputModality");
        assert_schema_description::<A2aServerConfig>("A2aServerConfig");
        assert_schema_description::<A2aServerSection>("A2aServerSection");
        assert_schema_description::<AgentA2aConfig>("AgentA2aConfig");
        assert_schema_description::<ModelProviderChoice>("ModelProviderChoice");
        assert_schema_description::<ChannelQuickStart>("ChannelQuickStart");
        assert_schema_description::<BuilderSubmission>("BuilderSubmission");
        assert_schema_description::<SelectorChoice<ModelProviderChoice>>("SelectorChoice");
        assert_schema_description::<ModelProviders>("ModelProviders");
        assert_schema_description::<Providers>("Providers");
        assert_schema_description::<ChannelPrecheckConfig>("ChannelPrecheckConfig");
        assert_schema_description::<SectionGroup>("SectionGroup");
        assert_schema_description::<Section>("Section");
        assert_schema_description::<ValidationWarning>("ValidationWarning");

        let map_key_schema =
            serde_json::to_value(schemars::schema_for!(crate::traits::MapKeySection))
                .expect("MapKeySection schema serializes to json");
        let natural_key = map_key_schema
            .pointer("/properties/natural_key/description")
            .and_then(serde_json::Value::as_str)
            .expect("MapKeySection.natural_key must have a schema description");
        assert!(natural_key.contains("natural key"));
    }

    #[test]
    async fn get_prop_returns_values_by_path() {
        let mx = test_matrix_config();

        assert_eq!(
            mx.get_prop("channels.matrix.homeserver").unwrap(),
            "https://m.org"
        );
        assert_eq!(
            mx.get_prop("channels.matrix.draft_update_interval_ms")
                .unwrap(),
            "1500"
        );
        assert_eq!(
            mx.get_prop("channels.matrix.user_id").unwrap(),
            "@bot:m.org"
        );
        assert_eq!(mx.get_prop("channels.matrix.device_id").unwrap(), "<unset>");
        // Secrets return masked value
        assert_eq!(
            mx.get_prop("channels.matrix.access_token").unwrap(),
            "**** (encrypted)"
        );
    }

    #[test]
    async fn get_prop_unknown_path_fails() {
        let mx = test_matrix_config();
        assert!(mx.get_prop("channels.matrix.nonexistent").is_err());
    }

    #[test]
    async fn set_prop_string() {
        let mut mx = test_matrix_config();
        mx.set_prop("channels.matrix.homeserver", "https://new.org")
            .unwrap();
        assert_eq!(mx.homeserver, "https://new.org");
    }

    #[test]
    async fn set_prop_bool() {
        let mut mx = test_matrix_config();
        mx.set_prop("channels.matrix.interrupt_on_new_message", "true")
            .unwrap();
        assert!(mx.interrupt_on_new_message);
    }

    #[test]
    async fn set_prop_bool_rejects_invalid() {
        let mut mx = test_matrix_config();
        let err = mx
            .set_prop("channels.matrix.interrupt_on_new_message", "yes")
            .unwrap_err();
        assert!(err.to_string().contains("bool"));
    }

    #[test]
    async fn set_prop_u64() {
        let mut mx = test_matrix_config();
        mx.set_prop("channels.matrix.draft_update_interval_ms", "3000")
            .unwrap();
        assert_eq!(mx.draft_update_interval_ms, 3000);
    }

    #[test]
    async fn set_prop_u64_rejects_invalid() {
        let mut mx = test_matrix_config();
        assert!(
            mx.set_prop("channels.matrix.draft_update_interval_ms", "abc")
                .is_err()
        );
    }

    #[test]
    async fn set_prop_option_string_set_and_clear() {
        let mut mx = test_matrix_config();
        mx.set_prop("channels.matrix.user_id", "@new:m.org")
            .unwrap();
        assert_eq!(mx.user_id.as_deref(), Some("@new:m.org"));

        // Empty string clears Option
        mx.set_prop("channels.matrix.user_id", "").unwrap();
        assert!(mx.user_id.is_none());
    }

    #[test]
    async fn set_prop_enum() {
        let mut mx = test_matrix_config();
        mx.set_prop("channels.matrix.stream_mode", "partial")
            .unwrap();
        assert_eq!(mx.stream_mode, StreamMode::Partial);

        mx.set_prop("channels.matrix.stream_mode", "multi_message")
            .unwrap();
        assert_eq!(mx.stream_mode, StreamMode::MultiMessage);
    }

    #[test]
    async fn set_prop_enum_rejects_invalid() {
        let mut mx = test_matrix_config();
        let err = mx
            .set_prop("channels.matrix.stream_mode", "invalid")
            .unwrap_err();
        assert!(err.to_string().contains("expected one of"));
    }

    #[test]
    async fn set_prop_unknown_path_fails() {
        let mut mx = test_matrix_config();
        assert!(mx.set_prop("channels.matrix.nonexistent", "val").is_err());
    }

    #[test]
    async fn prop_is_secret_static_check() {
        assert!(MatrixConfig::prop_is_secret("channels.matrix.access_token"));
        assert!(MatrixConfig::prop_is_secret("channels.matrix.recovery_key"));
        assert!(!MatrixConfig::prop_is_secret("channels.matrix.homeserver"));
        assert!(!MatrixConfig::prop_is_secret(
            "channels.matrix.interrupt_on_new_message"
        ));
    }

    #[test]
    async fn apply_env_overrides_rejects_schema_version() {
        let _env_guard = env_override_lock().await;
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::set_var("ZEROCLAW_schema_version", "99") };
        let mut config = Config::default();
        let result = crate::env_overrides::apply_env_overrides(&mut config);
        // SAFETY: test-only, single-threaded test runner.
        unsafe { std::env::remove_var("ZEROCLAW_schema_version") };

        let err = result.expect_err("schema_version override must be rejected");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("schema_version") && msg.contains("not overridable"),
            "error must name the path and the reason: {msg}",
        );
        // Untouched on rejection.
        assert_eq!(
            config.schema_version,
            crate::migration::CURRENT_SCHEMA_VERSION
        );
    }

    #[test]
    async fn prop_is_env_overridden_reflects_env_overridden_paths() {
        // Empty by default — no env applied.
        let mut cfg = Config::default();
        assert!(!cfg.prop_is_env_overridden("channels.matrix.homeserver"));
        assert!(!cfg.prop_is_env_overridden("gateway.request_timeout_secs"));

        // Populate the field directly (the same set that
        // `apply_env_overrides` returns from `load_or_init`).
        cfg.env_overridden_paths = std::collections::HashSet::from([
            "channels.matrix.homeserver".to_string(),
            "gateway.request_timeout_secs".to_string(),
        ]);

        // True for paths in the list, false for anything else.
        assert!(cfg.prop_is_env_overridden("channels.matrix.homeserver"));
        assert!(cfg.prop_is_env_overridden("gateway.request_timeout_secs"));
        assert!(!cfg.prop_is_env_overridden("channels.matrix.access_token"));
        assert!(!cfg.prop_is_env_overridden("gateway.host"));
        // Empty path / non-schema path → false.
        assert!(!cfg.prop_is_env_overridden(""));
        assert!(!cfg.prop_is_env_overridden("does.not.exist"));
    }

    #[test]
    async fn prop_is_secret_routes_through_hashmap_keyed_paths() {
        // Regression: the macro's HashMap<String, T> arm previously passed the
        // full materialised path (e.g. `model_providers.openrouter.api-key`)
        // straight to the inner type's `prop_is_secret`, which then matched on
        // its own configurable_prefix and returned false. Result: the CLI's
        // `config set --json` and the gateway's PropResponse both took the
        // non-secret branch and emitted `{value}` instead of `{populated}` for
        // any secret on a map-keyed nested type.
        assert!(Config::prop_is_secret(
            "providers.models.openrouter.default.api_key"
        ));
        assert!(Config::prop_is_secret(
            "providers.models.anthropic.default.api_key"
        ));
        assert!(!Config::prop_is_secret(
            "providers.models.openrouter.default.endpoint"
        ));
        assert!(!Config::prop_is_secret(
            "providers.models.openrouter.default.context-window"
        ));
    }

    #[test]
    async fn file_transfer_header_maps_are_secret() {
        assert!(Config::prop_is_secret(
            "file_download.headers.Authorization"
        ));
        assert!(Config::prop_is_secret("file_upload.headers.Authorization"));
        assert!(Config::prop_is_secret(
            "file_upload_bundle.headers.Authorization"
        ));
        assert!(Config::prop_is_secret(
            "mcp.servers.acme.headers.Authorization"
        ));
        assert!(!Config::prop_is_secret("file_download.timeout_secs"));
        assert!(!Config::prop_is_secret("file_download.headers"));
    }

    #[test]
    async fn typed_custom_slot_round_trips_uri_through_save_and_load() {
        // Legacy colon-URL keys (`custom:https://...`) are gone — `custom`
        // is a typed slot whose `uri` field carries the operator URL.
        // This pins: secret routing, save/encrypt, and round-trip reload
        // for the typed `custom` slot.
        let dir = TempDir::new().unwrap();
        let mut config = Config {
            config_path: dir.path().join("config.toml"),
            data_dir: dir.path().join("workspace"),
            ..Default::default()
        };
        let alias = "default";
        config
            .providers
            .models
            .ensure("custom", alias)
            .expect("custom typed slot");

        let prefix = format!("providers.models.custom.{alias}");
        let api_key_path = format!("{prefix}.api_key");
        let uri_path = format!("{prefix}.uri");
        let model_path = format!("{prefix}.model");
        let temperature_path = format!("{prefix}.temperature");

        assert!(
            Config::prop_is_secret(&api_key_path),
            "typed custom-slot api-key must route through the secret marker",
        );

        config.set_prop(&api_key_path, "sk-test-custom").unwrap();
        config
            .set_prop(&uri_path, "https://api.example.invalid/v1")
            .unwrap();
        config.set_prop(&model_path, "local-large").unwrap();
        config.set_prop(&temperature_path, "0.2").unwrap();

        let provider = config
            .providers
            .models
            .find("custom", alias)
            .expect("custom typed slot entry must be present");
        assert_eq!(provider.api_key.as_deref(), Some("sk-test-custom"));
        assert_eq!(
            provider.uri.as_deref(),
            Some("https://api.example.invalid/v1")
        );
        assert_eq!(provider.model.as_deref(), Some("local-large"));
        assert_eq!(provider.temperature, Some(0.2));

        assert_eq!(config.get_prop(&api_key_path).unwrap(), "**** (encrypted)");
        assert_eq!(
            config.get_prop(&uri_path).unwrap(),
            "https://api.example.invalid/v1"
        );

        config.save().await.unwrap();
        let raw_toml = tokio::fs::read_to_string(&config.config_path)
            .await
            .unwrap();
        assert!(
            raw_toml.contains("[providers.models.custom.default]"),
            "saved TOML should write under the typed custom slot",
        );
        assert!(
            !raw_toml.contains("sk-test-custom"),
            "saved TOML must not contain the plaintext custom provider API key",
        );

        let mut loaded: Config = crate::migration::migrate_to_current(&raw_toml).unwrap();
        loaded.config_path = config.config_path.clone();
        loaded.data_dir = config.data_dir.clone();
        let store = crate::secrets::SecretStore::new(dir.path(), loaded.secrets.encrypt);
        loaded.decrypt_secrets(&store).unwrap();
        let loaded_provider = loaded
            .providers
            .models
            .find("custom", alias)
            .expect("typed custom slot entry must round-trip through save/load");
        assert_eq!(loaded_provider.api_key.as_deref(), Some("sk-test-custom"));
        assert_eq!(
            loaded_provider.uri.as_deref(),
            Some("https://api.example.invalid/v1")
        );
        assert_eq!(loaded_provider.model.as_deref(), Some("local-large"));
        assert_eq!(loaded_provider.temperature, Some(0.2));
    }

    #[test]
    async fn env_override_save_cycle_preserves_on_disk_secret() {
        // Regression bar for the data-loss bug identified in PR
        // review: an operator with a real on-disk credential who sets a
        // `ZEROCLAW_*` env override for the same path and triggers any
        // save (dashboard auto-save, CLI `config set` for an unrelated
        // field, Quickstart finalizer) must NOT corrupt the disk file.
        //
        // Pre-fix behavior: `mask_env_overrides_for_save` read disk via
        // `get_prop`, which returns `"**** (encrypted)"` for secret-typed
        // fields regardless of underlying state. That mask string then got
        // re-encrypted as plaintext and written to disk, destroying the
        // operator's real credential on the next reload.
        //
        // Post-fix: `apply_env_overrides` snapshots the post-decrypt
        // plaintext at apply time; `mask_env_overrides_for_save` restores
        // from that snapshot before `encrypt_secrets()` runs. The disk
        // secret survives the cycle.
        let dir = TempDir::new().unwrap();
        let mut config = Config {
            config_path: dir.path().join("config.toml"),
            data_dir: dir.path().join("workspace"),
            ..Default::default()
        };
        let original_secret = "sk-ant-real-on-disk-credential";
        let api_key_path = "providers.models.anthropic.default.api_key";
        config
            .providers
            .models
            .ensure("anthropic", "default")
            .expect("typed slot");
        config.set_prop(api_key_path, original_secret).unwrap();

        // First save: encrypts the original plaintext, writes to disk.
        config.save().await.unwrap();

        // Reload from disk to confirm the original landed correctly.
        let raw = tokio::fs::read_to_string(&config.config_path)
            .await
            .unwrap();
        let mut reloaded: Config = crate::migration::migrate_to_current(&raw).unwrap();
        reloaded.config_path = config.config_path.clone();
        reloaded.data_dir = config.data_dir.clone();
        let store = crate::secrets::SecretStore::new(dir.path(), reloaded.secrets.encrypt);
        reloaded.decrypt_secrets(&store).unwrap();
        assert_eq!(
            reloaded
                .providers
                .models
                .anthropic
                .get("default")
                .and_then(|c| c.base.api_key.as_deref()),
            Some(original_secret),
            "baseline: original secret round-trips through one save/reload cycle",
        );

        // Simulate `apply_env_overrides` having injected a different value
        // for the same path — this is the state `Config::load_or_init`
        // leaves the in-memory config in when an operator boots with
        // `ZEROCLAW_providers__models__anthropic__default__api_key=...`
        // set in the environment.
        let env_value = "sk-ant-from-env-DIFFERENT";
        reloaded.env_overridden_paths = std::collections::HashSet::from([api_key_path.to_string()]);
        reloaded.pre_override_snapshots = std::collections::HashMap::from([(
            api_key_path.to_string(),
            original_secret.to_string(),
        )]);
        reloaded.set_prop(api_key_path, env_value).unwrap();

        // Save again. With the pre-fix code path, this is the moment the
        // disk file got corrupted with the encrypted display mask.
        reloaded.save().await.unwrap();

        // Reload, decrypt, and confirm the original secret survived
        // (and the env value did NOT leak to disk, and the literal mask
        // string was NOT persisted).
        let raw_after = tokio::fs::read_to_string(&reloaded.config_path)
            .await
            .unwrap();
        assert!(
            !raw_after.contains(env_value),
            "env-injected value must never reach disk: {raw_after}",
        );
        assert!(
            !raw_after.contains("**** (encrypted)"),
            "display mask must never be persisted as a secret value: {raw_after}",
        );

        let mut after: Config = crate::migration::migrate_to_current(&raw_after).unwrap();
        after.config_path = reloaded.config_path.clone();
        after.data_dir = reloaded.data_dir.clone();
        let store2 = crate::secrets::SecretStore::new(dir.path(), after.secrets.encrypt);
        after.decrypt_secrets(&store2).unwrap();
        assert_eq!(
            after
                .providers
                .models
                .anthropic
                .get("default")
                .and_then(|c| c.base.api_key.as_deref()),
            Some(original_secret),
            "original on-disk secret must survive an env-override + save cycle",
        );
    }

    #[cfg(unix)]
    #[test]
    async fn onepassword_reference_survives_load_save_cycle() {
        let _env_guard = env_override_lock().await;
        let dir = TempDir::new().unwrap();
        let bin_dir = dir.path().join("bin");
        std::fs::create_dir_all(&bin_dir).unwrap();
        write_fake_op(
            &bin_dir,
            r#"#!/bin/sh
if [ "$1" = "read" ] && [ "$2" = "op://zeroclaw/provider/openai-api-key" ]; then
  printf '%s\n' 'sk-proj-from-onepassword'
  exit 0
fi
printf '%s\n' 'unexpected op invocation' >&2
exit 65
"#,
        );
        let path = match std::env::var_os("PATH") {
            Some(existing) if !existing.is_empty() => {
                format!("{}:{}", bin_dir.display(), existing.to_string_lossy())
            }
            _ => bin_dir.display().to_string(),
        };
        let _path_guard = EnvValueGuard::set("PATH", path);
        let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", dir.path());
        let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");

        let config_path = dir.path().join("config.toml");
        std::fs::write(
            &config_path,
            r#"
schema_version = 3

[providers.models.openai.default]
model = "gpt-5"
api_key = "op://zeroclaw/provider/openai-api-key"
"#,
        )
        .unwrap();

        let config = Config::load_or_init().await.unwrap();
        assert_eq!(
            config
                .providers
                .models
                .openai
                .get("default")
                .and_then(|entry| entry.base.api_key.as_deref()),
            Some("sk-proj-from-onepassword"),
            "runtime config uses resolved 1Password secret"
        );

        config.save().await.unwrap();
        let raw_after = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            raw_after.contains("op://zeroclaw/provider/openai-api-key"),
            "on-disk config must keep the 1Password reference: {raw_after}"
        );
        assert!(
            !raw_after.contains("sk-proj-from-onepassword"),
            "resolved secret must not be written back to disk: {raw_after}"
        );
    }

    #[cfg(unix)]
    #[allow(
        clippy::disallowed_methods,
        reason = "test asserts Tokio worker responsiveness"
    )]
    #[test(flavor = "multi_thread", worker_threads = 1)]
    async fn onepassword_reference_load_does_not_block_runtime_worker() {
        let _env_guard = env_override_lock().await;
        let dir = TempDir::new().unwrap();
        let bin_dir = dir.path().join("bin");
        std::fs::create_dir_all(&bin_dir).unwrap();
        write_fake_op(
            &bin_dir,
            r#"#!/bin/sh
if [ "$1" = "read" ] && [ "$2" = "op://zeroclaw/provider/openai-api-key" ]; then
  sleep 1
  printf '%s\n' 'sk-proj-from-onepassword'
  exit 0
fi
exit 65
"#,
        );
        let path = match std::env::var_os("PATH") {
            Some(existing) if !existing.is_empty() => {
                format!("{}:{}", bin_dir.display(), existing.to_string_lossy())
            }
            _ => bin_dir.display().to_string(),
        };
        let _path_guard = EnvValueGuard::set("PATH", path);
        let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", dir.path());
        let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");

        let config_path = dir.path().join("config.toml");
        std::fs::write(
            &config_path,
            r#"
schema_version = 3

[providers.models.openai.default]
model = "gpt-5"
api_key = "op://zeroclaw/provider/openai-api-key"
"#,
        )
        .unwrap();

        let started = std::time::Instant::now();
        let load_task = tokio::spawn(Config::load_or_init());
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;

        assert!(
            started.elapsed() < std::time::Duration::from_millis(500),
            "op:// config load should not block the async runtime worker"
        );

        let config = load_task.await.unwrap().unwrap();
        assert_eq!(
            config
                .providers
                .models
                .openai
                .get("default")
                .and_then(|entry| entry.base.api_key.as_deref()),
            Some("sk-proj-from-onepassword")
        );
    }

    #[cfg(unix)]
    #[test]
    async fn dirty_onepassword_secret_edit_replaces_reference() {
        let _env_guard = env_override_lock().await;
        let dir = TempDir::new().unwrap();
        let bin_dir = dir.path().join("bin");
        std::fs::create_dir_all(&bin_dir).unwrap();
        write_fake_op(
            &bin_dir,
            r#"#!/bin/sh
printf '%s\n' 'sk-proj-from-onepassword'
"#,
        );
        let path = match std::env::var_os("PATH") {
            Some(existing) if !existing.is_empty() => {
                format!("{}:{}", bin_dir.display(), existing.to_string_lossy())
            }
            _ => bin_dir.display().to_string(),
        };
        let _path_guard = EnvValueGuard::set("PATH", path);
        let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", dir.path());
        let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");

        let config_path = dir.path().join("config.toml");
        std::fs::write(
            &config_path,
            r#"
schema_version = 3

[providers.models.openai.default]
model = "gpt-5"
api_key = "op://zeroclaw/provider/openai-api-key"
"#,
        )
        .unwrap();

        let mut config = Config::load_or_init().await.unwrap();
        config
            .set_prop_persistent(
                "providers.models.openai.default.api_key",
                "sk-proj-new-direct-key",
            )
            .unwrap();
        config.save_dirty().await.unwrap();

        let raw_after = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            !raw_after.contains("op://zeroclaw/provider/openai-api-key"),
            "dirty secret edits should replace the old 1Password reference: {raw_after}"
        );
        assert!(
            !raw_after.contains("sk-proj-new-direct-key"),
            "direct replacement should still be encrypted at rest: {raw_after}"
        );

        let stored: Config = toml::from_str(&raw_after).unwrap();
        let encrypted = stored
            .providers
            .models
            .openai
            .get("default")
            .and_then(|entry| entry.base.api_key.as_deref())
            .unwrap();
        let store = crate::secrets::SecretStore::new(dir.path(), true);
        assert_eq!(store.decrypt(encrypted).unwrap(), "sk-proj-new-direct-key");
    }

    #[test]
    async fn enum_variants_callback_returns_values() {
        let mx = test_matrix_config();
        let fields = mx.prop_fields();
        let stream_field = fields
            .iter()
            .find(|f| f.name == "channels.matrix.stream_mode")
            .unwrap();
        let variants = (stream_field.enum_variants.unwrap())();
        assert!(variants.contains(&"off".to_string()));
        assert!(variants.contains(&"partial".to_string()));
        assert!(variants.contains(&"multi_message".to_string()));
    }

    #[test]
    async fn map_key_sections_discovers_per_family_provider_slots() {
        // Typed-family split: `providers.models` is a struct of typed
        // family maps, not a single open HashMap. Each family slot
        // (`providers.models.<family>`) is its own Map-kind section; the
        // dashboard's "+ Add alias" affordance hangs off the family path.
        let sections = Config::map_key_sections();
        let anthropic = sections
            .iter()
            .find(|s| s.path == "providers.models.anthropic")
            .expect("providers.models.anthropic must be discoverable as a map-keyed section");
        assert_eq!(anthropic.kind, crate::traits::MapKeyKind::Map);
        assert_eq!(anthropic.value_type, "AnthropicModelProviderConfig");

        // agents is also #[nested] HashMap on root Config.
        assert!(
            sections.iter().any(|s| s.path == "agents"),
            "agents map should be discoverable"
        );

        // mcp.servers is a Vec<McpServerConfig> with #[nested] — should
        // surface as a List-kind section so the dashboard's "+ Add MCP
        // server" affordance picks it up. Without this, dashboard users
        // hit a silent dead-end and have to hand-edit config.toml. Pinned
        // here so a regression that drops the #[nested] annotation or the
        // Configurable derive on McpServerConfig fails CI.
        let mcp_servers = sections
            .iter()
            .find(|s| s.path == "mcp.servers")
            .expect("mcp.servers must be discoverable as a list-shaped section");
        assert_eq!(mcp_servers.kind, crate::traits::MapKeyKind::List);
        assert_eq!(mcp_servers.value_type, "McpServerConfig");
    }

    #[test]
    async fn create_map_key_inserts_default_mcp_server() {
        // Round-trip: `POST /api/config/map-key?path=mcp.servers&key=github`.
        // The new entry's `name` field is initialized to the supplied key
        // by the macro's List-kind insertion logic.
        let mut config = Config::default();
        assert!(config.mcp.servers.is_empty());

        let created = config
            .create_map_key("mcp.servers", "github")
            .expect("mcp.servers should accept new list entries");
        assert!(created, "first add should report created=true");
        assert_eq!(config.mcp.servers.len(), 1);
        assert_eq!(
            config.mcp.servers[0].name, "github",
            "new entry must carry the supplied key as its name field"
        );
    }

    #[test]
    async fn create_map_key_seeds_plugin_entry_and_routes_config_set() {
        // The `zeroclaw plugin install` seeding path: a fresh
        // `[[plugins.entries]]` entry named after the plugin must make
        // `config set plugins.entries.<name>.config.<key>` routable;
        // natural-key path routing only matches keys already present in
        // live config.
        let mut config = Config::default();
        let created = config
            .create_map_key("plugins.entries", "weather-tool")
            .expect("plugins.entries must accept new natural-key entries");
        assert!(created, "first add should report created=true");
        assert_eq!(config.plugins.entries.len(), 1);
        assert_eq!(config.plugins.entries[0].name, "weather-tool");

        config
            .set_prop("plugins.entries.weather-tool.config.api_key", "sk-test")
            .expect("config set must route through the seeded entry");
        assert_eq!(
            config
                .plugins
                .entry_config("weather-tool")
                .and_then(|c| c.get("api_key"))
                .map(String::as_str),
            Some("sk-test")
        );

        // Idempotent: reinstalling must not clobber operator values.
        let again = config
            .create_map_key("plugins.entries", "weather-tool")
            .expect("second add still resolves the section");
        assert!(!again, "duplicate add should report created=false");
        assert_eq!(config.plugins.entries.len(), 1);
        assert_eq!(
            config
                .plugins
                .entry_config("weather-tool")
                .and_then(|c| c.get("api_key"))
                .map(String::as_str),
            Some("sk-test"),
            "re-seeding must leave existing config values untouched"
        );
    }

    #[test]
    async fn create_map_key_inserts_default_alias_under_typed_family() {
        // Dashboard "+ Add alias" target is the typed family slot,
        // not a free-form provider key under `providers.models`.
        let mut config = Config::default();
        assert!(
            !config
                .providers
                .models
                .contains_model_provider_type("anthropic")
        );

        let created = config
            .create_map_key("providers.models.anthropic", "default")
            .expect("typed family slot should accept a new alias");
        assert!(created, "first add should report created=true");
        assert!(
            config
                .providers
                .models
                .find("anthropic", "default")
                .is_some(),
            "the new alias must show up under the typed family slot",
        );

        // Idempotent: second add returns false, doesn't error.
        let again = config
            .create_map_key("providers.models.anthropic", "default")
            .expect("second add still resolves the section");
        assert!(!again, "duplicate add should report created=false");
    }

    #[test]
    async fn ensure_map_key_for_path_materializes_typed_provider_maps() {
        for (path, value) in [
            ("providers.models.openai.default.model", "gpt-4o"),
            ("providers.tts.openai.default.voice", "alloy"),
            ("providers.transcription.openai.default.model", "whisper-1"),
            ("channels.telegram.default.bot_token", "tok"),
        ] {
            let mut config = Config::default();
            assert!(
                config.set_prop(path, value).is_err(),
                "precondition: {path} is unknown on a fresh config"
            );
            config.ensure_map_key_for_path(path);
            assert!(
                config.set_prop(path, value).is_ok(),
                "{path} must be settable after ensure_map_key_for_path"
            );
        }
    }

    #[test]
    async fn ensure_map_key_for_path_ignores_plain_fields() {
        let mut config = Config::default();
        config.ensure_map_key_for_path("gateway.port");
        config.ensure_map_key_for_path("locale");
        assert!(config.set_prop("gateway.port", "8080").is_ok());
    }

    // ── nested map-routed set_prop must not mask value errors as
    // "Unknown property" ────────────────────────────────────────────────
    //
    // Once the router/key lookup has confirmed a path belongs to a
    // materialized map alias, a failure from the inner `set_prop` call is a
    // real value problem (bad type, bad enum variant, ...) and must
    // propagate as-is rather than being swallowed into the generic
    // "Unknown property" fallback (which downstream consumers, e.g.
    // `zeroclaw-gateway`'s `map_prop_error` and `src/main.rs`'s
    // `config_patch_map_prop_error`, translate into a 404 PathNotFound
    // instead of a 400 ValueTypeMismatch).

    #[track_caller]
    fn assert_value_error(err: &str) {
        assert!(
            !err.starts_with("Unknown property"),
            "bad value must not be reported as an unknown path: {err}"
        );
        assert!(
            err.contains("bool")
                || err.contains("Invalid")
                || err.contains("invalid")
                || err.contains("expected"),
            "error should describe the value problem: {err}"
        );
    }

    #[track_caller]
    fn assert_unknown_property(err: &str) {
        assert!(
            err.starts_with("Unknown property"),
            "an unknown leaf must still surface as a path problem (404), got: {err}"
        );
    }

    #[test]
    async fn set_prop_single_level_map_rejects_invalid_value_not_unknown_property() {
        let mut config = Config::default();
        config.ensure_map_key_for_path("channels.telegram.default.bot_token");
        let err = config
            .set_prop("channels.telegram.default.enabled", "notabool")
            .unwrap_err()
            .to_string();
        assert_value_error(&err);
    }

    #[test]
    async fn set_prop_single_level_map_accepts_valid_value() {
        let mut config = Config::default();
        config.ensure_map_key_for_path("channels.telegram.default.bot_token");
        config
            .set_prop("channels.telegram.default.enabled", "true")
            .unwrap();
        assert!(
            config
                .channels
                .telegram
                .get("default")
                .expect("alias materialized by ensure_map_key_for_path")
                .enabled
        );
    }

    #[test]
    async fn set_prop_single_level_map_unknown_leaf_still_unknown_property() {
        let mut config = Config::default();
        config.ensure_map_key_for_path("channels.telegram.default.bot_token");
        let err = config
            .set_prop("channels.telegram.default.nonexistent_field", "x")
            .unwrap_err()
            .to_string();
        assert_unknown_property(&err);
    }

    // The production schema has no `#[nested] HashMap<String, HashMap<String,
    // T: Configurable>>` field today (every `providers.models.<type>` slot is
    // itself a single-level `HashMap<String, T>` field of a plain nested
    // struct, not a hashmap key) — so the two-level routing branch in
    // `derive_configurable` (crates/zeroclaw-macros/src/lib.rs, the
    // `double_value_ty` arm) can't be exercised through `Config` directly.
    // Exercise it directly with a minimal local fixture instead.
    #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
    #[prefix = "dm_sub"]
    struct DoubleMapSub {
        #[serde(default)]
        pub value: bool,
    }

    #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
    #[prefix = "dm_leaf"]
    struct DoubleMapLeaf {
        #[serde(default)]
        pub flag: bool,
        #[serde(default)]
        #[nested]
        pub sub: DoubleMapSub,
    }

    #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
    #[prefix = "dm"]
    struct DoubleMapOuter {
        #[serde(default, skip_serializing_if = "HashMap::is_empty")]
        #[nested]
        pub types: HashMap<String, HashMap<String, DoubleMapLeaf>>,
    }

    fn double_map_fixture() -> DoubleMapOuter {
        let mut outer = DoubleMapOuter::default();
        outer
            .types
            .entry("anthropic".to_string())
            .or_default()
            .insert("default".to_string(), DoubleMapLeaf::default());
        // Dotted-outer-key ambiguity: `dm.types.a.x.sub.value` splits as
        // outer="a.x"/inner="sub" (longest outer key wins, tried first)
        // AND as outer="a"/inner="x" — the candidate loop must be able to
        // retry the shorter split when the longest one dead-ends.
        outer
            .types
            .entry("a".to_string())
            .or_default()
            .insert("x".to_string(), DoubleMapLeaf::default());
        outer
            .types
            .entry("a.x".to_string())
            .or_default()
            .insert("sub".to_string(), DoubleMapLeaf::default());
        outer
    }

    #[test]
    async fn set_prop_double_level_map_rejects_invalid_value_not_unknown_property() {
        let mut outer = double_map_fixture();
        let err = outer
            .set_prop("dm.types.anthropic.default.flag", "notabool")
            .unwrap_err()
            .to_string();
        assert_value_error(&err);
    }

    #[test]
    async fn set_prop_double_level_map_accepts_valid_value() {
        let mut outer = double_map_fixture();
        outer
            .set_prop("dm.types.anthropic.default.flag", "true")
            .unwrap();
        assert!(outer.types["anthropic"]["default"].flag);
    }

    #[test]
    async fn set_prop_double_level_map_unknown_leaf_still_unknown_property() {
        let mut outer = double_map_fixture();
        let err = outer
            .set_prop("dm.types.anthropic.default.nonexistent_field", "x")
            .unwrap_err()
            .to_string();
        assert_unknown_property(&err);
    }

    #[test]
    async fn set_prop_double_level_map_dotted_outer_key_retries_next_candidate() {
        // The longest candidate split (outer="a.x"/inner="sub") is tried
        // first and its leaf lookup yields "Unknown property" —
        // `dm_leaf.value` is not a direct DoubleMapLeaf field. The loop
        // must fall through to outer="a"/inner="x", whose nested
        // `sub.value` resolves — keeping set_prop in agreement with
        // get_prop's retry semantics on the same path.
        let mut outer = double_map_fixture();
        outer.set_prop("dm.types.a.x.sub.value", "true").unwrap();
        assert!(outer.types["a"]["x"].sub.value);
        assert_eq!(outer.get_prop("dm.types.a.x.sub.value").unwrap(), "true");
    }

    // ── regression anchor: repro through the serde(flatten)
    // delegation site (`OpenAIModelProviderConfig { #[serde(flatten)] base }`).
    // A bad-typed value on a flattened base field of a live alias must
    // propagate the value error, not degrade into "Unknown property".

    #[test]
    async fn set_prop_flatten_alias_leaf_rejects_invalid_value_not_unknown_property() {
        let mut config = Config::default();
        config
            .create_map_key("providers.models.openai", "k8")
            .expect("typed family slot accepts a new alias");

        // Happy path first: valid value round-trips.
        config
            .set_prop("providers.models.openai.k8.temperature", "0.5")
            .unwrap();
        assert_eq!(
            config
                .get_prop("providers.models.openai.k8.temperature")
                .unwrap(),
            "0.5"
        );

        // The issue's repro: non-numeric value on the same confirmed path.
        let err = config
            .set_prop("providers.models.openai.k8.temperature", "abc")
            .unwrap_err()
            .to_string();
        assert_value_error(&err);
    }

    #[test]
    async fn set_prop_flatten_alias_unknown_leaf_still_unknown_property() {
        let mut config = Config::default();
        config
            .create_map_key("providers.models.openai", "k8")
            .expect("typed family slot accepts a new alias");
        let err = config
            .set_prop("providers.models.openai.k8.nonexistent_field", "x")
            .unwrap_err()
            .to_string();
        assert_unknown_property(&err);
    }

    #[test]
    async fn set_prop_flatten_own_field_still_resolves_after_base_unknown_property() {
        // AzureModelProviderConfig has BOTH a flattened base and its own
        // direct fields (resource / deployment / api_version). Setting an
        // own-field goes through the flatten site first, which returns
        // "Unknown property" — that must keep falling through so the own
        // field still resolves (the no-over-propagation guarantee).
        let mut config = Config::default();
        config
            .create_map_key("providers.models.azure", "k8")
            .expect("typed family slot accepts a new alias");
        config
            .set_prop("providers.models.azure.k8.resource", "myres")
            .unwrap();
        assert_eq!(
            config
                .providers
                .models
                .azure
                .get("k8")
                .expect("alias created above")
                .resource
                .as_deref(),
            Some("myres")
        );
    }

    #[test]
    async fn set_prop_flatten_base_field_via_azure_alias_propagates_value_error() {
        let mut config = Config::default();
        config
            .create_map_key("providers.models.azure", "k8")
            .expect("typed family slot accepts a new alias");
        let err = config
            .set_prop("providers.models.azure.k8.temperature", "abc")
            .unwrap_err()
            .to_string();
        assert_value_error(&err);
    }

    // ── regression: a genuine value error whose own message starts with
    // "Unknown property" must still propagate, not be reclassified as the
    // generated fall-through marker and swallowed as a retry.
    // `is_unknown_property_error` used to match on that prefix alone, so a
    // validator error crafted to begin the same way would have been
    // misread as "not mine" at the `Option<T>` delegation gate, silently
    // discarded, and reported as an unknown-leaf error instead of the real
    // value problem it is. (The struct-level TOML round-trip that runs the
    // field's custom deserializer wraps the raw message with its own
    // "TOML parse error at line ..." position preamble, so the *final*
    // propagated error contains rather than starts with the crafted text —
    // the point under test is that the crafted sentence survives at all
    // instead of being replaced by the generic `Unknown property '<name>'`
    // fallback a misclassification would produce.)

    fn reject_poison_string_value<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
    where
        D: Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        if value == "poison" {
            return Err(de::Error::custom(
                "Unknown property value rejected by a custom field validator",
            ));
        }
        Ok(value)
    }

    #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
    #[prefix = "unk_collision"]
    struct UnknownPropertyCollisionInner {
        #[serde(default, deserialize_with = "reject_poison_string_value")]
        pub label: String,
    }

    #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
    #[prefix = "unk_collision_outer"]
    struct UnknownPropertyCollisionOuter {
        #[serde(default)]
        #[nested]
        pub inner: Option<UnknownPropertyCollisionInner>,
    }

    #[test]
    async fn set_prop_option_nested_value_error_starting_with_unknown_property_prefix_still_propagates()
     {
        let mut outer = UnknownPropertyCollisionOuter {
            inner: Some(UnknownPropertyCollisionInner::default()),
        };
        let err = outer
            .set_prop("unk_collision.label", "poison")
            .unwrap_err()
            .to_string();
        // The specific validator message must survive — a
        // misclassification would instead produce the generic
        // `Unknown property '<name>'` fallback, which doesn't contain this
        // text.
        assert!(
            err.contains("Unknown property value rejected by a custom field validator"),
            "a genuine value error must propagate, not be masked as an unknown property: {err}"
        );
        // Sanity: confirm this isn't the generic fallback shape (which
        // would also technically satisfy a looser check).
        assert!(
            !err.contains("Unknown property 'unk_collision.label'"),
            "must not have degraded into the generic unknown-property fallback: {err}"
        );
    }

    #[test]
    async fn ensure_map_key_for_path_ignores_resource_keyed_rate_sections() {
        let mut config = Config::default();
        config.ensure_map_key_for_path("cost.rates.providers.models.openai.gpt-4.1.input_per_mtok");
        // The return value proves nothing here: it is `true` only for the
        // reserved-agent refusal, so it is already `false` without the
        // `resource_key` filter. The emptiness check is the regression signal.
        assert!(
            config.cost.rates.providers.models.openai.is_empty(),
            "a leaf write must not auto-create a resource-keyed rate row"
        );
    }

    #[test]
    async fn ensure_map_key_for_path_leaves_dotted_resource_ids_alone() {
        let mut config = Config::default();
        config
            .create_map_key("cost.rates.providers.models.openai", "gpt-4.1")
            .expect("resource-keyed sections accept dotted model ids");
        let path = "cost.rates.providers.models.openai.gpt-4.1.input_per_mtok";
        config.ensure_map_key_for_path(path);
        assert_eq!(
            config
                .get_map_keys("cost.rates.providers.models.openai")
                .expect("known section"),
            vec!["gpt-4.1".to_string()],
            "the first-dot split must not plant a phantom `gpt-4` sibling"
        );
        assert!(
            config.set_prop(path, "1.5").is_ok(),
            "editing a real dotted resource id must still work"
        );
    }

    #[test]
    async fn ensure_map_key_for_path_ignores_cost_rate_tools() {
        let mut config = Config::default();
        config.ensure_map_key_for_path("cost.rates.tools.web_search.per_call");
        assert!(
            config.cost.rates.tools.is_empty(),
            "`cost.rates.tools` is resource-keyed by the tool's registered name"
        );
    }

    #[test]
    async fn create_map_key_rejects_unknown_section() {
        let mut config = Config::default();
        let err = config
            .create_map_key("not.a.real.section", "anything")
            .expect_err("unknown section path should error");
        assert!(err.contains("not.a.real.section"));
    }

    #[test]
    async fn provider_slot_names_match_struct_fields() {
        // TtsProviders/TranscriptionProviders::slot_names are inline lists
        // (their slot macros carry a rate-type param); pin them against the
        // actual serialized field names so adding a family without updating
        // slot_names fails here.
        let tts = toml::Value::try_from(crate::providers::TtsProviders {
            openai: std::iter::once(("a".to_string(), Default::default())).collect(),
            elevenlabs: std::iter::once(("a".to_string(), Default::default())).collect(),
            google: std::iter::once(("a".to_string(), Default::default())).collect(),
            edge: std::iter::once(("a".to_string(), Default::default())).collect(),
            piper: std::iter::once(("a".to_string(), Default::default())).collect(),
        })
        .unwrap();
        let mut tts_fields: Vec<&str> =
            tts.as_table().unwrap().keys().map(String::as_str).collect();
        tts_fields.sort_unstable();
        let mut tts_slots = crate::providers::TtsProviders::slot_names().to_vec();
        tts_slots.sort_unstable();
        assert_eq!(tts_fields, tts_slots);

        let tr = toml::Value::try_from(crate::providers::TranscriptionProviders {
            groq: std::iter::once(("a".to_string(), Default::default())).collect(),
            openai: std::iter::once(("a".to_string(), Default::default())).collect(),
            deepgram: std::iter::once(("a".to_string(), Default::default())).collect(),
            assemblyai: std::iter::once(("a".to_string(), Default::default())).collect(),
            google: std::iter::once(("a".to_string(), Default::default())).collect(),
            local_whisper: std::iter::once(("a".to_string(), Default::default())).collect(),
        })
        .unwrap();
        let mut tr_fields: Vec<&str> = tr.as_table().unwrap().keys().map(String::as_str).collect();
        tr_fields.sort_unstable();
        let mut tr_slots = crate::providers::TranscriptionProviders::slot_names().to_vec();
        tr_slots.sort_unstable();
        assert_eq!(tr_fields, tr_slots);
    }

    #[test]
    async fn unknown_provider_families_flags_silent_serde_drop() {
        // serde ignores unknown keys under providers.models, so a typo'd
        // family parses cleanly and its aliases vanish on reload. The
        // detector must flag it; known families must pass.
        let raw = r#"
schema_version = 3

[providers.models.antropic.main]
model = "claude-sonnet-4-6"

[providers.models.openai.work]
model = "gpt-4o"
"#;
        let parsed: Config = toml::from_str(raw).expect("unknown family must not fail parse");
        assert!(
            parsed.providers.models.find("antropic", "main").is_none(),
            "precondition: serde silently drops the unknown family"
        );
        assert_eq!(
            Config::unknown_provider_families(raw),
            vec!["models.antropic".to_string()]
        );
        assert_eq!(
            Config::unknown_provider_families(
                "schema_version = 3\n[providers.tts.bogustts.x]\nenabled = true\n",
            ),
            vec!["tts.bogustts".to_string()]
        );
        assert!(Config::unknown_provider_families("not even toml {{{").is_empty());
        // Hostile shapes: scalar providers node, scalar kind node,
        // array-of-tables family. as_table() filters all of them; the
        // detector must stay silent rather than panic or false-positive.
        assert!(Config::unknown_provider_families("providers = 3\n").is_empty());
        assert!(Config::unknown_provider_families("[providers]\nmodels = 3\n").is_empty());
        assert_eq!(
            Config::unknown_provider_families("[[providers.models.weird]]\nx = 1\n"),
            vec!["models.weird".to_string()],
            "array-of-tables under an unknown family is still an unknown family"
        );
    }

    #[test]
    async fn extra_nested_model_provider_tables_flags_dropped_provider_fields() {
        let finding = |family: &str, alias: &str, nested: &str| ExtraNestedModelProviderTable {
            family: family.to_string(),
            alias: alias.to_string(),
            nested: nested.to_string(),
        };

        // serde accepts `[providers.models.zai.default.default]` by treating
        // the first `default` as the provider alias and erasing the second
        // child table. The typed Config therefore looks like an empty
        // `zai.default` provider and `validate()` accepts it as an
        // in-progress quickstart entry. The raw-TOML detector must preserve
        // that diagnostic signal before deserialization erases the shape.
        let raw = r#"
schema_version = 3

[providers.models.zai.default.default]
model = "glm-5.1"
api_key = "sk-test"
endpoint = "global"

[risk_profiles.default]
level = "supervised"

[agents.default]
enabled = true
model_provider = "zai.default"
risk_profile = "default"
"#;
        let parsed: Config = toml::from_str(raw).expect("extra nesting must not fail parse");
        let provider = parsed
            .providers
            .models
            .find("zai", "default")
            .expect("outer alias is still present");
        assert!(
            provider.model.is_none() && provider.api_key.is_none(),
            "precondition: serde silently drops the nested model/api_key"
        );
        parsed
            .validate()
            .expect("typed validation cannot see the raw extra nesting");
        assert_eq!(
            Config::extra_nested_model_provider_tables(raw),
            vec![finding("zai", "default", "default")]
        );

        let valid = r#"
schema_version = 3

[providers.models.zai.default]
model = "glm-5.1"
api_key = "sk-test"
endpoint = "global"
"#;
        assert!(
            Config::extra_nested_model_provider_tables(valid).is_empty(),
            "valid V3 alias tables must not be flagged"
        );

        let valid_table_fields = r#"
schema_version = 3

[providers.models.openai.default]
model = "gpt-4o"

[providers.models.openai.default.extra_headers]
model = "header-value"
api_key = "header-value"

[providers.models.openai.default.provider_extra]
model = "router-model"
api_key = "provider-extra-value"

[providers.models.openai.default.chat_template_kwargs]
model = "template-model"
api_key = "template-value"

[providers.models.openai.default.pricing]
model = 1.0
api_key = 2.0
"#;
        assert!(
            Config::extra_nested_model_provider_tables(valid_table_fields).is_empty(),
            "valid table-valued provider fields must not be flagged even when child keys collide"
        );

        let valid_pricing = r#"
schema_version = 3

[providers.models.openai.default]
model = "gpt-4o"
pricing = { "gpt-4o.input" = 5.0, "gpt-4o.output" = 15.0 }
"#;
        assert!(
            Config::extra_nested_model_provider_tables(valid_pricing).is_empty(),
            "valid nested provider fields such as pricing must not be flagged"
        );

        let family_specific = r#"
schema_version = 3

[providers.models.azure.default.default]
api_version = "2024-10-21"

[providers.models.ollama.local.default]
num_ctx = 16384
"#;
        assert_eq!(
            Config::extra_nested_model_provider_tables(family_specific),
            vec![
                finding("azure", "default", "default"),
                finding("ollama", "local", "default")
            ],
            "family-specific fields must still be flagged when extra-nested"
        );

        let dotted_alias = r#"
schema_version = 3

[providers.models.openai."prod.v2".default]
model = "gpt-4o"
"#;
        assert_eq!(
            Config::extra_nested_model_provider_tables(dotted_alias),
            vec![finding("openai", "prod.v2", "default")],
            "aliases containing dots must stay intact in diagnostics"
        );

        assert!(
            Config::extra_nested_model_provider_tables(
                "schema_version = 3\n[providers.models.zia.default.default]\nmodel = \"x\"\n",
            )
            .is_empty(),
            "unknown families are handled by unknown_provider_families"
        );
        assert!(Config::extra_nested_model_provider_tables("not toml {{{").is_empty());
        assert!(Config::extra_nested_model_provider_tables("providers = 3\n").is_empty());
    }

    #[test]
    async fn map_key_create_survives_incremental_save() {
        // Repro for the zerocode "providers vanish after restart" report:
        // the RPC config/map-key-create path is create_map_key + mark_dirty
        // + save_dirty. The new alias must reach config.toml, otherwise it
        // exists only in-memory and a daemon restart silently drops it
        // (and any agents.*.model_provider referencing it dangles).
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Seed a non-empty on-disk file so the incremental path runs, not
        // the new-file fallback to full save().
        std::fs::write(
            &config_path,
            "schema_version = 9\n\n[observability]\nbackend = \"none\"\n",
        )
        .unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        let created = config
            .create_map_key("providers.models.openai", "myalias")
            .expect("typed family slot accepts a new alias");
        assert!(created);
        assert_eq!(
            config
                .providers
                .models
                .find("openai", "myalias")
                .and_then(|e| e.wire_api),
            Some(WireApi::Responses),
            "new OpenAI provider slots default to wire_api = responses"
        );
        config.mark_dirty("providers.models.openai.myalias");
        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        let reloaded: Config = toml::from_str(&written)
            .unwrap_or_else(|e| panic!("rewritten config must reparse: {e}\n---\n{written}"));
        assert!(
            reloaded
                .providers
                .models
                .find("openai", "myalias")
                .is_some(),
            "created alias must survive save_dirty + reload; got:\n{written}"
        );
        assert_eq!(
            reloaded
                .providers
                .models
                .find("openai", "myalias")
                .and_then(|e| e.wire_api),
            Some(WireApi::Responses),
            "default wire_api must survive save_dirty + reload; got:\n{written}"
        );
    }

    #[test]
    async fn telegram_alias_create_survives_incremental_save() {
        // Regression test: create_map_key seeds TelegramConfig::default()
        // (bot_token = ""), save_dirty's prune_empty_leaves then strips the
        // empty bot_token from the written TOML, and on reload the alias
        // must still deserialize (bot_token now has #[serde(default)])
        // instead of being silently salvage-dropped.
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Seed a non-empty on-disk file so the incremental path runs, not
        // the new-file fallback to full save().
        std::fs::write(
            &config_path,
            "schema_version = 9\n\n[observability]\nbackend = \"none\"\n",
        )
        .unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        let created = config
            .create_map_key("channels.telegram", "myalias")
            .expect("map-keyed section accepts a new alias");
        assert!(created);
        config.mark_dirty("channels.telegram.myalias");
        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        let reloaded: Config = toml::from_str(&written)
            .unwrap_or_else(|e| panic!("rewritten config must reparse: {e}\n---\n{written}"));
        assert!(
            reloaded.channels.telegram.contains_key("myalias"),
            "created telegram alias must survive save_dirty + reload; got:\n{written}"
        );
    }

    #[test]
    async fn discord_alias_create_survives_incremental_save() {
        // Discord twin of telegram_alias_create_survives_incremental_save:
        // DiscordConfig.bot_token has the same serde default.
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");

        // Seed a non-empty on-disk file so the incremental path runs, not
        // the new-file fallback to full save().
        std::fs::write(
            &config_path,
            "schema_version = 9\n\n[observability]\nbackend = \"none\"\n",
        )
        .unwrap();

        let mut config = Config {
            config_path: config_path.clone(),
            ..Default::default()
        };
        let created = config
            .create_map_key("channels.discord", "myalias")
            .expect("map-keyed section accepts a new alias");
        assert!(created);
        config.mark_dirty("channels.discord.myalias");
        config.save_dirty().await.unwrap();

        let written = std::fs::read_to_string(&config_path).unwrap();
        let reloaded: Config = toml::from_str(&written)
            .unwrap_or_else(|e| panic!("rewritten config must reparse: {e}\n---\n{written}"));
        assert!(
            reloaded.channels.discord.contains_key("myalias"),
            "created discord alias must survive save_dirty + reload; got:\n{written}"
        );
    }

    #[test]
    async fn init_defaults_instantiates_none_sections() {
        let mut config = Config::default();
        assert!(config.channels.matrix.is_empty());

        // Channels are HashMaps — init_defaults cannot insert a default key
        // (there is no meaningful default alias). Callers use create_map_key.
        config
            .create_map_key("channels.matrix", "default")
            .expect("create_map_key should insert a default matrix entry");
        assert!(
            config.channels.matrix.contains_key("default"),
            "create_map_key must add the 'default' alias"
        );

        // init_defaults on an already-populated map section is a no-op.
        let initialized = config.init_defaults(Some("channels.matrix"));
        assert!(
            !initialized.contains(&"channels.matrix"),
            "init_defaults should not report channels.matrix when entry already exists"
        );
    }

    #[test]
    async fn deserialized_matrix_set_prop_round_trips_vec_string() {
        // Mirror the real-world daemon flow: config loaded from disk where
        // [channels.matrix] is present (possibly with all default fields),
        // then a PATCH from the dashboard hits set_prop.
        let toml_src = r#"
schema_version = 3

[channels.matrix.default]
enabled = false
homeserver = ""
access_token = ""
allowed_rooms = []
allowed_users = []
"#;
        let mut config: Config = toml::from_str(toml_src).expect("parse toml");
        assert!(
            config.channels.matrix.contains_key("default"),
            "matrix must have a 'default' alias after deserialize"
        );

        config
            .set_prop(
                "channels.matrix.default.allowed_rooms",
                r#"["alice","bob"]"#,
            )
            .expect("set_prop should succeed against deserialized matrix");
        assert_eq!(
            config.channels.matrix.get("default").unwrap().allowed_rooms,
            vec!["alice".to_string(), "bob".to_string()],
        );
    }

    #[test]
    async fn init_defaults_then_set_prop_round_trips_vec_string() {
        // Regression for Channels picker → form → save:
        // 1. create_map_key inserts channels.matrix["default"] = MatrixConfig::default()
        // 2. set_prop on channels.matrix.default.allowed_rooms must accept a JSON-array
        //    string (the shape coerce_for_set_prop emits for Vec<String>).
        // 3. get_prop reads it back.
        let mut config = Config::default();
        config
            .create_map_key("channels.matrix", "default")
            .expect("create_map_key should insert a default matrix entry");
        assert!(config.channels.matrix.contains_key("default"));

        // prop_fields must surface the kebab path so the form can render it.
        let has_field = config
            .prop_fields()
            .iter()
            .any(|f| f.name == "channels.matrix.default.allowed_rooms");
        assert!(
            has_field,
            "channels.matrix.default.allowed_rooms must appear in prop_fields after init"
        );

        // set_prop with the JSON-array string the gateway PATCH path produces.
        config
            .set_prop(
                "channels.matrix.default.allowed_rooms",
                r#"["alice","bob"]"#,
            )
            .expect("set_prop should accept JSON-array string for Vec<String>");
        assert_eq!(
            config.channels.matrix.get("default").unwrap().allowed_rooms,
            vec!["alice".to_string(), "bob".to_string()],
        );
    }

    #[test]
    async fn mcp_servers_addable_via_create_map_key_and_per_entry_props() {
        // `mcp.servers` is a `Vec<McpServerConfig>` with `#[nested]`, so the
        // `Configurable` derive surfaces it as a List section (not an
        // ObjectArray prop) — operators add servers via
        // `POST /api/config/map-key?path=mcp.servers&key=<name>` and edit
        // each server's fields via per-prop GET/PUT.
        //
        // This replaces the prior model where the entire Vec round-tripped
        // through set_prop("mcp.servers", "<json-array>"). The List model
        // matches the rest of the schema (`providers.models`, `agents`,
        // etc.) and gives the dashboard a per-field editor instead of a
        // monolithic JSON blob.
        let mut config = Config::default();

        // The List section is discoverable.
        let sections = Config::map_key_sections();
        assert!(
            sections
                .iter()
                .any(|s| s.path == "mcp.servers" && s.kind == crate::traits::MapKeyKind::List),
            "mcp.servers should surface as a List section in map_key_sections()"
        );

        // create_map_key inserts a default-valued entry and seeds its
        // `name` field from the supplied key.
        config
            .create_map_key("mcp.servers", "fs")
            .expect("mcp.servers should accept new list entries via create_map_key");
        assert_eq!(config.mcp.servers.len(), 1);
        assert_eq!(config.mcp.servers[0].name, "fs");

        // Per-entry fields are mutated via standard set_prop on the inner
        // path; routing goes through the `#[natural_key = "name"]` arm
        // on `McpConfig::servers` (see `route_vec_path` and the
        // `Configurable` derive's natural-key arm for the wiring).
        config
            .set_prop("mcp.servers.fs.command", "/usr/bin/mcp-fs")
            .expect("set_prop on mcp.servers.fs.command should route through natural-key arm");
        assert_eq!(config.mcp.servers[0].command, "/usr/bin/mcp-fs");

        // Round-trip via get_prop.
        let got = config.get_prop("mcp.servers.fs.command").expect(
            "get_prop on mcp.servers.fs.command should resolve through the natural-key arm",
        );
        assert_eq!(got, "/usr/bin/mcp-fs");

        // Enum-typed fields (transport) parse from their wire form.
        config
            .set_prop("mcp.servers.fs.transport", "http")
            .expect("transport should accept its enum variants as strings");
        assert_eq!(
            config.mcp.servers[0].transport,
            crate::schema::McpTransport::Http
        );

        // The natural-key field itself is read-only via set_prop — the
        // routing arm returns an explicit error pointing at
        // config_map_key_rename rather than mutating `name` in place
        // (which would silently re-key the entry and strand any
        // in-flight references to the old key).
        let err = config
            .set_prop("mcp.servers.fs.name", "filesystem")
            .expect_err("set_prop on the natural-key field must refuse");
        let msg = err.to_string();
        assert!(
            msg.contains("natural key")
                && msg.contains("read-only")
                && msg.contains("config_map_key_rename"),
            "unexpected error message for read-only natural-key set: {msg}"
        );

        // Rename via the dedicated path. The element's `name` field
        // changes in place; subsequent prop access uses the new key.
        let renamed = config
            .rename_map_key("mcp.servers", "fs", "filesystem")
            .expect("rename should succeed when the new key is free");
        assert!(renamed, "rename_map_key should report Ok(true) on success");
        assert_eq!(config.mcp.servers[0].name, "filesystem");
        assert_eq!(
            config.get_prop("mcp.servers.filesystem.command").unwrap(),
            "/usr/bin/mcp-fs"
        );

        // The old key no longer resolves — confirming the rename was
        // not just an alias add.
        assert!(
            config.get_prop("mcp.servers.fs.command").is_err(),
            "old natural key should stop resolving after rename"
        );

        // prop_fields enumerates the per-element fields under the
        // current natural key, and filters out the natural-key field
        // itself (no editable `name` row in the TUI).
        let paths: Vec<String> = config
            .prop_fields()
            .into_iter()
            .map(|f| f.name)
            .filter(|n| n.starts_with("mcp.servers."))
            .collect();
        assert!(
            paths.iter().any(|n| n == "mcp.servers.filesystem.command"),
            "prop_fields should surface per-element child props; got: {paths:?}"
        );
        assert!(
            !paths.iter().any(|n| n == "mcp.servers.filesystem.name"),
            "prop_fields must hide the natural-key field to keep it read-only; got: {paths:?}"
        );

        // delete_map_key by natural key removes the matching element.
        let deleted = config
            .delete_map_key("mcp.servers", "filesystem")
            .expect("delete by natural key should resolve");
        assert!(deleted);
        assert!(config.mcp.servers.is_empty());
    }

    #[test]
    async fn mcp_servers_create_map_key_is_idempotent_on_existing_natural_key() {
        // Regression for the per-field editor contract: the rest of the
        // natural-key surface (`get_prop` / `set_prop` / `rename_map_key`)
        // treats duplicate natural keys as `VecRoute::Ambiguous` and
        // refuses to mutate (see `mcp_servers_routing_is_ambiguous_on_
        // duplicate_names`). If `create_map_key` always appended, a UI
        // retry — or any caller that re-issued the same add after an
        // uncertain RPC response — would drop `mcp.servers` into that
        // invalid state and leave `mcp.servers.<name>.command` no longer
        // routing until the operator hand-repaired the duplicate.
        //
        // The contract is the same as the `HashMap<String, T>` arm:
        // re-adding an existing key is `Ok(false)` (idempotent no-op),
        // not "append a second element that happens to share the key".
        let mut config = Config::default();

        let first = config
            .create_map_key("mcp.servers", "fs")
            .expect("first add should succeed");
        assert!(first, "first add should report created=true");
        assert_eq!(config.mcp.servers.len(), 1);
        assert_eq!(config.mcp.servers[0].name, "fs");

        // Seed an inner field so we can prove the existing entry's
        // state is preserved across the no-op second add (rather than,
        // say, the second call clobbering it with a default).
        config
            .set_prop("mcp.servers.fs.command", "/usr/bin/mcp-fs")
            .expect("set_prop on the freshly-added entry should route");

        // Repeat add for the same natural key. Must report Ok(false)
        // and must not push a second element.
        let second = config
            .create_map_key("mcp.servers", "fs")
            .expect("repeat add for an existing natural key must not error");
        assert!(
            !second,
            "repeat add for an existing natural key should report created=false (idempotent)"
        );
        assert_eq!(
            config.mcp.servers.len(),
            1,
            "repeat add must not append a duplicate; got {} entries",
            config.mcp.servers.len()
        );
        assert_eq!(config.mcp.servers[0].name, "fs");

        // The natural-key surface still routes — the duplicate was
        // never created, so `set_prop` / `get_prop` are not in the
        // `VecRoute::Ambiguous` state. The previously-set command
        // round-trips and a new edit lands on the original entry.
        assert_eq!(
            config.get_prop("mcp.servers.fs.command").unwrap(),
            "/usr/bin/mcp-fs",
            "existing entry's state must survive the no-op second add"
        );
        config
            .set_prop("mcp.servers.fs.command", "/usr/local/bin/mcp-fs")
            .expect("set_prop must keep routing after the repeat add");
        assert_eq!(config.mcp.servers[0].command, "/usr/local/bin/mcp-fs");

        // A genuinely new key is still added: idempotency is per-key,
        // not "first add wins everything".
        let third = config
            .create_map_key("mcp.servers", "github")
            .expect("a distinct natural key should still be addable");
        assert!(third, "distinct-key add should report created=true");
        assert_eq!(config.mcp.servers.len(), 2);
    }

    #[test]
    async fn mcp_servers_routing_is_ambiguous_on_duplicate_names() {
        // `validate_mcp_config` rejects duplicate `name` at save time,
        // but until the operator repairs the config the in-flight
        // routing must refuse to silently mutate one of the duplicates.
        // This is the schema-side anchor for that contract; the helper
        // function's behaviour is unit-tested directly in
        // `helpers::tests::route_vec_path_reports_ambiguous_duplicates`.
        let mut config = Config::default();
        config.mcp.servers.push(McpServerConfig {
            name: "dupe".into(),
            transport: McpTransport::Stdio,
            command: "/a".into(),
            ..Default::default()
        });
        config.mcp.servers.push(McpServerConfig {
            name: "dupe".into(),
            transport: McpTransport::Stdio,
            command: "/b".into(),
            ..Default::default()
        });

        let set_err = config
            .set_prop("mcp.servers.dupe.command", "/c")
            .expect_err("set_prop on a duplicated natural key must refuse");
        assert!(
            set_err.to_string().contains("ambiguous"),
            "expected ambiguity error, got: {set_err}"
        );

        let get_err = config
            .get_prop("mcp.servers.dupe.command")
            .expect_err("get_prop on a duplicated natural key must refuse");
        assert!(
            get_err.to_string().contains("ambiguous"),
            "expected ambiguity error, got: {get_err}"
        );

        // Neither side mutated the underlying state.
        assert_eq!(config.mcp.servers[0].command, "/a");
        assert_eq!(config.mcp.servers[1].command, "/b");

        // rename_map_key likewise refuses, with an actionable message.
        let rename_err = config
            .rename_map_key("mcp.servers", "dupe", "ok")
            .expect_err("rename of a duplicated natural key must refuse");
        assert!(
            rename_err.contains("ambiguous"),
            "expected ambiguity error from rename, got: {rename_err}"
        );
    }

    #[test]
    async fn mcp_servers_rename_refuses_when_new_key_is_taken() {
        let mut config = Config::default();
        config.create_map_key("mcp.servers", "fs").unwrap();
        config.create_map_key("mcp.servers", "github").unwrap();

        let err = config
            .rename_map_key("mcp.servers", "fs", "github")
            .expect_err("rename should refuse when the target key already exists");
        assert!(
            err.contains("already exists"),
            "expected target-collision error, got: {err}"
        );

        // State untouched.
        assert_eq!(config.mcp.servers.len(), 2);
        assert_eq!(config.mcp.servers[0].name, "fs");
        assert_eq!(config.mcp.servers[1].name, "github");
    }

    #[test]
    async fn mcp_servers_get_map_keys_lists_natural_keys_in_insertion_order() {
        let mut config = Config::default();
        config.create_map_key("mcp.servers", "a").unwrap();
        config.create_map_key("mcp.servers", "b").unwrap();
        config.create_map_key("mcp.servers", "c").unwrap();

        let keys = config
            .get_map_keys("mcp.servers")
            .expect("mcp.servers must surface its natural keys via get_map_keys");
        assert_eq!(
            keys,
            vec!["a".to_string(), "b".to_string(), "c".to_string()]
        );
    }

    #[test]
    async fn init_defaults_skips_already_set() {
        let mut config = Config::default();
        config
            .channels
            .matrix
            .insert("default".to_string(), test_matrix_config());

        let initialized = config.init_defaults(Some("channels.matrix"));
        // Already set — should not re-initialize
        assert!(!initialized.contains(&"channels.matrix"));
        // Original value preserved
        assert_eq!(
            config.channels.matrix.get("default").unwrap().homeserver,
            "https://m.org"
        );
    }

    #[test]
    async fn nested_get_set_prop_traverses_config_tree() {
        let mut config = Config::default();
        config
            .channels
            .matrix
            .insert("default".to_string(), test_matrix_config());

        // get_prop traverses Config → ChannelsConfig → channels.matrix["default"] → MatrixConfig
        assert_eq!(
            config
                .get_prop("channels.matrix.default.homeserver")
                .unwrap(),
            "https://m.org"
        );

        // set_prop traverses the same path
        config
            .set_prop("channels.matrix.default.homeserver", "https://new.org")
            .unwrap();
        assert_eq!(
            config.channels.matrix.get("default").unwrap().homeserver,
            "https://new.org"
        );
    }

    #[test]
    async fn hashmap_nested_encrypt_decrypt_traverses_values() {
        let dir = TempDir::new().unwrap();
        let store = crate::secrets::SecretStore::new(dir.path(), true);

        let mut config = Config::default();
        config.providers.models.openrouter.insert(
            "test".into(),
            crate::schema::OpenRouterModelProviderConfig {
                base: ModelProviderConfig {
                    api_key: Some("secret-key".into()),
                    ..Default::default()
                },
            },
        );

        config.encrypt_secrets(&store).unwrap();
        let encrypted_key = config
            .providers
            .models
            .find("openrouter", "test")
            .expect("entry exists")
            .api_key
            .as_ref()
            .unwrap();
        assert!(crate::secrets::SecretStore::is_encrypted(encrypted_key));

        config.decrypt_secrets(&store).unwrap();
        assert_eq!(
            config
                .providers
                .models
                .find("openrouter", "test")
                .expect("entry exists")
                .api_key
                .as_deref(),
            Some("secret-key")
        );
    }

    #[test]
    async fn vec_secret_encrypt_decrypt_traverses_elements() {
        let dir = TempDir::new().unwrap();
        let store = crate::secrets::SecretStore::new(dir.path(), true);

        let mut config = Config::default();
        config.gateway.paired_tokens = vec!["token-a".into(), "token-b".into()];

        config.encrypt_secrets(&store).unwrap();
        for token in &config.gateway.paired_tokens {
            assert!(crate::secrets::SecretStore::is_encrypted(token));
        }

        config.decrypt_secrets(&store).unwrap();
        assert_eq!(config.gateway.paired_tokens, vec!["token-a", "token-b"]);
    }

    /// Walk every property on a default Config: get_prop must succeed,
    /// and set_prop must round-trip for non-secret, non-enum scalar fields.
    #[test]
    async fn every_prop_is_gettable_and_settable() {
        let mut config = Config::default();
        // Initialize all Option<T> sections so their fields are reachable
        config.init_defaults(None);

        let fields = config.prop_fields();
        assert!(
            fields.len() > 50,
            "Expected 50+ props, got {} — macro may be skipping fields",
            fields.len()
        );

        for field in &fields {
            // get_prop must not panic or error
            let get_result = config.get_prop(&field.name);
            assert!(
                get_result.is_ok(),
                "get_prop failed for '{}': {}",
                field.name,
                get_result.unwrap_err()
            );

            // set_prop: round-trip the display value back through set_prop.
            // Skip secrets (masked), enums (need valid variant), and <unset> Options.
            if field.is_secret
                || field.is_enum()
                || field.display_value == crate::traits::UNSET_DISPLAY
            {
                continue;
            }

            let set_result = config.set_prop(&field.name, &field.display_value);
            assert!(
                set_result.is_ok(),
                "set_prop failed for '{}' with value '{}': {}",
                field.name,
                field.display_value,
                set_result.unwrap_err()
            );

            // Value should survive the round-trip
            let after = config.get_prop(&field.name).unwrap();
            assert_eq!(
                after, field.display_value,
                "round-trip mismatch for '{}': set '{}', got '{}'",
                field.name, field.display_value, after
            );
        }
    }

    /// Audit gate: every path emitted by `prop_fields()` must round-trip
    /// through `get_prop`. The CLI (`zeroclaw config get/set`), the TUI
    /// Quickstart prompts (`prompt_field`), the gateway list endpoint
    /// (`/api/config/list`), and the dashboard form all derive from
    /// `prop_fields()`; if a path appears here but `get_prop` rejects
    /// it, that field is unreachable on every surface.
    ///
    /// `init_defaults(None)` populates Option-shaped subsections (memory
    /// backend specifics, tunnel provider details, etc.) so the walk
    /// also exercises fields that only materialize once a backend is
    /// chosen.
    #[test]
    async fn every_prop_field_path_is_reachable_via_get_prop() {
        let mut config = Config::default();
        config.init_defaults(None);
        for field in config.prop_fields() {
            let result = config.get_prop(&field.name);
            assert!(
                result.is_ok(),
                "get_prop('{}') failed: {} \u{2014} prop_fields() advertises a path \
                 that the CLI / gateway / TUI all expect to be readable. \
                 Either the macro emits the path but routing is missing, \
                 or the field shouldn't be in prop_fields().",
                field.name,
                result.unwrap_err()
            );
        }
    }

    /// The dashboard's `/config/channels` global-settings tab filters the
    /// canonical `prop_fields()` list down to direct `[channels]` fields.
    /// Keep root settings such as `show_tool_calls` discoverable there without
    /// mixing per-alias fields into the same editor.
    #[test]
    async fn channels_root_settings_stay_on_direct_prop_surface() {
        let mut config = Config::default();
        config.init_defaults(None);
        config
            .channels
            .matrix
            .insert("default".into(), MatrixConfig::default());

        let paths: Vec<_> = config
            .prop_fields()
            .into_iter()
            .map(|field| field.name)
            .collect();
        let direct_channel_paths: Vec<_> = paths
            .iter()
            .filter_map(|path| {
                path.strip_prefix("channels.")
                    .filter(|rest| !rest.contains('.'))
                    .map(|_| path.as_str())
            })
            .collect();

        assert!(
            direct_channel_paths.contains(&"channels.show_tool_calls"),
            "root [channels] settings should include show_tool_calls: {direct_channel_paths:?}"
        );
        assert!(
            direct_channel_paths.contains(&"channels.ack_reactions"),
            "root [channels] settings should include other global channel controls"
        );
        assert!(
            paths
                .iter()
                .any(|path| path == "channels.matrix.default.enabled"),
            "fixture should include a nested channel alias field"
        );
        assert!(
            !direct_channel_paths.contains(&"channels.matrix.default.enabled"),
            "global channel settings must not include per-alias fields"
        );
    }

    /// Audit gate for RFC Phase 0: any credential-shaped property path
    /// that reaches the CLI/gateway/TUI property surface must have an explicit
    /// classification. This catches future config additions whose names imply
    /// credential handling before they silently land without a security call.
    #[test]
    async fn credential_shaped_prop_fields_have_explicit_classification() {
        let mut config = Config::default();
        config.init_defaults(None);
        config
            .providers
            .models
            .anthropic
            .insert("default".into(), AnthropicModelProviderConfig::default());
        config
            .providers
            .tts
            .openai
            .insert("default".into(), OpenAITtsProviderConfig::default());
        config.providers.transcription.openai.insert(
            "default".into(),
            OpenAiTranscriptionProviderConfig::default(),
        );
        config.providers.transcription.local_whisper.insert(
            "default".into(),
            LocalWhisperTranscriptionProviderConfig::default(),
        );
        config
            .channels
            .matrix
            .insert("default".into(), MatrixConfig::default());
        config
            .storage
            .qdrant
            .insert("default".into(), QdrantStorageConfig::default());

        let fields = config.prop_fields();
        let missing: Vec<_> = fields
            .iter()
            .filter(|field| credential_shaped_prop_path(&field.name))
            .filter(|field| field.credential_class.is_none())
            .map(|field| field.name.clone())
            .collect();

        assert!(
            missing.is_empty(),
            "credential-shaped config fields need explicit classification: {missing:?}"
        );

        let unmarked_secrets: Vec<_> = fields
            .iter()
            .filter(|field| {
                field.credential_class
                    == Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
            })
            .filter(|field| !field.is_secret && !Config::prop_is_secret(&field.name))
            .map(|field| field.name.clone())
            .collect();

        assert!(
            unmarked_secrets.is_empty(),
            "EncryptedSecret classifications must route through #[secret]: {unmarked_secrets:?}"
        );
    }

    #[test]
    async fn prop_fields_carry_credential_classification_from_schema_fields() {
        let mut config = Config::default();
        config.init_defaults(None);
        config.providers.models.openai.insert(
            "codex".into(),
            OpenAIModelProviderConfig {
                base: ModelProviderConfig {
                    requires_openai_auth: true,
                    ..ModelProviderConfig::default()
                },
            },
        );
        config
            .providers
            .tts
            .openai
            .insert("default".into(), OpenAITtsProviderConfig::default());
        config.providers.transcription.local_whisper.insert(
            "default".into(),
            LocalWhisperTranscriptionProviderConfig::default(),
        );
        config
            .channels
            .matrix
            .insert("default".into(), MatrixConfig::default());

        let fields = config.prop_fields();
        let class_for = |name: &str| {
            fields
                .iter()
                .find(|field| field.name == name)
                .and_then(|field| field.credential_class)
        };

        assert_eq!(
            class_for("providers.models.openai.codex.requires_openai_auth"),
            Some(crate::config::CredentialSurfaceClass::ExternalAuthStore)
        );
        assert_eq!(
            class_for("providers.tts.openai.default.api_key"),
            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
        );
        assert_eq!(
            class_for("providers.transcription.local_whisper.default.bearer_token"),
            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
        );
        assert_eq!(
            class_for("channels.matrix.default.access_token"),
            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
        );
        // model_routes and embedding_routes are now #[nested] Vec fields —
        // they are surfaced via map_key_sections(), not as flat prop_fields.
        // After adding a route entry, its api_key sub-field appears in
        // prop_fields with EncryptedSecret classification (from #[secret]).
        config.model_routes.push(ModelRouteConfig {
            hint: "reasoning".into(),
            model_provider: "openai.default".into(),
            model: "gpt-4".into(),
            api_key: None,
        });
        config.embedding_routes.push(EmbeddingRouteConfig {
            hint: "semantic".into(),
            model_provider: "openai.embeddings".into(),
            model: "text-embedding-3-small".into(),
            dimensions: None,
            api_key: None,
        });
        let nested_fields = config.prop_fields();
        let nested_class_for = |name: &str| {
            nested_fields
                .iter()
                .find(|field| field.name == name)
                .and_then(|field| field.credential_class)
        };
        assert_eq!(
            nested_class_for("model_routes.reasoning.api_key"),
            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
        );
        assert_eq!(
            nested_class_for("embedding_routes.semantic.api_key"),
            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
        );
        assert!(Config::prop_is_secret(
            "providers.tts.openai.default.api_key"
        ));
        assert!(Config::prop_is_secret(
            "providers.transcription.local_whisper.default.bearer_token"
        ));
        assert!(Config::prop_is_secret(
            "channels.matrix.default.access_token"
        ));
    }

    fn credential_shaped_prop_path(path: &str) -> bool {
        path.split('.').any(|part| {
            let normalized = part.replace('_', "-");
            let has_term = |needle| normalized.split('-').any(|term| term == needle);
            normalized.contains("api-key")
                || normalized.contains("api-token")
                || normalized.contains("auth-file")
                || normalized.contains("auth-header")
                || normalized.contains("auth-token")
                || normalized.contains("bearer-token")
                || normalized.contains("bot-token")
                || normalized.contains("access-token")
                || normalized.contains("refresh-token")
                || normalized.contains("verification-token")
                || normalized.contains("paired-tokens")
                || part == "token"
                || has_term("credential")
                || has_term("env")
                || has_term("header")
                || has_term("headers")
                || has_term("password")
                || has_term("secret")
        })
    }

    #[test]
    async fn object_array_prop_display_redacts_nested_secret_fields() {
        let fixture = ObjectArraySecretFixture {
            entries: vec![
                ObjectArraySecretEntry {
                    name: "primary".to_string(),
                    token: Some("nested-token-credential".to_string()),
                    headers: HashMap::from([
                        (
                            "Authorization".to_string(),
                            "Bearer nested-header-credential".to_string(),
                        ),
                        ("X-Tenant".to_string(), "tenant-credential".to_string()),
                    ]),
                },
                ObjectArraySecretEntry {
                    name: "unset-secret".to_string(),
                    token: None,
                    headers: HashMap::new(),
                },
            ],
        };

        let display_value = fixture
            .prop_fields()
            .into_iter()
            .find(|field| field.name == "test.object_array.entries")
            .expect("object-array field should be surfaced")
            .display_value;
        let readback = fixture
            .get_prop("test.object_array.entries")
            .expect("object-array field should be readable");

        for rendered in [&display_value, &readback] {
            assert!(
                !rendered.contains("nested-token-credential"),
                "object-array display/readback must redact scalar nested secrets: {rendered}"
            );
            assert!(
                !rendered.contains("Bearer nested-header-credential"),
                "object-array display/readback must redact nested secret map values: {rendered}"
            );
            assert!(
                !rendered.contains("tenant-credential"),
                "object-array display/readback must redact every value in nested secret maps: {rendered}"
            );
            assert!(
                rendered.contains("primary"),
                "non-secret object-array fields should remain visible: {rendered}"
            );
            assert!(
                rendered.contains("unset-secret"),
                "non-secret fields on entries with unset secrets should remain visible: {rendered}"
            );
            assert!(
                rendered.contains("****"),
                "redacted object-array output should show masked placeholders: {rendered}"
            );
        }

        assert!(
            display_value.contains(r#""token":null"#),
            "JSON display should preserve unset optional secrets as null, not a populated mask: {display_value}"
        );
    }

    #[test]
    async fn onboard_state_prop_path_uses_top_level_kebab_field_name() {
        let mut config = Config::default();

        config
            .set_prop("onboard_state.completed_sections", "agents")
            .expect("onboard state marker path should be writable");
        assert_eq!(
            config
                .get_prop("onboard_state.completed_sections")
                .expect("onboard state marker path should be readable"),
            "[\"agents\"]"
        );
    }

    /// `onboard_state.quickstart_completed` is the flag the Quickstart
    /// flips when it lands a `BuilderSubmission`. Defaults to `false`
    /// so first launches auto-open the Quickstart; round-trips through
    /// `set_prop` / `get_prop` like any other top-level config field.
    #[test]
    async fn onboard_state_quickstart_completed_round_trips() {
        let mut config = Config::default();

        assert_eq!(
            config
                .get_prop("onboard_state.quickstart_completed")
                .expect("default quickstart-completed should be readable"),
            "false",
            "fresh configs default to quickstart-completed=false so the \
             Quickstart auto-opens on first launch",
        );

        config
            .set_prop("onboard_state.quickstart_completed", "true")
            .expect("quickstart-completed should be writable via prop path");
        assert_eq!(
            config
                .get_prop("onboard_state.quickstart_completed")
                .expect("quickstart-completed should be readable after set"),
            "true"
        );
    }

    #[test]
    async fn per_agent_nested_prop_fields_use_agent_alias_paths() {
        let mut config = Config::default();
        config
            .agents
            .insert("bob".to_string(), AliasedAgentConfig::default());
        config.runtime_profiles.insert(
            "fast".to_string(),
            crate::schema::RuntimeProfileConfig::default(),
        );

        let fields = config.prop_fields();
        assert!(
            fields
                .iter()
                .any(|field| field.name == "runtime_profiles.fast.history_pruning.enabled"),
            "history-pruning is a runtime-profile field, emitted under the profile alias"
        );
        assert!(
            !fields
                .iter()
                .any(|field| field.name.starts_with("agents.bob.history_pruning")),
            "history-pruning must no longer be settable on the agent"
        );

        config
            .set_prop("runtime_profiles.fast.history_pruning.enabled", "true")
            .expect("set_prop should accept the runtime-profile nested path");
        assert_eq!(
            config
                .get_prop("runtime_profiles.fast.history_pruning.enabled")
                .expect("get_prop should accept the runtime-profile nested path"),
            "true"
        );
    }

    /// Audit gate: every non-secret scalar prop round-trips through
    /// `set_prop(get_prop(p))`. The CLI's `zeroclaw config set` and the
    /// dashboard's PATCH op both rely on this being true so an operator
    /// can read a value, edit it locally, and write it back. Vec /
    /// object-array fields are skipped — they pass through serde-JSON
    /// rather than scalar string parsing.
    #[test]
    async fn every_scalar_prop_round_trips_through_set_prop() {
        let mut config = Config::default();
        config.init_defaults(None);
        let fields = config.prop_fields();
        for field in &fields {
            if field.is_secret
                || matches!(
                    field.kind,
                    crate::config::PropKind::StringArray | crate::config::PropKind::ObjectArray
                )
            {
                continue;
            }
            let value = match config.get_prop(&field.name) {
                Ok(v) => v,
                Err(_) => continue,
            };
            // Sentinel for unset Option fields — no round-trip applies.
            if value == crate::traits::UNSET_DISPLAY {
                continue;
            }
            let result = config.set_prop(&field.name, &value);
            assert!(
                result.is_ok(),
                "round-trip set_prop('{}', '{}') failed: {}",
                field.name,
                value,
                result.unwrap_err()
            );
        }
    }

    /// Every enum field must have a working enum_variants callback, and
    /// set_prop must accept each variant it advertises.
    #[test]
    async fn every_enum_variant_is_settable() {
        let mut config = Config::default();
        config.init_defaults(None);

        for field in config.prop_fields() {
            if !field.is_enum() {
                continue;
            }
            let get_variants = field.enum_variants.unwrap_or_else(|| {
                panic!("enum field '{}' has no enum_variants callback", field.name)
            });
            let variants = get_variants();
            assert!(
                !variants.is_empty(),
                "enum field '{}' returned no variants",
                field.name
            );

            for variant in &variants {
                let result = config.set_prop(&field.name, variant);
                assert!(
                    result.is_ok(),
                    "set_prop('{}', '{}') failed: {}",
                    field.name,
                    variant,
                    result.unwrap_err()
                );
            }
        }
    }

    #[test]
    async fn channel_approval_timeout_secs_defaults_to_300() {
        let discord: DiscordConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
        assert_eq!(discord.approval_timeout_secs, 300);

        let slack: SlackConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
        assert_eq!(slack.approval_timeout_secs, 300);

        let signal: SignalConfig =
            serde_json::from_str(r#"{"http_url":"http://localhost","account":"+1"}"#).unwrap();
        assert_eq!(signal.approval_timeout_secs, 300);

        let matrix: MatrixConfig = serde_json::from_str(
            r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[]}"#,
        )
        .unwrap();
        assert_eq!(matrix.approval_timeout_secs, 300);

        let whatsapp: WhatsAppConfig = serde_json::from_str(r#"{}"#).unwrap();
        assert_eq!(whatsapp.approval_timeout_secs, 300);
    }

    #[test]
    async fn channel_approval_timeout_secs_explicit_override() {
        let discord: DiscordConfig =
            serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":60}"#).unwrap();
        assert_eq!(discord.approval_timeout_secs, 60);

        let slack: SlackConfig =
            serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":120}"#).unwrap();
        assert_eq!(slack.approval_timeout_secs, 120);

        let signal: SignalConfig = serde_json::from_str(
            r#"{"http_url":"http://localhost","account":"+1","approval_timeout_secs":90}"#,
        )
        .unwrap();
        assert_eq!(signal.approval_timeout_secs, 90);

        let matrix: MatrixConfig = serde_json::from_str(
            r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[],"approval_timeout_secs":45}"#,
        )
        .unwrap();
        assert_eq!(matrix.approval_timeout_secs, 45);

        let whatsapp: WhatsAppConfig =
            serde_json::from_str(r#"{"approval_timeout_secs":180}"#).unwrap();
        assert_eq!(whatsapp.approval_timeout_secs, 180);
    }

    // ── Multi-agent cross-reference validators ─────────────────────

    /// Build a minimal valid Config with one agent on a configured
    /// channel + risk profile + model provider. Each test mutates a
    /// single field to provoke a validator.
    fn multi_agent_test_config() -> Config {
        use crate::providers::ChannelRef;

        let mut config = Config::default();

        // Risk profile (mandatory for enabled agents).
        config
            .risk_profiles
            .insert("default".to_string(), RiskProfileConfig::default());

        // Anthropic model provider (mandatory for the agent).
        config.providers.models.anthropic.insert(
            "default".to_string(),
            AnthropicModelProviderConfig::default(),
        );

        // A configured Telegram channel the agent can reference. Just
        // having the entry in the map is enough for the dotted-alias
        // validator; we are not exercising channel-level behavior here.
        config
            .channels
            .telegram
            .insert("draft".to_string(), TelegramConfig::default());

        // Agent that targets the model provider, risk profile, and
        // channel. Default workspace is jailed.
        let agent = AliasedAgentConfig {
            channels: vec![ChannelRef::new("telegram.draft")],
            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
            risk_profile: "default".into(),
            ..AliasedAgentConfig::default()
        };
        config.agents.insert("alpha".to_string(), agent);

        config
    }

    #[test]
    async fn validate_rejects_workspace_access_self_reference() {
        let mut config = multi_agent_test_config();
        let alpha = config.agents.get_mut("alpha").unwrap();
        alpha.workspace.access.insert(
            crate::multi_agent::AgentAlias::new("alpha"),
            crate::multi_agent::AccessMode::Read,
        );
        let err = config
            .validate()
            .expect_err("self-reference must fail validation");
        let msg = err.to_string();
        assert!(
            msg.contains("agents.alpha.workspace.access.alpha"),
            "expected field path in error, got: {msg}"
        );
        assert!(
            msg.contains("self-references"),
            "expected self-reference explanation, got: {msg}"
        );
    }

    #[test]
    async fn validate_rejects_workspace_access_dangling_target() {
        let mut config = multi_agent_test_config();
        let alpha = config.agents.get_mut("alpha").unwrap();
        alpha.workspace.access.insert(
            crate::multi_agent::AgentAlias::new("ghost"),
            crate::multi_agent::AccessMode::ReadWrite,
        );
        let err = config
            .validate()
            .expect_err("dangling target must fail validation");
        let msg = err.to_string();
        assert!(
            msg.contains("agents.ghost is not configured"),
            "expected dangling-ref explanation, got: {msg}"
        );
    }

    #[test]
    async fn validate_rejects_read_memory_from_self_reference() {
        let mut config = multi_agent_test_config();
        let alpha = config.agents.get_mut("alpha").unwrap();
        alpha
            .workspace
            .read_memory_from
            .push(crate::multi_agent::AgentAlias::new("alpha"));
        let err = config
            .validate()
            .expect_err("self-reference must fail validation");
        assert!(
            err.to_string().contains("read_memory_from[0]"),
            "expected indexed field path, got: {err}"
        );
    }

    #[test]
    async fn validate_rejects_read_memory_from_cross_backend() {
        let mut config = multi_agent_test_config();

        // Add a second agent on Postgres.
        let beta = AliasedAgentConfig {
            channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
            risk_profile: "default".into(),
            memory: crate::multi_agent::AgentMemoryConfig {
                backend: crate::multi_agent::MemoryBackendKind::Postgres,
            },
            ..AliasedAgentConfig::default()
        };
        config.agents.insert("beta".to_string(), beta);

        // Alpha (Sqlite default) tries to read from beta (Postgres).
        let alpha = config.agents.get_mut("alpha").unwrap();
        alpha
            .workspace
            .read_memory_from
            .push(crate::multi_agent::AgentAlias::new("beta"));

        let err = config
            .validate()
            .expect_err("cross-backend allowlist must fail validation");
        let msg = err.to_string();
        assert!(
            msg.contains("same-backend siblings only"),
            "expected cross-backend explanation, got: {msg}"
        );
    }

    #[test]
    async fn validate_rejects_typed_memory_flags_on_non_sqlite_global_backend() {
        let mut config = multi_agent_test_config();
        config.memory.types.enabled = true;
        config.memory.backend = "postgres.work".to_string();

        let err = config
            .validate()
            .expect_err("typed memory on a non-sqlite global backend must fail validation");
        let msg = err.to_string();
        assert!(
            msg.contains("memory.types.enabled") && msg.contains("SQLite-only"),
            "expected SQLite-only explanation naming the flag, got: {msg}"
        );
    }

    #[test]
    async fn validate_rejects_typed_memory_flags_on_non_sqlite_agent_backend() {
        let mut config = multi_agent_test_config();
        config.memory.consolidation_extract_facts = true;

        let beta = AliasedAgentConfig {
            channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
            risk_profile: "default".into(),
            memory: crate::multi_agent::AgentMemoryConfig {
                backend: crate::multi_agent::MemoryBackendKind::Markdown,
            },
            ..AliasedAgentConfig::default()
        };
        config.agents.insert("beta".to_string(), beta);

        let err = config
            .validate()
            .expect_err("typed memory with a non-sqlite agent backend must fail validation");
        let msg = err.to_string();
        assert!(
            msg.contains("memory.consolidation_extract_facts")
                && msg.contains("agents.beta.memory.backend")
                && msg.contains("Markdown"),
            "expected SQLite-only explanation naming the agent, got: {msg}"
        );
    }

    #[test]
    async fn validate_accepts_typed_memory_flags_on_sqlite() {
        let mut config = multi_agent_test_config();
        config.memory.types.enabled = true;
        config.memory.consolidation_extract_facts = true;
        config.memory.backend = "sqlite".to_string();

        config
            .validate()
            .expect("typed memory on sqlite everywhere must pass validation");
    }

    #[test]
    async fn validate_accepts_typed_memory_flags_on_mixed_case_sqlite() {
        // The runtime classifies backends case-insensitively
        // (`backend_kind_from_dotted` lowercases), so the SQLite-only gate
        // must accept every spelling the runtime resolves to sqlite.
        let mut config = multi_agent_test_config();
        config.memory.types.enabled = true;
        config.memory.backend = " SQLite.default ".to_string();

        config
            .validate()
            .expect("mixed-case sqlite spellings the runtime accepts must pass the typed gate");
    }

    #[test]
    async fn validate_allows_non_sqlite_backend_when_typed_memory_flags_off() {
        let mut config = multi_agent_test_config();
        config.memory.backend = "postgres.work".to_string();
        let alpha = config.agents.get_mut("alpha").unwrap();
        alpha.memory.backend = crate::multi_agent::MemoryBackendKind::Postgres;

        config
            .validate()
            .expect("flags-off configs keep every backend choice valid");
    }

    #[test]
    async fn validate_rejects_peer_group_dangling_member() {
        let mut config = multi_agent_test_config();
        let group = crate::multi_agent::PeerGroupConfig {
            channel: "telegram".into(),
            agents: vec![
                crate::multi_agent::AgentAlias::new("alpha"),
                crate::multi_agent::AgentAlias::new("ghost"),
            ],
            ..crate::multi_agent::PeerGroupConfig::default()
        };
        config.peer_groups.insert("team_chat".to_string(), group);
        let err = config
            .validate()
            .expect_err("dangling group member must fail validation");
        assert!(
            err.to_string().contains("peer_groups.team_chat.agents[1]"),
            "expected indexed field path, got: {err}"
        );
    }

    #[test]
    async fn validate_rejects_peer_group_member_without_channel() {
        let mut config = multi_agent_test_config();

        // Add a discord channel and a beta agent that ONLY uses discord.
        config
            .channels
            .discord
            .insert("ops".to_string(), DiscordConfig::default());
        let beta = AliasedAgentConfig {
            channels: vec![crate::providers::ChannelRef::new("discord.ops")],
            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
            risk_profile: "default".into(),
            ..AliasedAgentConfig::default()
        };
        config.agents.insert("beta".to_string(), beta);

        // Group on telegram.draft includes beta (who only has discord).
        let group = crate::multi_agent::PeerGroupConfig {
            channel: "telegram".into(),
            agents: vec![
                crate::multi_agent::AgentAlias::new("alpha"),
                crate::multi_agent::AgentAlias::new("beta"),
            ],
            ..crate::multi_agent::PeerGroupConfig::default()
        };
        config.peer_groups.insert("team_chat".to_string(), group);

        let err = config
            .validate()
            .expect_err("channel-mismatch group member must fail validation");
        let msg = err.to_string();
        assert!(
            msg.contains("agents.beta.channels has no entry of type"),
            "expected channel-mismatch explanation, got: {msg}"
        );
    }

    #[test]
    async fn validate_accepts_valid_peer_group_with_two_compatible_members() {
        let mut config = multi_agent_test_config();

        // Beta on the same telegram channel.
        let beta = AliasedAgentConfig {
            channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
            risk_profile: "default".into(),
            ..AliasedAgentConfig::default()
        };
        config.agents.insert("beta".to_string(), beta);

        // Group on telegram.draft includes both members.
        let group = crate::multi_agent::PeerGroupConfig {
            channel: "telegram".into(),
            agents: vec![
                crate::multi_agent::AgentAlias::new("alpha"),
                crate::multi_agent::AgentAlias::new("beta"),
            ],
            ..crate::multi_agent::PeerGroupConfig::default()
        };
        config.peer_groups.insert("team_chat".to_string(), group);

        config
            .validate()
            .expect("two-member same-channel peer group must validate cleanly");
    }

    #[test]
    async fn config_validate_rejects_classifier_provider_pointing_at_missing_alias() {
        // Use the SHARED `typed_provider_refs` validation loop — same error
        // surface as tts_provider / transcription_provider.
        let toml = r#"
            [providers.models.custom.default]
            api_key = "k"
            model = "qwen3.6-plus"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [agents.default]
            enabled = true
            model_provider = "custom.default"
            risk_profile = "default"
            classifier_provider = "custom.does-not-exist"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let err = cfg
            .validate()
            .expect_err("missing alias must fail validate");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("classifier_provider")
                && msg.contains("does-not-exist")
                && msg.contains("providers.models.custom.does-not-exist is not configured"),
            "expected DanglingReference error mentioning field + alias + section, got: {msg}"
        );
    }

    // agent-level summary_provider validated like classifier_provider.
    #[tokio::test]
    async fn config_validate_rejects_agent_summary_provider_missing_alias() {
        let toml = r#"
            [providers.models.custom.default]
            api_key = "k"
            model = "qwen3.6-plus"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [agents.default]
            enabled = true
            model_provider = "custom.default"
            risk_profile = "default"
            summary_provider = "custom.does-not-exist"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let msg = format!("{:#}", cfg.validate().expect_err("missing alias must fail"));
        assert!(
            msg.contains("summary_provider")
                && msg.contains("providers.models.custom.does-not-exist is not configured"),
            "expected DanglingReference for agent summary_provider, got: {msg}"
        );
    }

    // profile-level summary_provider validated by the new profile loop.
    #[tokio::test]
    async fn config_validate_rejects_profile_summary_provider_missing_alias() {
        let toml = r#"
            [providers.models.custom.default]
            api_key = "k"
            model = "qwen3.6-plus"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.fast.context_compression]
            summary_provider = "custom.nope"

            [agents.default]
            enabled = true
            model_provider = "custom.default"
            risk_profile = "default"
            runtime_profile = "fast"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let msg = format!(
            "{:#}",
            cfg.validate().expect_err("missing profile alias must fail")
        );
        assert!(
            msg.contains("runtime_profiles.fast.context_compression.summary_provider")
                && msg.contains("providers.models.custom.nope is not configured"),
            "expected DanglingReference for profile summary_provider, got: {msg}"
        );
    }

    // effective_summary_provider precedence — agent → profile → None.
    #[tokio::test]
    async fn effective_summary_provider_precedence() {
        let toml = r#"
            [providers.models.custom.main]
            api_key = "k"
            model = "m-main"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"
            [providers.models.custom.cheap]
            api_key = "k"
            model = "m-cheap"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"
            [providers.models.custom.profilesum]
            api_key = "k"
            model = "m-profile"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.fast.context_compression]
            summary_provider = "custom.profilesum"

            [agents.a]
            enabled = true
            model_provider = "custom.main"
            risk_profile = "default"
            runtime_profile = "fast"
            summary_provider = "custom.cheap"

            [agents.b]
            enabled = true
            model_provider = "custom.main"
            risk_profile = "default"
            runtime_profile = "fast"

            [agents.c]
            enabled = true
            model_provider = "custom.main"
            risk_profile = "default"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        // agent override wins over the profile
        assert_eq!(
            cfg.effective_summary_provider("a").as_deref(),
            Some("custom.cheap")
        );
        // agent empty → profile value
        assert_eq!(
            cfg.effective_summary_provider("b").as_deref(),
            Some("custom.profilesum")
        );
        // no agent override + no runtime profile → None (caller uses agent's own)
        assert_eq!(cfg.effective_summary_provider("c"), None);
    }

    // config-time diagnostic for the legacy cross-provider summary_model
    // shape. A profile sets the deprecated bare summary_model and is shared by
    // two agents on DIFFERENT providers with no summary_provider override -> the
    // diagnostic fires and names the profile + the affected agents + providers.
    #[tokio::test]
    async fn collect_warnings_flags_cross_provider_summary_model() {
        let toml = r#"
            [providers.models.custom.p1]
            api_key = "k"
            model = "m1"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"
            [providers.models.custom.p2]
            api_key = "k"
            model = "m2"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.shared.context_compression]
            summary_model = "haiku"

            [agents.alpha]
            enabled = true
            model_provider = "custom.p1"
            risk_profile = "default"
            runtime_profile = "shared"

            [agents.beta]
            enabled = true
            model_provider = "custom.p2"
            risk_profile = "default"
            runtime_profile = "shared"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        let w = warnings
            .iter()
            .find(|w| w.code == "cross_provider_summary_model")
            .expect("expected cross_provider_summary_model warning");
        assert_eq!(
            w.path,
            "runtime_profiles.shared.context_compression.summary_model"
        );
        assert!(
            w.message.contains("haiku"),
            "message names the model: {}",
            w.message
        );
        assert!(
            w.message.contains("alpha -> custom.p1"),
            "message names alpha + provider: {}",
            w.message
        );
        assert!(
            w.message.contains("beta -> custom.p2"),
            "message names beta + provider: {}",
            w.message
        );
    }

    // The `cross_provider_summary_model` diagnostic must report the setting
    // as unsupported/inert like every other `context_compression` knob, not
    // as something that is actively dispatched onto per-agent providers and
    // fails at runtime — there is no runtime consumer left to dispatch
    // anything. The cross-provider detail (which agents, which providers)
    // must still be present since it is useful context for the fix, but the
    // message must not claim any runtime behavior.
    #[tokio::test]
    async fn collect_warnings_cross_provider_summary_model_reports_inert_not_dispatch() {
        let toml = r#"
            [providers.models.custom.p1]
            api_key = "k"
            model = "m1"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"
            [providers.models.custom.p2]
            api_key = "k"
            model = "m2"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.shared.context_compression]
            summary_model = "haiku"

            [agents.alpha]
            enabled = true
            model_provider = "custom.p1"
            risk_profile = "default"
            runtime_profile = "shared"

            [agents.beta]
            enabled = true
            model_provider = "custom.p2"
            risk_profile = "default"
            runtime_profile = "shared"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        let w = warnings
            .iter()
            .find(|w| w.code == "cross_provider_summary_model")
            .expect("expected cross_provider_summary_model warning");
        assert!(
            w.message.contains("not currently implemented") && w.message.contains("no effect"),
            "message must truthfully report the setting as unsupported/inert: {}",
            w.message
        );
        assert!(
            !w.message.contains("silently fails"),
            "message must not claim the setting silently fails at runtime: {}",
            w.message
        );
        assert!(
            !w.message.contains("dispatched"),
            "message must not claim the setting is dispatched to a provider at runtime: {}",
            w.message
        );
        // Cross-provider specificity must survive the rewrite — it is still
        // useful detail even though the setting is inert.
        assert!(
            w.message.contains("alpha -> custom.p1") && w.message.contains("beta -> custom.p2"),
            "message must keep naming the affected agents and providers: {}",
            w.message
        );
        // The remediation must NOT send the operator to another inert
        // context_compression field: this PR's per-field pass classifies a
        // non-default `summary_provider` as unsupported/inert too, so
        // "migrate to context_compression.summary_provider" would just produce
        // another no-effect setting and another warning.
        assert!(
            !w.message
                .contains("Migrate to context_compression.summary_provider"),
            "remediation must not recommend migrating to the inert summary_provider: {}",
            w.message
        );
        assert!(
            w.message
                .contains("Remove the unsupported context_compression setting"),
            "remediation should tell the operator to remove the inert setting: {}",
            w.message
        );
    }

    // The runtime context compressor was removed; nothing reads
    // `context_compression` at runtime anymore, so an explicit
    // `enabled = true` on a named runtime profile is inert and must be
    // flagged.
    #[tokio::test]
    async fn collect_warnings_flags_context_compression_enabled_on_runtime_profile() {
        let toml = r#"
            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.fast.context_compression]
            enabled = true

            [agents.alpha]
            enabled = true
            risk_profile = "default"
            runtime_profile = "fast"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        let w = warnings
            .iter()
            .find(|w| w.code == "context_compression_unsupported")
            .expect("expected context_compression_unsupported warning");
        assert_eq!(w.path, "runtime_profiles.fast.context_compression.enabled");
        assert!(
            w.message.contains("not currently implemented"),
            "message explains the flag is inert: {}",
            w.message
        );
    }

    // The legacy pre-V3 `[agent.context_compression]` top-level table is
    // folded into `[runtime_profiles.default]` by the V1/V2→V3 migration
    // (see `schema/v2.rs`), so it must surface the same diagnostic once
    // migrated — this is the historical form of the surface commonly
    // called "agent-level" configuration.
    #[::core::prelude::v1::test]
    fn collect_warnings_flags_context_compression_enabled_via_legacy_agent_table() {
        let raw = r#"
            default_temperature = 0.7

            [agent.context_compression]
            enabled = true
        "#;
        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
        let warnings = parsed.collect_warnings();
        let w = warnings
            .iter()
            .find(|w| w.code == "context_compression_unsupported")
            .expect("expected context_compression_unsupported warning after migration");
        assert_eq!(
            w.path,
            "runtime_profiles.default.context_compression.enabled"
        );
    }

    // A default config (no explicit `context_compression.enabled`) must stay
    // silent — the flag now defaults to `false`, matching the runtime, which
    // does not consult it at all.
    #[tokio::test]
    async fn collect_warnings_silent_for_context_compression_default() {
        let toml = r#"
            [risk_profiles.default]
            level = "supervised"

            [agents.alpha]
            enabled = true
            risk_profile = "default"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        assert!(
            !warnings
                .iter()
                .any(|w| w.code == "context_compression_unsupported"),
            "default config must not flag context_compression_unsupported: {warnings:?}"
        );
    }

    // Every `context_compression` knob is inert, not just `enabled` — tuning
    // fields set to non-default values must each surface their own warning
    // with a per-field path, even with `enabled` left off, since the whole
    // struct is covered.
    #[tokio::test]
    async fn collect_warnings_flags_context_compression_tuning_fields() {
        let toml = r#"
            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.fast.context_compression]
            threshold_ratio = 0.9
            protect_first_n = 500

            [agents.alpha]
            enabled = true
            risk_profile = "default"
            runtime_profile = "fast"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        let paths: Vec<&str> = warnings
            .iter()
            .filter(|w| w.code == "context_compression_unsupported")
            .map(|w| w.path.as_str())
            .collect();
        assert!(
            paths.contains(&"runtime_profiles.fast.context_compression.threshold_ratio"),
            "threshold_ratio must be flagged: {paths:?}"
        );
        assert!(
            paths.contains(&"runtime_profiles.fast.context_compression.protect_first_n"),
            "protect_first_n must be flagged: {paths:?}"
        );
        // `enabled` was not set (defaults to false) — no warning for it.
        assert!(
            !paths.contains(&"runtime_profiles.fast.context_compression.enabled"),
            "unset enabled must not be flagged: {paths:?}"
        );
        let w = warnings
            .iter()
            .find(|w| w.path == "runtime_profiles.fast.context_compression.threshold_ratio")
            .expect("threshold_ratio warning present");
        assert!(
            w.message.contains("non-default value"),
            "message says the value is non-default: {}",
            w.message
        );
    }

    // A knob explicitly written at its default value is indistinguishable
    // from an omitted one post-deserialization and must stay silent — the
    // same accepted limitation as `validate_memory_semantics`.
    #[tokio::test]
    async fn collect_warnings_silent_for_context_compression_default_values_written_explicitly() {
        let toml = r#"
            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.fast.context_compression]
            enabled = false
            threshold_ratio = 0.50
            protect_first_n = 3
            tool_result_retrim_chars = 2000

            [agents.alpha]
            enabled = true
            risk_profile = "default"
            runtime_profile = "fast"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        assert!(
            !warnings
                .iter()
                .any(|w| w.code == "context_compression_unsupported"),
            "explicit default values must not flag context_compression_unsupported: {warnings:?}"
        );
    }

    // Specific-warning-wins dedup: a bare cross-provider `summary_model`
    // already draws the more specific `cross_provider_summary_model`
    // diagnostic, which itself reports the setting as inert (same fact as
    // `context_compression_unsupported`) plus the cross-provider detail, so
    // the generic inert warning must NOT also fire for the identical path —
    // doctor/gateway print both with no dedup, and it would just be the same
    // statement twice.
    #[tokio::test]
    async fn collect_warnings_context_compression_defers_to_cross_provider_summary_model() {
        let toml = r#"
            [providers.models.custom.p1]
            api_key = "k"
            model = "m1"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"
            [providers.models.custom.p2]
            api_key = "k"
            model = "m2"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.shared.context_compression]
            summary_model = "haiku"

            [agents.alpha]
            enabled = true
            model_provider = "custom.p1"
            risk_profile = "default"
            runtime_profile = "shared"

            [agents.beta]
            enabled = true
            model_provider = "custom.p2"
            risk_profile = "default"
            runtime_profile = "shared"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        let summary_model_warnings: Vec<_> = warnings
            .iter()
            .filter(|w| w.path == "runtime_profiles.shared.context_compression.summary_model")
            .collect();
        assert_eq!(
            summary_model_warnings.len(),
            1,
            "exactly one warning for the summary_model path: {summary_model_warnings:?}"
        );
        assert_eq!(
            summary_model_warnings[0].code, "cross_provider_summary_model",
            "the specific cross-provider diagnostic wins for the shared path"
        );
    }

    // Same-provider control: without a cross-provider diagnostic covering
    // the path, the inert warning must still fire for `summary_model` — no
    // other diagnostic covers the single-provider shape.
    #[tokio::test]
    async fn collect_warnings_context_compression_flags_same_provider_summary_model() {
        let toml = r#"
            [providers.models.custom.p1]
            api_key = "k"
            model = "m1"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.shared.context_compression]
            summary_model = "haiku"

            [agents.alpha]
            enabled = true
            model_provider = "custom.p1"
            risk_profile = "default"
            runtime_profile = "shared"

            [agents.beta]
            enabled = true
            model_provider = "custom.p1"
            risk_profile = "default"
            runtime_profile = "shared"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        let w = warnings
            .iter()
            .find(|w| w.path == "runtime_profiles.shared.context_compression.summary_model")
            .expect("expected a warning for the summary_model path");
        assert_eq!(
            w.code, "context_compression_unsupported",
            "single-provider summary_model gets the inert warning"
        );
    }

    // exposed_skills set with no skill_bundles -> the agent card resolves no
    // skills (skills: []) silently; the diagnostic fires and names the agent.
    #[tokio::test]
    async fn collect_warnings_flags_exposed_skills_without_bundles() {
        let toml = r#"
            [risk_profiles.default]
            level = "supervised"

            [agents.merchant]
            enabled = true
            risk_profile = "default"

            [agents.merchant.a2a]
            published = true
            exposed_skills = ["ucp_discovery_get", "ucp_merchant_get"]
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        let w = warnings
            .iter()
            .find(|w| w.code == "a2a_exposed_skills_without_bundles")
            .expect("expected a2a_exposed_skills_without_bundles warning");
        assert_eq!(w.path, "agents.merchant.a2a.exposed_skills");
        assert!(
            w.message.contains("merchant"),
            "message names the agent: {}",
            w.message
        );
        assert!(
            w.message.contains("skill_bundles"),
            "message points at skill_bundles: {}",
            w.message
        );
    }

    // exposed_skills set alongside at least one declared skill_bundle -> no
    // structural diagnostic (disk resolution governs whether ids actually
    // resolve; that is out of scope for this offline check).
    #[tokio::test]
    async fn collect_warnings_silent_for_exposed_skills_with_bundles() {
        let toml = r#"
            [risk_profiles.default]
            level = "supervised"

            [agents.merchant]
            enabled = true
            risk_profile = "default"
            skill_bundles = ["commerce"]

            [agents.merchant.a2a]
            published = true
            exposed_skills = ["ucp_discovery_get"]
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = cfg.collect_warnings();
        assert!(
            !warnings
                .iter()
                .any(|w| w.code == "a2a_exposed_skills_without_bundles"),
            "no exposed_skills warning when a bundle is declared: {warnings:?}"
        );
    }

    // Control: same profile + summary_model but both agents on the SAME provider
    // -> no diagnostic (deprecated-but-correct; runtime WARN still nudges).
    #[tokio::test]
    async fn collect_warnings_silent_for_same_provider_summary_model() {
        let toml = r#"
            [providers.models.custom.p1]
            api_key = "k"
            model = "m1"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.shared.context_compression]
            summary_model = "haiku"

            [agents.alpha]
            enabled = true
            model_provider = "custom.p1"
            risk_profile = "default"
            runtime_profile = "shared"

            [agents.beta]
            enabled = true
            model_provider = "custom.p1"
            risk_profile = "default"
            runtime_profile = "shared"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(
            !cfg.collect_warnings()
                .iter()
                .any(|w| w.code == "cross_provider_summary_model"),
            "same-provider use must not warn"
        );
    }

    // Control: cross-provider agents but each sets an agent-level
    // summary_provider override -> the override supersedes the bare id, so no
    // diagnostic.
    #[tokio::test]
    async fn collect_warnings_silent_when_summary_provider_override_present() {
        let toml = r#"
            [providers.models.custom.p1]
            api_key = "k"
            model = "m1"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"
            [providers.models.custom.p2]
            api_key = "k"
            model = "m2"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"
            [providers.models.custom.sum]
            api_key = "k"
            model = "ms"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [runtime_profiles.shared.context_compression]
            summary_model = "haiku"

            [agents.alpha]
            enabled = true
            model_provider = "custom.p1"
            risk_profile = "default"
            runtime_profile = "shared"
            summary_provider = "custom.sum"

            [agents.beta]
            enabled = true
            model_provider = "custom.p2"
            risk_profile = "default"
            runtime_profile = "shared"
            summary_provider = "custom.sum"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(
            !cfg.collect_warnings()
                .iter()
                .any(|w| w.code == "cross_provider_summary_model"),
            "agent-level summary_provider override must suppress the warning"
        );
    }

    const WA_INERT_WARNING: &str = "whatsapp_chat_policy_inert";
    const WA_OPEN_GROUPS_WARNING: &str = "whatsapp_empty_group_allowlist_permits_all";

    /// A Web channel in business mode: the chat policies are accepted and never
    /// consulted, and dm_policy DEFAULTS to allowlist, so the operator believes
    /// the channel is gated when every DM is answered.
    #[test]
    async fn whatsapp_business_mode_flags_inert_chat_policies() {
        let toml = r#"
[channels.whatsapp.shop]
enabled = true
mode = "business"
session_path = "/tmp/wa-session"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = warnings_with_code(&cfg, WA_INERT_WARNING);
        assert!(
            warnings
                .iter()
                .any(|w| w.path == "channels.whatsapp.shop.dm_policy"),
            "default dm_policy under business mode must be flagged: {warnings:?}"
        );
        assert!(
            warnings.iter().any(|w| w.message.contains("personal")),
            "the warning should name the mode that would honour it: {warnings:?}"
        );
    }

    /// Personal mode consults the policies, so there is nothing to warn about.
    #[test]
    async fn whatsapp_personal_mode_does_not_flag_chat_policies() {
        let toml = r#"
[channels.whatsapp.shop]
enabled = true
mode = "personal"
session_path = "/tmp/wa-session"
allowed_groups = ["123@g.us"]
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(
            warnings_with_code(&cfg, WA_INERT_WARNING).is_empty(),
            "personal mode honours the policies and must not warn"
        );
    }

    /// A Cloud-API channel has no Web transport, so neither warning applies.
    /// Without this guard the check would fire for every Cloud user.
    #[test]
    async fn whatsapp_cloud_channel_is_not_flagged() {
        let toml = r#"
[channels.whatsapp.cloud]
enabled = true
phone_number_id = "1234567890"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(warnings_with_code(&cfg, WA_INERT_WARNING).is_empty());
        assert!(warnings_with_code(&cfg, WA_OPEN_GROUPS_WARNING).is_empty());
    }

    /// A disabled channel cannot answer anything, so it must stay quiet.
    #[test]
    async fn whatsapp_disabled_channel_is_not_flagged() {
        let toml = r#"
[channels.whatsapp.shop]
enabled = false
mode = "business"
session_path = "/tmp/wa-session"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(warnings_with_code(&cfg, WA_INERT_WARNING).is_empty());
        assert!(warnings_with_code(&cfg, WA_OPEN_GROUPS_WARNING).is_empty());
    }

    /// The group gate runs in BOTH modes and returns true when the list is
    /// empty, which makes the default the open case.
    #[test]
    async fn whatsapp_empty_allowed_groups_is_flagged() {
        let toml = r#"
[channels.whatsapp.shop]
enabled = true
mode = "personal"
session_path = "/tmp/wa-session"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = warnings_with_code(&cfg, WA_OPEN_GROUPS_WARNING);
        assert_eq!(warnings.len(), 1, "{warnings:?}");
        assert_eq!(warnings[0].path, "channels.whatsapp.shop.allowed_groups");
    }

    /// An explicit list is the closed case and must not warn.
    #[test]
    async fn whatsapp_populated_allowed_groups_is_not_flagged() {
        let toml = r#"
[channels.whatsapp.shop]
enabled = true
mode = "personal"
session_path = "/tmp/wa-session"
allowed_groups = ["123@g.us"]
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(warnings_with_code(&cfg, WA_OPEN_GROUPS_WARNING).is_empty());
    }

    /// The remediation the warning itself recommends must SILENCE the warning.
    /// Under personal mode the channel gate drops every group message when
    /// group_policy = "ignore", so an empty list permits nothing. Warning here
    /// told the operator to set exactly this and then kept firing after they
    /// did, which is the defect this test pins.
    #[test]
    async fn whatsapp_personal_ignore_groups_is_not_flagged() {
        let toml = r#"
[channels.whatsapp.shop]
enabled = true
mode = "personal"
session_path = "/tmp/wa-session"
group_policy = "ignore"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(
            warnings_with_code(&cfg, WA_OPEN_GROUPS_WARNING).is_empty(),
            "group_policy = \"ignore\" drops every group message, so an empty \
             allowed_groups permits nothing and the warning must not fire"
        );
    }

    /// group_policy = "all" is the explicit opt-in to open group access. An
    /// empty list is then consistent with a deliberate choice, not an accident,
    /// and reporting it as unsafe would flag intent as error.
    #[test]
    async fn whatsapp_personal_all_groups_is_not_flagged() {
        let toml = r#"
[channels.whatsapp.shop]
enabled = true
mode = "personal"
session_path = "/tmp/wa-session"
group_policy = "all"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(
            warnings_with_code(&cfg, WA_OPEN_GROUPS_WARNING).is_empty(),
            "group_policy = \"all\" is an explicit opt-in to open groups and must \
             not be reported as an unintended configuration"
        );
    }

    /// Business mode never consults group_policy, so the list is the only gate
    /// and an empty one really does admit every group. This is the positive
    /// case that must survive narrowing the warning.
    #[test]
    async fn whatsapp_business_empty_allowed_groups_is_flagged() {
        let toml = r#"
[channels.whatsapp.shop]
enabled = true
mode = "business"
session_path = "/tmp/wa-session"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let warnings = warnings_with_code(&cfg, WA_OPEN_GROUPS_WARNING);
        assert_eq!(
            warnings.len(),
            1,
            "business mode with an empty allowed_groups must still warn: {warnings:?}"
        );
        assert_eq!(warnings[0].path, "channels.whatsapp.shop.allowed_groups");
    }

    /// Business mode ignores group_policy entirely, so even the value that
    /// silences the warning under personal mode must NOT silence it here.
    /// Without this, narrowing the check could be over-applied and reopen the
    /// original bug from the other side.
    #[test]
    async fn whatsapp_business_ignore_group_policy_still_flagged() {
        let toml = r#"
[channels.whatsapp.shop]
enabled = true
mode = "business"
session_path = "/tmp/wa-session"
group_policy = "ignore"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(
            warnings_with_code(&cfg, WA_OPEN_GROUPS_WARNING).len(),
            1,
            "group_policy is inert under business mode, so it must not silence \
             the open-groups warning"
        );
    }

    const SEMANTIC_MEMORY_WARNING: &str = "memory_semantic_search_without_embedder";

    fn warnings_with_code(
        config: &Config,
        code: &str,
    ) -> Vec<crate::validation_warnings::ValidationWarning> {
        config
            .collect_warnings()
            .into_iter()
            .filter(|warning| warning.code == code)
            .collect()
    }

    fn suppress_semantic_memory_warning(config: &mut Config) {
        config.memory.search_mode = SearchMode::Bm25;
    }

    #[test]
    async fn collect_warnings_flags_sqlite_hybrid_without_embedder() {
        let config = Config::default();

        let warnings = warnings_with_code(&config, SEMANTIC_MEMORY_WARNING);
        assert_eq!(warnings.len(), 1);
        let warning = &warnings[0];
        assert_eq!(warning.path, "memory.search_mode");
        assert!(
            warning.message.contains("sqlite"),
            "warning should name sqlite memory: {}",
            warning.message
        );
        assert!(
            warning.message.contains("keyword-only"),
            "warning should describe the runtime degradation: {}",
            warning.message
        );
    }

    #[test]
    async fn collect_warnings_flags_sqlite_embedding_without_embedder() {
        let mut config = Config::default();
        config.memory.search_mode = SearchMode::Embedding;

        let warnings = warnings_with_code(&config, SEMANTIC_MEMORY_WARNING);
        assert_eq!(warnings.len(), 1);
        assert!(
            warnings[0].message.contains("\"embedding\""),
            "warning should name embedding search mode: {}",
            warnings[0].message
        );
    }

    #[test]
    async fn collect_warnings_silent_for_valid_hint_embedding_route() {
        let mut config = Config::default();
        config.memory.embedding_model = "hint:semantic".to_string();
        config.providers.models.openai.insert(
            "default".to_string(),
            OpenAIModelProviderConfig {
                base: ModelProviderConfig {
                    api_key: Some("k".to_string()),
                    model: Some("gpt-4o".to_string()),
                    ..Default::default()
                },
            },
        );
        config.embedding_routes.push(EmbeddingRouteConfig {
            hint: "semantic".to_string(),
            model_provider: "openai.default".to_string(),
            model: "text-embedding-3-small".to_string(),
            dimensions: Some(1536),
            api_key: None,
        });
        config.validate().expect("hint route should validate");

        assert!(
            warnings_with_code(&config, SEMANTIC_MEMORY_WARNING).is_empty(),
            "valid hint route must count as an effective embedder"
        );
    }

    #[test]
    async fn collect_warnings_flags_missing_hint_embedding_route() {
        let mut config = Config::default();
        config.memory.embedding_model = "hint:semantic".to_string();

        let warnings = warnings_with_code(&config, SEMANTIC_MEMORY_WARNING);
        assert_eq!(warnings.len(), 1);
    }

    #[test]
    async fn collect_warnings_flags_invalid_hint_embedding_route() {
        let mut config = Config::default();
        config.memory.embedding_model = "hint:semantic".to_string();
        config.embedding_routes.push(EmbeddingRouteConfig {
            hint: "semantic".to_string(),
            model_provider: "none".to_string(),
            model: "text-embedding-3-small".to_string(),
            dimensions: Some(1536),
            api_key: None,
        });

        let warnings = warnings_with_code(&config, SEMANTIC_MEMORY_WARNING);
        assert_eq!(warnings.len(), 1);
    }

    #[test]
    async fn collect_warnings_silent_for_bm25_without_embedder() {
        let mut config = Config::default();
        config.memory.search_mode = SearchMode::Bm25;

        assert!(warnings_with_code(&config, SEMANTIC_MEMORY_WARNING).is_empty());
    }

    #[test]
    async fn collect_warnings_silent_for_non_sqlite_without_embedder() {
        let mut config = Config::default();
        config.memory.backend = "markdown.default".to_string();

        assert!(warnings_with_code(&config, SEMANTIC_MEMORY_WARNING).is_empty());
    }

    const INERT_MEMORY_KNOB_WARNING: &str = "memory_config_knob_inert";

    fn inert_knob_paths(config: &Config) -> Vec<String> {
        warnings_with_code(config, INERT_MEMORY_KNOB_WARNING)
            .into_iter()
            .map(|warning| warning.path)
            .collect()
    }

    #[test]
    async fn validate_memory_semantics_silent_at_defaults() {
        let config = Config::default();

        assert!(inert_knob_paths(&config).is_empty());
    }

    #[test]
    async fn validate_memory_semantics_warns_for_non_default_retrieval_stages() {
        let mut config = Config::default();
        config.memory.retrieval_stages = vec!["fts".into()];

        assert_eq!(inert_knob_paths(&config), vec!["memory.retrieval_stages"]);
    }

    #[test]
    async fn validate_memory_semantics_warns_for_non_default_fts_early_return_score() {
        let mut config = Config::default();
        config.memory.fts_early_return_score = 0.5;

        assert_eq!(
            inert_knob_paths(&config),
            vec!["memory.fts_early_return_score"]
        );
    }

    #[test]
    async fn validate_memory_semantics_warns_for_rerank_enabled() {
        let mut config = Config::default();
        config.memory.rerank_enabled = true;

        let warnings = warnings_with_code(&config, INERT_MEMORY_KNOB_WARNING);
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].path, "memory.rerank_enabled");
        assert!(
            warnings[0].message.contains("currently has no effect"),
            "warning should state the knob has no effect: {}",
            warnings[0].message
        );
    }

    #[test]
    async fn validate_memory_semantics_warns_for_non_default_rerank_threshold() {
        let mut config = Config::default();
        config.memory.rerank_threshold = 10;

        assert_eq!(inert_knob_paths(&config), vec!["memory.rerank_threshold"]);
    }

    #[test]
    async fn validate_memory_semantics_reports_each_set_knob() {
        let mut config = Config::default();
        config.memory.rerank_enabled = true;
        config.memory.rerank_threshold = 10;

        assert_eq!(
            inert_knob_paths(&config),
            vec!["memory.rerank_enabled", "memory.rerank_threshold"]
        );
    }

    #[test]
    async fn config_validate_accepts_classifier_provider_pointing_at_existing_alias() {
        let toml = r#"
            [providers.models.custom.default]
            api_key = "k1"
            model = "qwen3.6-plus"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [providers.models.custom.kimi-k2-5]
            api_key = "k2"
            model = "kimi-k2.5"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [agents.default]
            enabled = true
            model_provider = "custom.default"
            risk_profile = "default"
            classifier_provider = "custom.kimi-k2-5"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        cfg.validate()
            .expect("validate must succeed for resolvable ref");
        assert_eq!(
            cfg.agents
                .get("default")
                .unwrap()
                .classifier_provider
                .as_str(),
            "custom.kimi-k2-5"
        );
    }

    #[test]
    async fn config_validate_accepts_empty_classifier_provider_as_inheritance_signal() {
        // No classifier_provider field at all → must validate, must remain
        // the empty default. This pins backward compatibility.
        let toml = r#"
            [providers.models.custom.default]
            api_key = "k"
            model = "qwen3.6-plus"
            uri = "https://example.com/v1"
            wire_api = "chat_completions"

            [risk_profiles.default]
            level = "supervised"

            [agents.default]
            enabled = true
            model_provider = "custom.default"
            risk_profile = "default"
        "#;
        let cfg: Config = toml::from_str(toml).unwrap();
        cfg.validate()
            .expect("missing classifier_provider must validate");
        assert!(
            cfg.agents
                .get("default")
                .unwrap()
                .classifier_provider
                .is_empty()
        );
    }

    fn provider_entry_with_fallback(fallback: &[&str]) -> OpenAIModelProviderConfig {
        OpenAIModelProviderConfig {
            base: ModelProviderConfig {
                model: Some("gpt-4o".to_string()),
                fallback: fallback
                    .iter()
                    .map(|s| crate::providers::ModelProviderRef::new(*s))
                    .collect(),
                ..Default::default()
            },
        }
    }

    #[test]
    async fn fallback_warns_on_dangling_ref() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        config.providers.models.openai.insert(
            "primary".to_string(),
            provider_entry_with_fallback(&["openai.ghost"]),
        );

        let warnings = config.collect_warnings();
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].code, "dangling_fallback_ref");
        assert_eq!(
            warnings[0].path,
            "providers.models.openai.primary.fallback[0]"
        );
    }

    #[test]
    async fn fallback_no_warning_when_ref_resolves() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        config.providers.models.openai.insert(
            "primary".to_string(),
            provider_entry_with_fallback(&["openai.backup"]),
        );
        config
            .providers
            .models
            .openai
            .insert("backup".to_string(), provider_entry_with_fallback(&[]));

        assert!(config.collect_warnings().is_empty());
    }

    #[test]
    async fn fallback_warns_on_two_node_cycle() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        config
            .providers
            .models
            .openai
            .insert("a".to_string(), provider_entry_with_fallback(&["openai.b"]));
        config
            .providers
            .models
            .openai
            .insert("b".to_string(), provider_entry_with_fallback(&["openai.a"]));

        let cycle_warnings: Vec<_> = config
            .collect_warnings()
            .into_iter()
            .filter(|w| w.code == "fallback_cycle")
            .collect();
        assert!(
            !cycle_warnings.is_empty(),
            "a->b->a must surface at least one fallback_cycle warning"
        );
    }

    #[test]
    async fn fallback_self_reference_is_a_cycle() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        config.providers.models.openai.insert(
            "loop".to_string(),
            provider_entry_with_fallback(&["openai.loop"]),
        );

        let warnings = config.collect_warnings();
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].code, "fallback_cycle");
    }

    #[test]
    async fn fallback_empty_ref_is_skipped() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        config
            .providers
            .models
            .openai
            .insert("primary".to_string(), provider_entry_with_fallback(&[""]));

        assert!(config.collect_warnings().is_empty());
    }

    #[test]
    async fn fallback_warns_when_chain_exceeds_max_depth() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        let n = crate::providers::MAX_FALLBACK_DEPTH + 2;
        for i in 0..n {
            let next = if i + 1 < n {
                vec![format!("openai.a{}", i + 1)]
            } else {
                vec![]
            };
            let refs: Vec<&str> = next.iter().map(String::as_str).collect();
            config
                .providers
                .models
                .openai
                .insert(format!("a{i}"), provider_entry_with_fallback(&refs));
        }

        let depth_warnings: Vec<_> = config
            .collect_warnings()
            .into_iter()
            .filter(|w| w.code == "max_fallback_depth_exceeded")
            .collect();
        assert!(
            !depth_warnings.is_empty(),
            "a chain deeper than MAX_FALLBACK_DEPTH must surface a max_fallback_depth_exceeded warning"
        );
    }

    #[test]
    async fn fallback_models_warns_on_empty_entry() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        let mut entry = provider_entry_with_fallback(&[]);
        entry.base.fallback_models = vec!["".to_string()];
        config
            .providers
            .models
            .openai
            .insert("primary".to_string(), entry);

        let warnings = config.collect_warnings();
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].code, "empty_fallback_model");
    }

    #[test]
    async fn fallback_models_warns_on_duplicate_of_primary() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        let mut entry = provider_entry_with_fallback(&[]);
        entry.base.fallback_models = vec!["gpt-4o".to_string()];
        config
            .providers
            .models
            .openai
            .insert("primary".to_string(), entry);

        let warnings = config.collect_warnings();
        assert_eq!(warnings.len(), 1);
        assert_eq!(warnings[0].code, "fallback_model_duplicates_primary");
    }

    #[test]
    async fn fallback_models_distinct_entries_do_not_warn() {
        let mut config = Config::default();
        suppress_semantic_memory_warning(&mut config);
        let mut entry = provider_entry_with_fallback(&[]);
        entry.base.fallback_models = vec!["gpt-4o-mini".to_string()];
        config
            .providers
            .models
            .openai
            .insert("primary".to_string(), entry);

        assert!(config.collect_warnings().is_empty());
    }

    fn delegate_roster_config() -> Config {
        let mut cfg = Config::default();
        cfg.providers.models.ollama.insert(
            "default".to_string(),
            crate::schema::OllamaModelProviderConfig::default(),
        );
        cfg.risk_profiles
            .insert("shared".to_string(), RiskProfileConfig::default());
        cfg.risk_profiles
            .insert("lore".to_string(), RiskProfileConfig::default());
        for (alias, profile) in [
            ("aaa", "shared"),
            ("aaatools", "shared"),
            ("aaalore", "lore"),
        ] {
            cfg.agents.insert(
                alias.to_string(),
                AliasedAgentConfig {
                    risk_profile: profile.into(),
                    model_provider: "ollama.default".into(),
                    ..AliasedAgentConfig::default()
                },
            );
        }
        cfg
    }

    fn assert_delegate_target_modes_match_roster(cfg: &Config, caller_alias: &str) {
        // `delegate_target_mode()` is the single-target convenience wrapper
        // used by runtime admission. Keep it mathematically tied to the
        // materialized roster so future resolver changes cannot split the two.
        let roster: std::collections::BTreeMap<_, _> = cfg
            .reachable_delegate_target_configs(caller_alias)
            .into_iter()
            .map(|target| (target.agent, target.mode))
            .collect();

        for alias in cfg.agents.keys() {
            assert_eq!(
                cfg.delegate_target_mode(caller_alias, alias),
                roster.get(alias).copied(),
                "direct delegate target mode must match materialized roster for caller {caller_alias:?}, target {alias:?}"
            );
        }

        assert_eq!(
            cfg.delegate_target_mode(caller_alias, ""),
            None,
            "empty target aliases are never reachable"
        );
        assert_eq!(
            cfg.delegate_target_mode(caller_alias, "missing-agent"),
            None,
            "unknown target aliases are never reachable"
        );
    }

    #[test]
    async fn reachable_targets_auto_allows_same_profile_peers() {
        let cfg = delegate_roster_config();
        assert_eq!(cfg.reachable_delegate_targets("aaa"), vec!["aaatools"]);
    }

    #[test]
    async fn reachable_targets_excludes_self() {
        let cfg = delegate_roster_config();
        assert!(
            !cfg.reachable_delegate_targets("aaa")
                .iter()
                .any(|a| a == "aaa")
        );
    }

    #[test]
    async fn reachable_targets_opt_out_hides_peers_keeps_explicit() {
        let mut cfg = delegate_roster_config();
        let aaa = cfg.agents.get_mut("aaa").unwrap();
        aaa.delegate_same_risk_profile = false;
        aaa.delegates = vec![DelegateTargetConfig::bounded("aaalore")];
        assert_eq!(cfg.reachable_delegate_targets("aaa"), vec!["aaalore"]);
    }

    #[test]
    async fn reachable_targets_unions_peers_and_explicit_cross_profile() {
        let mut cfg = delegate_roster_config();
        cfg.agents.get_mut("aaa").unwrap().delegates =
            vec![DelegateTargetConfig::bounded("aaalore")];
        assert_eq!(
            cfg.reachable_delegate_targets("aaa"),
            vec!["aaalore", "aaatools"]
        );
    }

    #[test]
    async fn delegate_target_mode_matches_reachable_roster_matrix() {
        // Matrix coverage for the roster/mode invariant: implicit peers,
        // explicit bounded targets, explicit independent overrides,
        // opt-out mode, disabled targets, and missing callers.
        let mut cfg = delegate_roster_config();
        assert_delegate_target_modes_match_roster(&cfg, "aaa");
        assert_delegate_target_modes_match_roster(&cfg, "nope");

        cfg.agents.get_mut("aaa").unwrap().delegates =
            vec![DelegateTargetConfig::bounded("aaalore")];
        assert_delegate_target_modes_match_roster(&cfg, "aaa");

        cfg.agents.get_mut("aaa").unwrap().delegates = vec![DelegateTargetConfig {
            agent: "aaatools".to_string(),
            mode: DelegateExecutionMode::Independent,
        }];
        assert_delegate_target_modes_match_roster(&cfg, "aaa");

        cfg.agents
            .get_mut("aaa")
            .unwrap()
            .delegate_same_risk_profile = false;
        assert_delegate_target_modes_match_roster(&cfg, "aaa");

        cfg.agents.get_mut("aaatools").unwrap().enabled = false;
        assert_delegate_target_modes_match_roster(&cfg, "aaa");
    }

    #[test]
    async fn delegate_target_mode_normalizes_target_alias_and_overrides_implicit_mode() {
        // Direct lookup receives a user/tool argument, not a config entry, so it
        // must trim the requested alias and still prefer explicit mode over the
        // implicit same-profile bounded default.
        let mut cfg = delegate_roster_config();
        cfg.agents.get_mut("aaa").unwrap().delegates = vec![DelegateTargetConfig {
            agent: "aaatools".to_string(),
            mode: DelegateExecutionMode::Independent,
        }];

        assert_eq!(
            cfg.delegate_target_mode("aaa", "aaatools"),
            Some(DelegateExecutionMode::Independent),
            "explicit entries must override same-profile bounded reach"
        );
        assert_eq!(
            cfg.delegate_target_mode("aaa", " aaatools "),
            Some(DelegateExecutionMode::Independent),
            "direct mode lookup must match reachable roster alias normalization"
        );
        assert_eq!(
            cfg.delegate_target_mode("aaa", "aaa"),
            None,
            "self-delegation is never reachable"
        );

        cfg.agents.get_mut("aaatools").unwrap().enabled = false;
        assert_eq!(
            cfg.delegate_target_mode("aaa", "aaatools"),
            None,
            "disabled explicit targets are not reachable"
        );
    }

    #[test]
    async fn reachable_targets_unknown_caller_is_empty() {
        let cfg = delegate_roster_config();
        assert!(cfg.reachable_delegate_targets("nope").is_empty());
    }

    #[test]
    async fn reachable_targets_excludes_disabled_same_profile_peer() {
        let mut cfg = delegate_roster_config();
        cfg.agents.get_mut("aaatools").unwrap().enabled = false;
        assert!(
            cfg.reachable_delegate_targets("aaa").is_empty(),
            "disabled same-profile peer must not be reachable"
        );
    }

    #[test]
    async fn reachable_targets_excludes_disabled_explicit_delegate() {
        let mut cfg = delegate_roster_config();
        cfg.agents
            .get_mut("aaa")
            .unwrap()
            .delegate_same_risk_profile = false;
        cfg.agents.get_mut("aaa").unwrap().delegates =
            vec![DelegateTargetConfig::bounded("aaalore")];
        cfg.agents.get_mut("aaalore").unwrap().enabled = false;
        assert!(
            cfg.reachable_delegate_targets("aaa").is_empty(),
            "disabled explicit delegate must not be reachable"
        );
    }

    #[test]
    async fn validate_rejects_dangling_delegate_target() {
        let mut cfg = delegate_roster_config();
        cfg.agents.get_mut("aaa").unwrap().delegates = vec![DelegateTargetConfig::bounded("ghost")];
        let err = cfg.validate().expect_err("dangling delegate must fail");
        assert!(format!("{err:#}").contains("delegates"), "{err:#}");
    }

    #[test]
    async fn validate_rejects_self_delegate() {
        let mut cfg = delegate_roster_config();
        cfg.agents.get_mut("aaa").unwrap().delegates = vec![DelegateTargetConfig::bounded("aaa")];
        let err = cfg.validate().expect_err("self-delegate must fail");
        assert!(format!("{err:#}").contains("itself"), "{err:#}");
    }

    #[test]
    async fn validate_rejects_duplicate_delegate_target() {
        // Duplicate detection is by target alias, not by the full object. A
        // bounded and independent entry for the same agent would otherwise make
        // runtime mode selection order-dependent.
        let mut cfg = delegate_roster_config();
        cfg.agents.get_mut("aaa").unwrap().delegates = vec![
            DelegateTargetConfig::bounded("aaalore"),
            DelegateTargetConfig {
                agent: "aaalore".to_string(),
                mode: DelegateExecutionMode::Independent,
            },
        ];
        let err = cfg.validate().expect_err("duplicate delegate must fail");
        assert!(format!("{err:#}").contains("duplicates"), "{err:#}");
    }

    #[test]
    async fn delegate_targets_parse_strings_as_bounded() {
        // Existing configs used bare strings. They must continue to parse as
        // bounded targets so simply upgrading the binary does not widen any
        // delegate's execution mode.
        let toml_src = "\
[agents.legacy]
risk_profile = \"shared\"
model_provider = \"ollama.default\"
delegates = [\"reviewer\"]
";
        let cfg: Config = toml::from_str(toml_src).expect("legacy delegate config parses");
        let agent = cfg.agents.get("legacy").expect("agent present");
        assert_eq!(
            agent.delegates,
            vec![DelegateTargetConfig::bounded("reviewer")]
        );
    }

    #[test]
    async fn delegate_targets_parse_objects_and_default_mode() {
        // Object entries are the new surface. Omitted mode deliberately keeps
        // the legacy bounded default; independent mode must be explicit.
        let toml_src = "\
[agents.legacy]
risk_profile = \"shared\"
model_provider = \"ollama.default\"
delegates = [
  { agent = \"reviewer\" },
  { agent = \"sysadmin\", mode = \"independent\" },
]
";
        let cfg: Config = toml::from_str(toml_src).expect("object delegate config parses");
        let agent = cfg.agents.get("legacy").expect("agent present");
        assert_eq!(
            agent.delegates,
            vec![
                DelegateTargetConfig::bounded("reviewer"),
                DelegateTargetConfig {
                    agent: "sysadmin".to_string(),
                    mode: DelegateExecutionMode::Independent,
                },
            ]
        );
    }

    #[test]
    async fn delegate_targets_parse_mixed_string_and_object_entries() {
        // Operators may migrate one target at a time. Mixed arrays are therefore
        // part of the supported config shape, not just a permissive accident.
        let toml_src = "\
[agents.legacy]
risk_profile = \"shared\"
model_provider = \"ollama.default\"
delegates = [
  \"reviewer\",
  { agent = \"sysadmin\", mode = \"independent\" },
]
";
        let cfg: Config = toml::from_str(toml_src).expect("mixed delegate config parses");
        let agent = cfg.agents.get("legacy").expect("agent present");
        assert_eq!(
            agent.delegates,
            vec![
                DelegateTargetConfig::bounded("reviewer"),
                DelegateTargetConfig {
                    agent: "sysadmin".to_string(),
                    mode: DelegateExecutionMode::Independent,
                },
            ]
        );
    }

    #[test]
    async fn delegate_targets_object_array_round_trips_through_set_prop_and_validate() {
        // The programmatic config-editing path must accept the same mixed shape
        // as TOML loading, then serialize back without dropping the agents
        // table. This is the CLI/config-tool version of the original failure.
        let mut cfg = delegate_roster_config();
        let value = serde_json::json!([
            "aaatools",
            { "agent": "aaalore", "mode": "independent" }
        ])
        .to_string();

        cfg.set_prop("agents.aaa.delegates", &value)
            .expect("delegate object-array set_prop accepts mixed JSON entries");
        cfg.validate()
            .expect("valid delegate object-array config validates");
        assert_eq!(
            cfg.reachable_delegate_target_configs("aaa"),
            vec![
                DelegateTargetConfig {
                    agent: "aaalore".to_string(),
                    mode: DelegateExecutionMode::Independent,
                },
                DelegateTargetConfig::bounded("aaatools"),
            ],
            "explicit independent entry should not remove configured agents from reachability"
        );

        let toml = toml::to_string(&cfg).expect("config serializes");
        let reparsed: Config = toml::from_str(&toml).expect("serialized config reparses");
        assert_eq!(
            reparsed
                .agents
                .get("aaa")
                .expect("agent remains visible")
                .delegates,
            vec![
                DelegateTargetConfig::bounded("aaatools"),
                DelegateTargetConfig {
                    agent: "aaalore".to_string(),
                    mode: DelegateExecutionMode::Independent,
                },
            ]
        );
        assert!(reparsed.agents.contains_key("aaa"));
        assert!(reparsed.agents.contains_key("aaatools"));
        assert!(reparsed.agents.contains_key("aaalore"));
    }

    #[test]
    async fn delegate_targets_serialize_as_object_array_with_explicit_mode() {
        // Saving always emits explicit objects, including bounded mode. That
        // makes future diffs unambiguous and avoids a lossy string/object mix on
        // writeback.
        let mut cfg = delegate_roster_config();
        cfg.agents.get_mut("aaa").unwrap().delegates = vec![
            DelegateTargetConfig::bounded("aaatools"),
            DelegateTargetConfig {
                agent: "aaalore".to_string(),
                mode: DelegateExecutionMode::Independent,
            },
        ];
        let toml = toml::to_string(&cfg).expect("config serializes");
        assert!(toml.contains("agent = \"aaatools\""), "{toml}");
        assert!(toml.contains("mode = \"bounded\""), "{toml}");
        assert!(toml.contains("agent = \"aaalore\""), "{toml}");
        assert!(toml.contains("mode = \"independent\""), "{toml}");
    }

    #[test]
    async fn delegate_fields_default_for_legacy_config_roundtrip() {
        let toml_src = "\
[agents.legacy]
risk_profile = \"shared\"
model_provider = \"ollama.default\"
";
        let cfg: Config = toml::from_str(toml_src).expect("legacy config parses");
        let agent = cfg.agents.get("legacy").expect("agent present");
        assert!(agent.delegate_same_risk_profile, "default must be true");
        assert!(agent.delegates.is_empty(), "default must be empty");
    }

    #[test]
    async fn channel_presence_names_are_unique_and_undeliverable_set_is_fixed() {
        let presence = ChannelsConfig::default().channel_presence();
        let mut seen = std::collections::HashSet::new();
        for (name, _, _) in presence {
            assert!(seen.insert(name), "duplicate channel_presence name: {name}");
        }
        let mut undeliverable: Vec<&str> = presence
            .iter()
            .filter(|(_, _, deliverable)| !*deliverable)
            .map(|(name, _, _)| *name)
            .collect();
        undeliverable.sort_unstable();
        assert_eq!(
            undeliverable,
            ["amqp", "filesystem", "mqtt", "voice_duplex", "voice_wake"],
            "only input-only transports may be non-deliverable; update channel_presence and is_channel_deliverable together"
        );
    }

    // ── Serde-default vs struct-Default drift guards ──────────────
    //
    // The save path prunes fields whose value equals serde's default.
    // If `#[serde(default)]` and `impl Default` disagree, a save →
    // load round-trip silently flips the field to the serde default,
    // which is. These tests catch that drift: an empty TOML
    // table (the extreme case of pruning — all fields pruned away)
    // must deserialize to the same value as the struct's `Default`.

    // ── Schema-walked reload round-trip smoke battery ─────────────
    //
    // Reload re-reads config.toml and rebuilds the in-memory Config; any
    // scalar field that does not survive a serialize -> deserialize cycle
    // is silently lost on reload (the class). This walks every
    // scalar prop the derive exposes, mutates it off-default, round-trips
    // the whole Config through TOML, and asserts the mutated value comes
    // back. Driven entirely off prop_fields() so it tracks the schema.

    fn values_match(a: &str, b: &str) -> bool {
        if a == b {
            return true;
        }
        match (a.parse::<f64>(), b.parse::<f64>()) {
            (Ok(x), Ok(y)) => (x - y).abs() < f64::EPSILON,
            _ => false,
        }
    }

    fn off_default_value_for(
        field: &crate::traits::PropFieldInfo,
        current: &str,
    ) -> Option<String> {
        match field.kind {
            PropKind::Bool => Some(if current == "true" { "false" } else { "true" }.to_string()),
            PropKind::Integer => {
                let n: i128 = current.parse().unwrap_or(0);
                Some((n.wrapping_add(7)).to_string())
            }
            PropKind::Float => {
                let n: f64 = current.parse().unwrap_or(0.0);
                Some(format!("{:.3}", n + 1.5))
            }
            PropKind::String => {
                let probe = "zc_reload_probe";
                if current == probe {
                    Some("zc_reload_probe_alt".to_string())
                } else {
                    Some(probe.to_string())
                }
            }
            PropKind::Enum => field.enum_variants.and_then(|variants| {
                variants()
                    .into_iter()
                    .find(|v| v != current)
                    .or_else(|| variants().into_iter().next())
            }),
            PropKind::AliasRef
            | PropKind::StringArray
            | PropKind::ObjectArray
            | PropKind::Object => None,
        }
    }

    #[test]
    async fn every_scalar_field_survives_toml_reload_round_trip() {
        let mut config = Config::default();
        let fields = config.prop_fields();

        let mut mutated: Vec<(String, String)> = Vec::new();
        let mut skipped_non_scalar = 0usize;
        let mut skipped_unsettable = 0usize;

        for field in &fields {
            if field.is_secret {
                continue;
            }
            let Ok(current) = config.get_prop(&field.name) else {
                skipped_unsettable += 1;
                continue;
            };
            let Some(target) = off_default_value_for(field, &current) else {
                skipped_non_scalar += 1;
                continue;
            };
            if config.set_prop(&field.name, &target).is_err() {
                skipped_unsettable += 1;
                continue;
            }
            mutated.push((field.name.clone(), target));
        }

        let serialized = toml::to_string(&config).expect("mutated config must serialize");
        let reloaded: Config =
            toml::from_str(&serialized).expect("serialized config must deserialize");

        let mut lost: Vec<String> = Vec::new();
        for (name, expected) in &mutated {
            match reloaded.get_prop(name) {
                Ok(got) if values_match(&got, expected) => {}
                Ok(got) => lost.push(format!("{name}: set {expected:?}, reloaded {got:?}")),
                Err(e) => lost.push(format!("{name}: set {expected:?}, reload read failed: {e}")),
            }
        }

        assert!(
            lost.is_empty(),
            "{} scalar field(s) did not survive a TOML reload round-trip ({} mutated, {} non-scalar skipped, {} unsettable skipped):\n{}",
            lost.len(),
            mutated.len(),
            skipped_non_scalar,
            skipped_unsettable,
            lost.join("\n")
        );
        assert!(
            mutated.len() > 100,
            "smoke battery covered only {} scalar fields; schema walk likely regressed",
            mutated.len()
        );
    }

    #[test]
    async fn empty_table_round_trips_to_http_request_config_default() {
        let from_empty: HttpRequestConfig = toml::from_str("").unwrap();
        let default = HttpRequestConfig::default();
        assert_eq!(from_empty.enabled, default.enabled);
        assert_eq!(from_empty.allowed_domains, default.allowed_domains);
        assert_eq!(from_empty.max_response_size, default.max_response_size);
    }

    #[test]
    async fn empty_table_round_trips_to_web_fetch_config_default() {
        let from_empty: WebFetchConfig = toml::from_str("").unwrap();
        let default = WebFetchConfig::default();
        assert_eq!(from_empty.enabled, default.enabled);
    }

    #[test]
    async fn empty_table_round_trips_to_web_search_config_default() {
        let from_empty: WebSearchConfig = toml::from_str("").unwrap();
        let default = WebSearchConfig::default();
        assert_eq!(from_empty.enabled, default.enabled);
    }

    #[test]
    async fn empty_table_round_trips_to_memory_config_default() {
        let from_empty: MemoryConfig = toml::from_str("").unwrap();
        let default = MemoryConfig::default();
        assert_eq!(from_empty.backend, default.backend);
    }

    #[test]
    async fn empty_table_round_trips_to_tunnel_config_default() {
        let from_empty: TunnelConfig = toml::from_str("").unwrap();
        let default = TunnelConfig::default();
        assert_eq!(from_empty.tunnel_provider, default.tunnel_provider);
    }

    #[test]
    async fn empty_table_round_trips_to_hooks_config_default() {
        let from_empty: HooksConfig = toml::from_str("").unwrap();
        let default = HooksConfig::default();
        assert_eq!(from_empty.enabled, default.enabled);
    }

    #[test]
    async fn empty_table_round_trips_to_builtin_hooks_config_default() {
        let from_empty: BuiltinHooksConfig = toml::from_str("").unwrap();
        let default = BuiltinHooksConfig::default();
        assert_eq!(from_empty.command_logger, default.command_logger);
    }

    #[test]
    async fn whitespace_only_model_provider_is_not_dispatchable() {
        // whitespace-only model_provider should not be dispatchable
        let agent = AliasedAgentConfig {
            enabled: true,
            risk_profile: "default".into(),
            runtime_profile: "default".into(),
            model_provider: "   ".into(),
            ..Default::default()
        };
        assert!(!agent.is_dispatchable());
        // non-empty model_provider should be dispatchable
        let agent = AliasedAgentConfig {
            enabled: true,
            risk_profile: "default".into(),
            runtime_profile: "default".into(),
            model_provider: "gpt4".into(),
            ..Default::default()
        };
        assert!(agent.is_dispatchable());
    }
}
