//! Config schema validation with typed checks and range enforcement.

use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use crate::plugins::loader::{is_reserved_plugin_id, RESERVED_PLUGIN_CONFIG_KEYS};
use crate::plugins::signature::SIGNATURE_CONFIG_FIELDS;

const MAX_REASONABLE_TYPING_INTERVAL_SECONDS: u64 = 3600;

/// Severity of a schema validation issue.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
    /// Hard error — startup must abort.
    Error,
    /// Advisory — logged but does not prevent startup.
    Warning,
}

/// A single schema validation finding.
#[derive(Debug, Clone)]
pub struct SchemaIssue {
    pub severity: Severity,
    pub path: String,
    pub message: String,
}

#[derive(Debug, Clone)]
pub struct SchemaValidationContext {
    /// Pre-computed snapshot of every process-env key that schema
    /// validation may consult. Sampled once at context construction so
    /// concurrent env mutations between validator calls (or between
    /// per-key lookups within a single call) cannot produce a result
    /// that no actual process state ever satisfied.
    runtime_env_snapshot: HashMap<&'static str, String>,
    config_password_available: bool,
    /// Pre-computed outcome of inspecting `<state_dir>/matrix/store_passphrase`
    /// at context construction. Bounded read avoids the WS hot-path
    /// DoS surface where a FIFO or multi-gigabyte file at that path
    /// would hang every `config.get` poll inside the synchronous
    /// validator.
    matrix_store_passphrase_file: MatrixStorePassphraseFileValidation,
}

/// Env keys schema validation reads from the process environment.
/// Adding a new key requires updating this list so the snapshot
/// captures it at context construction; otherwise validation would
/// silently fall back to live reads (re-introducing the H5 race).
const SCHEMA_RUNTIME_ENV_KEYS: &[&str] = &[
    "MATRIX_HOMESERVER_URL",
    "MATRIX_USER_ID",
    "MATRIX_ACCESS_TOKEN",
    "MATRIX_PASSWORD",
    "MATRIX_DEVICE_ID",
    "MATRIX_STORE_PASSPHRASE",
];

/// Cap for the bounded read of `<state_dir>/matrix/store_passphrase`.
/// The validator only needs to know whether the file is non-empty
/// after trim; the actual passphrase has no useful upper bound near
/// this cap, so reading more than 64 KiB would be a sign that
/// something is wrong (FIFO streaming, accidental log redirect,
/// etc.). The runtime resolver enforces the same ceiling — both
/// sides import this constant so the contract is structural.
const SCHEMA_PASSPHRASE_FILE_MAX_BYTES: u64 =
    crate::channels::matrix::MATRIX_STORE_PASSPHRASE_FILE_MAX_BYTES;

#[derive(Debug, Clone)]
enum MatrixStorePassphraseFileValidation {
    /// Deterministic schema context — never inspect the filesystem.
    NotChecked,
    /// Regular file present at the passphrase path; trimmed content
    /// was non-empty within the bounded read.
    Available,
    /// `NotFound` from `symlink_metadata` — no passphrase file at all.
    Missing,
    /// Regular file present but trimmed to empty.
    EmptyAfterTrim,
    /// Path exists but is not a regular file (symlink, directory,
    /// FIFO, socket, device). The validator refuses to read because
    /// FIFOs/sockets would block; the runtime resolver fails the
    /// same way.
    NotRegularFile,
    /// File exceeds `SCHEMA_PASSPHRASE_FILE_MAX_BYTES`. The validator
    /// refuses to read rather than risk a multi-gigabyte block on
    /// every WS `config.get` poll.
    TooLarge,
    /// Any other IO error during the bounded read.
    ReadError(String),
}

impl SchemaValidationContext {
    pub fn deterministic() -> Self {
        Self {
            runtime_env_snapshot: HashMap::new(),
            config_password_available: false,
            matrix_store_passphrase_file: MatrixStorePassphraseFileValidation::NotChecked,
        }
    }

    pub fn runtime_readiness() -> Self {
        Self::runtime_readiness_internal(crate::paths::resolve_state_dir())
    }

    fn runtime_readiness_internal(state_dir: PathBuf) -> Self {
        let mut runtime_env_snapshot = HashMap::with_capacity(SCHEMA_RUNTIME_ENV_KEYS.len());
        // Read every key under ONE acquisition of `CONFIG_ENV_STATE`
        // via the batched helper, not per-iteration. The pre-fix
        // loop reacquired the lock for each key — a concurrent
        // `config.set` reload between iterations could splice
        // half-old / half-new env values into the validator's
        // view, exactly the H5 race the commit message at
        // `f4b02ac` claimed to close. `read_config_env_os_many` is
        // the helper added for this purpose and documents itself as
        // "one consistent snapshot."
        let entries =
            crate::config::read_config_env_os_many(SCHEMA_RUNTIME_ENV_KEYS.iter().copied());
        for (key_os, value_os) in entries {
            let Some(key_str) = key_os.to_str() else {
                continue;
            };
            let Some(&canonical_key) = SCHEMA_RUNTIME_ENV_KEYS.iter().find(|k| **k == key_str)
            else {
                continue;
            };
            let Ok(value) = value_os.into_string() else {
                continue;
            };
            // Treat empty/whitespace-only env values as missing
            // BEFORE they enter the snapshot. See
            // `test_runtime_env_snapshot_filters_empty_at_insertion`
            // for the invariant.
            if value.trim().is_empty() {
                continue;
            }
            runtime_env_snapshot.insert(canonical_key, value);
        }
        let passphrase_path =
            crate::channels::matrix::matrix_store_passphrase_file_path(&state_dir);
        let matrix_store_passphrase_file = inspect_matrix_store_passphrase_file(&passphrase_path);
        Self {
            runtime_env_snapshot,
            config_password_available: crate::config::config_password().is_some(),
            matrix_store_passphrase_file,
        }
    }

    #[cfg(test)]
    fn runtime_readiness_for_state_dir(state_dir: PathBuf) -> Self {
        Self::runtime_readiness_internal(state_dir)
    }
}

fn inspect_matrix_store_passphrase_file(path: &Path) -> MatrixStorePassphraseFileValidation {
    use MatrixStorePassphraseFileValidation as M;
    // Follow symlinks so an operator who routes the passphrase
    // through a secret-management tool (1Password, `pass`, secret
    // volumes) is not rejected at validation while the runtime
    // resolver — which also follows symlinks — would succeed. The
    // `is_file()` check applies to the resolved target (via fstat
    // on the held fd) so a symlink pointing at a FIFO/socket is
    // still caught.
    //
    // SECURITY: open with O_NONBLOCK FIRST so a same-uid attacker
    // who plants a FIFO at the path cannot hang the validator
    // during `open(2)`. The prior `metadata(path)` → `File::open(path)`
    // sequence permitted an inverse-order race: metadata sees a
    // regular file, attacker swaps to FIFO, open blocks. Mirrors
    // the runtime-side fix at `read_matrix_store_passphrase_file`
    // in `channels/matrix.rs`.
    // Pre-check via `metadata` (which follows symlinks) so platforms
    // where `OpenOptions::open` returns a generic permission-denied
    // for a directory at the path (Windows: "Access is denied", AIX,
    // etc.) surface `NotRegularFile` instead of `ReadError(...)`.
    // Without this, a directory at the passphrase path on Windows
    // gets reported as a generic read error, losing the typed
    // category that operators (and tests) rely on. Symmetric to the
    // pre-check in `paths::open_regular_file_no_hang` and to the
    // runtime resolver at
    // `channels/matrix.rs::read_matrix_store_passphrase_file`.
    match std::fs::metadata(path) {
        Ok(meta) => {
            if !meta.is_file() {
                return M::NotRegularFile;
            }
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return M::Missing,
        Err(err) => return M::ReadError(err.to_string()),
    }

    let mut options = std::fs::OpenOptions::new();
    options.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.custom_flags(libc::O_NONBLOCK);
    }
    let file = match options.open(path) {
        Ok(file) => file,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return M::Missing,
        Err(err) => return M::ReadError(err.to_string()),
    };
    // Post-open re-check defends against TOCTOU between the pre-check
    // metadata and the open: a same-uid attacker who races a swap to
    // a non-file between the two syscalls still gets `NotRegularFile`
    // here from the held-fd fstat.
    let metadata = match file.metadata() {
        Ok(meta) => meta,
        Err(err) => return M::ReadError(err.to_string()),
    };
    if !metadata.is_file() {
        return M::NotRegularFile;
    }
    if metadata.len() > SCHEMA_PASSPHRASE_FILE_MAX_BYTES {
        return M::TooLarge;
    }
    use std::io::Read;
    // Wrap the read buffer in `Zeroizing` so the passphrase bytes are
    // wiped from the heap when the buffer drops at function return.
    // Mirrors the daemon-side resolver at
    // `channels/matrix.rs::read_matrix_store_passphrase_file` so both
    // call sites have the same secret-handling discipline. Without
    // this, a coredump captured between schema-validation and
    // allocator reclaim could leak the operator's passphrase value
    // even though the validator only returns a category enum.
    let mut buf = zeroize::Zeroizing::new(String::new());
    // Cap the read so a same-call truncate-and-rewrite (or a FIFO
    // that slipped past `is_file()` on platforms where `metadata.is_file`
    // misreports) cannot stream past the validator's budget.
    if let Err(err) = file
        .take(SCHEMA_PASSPHRASE_FILE_MAX_BYTES + 1)
        .read_to_string(&mut buf)
    {
        return M::ReadError(err.to_string());
    }
    if buf.len() as u64 > SCHEMA_PASSPHRASE_FILE_MAX_BYTES {
        return M::TooLarge;
    }
    if buf.trim().is_empty() {
        M::EmptyAfterTrim
    } else {
        M::Available
    }
}

/// Return the list of known top-level configuration keys.
pub fn known_top_level_keys() -> &'static [&'static str] {
    KNOWN_TOP_LEVEL_KEYS
}

/// Known top-level configuration keys.
const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
    "meta",
    "env",
    "wizard",
    "diagnostics",
    "logging",
    "update",
    "browser",
    "ui",
    "auth",
    "models",
    "nodeHost",
    "agents",
    "tools",
    "bindings",
    "broadcast",
    "audio",
    "media",
    "messages",
    "commands",
    "approvals",
    "session",
    "cron",
    "hooks",
    "web",
    "channels",
    "discovery",
    "canvasHost",
    "talk",
    "gateway",
    "usage",
    "plugins",
    "anthropic",
    "sessions",
    "openai",
    "codex",
    "google",
    "nearai",
    "providers",
    "bedrock",
    "venice",
    "signal",
    "telegram",
    "discord",
    "slack",
    "matrix",
    "classifier",
    "vertex",
    "filesystem",
    "routes",
];

/// Built-in channel IDs used to catch obvious typos without rejecting plugin
/// channel IDs that are registered outside the core binary.
const BUILTIN_CHANNEL_CONFIG_IDS: &[&str] = &[
    "console", "signal", "telegram", "discord", "slack", "webhook", "matrix",
];

/// Validate a config value against the full schema.
///
/// Returns a (possibly empty) list of issues. Callers should inspect each
/// issue's `severity` to decide whether to abort or merely warn.
pub fn validate_schema(config: &Value) -> Vec<SchemaIssue> {
    validate_schema_with_context(config, &SchemaValidationContext::deterministic())
}

pub fn validate_schema_for_runtime(config: &Value) -> Vec<SchemaIssue> {
    validate_schema_with_context(config, &SchemaValidationContext::runtime_readiness())
}

pub fn validate_schema_with_context(
    config: &Value,
    context: &SchemaValidationContext,
) -> Vec<SchemaIssue> {
    let mut issues = Vec::new();

    let obj = match config.as_object() {
        Some(o) => o,
        None => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".".to_string(),
                message: "Config root must be an object".to_string(),
            });
            return issues;
        }
    };

    // Unknown top-level keys
    for key in obj.keys() {
        if !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!(".{}", key),
                message: format!("Unknown configuration key: {}", key),
            });
        }
    }

    validate_gateway(obj, &mut issues);
    validate_hooks(obj, &mut issues);
    validate_logging(obj, &mut issues);
    validate_auth(obj, &mut issues);
    validate_anthropic(obj, &mut issues);
    validate_google(obj, &mut issues);
    validate_codex(obj, &mut issues);
    validate_matrix(obj, context, &mut issues);
    validate_agents(obj, &mut issues);
    validate_session(obj, &mut issues);
    validate_channels(obj, &mut issues);
    validate_cron(obj, &mut issues);
    validate_prompt_guard(obj, &mut issues);
    validate_output_sanitizer(obj, &mut issues);
    validate_plugins_signature(obj, &mut issues);
    validate_plugins_sandbox(obj, &mut issues);
    validate_plugins(obj, &mut issues);
    validate_session_integrity(obj, &mut issues);
    validate_session_encryption(obj, &mut issues);
    validate_usage(obj, &mut issues);
    validate_vertex(obj, &mut issues);
    validate_filesystem(obj, &mut issues);
    validate_routes_map(obj, &mut issues);
    validate_route_references(obj, &mut issues);
    validate_route_model_both_set(obj, &mut issues);
    validate_provider_secret_strings(obj, &mut issues);

    // Run agent config lint if prompt guard config lint is enabled
    if let Some(agents) = obj.get("agents") {
        let lint_enabled = prompt_guard_obj(agents)
            .and_then(prompt_guard_config_lint_obj)
            .and_then(|cl| cl.get("enabled"))
            .and_then(|e| e.as_bool())
            .unwrap_or(false);

        if lint_enabled {
            let lint_config = crate::agent::prompt_guard::ConfigLintConfig { enabled: true };
            let lint_issues =
                crate::agent::prompt_guard::config_lint::lint_agent_configs(agents, &lint_config);
            issues.extend(lint_issues);
        }
    }

    issues
}

const PROVIDER_SECRET_STRING_PATHS: &[(&[&str], &str)] = &[
    (&["openai", "apiKey"], ".openai.apiKey"),
    (&["nearai", "apiKey"], ".nearai.apiKey"),
    (&["venice", "apiKey"], ".venice.apiKey"),
    (&["ollama", "apiKey"], ".ollama.apiKey"),
    (
        &["providers", "ollama", "apiKey"],
        ".providers.ollama.apiKey",
    ),
    (&["bedrock", "accessKeyId"], ".bedrock.accessKeyId"),
    (&["bedrock", "secretAccessKey"], ".bedrock.secretAccessKey"),
    (&["bedrock", "sessionToken"], ".bedrock.sessionToken"),
    (
        &["models", "providers", "openai", "apiKey"],
        ".models.providers.openai.apiKey",
    ),
    (&["telegram", "botToken"], ".telegram.botToken"),
    (&["telegram", "webhookSecret"], ".telegram.webhookSecret"),
    (&["discord", "botToken"], ".discord.botToken"),
    (&["slack", "botToken"], ".slack.botToken"),
    (&["slack", "signingSecret"], ".slack.signingSecret"),
];

fn validate_provider_secret_strings(
    obj: &serde_json::Map<String, Value>,
    issues: &mut Vec<SchemaIssue>,
) {
    for &(path, dot_path) in PROVIDER_SECRET_STRING_PATHS {
        if let Some(value) = nested_config_value(obj, path) {
            validate_secret_string_or_null(value, dot_path, issues);
        }
    }
}

fn nested_config_value<'a>(
    root: &'a serde_json::Map<String, Value>,
    path: &[&str],
) -> Option<&'a Value> {
    let (first, rest) = path.split_first()?;
    let mut current = root.get(*first)?;
    for key in rest {
        current = current.get(*key)?;
    }
    Some(current)
}

fn validate_secret_string_or_null(value: &Value, path: &str, issues: &mut Vec<SchemaIssue>) {
    if !value.is_string() && !value.is_null() {
        let field = path.rsplit('.').next().unwrap_or("value");
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: path.to_string(),
            message: format!("{field} must be a string or null"),
        });
    }
}

// ---------------------------------------------------------------------------
// Per-section validators
// ---------------------------------------------------------------------------

fn validate_gateway(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let gateway = match obj.get("gateway").and_then(|v| v.as_object()) {
        Some(g) => g,
        None => return,
    };

    // .gateway.port — integer, 1..=65535
    if let Some(port) = gateway.get("port") {
        match port.as_u64() {
            Some(p) if (1..=65535).contains(&p) => {}
            Some(p) => {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: ".gateway.port".to_string(),
                    message: format!("port must be between 1 and 65535, got {}", p),
                });
            }
            None => {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: ".gateway.port".to_string(),
                    message: "port must be a positive integer".to_string(),
                });
            }
        }
    }

    // .gateway.bind — string, known value or valid IP
    if let Some(bind) = gateway.get("bind") {
        if let Some(s) = bind.as_str() {
            let known = ["loopback", "lan", "auto", "tailnet"];
            if !known.contains(&s) && s.parse::<std::net::IpAddr>().is_err() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".gateway.bind".to_string(),
                    message: format!(
                        "bind should be one of loopback/lan/auto/tailnet or a valid IP address, got \"{}\"",
                        s
                    ),
                });
            }
        }
    }

    // .gateway.reload sub-section
    if let Some(reload) = gateway.get("reload").and_then(|v| v.as_object()) {
        if let Some(mode) = reload.get("mode") {
            if let Some(s) = mode.as_str() {
                let valid = ["hot", "hybrid", "off"];
                if !valid.contains(&s) {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: ".gateway.reload.mode".to_string(),
                        message: format!(
                            "reload mode should be one of hot/hybrid/off, got \"{}\"",
                            s
                        ),
                    });
                }
            }
        }
        if let Some(debounce) = reload.get("debounceMs") {
            check_positive_integer(debounce, ".gateway.reload.debounceMs", issues);
        }
    }

    // .gateway.auth sub-section
    if let Some(auth) = gateway.get("auth").and_then(|v| v.as_object()) {
        if let Some(token) = auth.get("token") {
            validate_secret_string_or_null(token, ".gateway.auth.token", issues);
        }
        if let Some(password) = auth.get("password") {
            validate_secret_string_or_null(password, ".gateway.auth.password", issues);
        }
        // Mode validation. Runtime `resolve_gateway_auth_config`
        // rejects unknown mode names with
        // `WsConfigError::Config(ValidationError)`, refusing daemon
        // startup. Without a schema check the operator's config
        // passes validation clean and then the daemon hard-fails on
        // boot — the schema/runtime contract Batch 77 enforces for
        // matrix.inboundDlq. Treat unknown wire values as Error so
        // schema mirrors the runtime's startup-blocking behavior.
        if let Some(mode) = auth.get("mode") {
            match mode.as_str().map(str::trim) {
                Some("none" | "local" | "token" | "password") => {}
                Some(value) => issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: ".gateway.auth.mode".to_string(),
                    message: format!(
                        "unknown gateway auth mode '{value}'; expected one of: none, local, token, password"
                    ),
                }),
                None => issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: ".gateway.auth.mode".to_string(),
                    message: "gateway auth mode must be a string".to_string(),
                }),
            }
        }
    }

    // .gateway.ws sub-section (for WS rate limit config)
    if let Some(ws) = gateway.get("ws").and_then(|v| v.as_object()) {
        if let Some(rate) = ws.get("messageRate") {
            check_positive_number(rate, ".gateway.ws.messageRate", issues);
        }
        if let Some(burst) = ws.get("messageBurst") {
            check_positive_number(burst, ".gateway.ws.messageBurst", issues);
        }
    }

    // .gateway.hooks sub-section
    if let Some(hooks) = gateway.get("hooks").and_then(|v| v.as_object()) {
        if let Some(max_bytes) = hooks.get("maxBodyBytes") {
            check_positive_integer(max_bytes, ".gateway.hooks.maxBodyBytes", issues);
        }

        if let Some(enabled) = hooks.get("enabled") {
            if !enabled.is_boolean() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".gateway.hooks.enabled".to_string(),
                    message: "enabled must be a boolean".to_string(),
                });
            }
        }

        if let Some(path) = hooks.get("path") {
            if !path.is_string() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".gateway.hooks.path".to_string(),
                    message: "path must be a string".to_string(),
                });
            }
        }

        if let Some(token) = hooks.get("token") {
            validate_secret_string_or_null(token, ".gateway.hooks.token", issues);
        }
    }

    // .gateway.controlUi sub-section
    if let Some(control_ui) = gateway.get("controlUi").and_then(|v| v.as_object()) {
        if let Some(enabled) = control_ui.get("enabled") {
            if !enabled.is_boolean() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".gateway.controlUi.enabled".to_string(),
                    message: "enabled must be a boolean".to_string(),
                });
            }
        }

        if let Some(path) = control_ui.get("path") {
            if !path.is_string() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".gateway.controlUi.path".to_string(),
                    message: "path must be a string".to_string(),
                });
            }
        }

        if let Some(base_path) = control_ui.get("basePath") {
            if !base_path.is_string() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".gateway.controlUi.basePath".to_string(),
                    message: "basePath must be a string".to_string(),
                });
            }
        }
    }
}

fn validate_channels(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let Some(channels_value) = obj.get("channels") else {
        return;
    };
    let Some(channels) = channels_value.as_object() else {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: ".channels".to_string(),
            message: format!(
                "channels must be an object, got {}",
                json_type_label(channels_value)
            ),
        });
        return;
    };

    for (channel_name, entry) in channels {
        if channel_name == "defaults" {
            validate_channel_defaults(entry, issues);
            continue;
        }

        let Some(entry_obj) = entry.as_object() else {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!(".channels.{}", channel_name),
                message: format!(
                    "channel config entry must be an object, got {}",
                    json_type_label(entry)
                ),
            });
            continue;
        };

        if let Some(suggested) = suggest_reserved_channel_defaults_key(channel_name) {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!(".channels.{}", channel_name),
                message: format!(
                    "'{}' is not a channel id; did you mean the reserved global defaults key '{}'?",
                    channel_name, suggested
                ),
            });
        }

        if let Some(suggested) = suggest_builtin_channel_id(channel_name) {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!(".channels.{}", channel_name),
                message: format!(
                    "unknown built-in channel id '{}'; did you mean '{}'?",
                    channel_name, suggested
                ),
            });
        }

        for key in entry_obj.keys() {
            if key != "features" && key != "session" {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: format!(".channels.{}.{}", channel_name, key),
                    message: format!(
                        "unknown channel config key '{}'; supported keys are .channels.{}.features and .channels.{}.session",
                        key, channel_name, channel_name
                    ),
                });
            }
        }

        if let Some(features) = entry_obj.get("features") {
            validate_channel_features(
                features,
                &format!(".channels.{}.features", channel_name),
                issues,
            );
        }

        if let Some(session) = entry_obj.get("session") {
            validate_channel_session(
                session,
                &format!(".channels.{}.session", channel_name),
                issues,
            );
        }
    }
}

fn validate_channel_defaults(entry: &Value, issues: &mut Vec<SchemaIssue>) {
    let Some(entry_obj) = entry.as_object() else {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: ".channels.defaults".to_string(),
            message: format!(
                "defaults entry must be an object, got {}",
                json_type_label(entry)
            ),
        });
        return;
    };

    for key in entry_obj.keys() {
        if key != "features" {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!(".channels.defaults.{}", key),
                message:
                    "unknown defaults config key; supported key is .channels.defaults.features"
                        .to_string(),
            });
        }
    }

    if let Some(features) = entry_obj.get("features") {
        validate_channel_features(features, ".channels.defaults.features", issues);
    }
}

fn suggest_reserved_channel_defaults_key(channel_name: &str) -> Option<&'static str> {
    if channel_name == "defaults" {
        return None;
    }

    if bounded_levenshtein(channel_name, "defaults", 2) <= 2 {
        Some("defaults")
    } else {
        None
    }
}

fn suggest_builtin_channel_id(channel_name: &str) -> Option<&'static str> {
    BUILTIN_CHANNEL_CONFIG_IDS.iter().copied().find(|builtin| {
        *builtin != channel_name && bounded_levenshtein(channel_name, builtin, 2) <= 2
    })
}

fn bounded_levenshtein(a: &str, b: &str, max_distance: usize) -> usize {
    let a = a.as_bytes();
    let b = b.as_bytes();
    if a == b {
        return 0;
    }
    if a.len().abs_diff(b.len()) > max_distance {
        return max_distance + 1;
    }

    let mut prev: Vec<usize> = (0..=b.len()).collect();
    let mut curr = vec![0; b.len() + 1];

    for (i, a_byte) in a.iter().enumerate() {
        curr[0] = i + 1;
        let mut row_min = curr[0];
        for (j, b_byte) in b.iter().enumerate() {
            let cost = usize::from(a_byte != b_byte);
            curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
            row_min = row_min.min(curr[j + 1]);
        }
        if row_min > max_distance {
            return max_distance + 1;
        }
        std::mem::swap(&mut prev, &mut curr);
    }

    prev[b.len()]
}

fn validate_channel_features(features: &Value, path: &str, issues: &mut Vec<SchemaIssue>) {
    let Some(features_obj) = features.as_object() else {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: path.to_string(),
            message: format!(
                "features must be an object, got {}",
                json_type_label(features)
            ),
        });
        return;
    };

    for key in features_obj.keys() {
        if !matches!(key.as_str(), "typing" | "readReceipts") {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!("{}.{}", path, key),
                message: format!("unknown channel feature '{}'", key),
            });
        }
    }

    if let Some(typing) = features_obj.get("typing") {
        let typing_path = format!("{}.typing", path);
        if let Some(typing_obj) = typing.as_object() {
            for key in typing_obj.keys() {
                if !matches!(key.as_str(), "enabled" | "mode" | "intervalSeconds") {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!("{}.{}", typing_path, key),
                        message: format!("unknown typing feature key '{}'", key),
                    });
                }
            }
            if let Some(enabled) = typing_obj.get("enabled") {
                if !enabled.is_boolean() {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!("{}.enabled", typing_path),
                        message: "typing enabled must be a boolean".to_string(),
                    });
                }
            }

            if let Some(mode) = typing_obj.get("mode").and_then(|value| value.as_str()) {
                if mode != "thinking" {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!("{}.mode", typing_path),
                        message: format!("typing mode should be \"thinking\", got \"{}\"", mode),
                    });
                }
            } else if let Some(mode) = typing_obj.get("mode") {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: format!("{}.mode", typing_path),
                    message: format!(
                        "typing mode must be a string, got {}",
                        json_type_label(mode)
                    ),
                });
            }

            if let Some(interval) = typing_obj.get("intervalSeconds") {
                check_typing_interval_seconds(
                    interval,
                    &format!("{}.intervalSeconds", typing_path),
                    issues,
                );
            }
        } else {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: typing_path,
                message: format!(
                    "typing feature config must be an object, got {}",
                    json_type_label(typing)
                ),
            });
        }
    }

    if let Some(read_receipts) = features_obj.get("readReceipts") {
        let read_receipts_path = format!("{}.readReceipts", path);
        if let Some(read_receipts_obj) = read_receipts.as_object() {
            for key in read_receipts_obj.keys() {
                if key != "enabled" {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!("{}.{}", read_receipts_path, key),
                        message: format!("unknown readReceipts feature key '{}'", key),
                    });
                }
            }
            if let Some(enabled) = read_receipts_obj.get("enabled") {
                if !enabled.is_boolean() {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!("{}.enabled", read_receipts_path),
                        message: "readReceipts enabled must be a boolean".to_string(),
                    });
                }
            }
        } else {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: read_receipts_path,
                message: format!(
                    "readReceipts feature config must be an object, got {}",
                    json_type_label(read_receipts)
                ),
            });
        }
    }
}

fn validate_channel_session(session: &Value, path: &str, issues: &mut Vec<SchemaIssue>) {
    let Some(session_obj) = session.as_object() else {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: path.to_string(),
            message: format!(
                "session must be an object, got {}",
                json_type_label(session)
            ),
        });
        return;
    };

    for key in session_obj.keys() {
        if key != "scope" && key != "reset" {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!("{}.{}", path, key),
                message: format!(
                    "unknown channel session key '{}'; supported keys are {}.scope and {}.reset",
                    key, path, path
                ),
            });
        }
    }

    if let Some(scope) = session_obj.get("scope") {
        match scope.as_str() {
            Some("per-sender" | "global" | "per-channel-peer") => {}
            Some(other) => issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!("{}.scope", path),
                message: format!(
                    "scope must be one of per-sender/global/per-channel-peer, got \"{}\"",
                    other
                ),
            }),
            None => issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!("{}.scope", path),
                message: "scope must be a string".to_string(),
            }),
        }
    }

    if let Some(reset) = session_obj.get("reset") {
        validate_channel_session_reset(reset, &format!("{}.reset", path), issues);
    }
}

fn validate_channel_session_reset(reset: &Value, path: &str, issues: &mut Vec<SchemaIssue>) {
    let Some(reset_obj) = reset.as_object() else {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: path.to_string(),
            message: format!("reset must be an object, got {}", json_type_label(reset)),
        });
        return;
    };

    for key in reset_obj.keys() {
        if key != "mode" && key != "idleMinutes" {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!("{}.{}", path, key),
                message: format!(
                    "unknown channel session reset key '{}'; supported keys are {}.mode and {}.idleMinutes",
                    key, path, path
                ),
            });
        }
    }

    if let Some(mode) = reset_obj.get("mode") {
        match mode.as_str() {
            Some("manual" | "daily" | "idle") => {}
            Some(other) => issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!("{}.mode", path),
                message: format!("mode must be one of manual/daily/idle, got \"{}\"", other),
            }),
            None => issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!("{}.mode", path),
                message: "mode must be a string".to_string(),
            }),
        }
    }

    if let Some(idle_minutes) = reset_obj.get("idleMinutes") {
        check_positive_integer(idle_minutes, &format!("{}.idleMinutes", path), issues);
    }
}

fn validate_hooks(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    if obj.get("hooks").is_some() {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".hooks".to_string(),
            message: "hooks must be configured under gateway.hooks".to_string(),
        });
    }
}

fn validate_logging(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let logging = match obj.get("logging").and_then(|v| v.as_object()) {
        Some(l) => l,
        None => return,
    };

    if let Some(level) = logging.get("level") {
        if let Some(s) = level.as_str() {
            let valid = ["trace", "debug", "info", "warn", "error"];
            if !valid.contains(&s) {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".logging.level".to_string(),
                    message: format!(
                        "level should be one of trace/debug/info/warn/error, got \"{}\"",
                        s
                    ),
                });
            }
        }
    }

    if let Some(format) = logging.get("format") {
        if let Some(s) = format.as_str() {
            let valid = ["json", "text"];
            if !valid.contains(&s) {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".logging.format".to_string(),
                    message: format!("format should be one of json/text, got \"{}\"", s),
                });
            }
        }
    }
}

fn validate_auth(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let auth = match obj.get("auth").and_then(|v| v.as_object()) {
        Some(a) => a,
        None => return,
    };

    let profiles = match auth.get("profiles").and_then(|v| v.as_object()) {
        Some(p) => p,
        None => return,
    };

    if let Some(enabled) = profiles.get("enabled") {
        if !enabled.is_boolean() {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".auth.profiles.enabled".to_string(),
                message: "enabled must be a boolean".to_string(),
            });
        }
    }

    if let Some(redirect_base_url) = profiles.get("redirectBaseUrl") {
        if !redirect_base_url.is_string() {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".auth.profiles.redirectBaseUrl".to_string(),
                message: "redirectBaseUrl must be a string".to_string(),
            });
        } else if let Some(raw) = redirect_base_url.as_str() {
            if let Err(err) = crate::auth::profiles::sanitize_redirect_base_url(raw) {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: ".auth.profiles.redirectBaseUrl".to_string(),
                    message: err.to_string(),
                });
            }
        }
    }

    let providers = match profiles.get("providers").and_then(|v| v.as_object()) {
        Some(p) => p,
        None => return,
    };

    for provider_key in ["google", "github", "discord", "openai"] {
        let provider = match providers.get(provider_key).and_then(|v| v.as_object()) {
            Some(p) => p,
            None => continue,
        };
        for field in ["clientId", "clientSecret", "redirectUri"] {
            if let Some(value) = provider.get(field) {
                if field == "clientSecret" {
                    validate_secret_string_or_null(
                        value,
                        &format!(".auth.profiles.providers.{provider_key}.{field}"),
                        issues,
                    );
                } else if !value.is_string() {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!(".auth.profiles.providers.{provider_key}.{field}"),
                        message: format!("{field} must be a string"),
                    });
                } else if field == "redirectUri" {
                    if let Some(raw) = value.as_str() {
                        if let Err(err) = crate::auth::profiles::sanitize_redirect_uri(raw) {
                            issues.push(SchemaIssue {
                                severity: Severity::Error,
                                path: format!(
                                    ".auth.profiles.providers.{provider_key}.redirectUri"
                                ),
                                message: err.to_string(),
                            });
                        }
                    }
                }
            }
        }
    }
}

fn config_env_value_for_validation(
    obj: &serde_json::Map<String, Value>,
    context: &SchemaValidationContext,
    key: &str,
) -> Option<String> {
    let from_config_env = obj.get("env").and_then(|env| {
        let env_obj = env.as_object()?;
        env_obj
            .get("vars")
            .and_then(|vars| vars.as_object())
            .and_then(|vars| vars.get(key))
            .or_else(|| env_obj.get(key))
            .and_then(|value| value.as_str())
    });
    let from_runtime_env = || context.runtime_env_snapshot.get(key).cloned();
    from_config_env
        .map(str::to_string)
        .or_else(from_runtime_env)
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

fn matrix_nonempty_string(matrix: &serde_json::Map<String, Value>, field: &str) -> Option<String> {
    matrix
        .get(field)
        .and_then(|value| value.as_str())
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

fn warn_matrix_config_env_shadow(
    obj: &serde_json::Map<String, Value>,
    context: &SchemaValidationContext,
    matrix: &serde_json::Map<String, Value>,
    issues: &mut Vec<SchemaIssue>,
    field: &str,
    env_key: &str,
) {
    let Some(config_value) = matrix_nonempty_string(matrix, field) else {
        return;
    };
    if config_value.trim() == format!("${{{env_key}}}") {
        return;
    }
    let Some(env_value) = config_env_value_for_validation(obj, context, env_key) else {
        return;
    };
    if config_value != env_value {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: format!(".matrix.{field}"),
            message: format!(
                "{field} is set in config and {env_key} is also set; the config field takes precedence"
            ),
        });
    }
}

fn is_matrix_mxid_shape(value: &str) -> bool {
    if value.contains(char::is_whitespace) || value.chars().any(char::is_control) {
        return false;
    }
    let Some(rest) = value.strip_prefix('@') else {
        return false;
    };
    matches!(rest.split_once(':'), Some((localpart, server)) if !localpart.is_empty() && is_matrix_server_name_shape(server))
}

fn is_matrix_server_name_shape(value: &str) -> bool {
    !value.is_empty()
        && !value.contains(char::is_whitespace)
        && !value.chars().any(char::is_control)
        && !value.contains('/')
        && !value.contains('@')
}

fn validate_matrix(
    obj: &serde_json::Map<String, Value>,
    context: &SchemaValidationContext,
    issues: &mut Vec<SchemaIssue>,
) {
    let matrix = match obj.get("matrix") {
        Some(value) => match value.as_object() {
            Some(matrix) => matrix,
            None => {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: ".matrix".to_string(),
                    message: format!("matrix must be an object, got {}", json_type_label(value)),
                });
                return;
            }
        },
        None => return,
    };

    for field in [
        "homeserverUrl",
        "userId",
        "accessToken",
        "password",
        "deviceId",
        "storePassphrase",
    ] {
        if let Some(value) = matrix.get(field) {
            if !value.is_string() && !value.is_null() {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: format!(".matrix.{field}"),
                    message: format!("{field} must be a string"),
                });
            }
        }
    }

    // userId must be a Matrix MXID: `@localpart:server-name`. The
    // resolver later catches malformed values via `OwnedUserId::parse`
    // (surfaces as `MatrixError::InvalidUserId` after the runtime
    // starts), but a startup-time schema check tells the operator the
    // typo before they wait through env-load + matrix-sdk client
    // construction. Empty-string is caught here; other shape errors
    // (missing `@`, missing `:`, whitespace) are ALL emitted with the
    // same canonical-form hint so an operator pasting `"cara@example.com"`
    // (email-style) gets a pointer to the right form.
    if let Some(Value::String(user_id)) = matrix.get("userId") {
        // The previous regex had three bypass cases: `@:example.com`
        // (empty localpart), `@cara:` (empty server), and any
        // leading/trailing whitespace (the whitespace check ran on
        // the already-trimmed string). Validate against the user-
        // supplied value (NOT the trimmed one) for whitespace, and
        // require both localpart and server to be non-empty after
        // splitting on the first `:`.
        let canonical = || {
            "userId must be a Matrix user ID in canonical form \
             `@localpart:server-name` (e.g. `@cara:example.com`)"
        };
        if user_id.trim().is_empty() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.userId".to_string(),
                message: "userId cannot be empty when matrix is enabled".to_string(),
            });
        } else if user_id.contains(char::is_whitespace) {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.userId".to_string(),
                message: format!("{}; got {user_id:?}", canonical()),
            });
        } else if let Some(rest) = user_id.strip_prefix('@') {
            // Split into (localpart, server) at first `:`. Require
            // both halves non-empty; SDK accepts ports in the server
            // part (e.g. `@u:s:8448`), so we do NOT reject extra `:`.
            match rest.split_once(':') {
                Some((localpart, server)) if !localpart.is_empty() && !server.is_empty() => {
                    // Valid shape. Deeper grammar (allowed
                    // localpart chars per Matrix spec) is left to
                    // the SDK's `OwnedUserId::parse` at runtime.
                }
                _ => {
                    issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: ".matrix.userId".to_string(),
                        message: format!("{}; got {user_id:?}", canonical()),
                    });
                }
            }
        } else {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.userId".to_string(),
                message: format!("{}; got {user_id:?}", canonical()),
            });
        }
    }

    for field in ["enabled", "encrypted"] {
        if let Some(value) = matrix.get(field) {
            if !value.is_boolean() {
                // Severity::Error rather than Warning: both fields gate
                // security-relevant behaviour and silently default to
                // `true` if non-boolean. An operator typing
                // `"enabled": "false"` to disable Matrix would parse as
                // `unwrap_or(true)` → enabled-anyway, and the same for
                // `"encrypted": "false"` → silent-plaintext. Refuse the
                // config at startup so the operator notices the typo.
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: format!(".matrix.{field}"),
                    message: format!("{field} must be a boolean"),
                });
            }
        }
    }

    let enabled = matrix
        .get("enabled")
        .and_then(|v| v.as_bool())
        .unwrap_or(true);
    if !enabled {
        return;
    }

    let homeserver_for_validation = matrix_nonempty_string(matrix, "homeserverUrl")
        .or_else(|| config_env_value_for_validation(obj, context, "MATRIX_HOMESERVER_URL"));
    if homeserver_for_validation.is_none() {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".matrix.homeserverUrl".to_string(),
            message: "homeserverUrl is required when matrix is enabled".to_string(),
        });
    }
    if matrix_nonempty_string(matrix, "userId")
        .or_else(|| config_env_value_for_validation(obj, context, "MATRIX_USER_ID"))
        .is_none()
    {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".matrix.userId".to_string(),
            message: "userId is required when matrix is enabled".to_string(),
        });
    }

    // Error when homeserverUrl uses plaintext `http://` against a
    // non-loopback host. matrix-sdk faithfully connects over plaintext
    // if asked, putting the SDK store passphrase, access token, and
    // recovery-key flow on the wire in clear.
    if let Some(homeserver) = homeserver_for_validation {
        if homeserver_is_plaintext_non_loopback(&homeserver) {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.homeserverUrl".to_string(),
                message: "homeserverUrl uses plaintext http:// against a non-loopback host; \
                          Matrix passphrases, access tokens, and recovery-key material would \
                          traverse the network in clear. Use https:// for any non-loopback homeserver."
                    .to_string(),
            });
        }
        // Promote resolver-fatal shapes to schema-level Errors so
        // `validate_config` and `register_matrix_channel_if_configured`
        // agree: length cap, scheme/credentials validation. Otherwise
        // operators see schema "OK with warnings" but daemon refuses
        // to start with `MatrixError::InvalidLength` / `InvalidUrl`.
        if homeserver.len() > crate::channels::matrix::MATRIX_HOMESERVER_URL_MAX_BYTES_PUB {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.homeserverUrl".to_string(),
                message: format!(
                    "homeserverUrl exceeds {} bytes (got {})",
                    crate::channels::matrix::MATRIX_HOMESERVER_URL_MAX_BYTES_PUB,
                    homeserver.len()
                ),
            });
        }
        if let Err(err) = crate::channels::matrix::validate_homeserver_url(&homeserver) {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.homeserverUrl".to_string(),
                message: format!("homeserverUrl is not a valid URL: {err}"),
            });
        }
    }

    let access_token = matrix_nonempty_string(matrix, "accessToken")
        .or_else(|| config_env_value_for_validation(obj, context, "MATRIX_ACCESS_TOKEN"));
    let password = matrix_nonempty_string(matrix, "password")
        .or_else(|| config_env_value_for_validation(obj, context, "MATRIX_PASSWORD"));
    if access_token.is_none() && password.is_none() {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".matrix.accessToken".to_string(),
            message: "matrix.accessToken or matrix.password is required when matrix is enabled"
                .to_string(),
        });
    }
    if access_token.is_some() && password.is_some() {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".matrix.accessToken".to_string(),
            message: "matrix.accessToken and matrix.password must not both be set; choose token restore or password login explicitly".to_string(),
        });
    }
    for (field, env_key) in [
        ("homeserverUrl", "MATRIX_HOMESERVER_URL"),
        ("userId", "MATRIX_USER_ID"),
        ("accessToken", "MATRIX_ACCESS_TOKEN"),
        ("password", "MATRIX_PASSWORD"),
        ("deviceId", "MATRIX_DEVICE_ID"),
        ("storePassphrase", "MATRIX_STORE_PASSPHRASE"),
    ] {
        warn_matrix_config_env_shadow(obj, context, matrix, issues, field, env_key);
    }
    // Resolve userId and deviceId via the same env-aware chain as
    // `resolve_matrix_config` (channels/matrix.rs:1909-1934). Pre-fix
    // these length caps were applied to in-config fields ONLY, so an
    // operator providing oversized MATRIX_USER_ID / MATRIX_DEVICE_ID
    // via env saw "schema OK" then the daemon refused to start with
    // MatrixError::InvalidLength. Same schema/runtime contract
    // mismatch the access-token-without-deviceId check fixes 80 lines
    // below.
    if let Some(uid) = matrix_nonempty_string(matrix, "userId")
        .or_else(|| config_env_value_for_validation(obj, context, "MATRIX_USER_ID"))
    {
        if uid.len() > crate::channels::matrix::MATRIX_USER_ID_MAX_BYTES_PUB {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.userId".to_string(),
                message: format!(
                    "userId exceeds {} bytes (got {})",
                    crate::channels::matrix::MATRIX_USER_ID_MAX_BYTES_PUB,
                    uid.len()
                ),
            });
        }
    }
    if let Some(did) = matrix_nonempty_string(matrix, "deviceId")
        .or_else(|| config_env_value_for_validation(obj, context, "MATRIX_DEVICE_ID"))
    {
        if did.len() > crate::channels::matrix::MATRIX_DEVICE_ID_MAX_BYTES_PUB {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.deviceId".to_string(),
                message: format!(
                    "deviceId exceeds {} bytes (got {})",
                    crate::channels::matrix::MATRIX_DEVICE_ID_MAX_BYTES_PUB,
                    did.len()
                ),
            });
        }
    }

    // Mirror resolve_matrix_config (src/channels/matrix.rs): both
    // accessToken and deviceId can come from EITHER the in-config
    // matrix.* fields OR the env/env.vars fallback. Reading only the
    // in-config field here let an operator pass `MATRIX_ACCESS_TOKEN`
    // via env without `matrix.deviceId`, see "schema OK", then watch
    // the daemon refuse to start with MissingDeviceIdForTokenRestore.
    // Use the same env-aware resolution path as the credentials-pair
    // check above so schema validation matches runtime behavior.
    let access_token = matrix_nonempty_string(matrix, "accessToken")
        .or_else(|| config_env_value_for_validation(obj, context, "MATRIX_ACCESS_TOKEN"));
    let device_id = matrix_nonempty_string(matrix, "deviceId")
        .or_else(|| config_env_value_for_validation(obj, context, "MATRIX_DEVICE_ID"));
    if access_token.is_some() && device_id.is_none() {
        // The runtime resolver rejects accessToken-without-deviceId
        // unconditionally (silent fall-through to password login would
        // churn the bot's device identity on every restart). Schema
        // must match the resolver — previously this was a warning that
        // also required password to be absent, which let setup produce
        // a config the daemon refused to load.
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".matrix.deviceId".to_string(),
            message: "deviceId is required whenever accessToken is configured \
                      (token restore needs the SDK-issued device ID)"
                .to_string(),
        });
    }
    // Resolve store_passphrase env-aware (same pattern as accessToken/
    // deviceId above and as `resolve_matrix_store_passphrase` in
    // channels/matrix.rs). Pre-fix, the encrypted=false warning only
    // fired when storePassphrase was set IN-CONFIG, missing the case
    // where the operator supplied MATRIX_STORE_PASSPHRASE via env or
    // env.vars while matrix.encrypted=false. The resolver-side warning
    // still fires at startup so it's not silent overall, but the
    // schema/wizard-side surface should agree with the resolver.
    let store_passphrase = matrix_nonempty_string(matrix, "storePassphrase")
        .or_else(|| config_env_value_for_validation(obj, context, "MATRIX_STORE_PASSPHRASE"));
    let encrypted = matrix
        .get("encrypted")
        .and_then(|v| v.as_bool())
        .unwrap_or(true);
    if !encrypted && store_passphrase.is_some() {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: ".matrix.storePassphrase".to_string(),
            message: "storePassphrase is set (via config or env) but matrix.encrypted=false; \
                      the value will be ignored — flip encrypted=true to use it"
                .to_string(),
        });
    }
    if encrypted
        && store_passphrase.is_none()
        && config_env_value_for_validation(obj, context, "MATRIX_STORE_PASSPHRASE").is_none()
        && !context.config_password_available
        && !matrix_store_passphrase_file_available_for_validation(context, issues)
    {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".matrix.encrypted".to_string(),
            message: "matrix.encrypted=true requires matrix.storePassphrase, MATRIX_STORE_PASSPHRASE, CARAPACE_CONFIG_PASSWORD, or a non-empty <state_dir>/matrix/store_passphrase file".to_string(),
        });
    }
    if let Some(value) = matrix.get("inboundDlq") {
        if let Some(inbound_dlq) = value.as_object() {
            if let Some(policy) = inbound_dlq.get("legacyEnvelopePolicy") {
                match policy.as_str().map(str::trim) {
                    Some("accept" | "refuse") => {}
                    Some(_) => issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: ".matrix.inboundDlq.legacyEnvelopePolicy".to_string(),
                        message: "legacyEnvelopePolicy must be `accept` or `refuse`".to_string(),
                    }),
                    None => issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: ".matrix.inboundDlq.legacyEnvelopePolicy".to_string(),
                        message: "legacyEnvelopePolicy must be a string".to_string(),
                    }),
                }
            }
            // Align with the forward-compat posture of the runtime
            // (`resolve_matrix_legacy_envelope_policy` in matrix.rs
            // intentionally warns-and-ignores unknown sibling keys so
            // an operator downgrading after a newer daemon added a
            // sibling option can still start). Demoting unknown keys
            // from `Error` to `Warning` keeps the diagnostic visible
            // to operators while preserving the schema/runtime
            // contract: anything the runtime accepts MUST pass schema
            // validation, even if with warnings.
            for key in inbound_dlq.keys() {
                if key != "legacyEnvelopePolicy" {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!(".matrix.inboundDlq.{key}"),
                        message: "unknown inboundDlq key (forward-compat: ignored at runtime)"
                            .to_string(),
                    });
                }
            }
        } else {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.inboundDlq".to_string(),
                message: "inboundDlq must be an object".to_string(),
            });
        }
    }

    // Severity::Error rather than Warning: `resolve_matrix_config`
    // (`src/channels/matrix.rs::resolve_matrix_config`) rejects the
    // same shape with `MatrixError::InvalidStringArray` and
    // `register_matrix_channel_if_configured` propagates it as a
    // startup error. A schema/runtime contract mismatch — config
    // validation passing with warnings while daemon startup fails
    // hard — is exactly the operator-confusion case round 16
    // promoted `enabled`/`encrypted` for. Same logic applies here.
    let Some(auto_join) = matrix.get("autoJoin") else {
        return;
    };
    let Some(auto_join) = auto_join.as_object() else {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".matrix.autoJoin".to_string(),
            message: "autoJoin must be an object".to_string(),
        });
        return;
    };
    for field in ["allowUsers", "allowServerNames"] {
        let Some(value) = auto_join.get(field) else {
            continue;
        };
        let Some(values) = value.as_array() else {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: format!(".matrix.autoJoin.{field}"),
                message: format!("{field} must be an array of strings"),
            });
            continue;
        };
        // Resolver enforces these limits via `MatrixError::AllowlistTooLarge`
        // and per-entry `InvalidLength`; align schema severity.
        if values.len() > crate::channels::matrix::MATRIX_ALLOWLIST_MAX_ENTRIES_PUB {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: format!(".matrix.autoJoin.{field}"),
                message: format!(
                    "{field} exceeds {} entries (got {})",
                    crate::channels::matrix::MATRIX_ALLOWLIST_MAX_ENTRIES_PUB,
                    values.len()
                ),
            });
        }
        let mut seen = HashSet::new();
        for (idx, value) in values.iter().enumerate() {
            match value.as_str() {
                Some(s)
                    if s.len() > crate::channels::matrix::MATRIX_ALLOWLIST_ENTRY_MAX_BYTES_PUB =>
                {
                    issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: format!(".matrix.autoJoin.{field}[{idx}]"),
                        message: format!(
                            "{field} entry exceeds {} bytes (got {})",
                            crate::channels::matrix::MATRIX_ALLOWLIST_ENTRY_MAX_BYTES_PUB,
                            s.len()
                        ),
                    });
                }
                Some(s) => {
                    let trimmed = s.trim();
                    if trimmed.is_empty() {
                        continue;
                    }
                    if !seen.insert(trimmed.to_string()) {
                        issues.push(SchemaIssue {
                            severity: Severity::Warning,
                            path: format!(".matrix.autoJoin.{field}[{idx}]"),
                            message: format!("{field} contains duplicate entry {trimmed:?}"),
                        });
                    }
                    let valid_shape = match field {
                        "allowUsers" => is_matrix_mxid_shape(trimmed),
                        "allowServerNames" => is_matrix_server_name_shape(trimmed),
                        _ => true,
                    };
                    if !valid_shape {
                        // Runtime `read_string_set` in
                        // `channels/matrix.rs` does NOT enforce
                        // mxid/server-name shape — it accepts any
                        // non-empty trimmed string up to
                        // `MATRIX_ALLOWLIST_ENTRY_MAX_BYTES`. A
                        // shape-mismatched entry is dead config
                        // (never matches a real MXID at gate
                        // evaluation) — not a security risk, just an
                        // operator typo. Demoting to Warning keeps
                        // the schema/runtime contract: anything the
                        // runtime accepts must validate, even if
                        // with warnings.
                        issues.push(SchemaIssue {
                            severity: Severity::Warning,
                            path: format!(".matrix.autoJoin.{field}[{idx}]"),
                            message: match field {
                                "allowUsers" => {
                                    "allowUsers entries should be Matrix user IDs like `@localpart:server-name`; runtime accepts the value but it will never match a real MXID".to_string()
                                }
                                "allowServerNames" => {
                                    "allowServerNames entries should be Matrix server names, not URLs or user IDs; runtime accepts the value but it will never match a real server name".to_string()
                                }
                                _ => format!("{field} entry has invalid shape"),
                            },
                        });
                    }
                }
                None => {
                    issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: format!(".matrix.autoJoin.{field}[{idx}]"),
                        message: format!("{field} entries must be strings"),
                    });
                }
            }
        }
    }
}

fn matrix_store_passphrase_file_available_for_validation(
    context: &SchemaValidationContext,
    issues: &mut Vec<SchemaIssue>,
) -> bool {
    use MatrixStorePassphraseFileValidation as M;
    match &context.matrix_store_passphrase_file {
        M::NotChecked | M::Missing => false,
        M::Available => true,
        M::EmptyAfterTrim => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.encrypted".to_string(),
                message:
                    "matrix.encrypted=true found <state_dir>/matrix/store_passphrase but it is empty after trimming"
                        .to_string(),
            });
            false
        }
        M::NotRegularFile => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.encrypted".to_string(),
                message:
                    "matrix.encrypted=true requires <state_dir>/matrix/store_passphrase to be a regular file (got a symlink, directory, FIFO, socket, or other non-file)"
                        .to_string(),
            });
            false
        }
        M::TooLarge => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.encrypted".to_string(),
                message: format!(
                    "matrix.encrypted=true requires <state_dir>/matrix/store_passphrase to be at most {} bytes; refusing to read a larger file at validation time",
                    SCHEMA_PASSPHRASE_FILE_MAX_BYTES
                ),
            });
            false
        }
        M::ReadError(err) => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".matrix.encrypted".to_string(),
                message: format!(
                    "matrix.encrypted=true could not read <state_dir>/matrix/store_passphrase: {err}"
                ),
            });
            false
        }
    }
}

/// True when the URL has scheme `http` (not `https`) and the host
/// is not a loopback address — i.e. the connection would carry
/// E2EE-bearing Matrix traffic in clear over the network. Loopback
/// (`127.0.0.0/8`, `::1`, the literal `localhost`) is exempted so
/// dev/test against a local synapse keeps working.
fn homeserver_is_plaintext_non_loopback(url: &str) -> bool {
    let Ok(parsed) = url::Url::parse(url) else {
        // If we can't parse it, leave the warning to the runtime client
        // builder — schema-level URL parsing isn't strict enough to gate
        // here.
        return false;
    };
    if parsed.scheme() != "http" {
        return false;
    }
    let Some(host) = parsed.host_str() else {
        return false;
    };
    !crate::net_util::is_loopback_host(host)
}

fn validate_anthropic(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let anthropic = match obj.get("anthropic").and_then(|v| v.as_object()) {
        Some(a) => a,
        None => return,
    };

    for (field, path) in [
        ("apiKey", ".anthropic.apiKey"),
        ("baseUrl", ".anthropic.baseUrl"),
        ("authProfile", ".anthropic.authProfile"),
    ] {
        if let Some(value) = anthropic.get(field) {
            if field == "apiKey" {
                validate_secret_string_or_null(value, path, issues);
            } else if !value.is_string() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: path.to_string(),
                    message: format!("{field} must be a string"),
                });
            }
        }
    }

    let api_key = anthropic
        .get("apiKey")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|v| !v.is_empty());
    let auth_profile = anthropic
        .get("authProfile")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|v| !v.is_empty());

    if api_key.is_some() && auth_profile.is_some() {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: ".anthropic".to_string(),
            message: "configure either anthropic.apiKey or anthropic.authProfile, not both"
                .to_string(),
        });
    }

    if auth_profile.is_some() {
        let auth_profiles_enabled = obj
            .get("auth")
            .and_then(|v| v.get("profiles"))
            .and_then(|v| v.get("enabled"))
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        if !auth_profiles_enabled {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".anthropic.authProfile".to_string(),
                message: "anthropic.authProfile requires auth.profiles.enabled = true".to_string(),
            });
        }
    }
}

fn validate_google(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let google = match obj.get("google").and_then(|v| v.as_object()) {
        Some(g) => g,
        None => return,
    };

    for (field, path) in [
        ("apiKey", ".google.apiKey"),
        ("baseUrl", ".google.baseUrl"),
        ("authProfile", ".google.authProfile"),
    ] {
        if let Some(value) = google.get(field) {
            if field == "apiKey" {
                validate_secret_string_or_null(value, path, issues);
            } else if !value.is_string() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: path.to_string(),
                    message: format!("{field} must be a string"),
                });
            }
        }
    }

    let api_key = google
        .get("apiKey")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|v| !v.is_empty());
    let auth_profile = google
        .get("authProfile")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|v| !v.is_empty());

    if api_key.is_some() && auth_profile.is_some() {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: ".google".to_string(),
            message: "configure either google.apiKey or google.authProfile, not both".to_string(),
        });
    }

    if auth_profile.is_some() {
        let auth_profiles_enabled = obj
            .get("auth")
            .and_then(|v| v.get("profiles"))
            .and_then(|v| v.get("enabled"))
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        if !auth_profiles_enabled {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".google.authProfile".to_string(),
                message: "google.authProfile requires auth.profiles.enabled = true".to_string(),
            });
        }
    }
}

fn validate_codex(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let codex = match obj.get("codex").and_then(|v| v.as_object()) {
        Some(c) => c,
        None => return,
    };

    if let Some(value) = codex.get("authProfile") {
        if !value.is_string() {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".codex.authProfile".to_string(),
                message: "authProfile must be a string".to_string(),
            });
        }
    }

    let auth_profile = codex
        .get("authProfile")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|v| !v.is_empty());

    if auth_profile.is_some() {
        let auth_profiles_enabled = obj
            .get("auth")
            .and_then(|v| v.get("profiles"))
            .and_then(|v| v.get("enabled"))
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        if !auth_profiles_enabled {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".codex.authProfile".to_string(),
                message: "codex.authProfile requires auth.profiles.enabled = true".to_string(),
            });
        }
    }
}

fn validate_agents(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let agents = match obj.get("agents").and_then(|v| v.as_object()) {
        Some(a) => a,
        None => return,
    };

    reject_removed_alias(
        agents,
        "prompt_guard",
        ".agents.prompt_guard",
        ".agents.promptGuard",
        issues,
    );
    reject_removed_alias(
        agents,
        "output_sanitizer",
        ".agents.output_sanitizer",
        ".agents.outputSanitizer",
        issues,
    );

    if let Some(defaults) = agents.get("defaults").and_then(|v| v.as_object()) {
        reject_agent_override_aliases(defaults, ".agents.defaults", issues);
        reject_removed_alias(
            defaults,
            "timeout",
            ".agents.defaults.timeout",
            ".agents.defaults.timeoutSeconds",
            issues,
        );

        if let Some(v) = defaults.get("maxConcurrent") {
            check_positive_integer(v, ".agents.defaults.maxConcurrent", issues);
        }
        if let Some(v) = defaults.get("timeoutSeconds") {
            check_positive_integer(v, ".agents.defaults.timeoutSeconds", issues);
        }
        if let Some(v) = defaults.get("contextTokens") {
            check_positive_integer(v, ".agents.defaults.contextTokens", issues);
        }

        if let Some(sandbox) = defaults.get("sandbox").and_then(|v| v.as_object()) {
            validate_agent_sandbox_fields(sandbox, ".agents.defaults.sandbox", issues);
        }

        // Validate model uses provider:model syntax.
        if let Some(model) = defaults.get("model") {
            check_model_field(model, ".agents.defaults.model", issues);
        }
    }

    // Validate per-agent models.
    if let Some(list) = agents.get("list").and_then(|v| v.as_array()) {
        for (i, entry) in list.iter().enumerate() {
            if let Some(entry) = entry.as_object() {
                reject_agent_override_aliases(entry, &format!(".agents.list[{i}]"), issues);
                if let Some(sandbox) = entry.get("sandbox").and_then(|v| v.as_object()) {
                    validate_agent_sandbox_fields(
                        sandbox,
                        &format!(".agents.list[{i}].sandbox"),
                        issues,
                    );
                }
            }
            if let Some(model) = entry.get("model") {
                check_model_field(model, &format!(".agents.list[{i}].model"), issues);
            }
        }
    }
}

/// Reject sandbox configurations that would corrupt the post-fork
/// pre-exec rlimit application. Each numeric field is treated as a
/// soft-limit value passed to setrlimit; values that are zero,
/// negative, or non-integer silently break every tool subprocess
/// (e.g. `max_processes: 0` blocks fork() entirely, hiding behind
/// opaque exec errors). `ProcessSandboxConfig::from_config` falls
/// back to `Self::default()` on any parse error, which would also
/// throw away the operator's other tuned values (allowed_paths,
/// network_access, env_filter) — so validating here surfaces the
/// problem at config-load time instead.
fn validate_agent_sandbox_fields(
    sandbox: &serde_json::Map<String, Value>,
    path: &str,
    issues: &mut Vec<SchemaIssue>,
) {
    for field in [
        "max_cpu_seconds",
        "max_memory_mb",
        "max_fds",
        "max_processes",
    ] {
        if let Some(v) = sandbox.get(field) {
            check_positive_integer(v, &format!("{path}.{field}"), issues);
        }
    }
}

fn reject_agent_override_aliases(
    obj: &serde_json::Map<String, Value>,
    path: &str,
    issues: &mut Vec<SchemaIssue>,
) {
    for (alias, canonical) in [
        ("max_turns", "maxTurns"),
        ("max_tokens", "maxTokens"),
        ("exfiltration_guard", "exfiltrationGuard"),
        ("prompt_guard", "promptGuard"),
        ("output_sanitizer", "outputSanitizer"),
        ("process_sandbox", "sandbox"),
        ("processSandbox", "sandbox"),
    ] {
        reject_removed_alias(
            obj,
            alias,
            &format!("{path}.{alias}"),
            &format!("{path}.{canonical}"),
            issues,
        );
    }
}

fn reject_removed_alias(
    obj: &serde_json::Map<String, Value>,
    alias: &str,
    path: &str,
    canonical_path: &str,
    issues: &mut Vec<SchemaIssue>,
) {
    if obj.contains_key(alias) {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: path.to_string(),
            message: format!("unknown field; use {canonical_path}"),
        });
    }
}

fn check_model_field(value: &Value, path: &str, issues: &mut Vec<SchemaIssue>) {
    match value.as_str() {
        Some(model) => check_model_has_provider_prefix(model, path, issues),
        None => issues.push(SchemaIssue {
            severity: Severity::Error,
            path: path.to_string(),
            message: format!(
                "`{path}` must be a string using the provider:model format \
                 (e.g. `anthropic:claude-sonnet-4-6`)"
            ),
        }),
    }
}

fn check_model_has_provider_prefix(model: &str, path: &str, issues: &mut Vec<SchemaIssue>) {
    if model != model.trim() {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: path.to_string(),
            message: format!(
                "`{path}` has leading or trailing whitespace; \
                 remove whitespace to avoid routing errors"
            ),
        });
    }
    let model = model.trim();
    if model.is_empty() {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: path.to_string(),
            message: format!(
                "`{path}` must not be empty; specify a model using the provider:model format \
                 (e.g. `anthropic:claude-sonnet-4-6`)"
            ),
        });
        return;
    }
    let has_known_prefix = crate::agent::anthropic::is_anthropic_model(model)
        || crate::agent::openai::is_openai_model(model)
        || crate::agent::gemini::is_gemini_model(model)
        || crate::agent::vertex::is_vertex_model(model)
        || crate::agent::bedrock::is_bedrock_model(model)
        || crate::agent::ollama::is_ollama_model(model)
        || crate::agent::codex::is_codex_model(model)
        || crate::agent::nearai::is_nearai_model(model)
        || crate::agent::venice::is_venice_model(model)
        || crate::agent::claude_cli::is_claude_cli_model(model);
    // Check for whitespace around the colon (e.g. "anthropic: claude-3-sonnet")
    if let Some((prefix, suffix)) = model.split_once(':') {
        if prefix != prefix.trim() || suffix != suffix.trim() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: path.to_string(),
                message: format!(
                    "`{path}` = \"{model}\" has whitespace around the colon separator; \
                     use `{}:{}` instead",
                    prefix.trim(),
                    suffix.trim()
                ),
            });
            return;
        }
        if suffix.trim().is_empty() && !prefix.contains('.') {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: path.to_string(),
                message: format!(
                    "`{path}` = \"{model}\" has a provider prefix but no model name; \
                     specify the model after the colon (e.g. `{prefix}:your-model`)"
                ),
            });
            return;
        }
    }

    if !has_known_prefix {
        let suggestion = crate::model_names::prefix_bare_model(model);
        let hint = if model.contains('/') {
            match crate::model_names::slash_form_model_suggestion(model) {
                Some(suggestion) => {
                    format!("`{path}` = \"{model}\" uses slash syntax; use `{suggestion}` instead")
                }
                None => format!("`{path}` = \"{model}\" is not a valid provider:model value"),
            }
        } else if suggestion != model {
            format!(
                "`{path}` = \"{model}\" is missing a provider prefix; use `{suggestion}` instead"
            )
        } else if let Some((prefix, _)) = model.split_once(':').filter(|(p, _)| !p.contains('.')) {
            let known_prefixes = crate::model_names::known_provider_prefixes_message();
            format!(
                "`{path}` = \"{model}\" uses unrecognized provider prefix \"{prefix}:\"; \
                 known prefixes are {known_prefixes}"
            )
        } else {
            format!(
                "`{path}` = \"{model}\" is missing a provider prefix; \
                 use the provider:model format (e.g. `anthropic:{model}`)"
            )
        };
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: path.to_string(),
            message: hint,
        });
    }
}

fn validate_session(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let sessions = obj.get("sessions").and_then(|v| v.as_object());

    if let Some(sessions_obj) = sessions {
        reject_removed_alias(
            sessions_obj,
            "retentionDays",
            ".sessions.retentionDays",
            ".sessions.retention.days",
            issues,
        );
    }

    if let Some(retention) = sessions
        .and_then(|s| s.get("retention"))
        .and_then(|v| v.as_object())
    {
        if let Some(days) = retention.get("days") {
            check_positive_integer(days, ".sessions.retention.days", issues);
        }
        if let Some(enabled) = retention.get("enabled") {
            if !enabled.is_boolean() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".sessions.retention.enabled".to_string(),
                    message: "enabled must be a boolean".to_string(),
                });
            }
        }
    }
}

fn validate_cron(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let cron = match obj.get("cron").and_then(|v| v.as_object()) {
        Some(c) => c,
        None => return,
    };

    let entries = match cron.get("entries").and_then(|v| v.as_array()) {
        Some(a) => a,
        None => return,
    };

    for (i, entry) in entries.iter().enumerate() {
        let entry_obj = match entry.as_object() {
            Some(o) => o,
            None => {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: format!(".cron.entries[{}]", i),
                    message: "cron entry must be an object".to_string(),
                });
                continue;
            }
        };

        // Validate schedule (basic regex: should have 5 or 6 cron fields)
        if let Some(schedule) = entry_obj.get("schedule") {
            if let Some(s) = schedule.as_str() {
                if !is_plausible_cron(s) {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!(".cron.entries[{}].schedule", i),
                        message: format!(
                            "schedule does not look like a valid cron expression: \"{}\"",
                            s
                        ),
                    });
                }
            }
        }

        // Validate payload is an object, then check route/model fields
        if let Some(payload) = entry_obj.get("payload") {
            if !payload.is_object() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: format!(".cron.entries[{}].payload", i),
                    message: "payload must be an object".to_string(),
                });
            } else if let Some(payload_obj) = payload.as_object() {
                let has_route = payload_obj.get("route").and_then(|v| v.as_str());
                let has_model = payload_obj.get("model").and_then(|v| v.as_str());

                // Validate route references a defined route
                if let Some(route_str) = has_route {
                    let route_str = route_str.trim();
                    if !route_str.is_empty() {
                        let routes_map = obj.get("routes").and_then(|v| v.as_object());
                        // Runtime (`cron::executor::execute_payload`)
                        // resolves `route` at execution time and
                        // gracefully fails the SINGLE cron fire on an
                        // unknown route — the daemon still starts and
                        // sibling crons still run. Schema Error here
                        // would block daemon startup for a stale
                        // route literal, blocking every other cron
                        // and the gateway. Demote to Warning so the
                        // schema/runtime contract holds (anything
                        // runtime accepts must validate, even if with
                        // warnings). Same pattern as Batch 77 for
                        // matrix.inboundDlq.
                        match routes_map {
                            None => {
                                issues.push(SchemaIssue {
                                    severity: Severity::Warning,
                                    path: format!(".cron.entries[{}].payload.route", i),
                                    message: format!(
                                        "references route \"{route_str}\" but no `routes` map is defined; \
                                         add a top-level `routes` section (runtime will fail this cron fire individually)"
                                    ),
                                });
                            }
                            Some(map) if !map.contains_key(route_str) => {
                                let available: Vec<&String> = map.keys().collect();
                                issues.push(SchemaIssue {
                                    severity: Severity::Warning,
                                    path: format!(".cron.entries[{}].payload.route", i),
                                    message: format!(
                                        "references unknown route \"{route_str}\"; \
                                         defined routes are: {available:?} (runtime will fail this cron fire individually)"
                                    ),
                                });
                            }
                            _ => {}
                        }
                    }
                }

                // Warn if both route and model are set
                if has_route.is_some() && has_model.is_some() {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!(".cron.entries[{}].payload", i),
                        message: "both `route` and `model` are set; \
                                  the cron executor will reject this at runtime"
                            .to_string(),
                    });
                }

                // Validate model has a provider prefix
                if let Some(model) = has_model {
                    let model = model.trim();
                    if !model.is_empty() {
                        check_model_has_provider_prefix(
                            model,
                            &format!(".cron.entries[{}].payload.model", i),
                            issues,
                        );
                    }
                }
            }
        }
    }
}

fn validate_prompt_guard(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let (prompt_guard_key, pg) = match obj.get("agents").and_then(prompt_guard_entry) {
        Some(entry) => entry,
        None => return,
    };
    let prompt_guard_path = format!(".agents.{}", prompt_guard_key);

    if let Some(enabled) = pg.get("enabled") {
        validate_enabled_bool(
            enabled,
            &format!("{prompt_guard_path}.enabled"),
            Severity::Warning,
            issues,
        );
    }

    // Validate sub-sections have boolean enabled fields.
    for section in &["preflight", "tagging", "postflight"] {
        if let Some(sub) = pg.get(*section).and_then(|v| v.as_object()) {
            if let Some(enabled) = sub.get("enabled") {
                validate_enabled_bool(
                    enabled,
                    &format!("{prompt_guard_path}.{section}.enabled"),
                    Severity::Warning,
                    issues,
                );
            }
        }
    }

    if let Some(sub) = pg.get("config_lint").and_then(|v| v.as_object()) {
        if let Some(enabled) = sub.get("enabled") {
            validate_enabled_bool(
                enabled,
                &format!("{prompt_guard_path}.config_lint.enabled"),
                Severity::Warning,
                issues,
            );
        }
    }

    reject_removed_alias(
        pg,
        "configLint",
        &format!("{prompt_guard_path}.configLint"),
        &format!("{prompt_guard_path}.config_lint"),
        issues,
    );
}

fn prompt_guard_obj(agents: &Value) -> Option<&serde_json::Map<String, Value>> {
    prompt_guard_entry(agents).map(|(_, value)| value)
}

fn prompt_guard_entry(agents: &Value) -> Option<(&'static str, &serde_json::Map<String, Value>)> {
    agents
        .get("promptGuard")
        .and_then(|v| v.as_object())
        .map(|v| ("promptGuard", v))
}

fn prompt_guard_config_lint_obj(
    prompt_guard: &serde_json::Map<String, Value>,
) -> Option<&serde_json::Map<String, Value>> {
    // The lint-enable path intentionally resolves only the canonical key.
    prompt_guard.get("config_lint").and_then(|v| v.as_object())
}

fn validate_enabled_bool(
    enabled: &Value,
    path: &str,
    severity: Severity,
    issues: &mut Vec<SchemaIssue>,
) {
    if !enabled.is_boolean() {
        issues.push(SchemaIssue {
            severity,
            path: path.to_string(),
            message: "enabled must be a boolean".to_string(),
        });
    }
}

fn validate_output_sanitizer(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let agents = match obj.get("agents").and_then(|v| v.as_object()) {
        Some(a) => a,
        None => return,
    };

    let output = agents.get("outputSanitizer").and_then(|v| v.as_object());
    let output = match output {
        Some(o) => o,
        None => return,
    };

    reject_removed_alias(
        output,
        "sanitize_html",
        ".agents.outputSanitizer.sanitize_html",
        ".agents.outputSanitizer.sanitizeHtml",
        issues,
    );
    reject_removed_alias(
        output,
        "csp_policy",
        ".agents.outputSanitizer.csp_policy",
        ".agents.outputSanitizer.cspPolicy",
        issues,
    );

    if let Some(enabled) = output.get("sanitizeHtml") {
        if !enabled.is_boolean() {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".agents.outputSanitizer.sanitizeHtml".to_string(),
                message: "sanitizeHtml must be a boolean".to_string(),
            });
        }
    }

    if let Some(policy) = output.get("cspPolicy") {
        if !policy.is_string() {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".agents.outputSanitizer.cspPolicy".to_string(),
                message: "cspPolicy must be a string".to_string(),
            });
        }
    }
}

fn validate_plugins_signature(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let sig = match obj
        .get("plugins")
        .and_then(|s| s.get("signature"))
        .and_then(|v| v.as_object())
    {
        Some(s) => s,
        None => return,
    };

    for (alias, canonical) in [
        ("require_signature", "requireSignature"),
        ("trusted_publishers", "trustedPublishers"),
    ] {
        reject_removed_alias(
            sig,
            alias,
            &format!(".plugins.signature.{alias}"),
            &format!(".plugins.signature.{canonical}"),
            issues,
        );
    }

    for field in sig.keys() {
        if !SIGNATURE_CONFIG_FIELDS.contains(&field.as_str())
            && !matches!(field.as_str(), "require_signature" | "trusted_publishers")
        {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: format!(".plugins.signature.{field}"),
                message: "unknown plugins.signature field".to_string(),
            });
        }
    }

    if let Some(enabled) = sig.get("enabled") {
        if !enabled.is_boolean() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins.signature.enabled".to_string(),
                message: "enabled must be a boolean".to_string(),
            });
        }
    }

    if let Some(require) = sig.get("requireSignature") {
        if !require.is_boolean() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins.signature.requireSignature".to_string(),
                message: "requireSignature must be a boolean".to_string(),
            });
        }
    }

    if let Some(publishers) = sig.get("trustedPublishers") {
        if !publishers.is_array() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins.signature.trustedPublishers".to_string(),
                message: "trustedPublishers must be an array".to_string(),
            });
        }
    }
}

fn validate_plugins_sandbox(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let sandbox = match obj
        .get("plugins")
        .and_then(|s| s.get("sandbox"))
        .and_then(|v| v.as_object())
    {
        Some(s) => s,
        None => return,
    };

    if let Some(enabled) = sandbox.get("enabled") {
        if !enabled.is_boolean() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins.sandbox.enabled".to_string(),
                message: "enabled must be a boolean".to_string(),
            });
        }
    }
    if let Some(allow_tailscale) = sandbox.get("allow_tailscale") {
        if !allow_tailscale.is_boolean() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins.sandbox.allow_tailscale".to_string(),
                message: "allow_tailscale must be a boolean".to_string(),
            });
        }
    }
    if sandbox.contains_key("allowTailscale") {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: ".plugins.sandbox.allowTailscale".to_string(),
            message: "allowTailscale (camelCase) is ignored at runtime; use `allow_tailscale`"
                .to_string(),
        });
    }

    if let Some(defaults) = sandbox.get("defaults").and_then(|v| v.as_object()) {
        // CapabilityPolicy uses default snake_case serde, so the wire keys
        // are `allow_http`, `allow_credentials`, `allow_media`. Older
        // configs and earlier docs used camelCase; warn so users notice
        // the rename instead of silently parsing to the type-default.
        for key in &["allow_http", "allow_credentials", "allow_media"] {
            if let Some(val) = defaults.get(*key) {
                if !val.is_boolean() {
                    issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: format!(".plugins.sandbox.defaults.{}", key),
                        message: format!("{} must be a boolean", key),
                    });
                }
            }
        }
        for (camel, snake) in [
            ("allowHttp", "allow_http"),
            ("allowCredentials", "allow_credentials"),
            ("allowMedia", "allow_media"),
        ] {
            if defaults.contains_key(camel) {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: format!(".plugins.sandbox.defaults.{}", camel),
                    message: format!(
                        "{} (camelCase) is ignored at runtime; use the snake_case form `{}`",
                        camel, snake
                    ),
                });
            }
        }
    }
}

fn validate_plugins(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let Some(plugins_value) = obj.get("plugins") else {
        return;
    };
    let plugins = match plugins_value.as_object() {
        Some(plugins) => plugins,
        None => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins".to_string(),
                message: "plugins must be an object".to_string(),
            });
            return;
        }
    };

    if let Some(enabled) = plugins.get("enabled") {
        if !enabled.is_boolean() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins.enabled".to_string(),
                message: "plugins.enabled must be a boolean".to_string(),
            });
        }
    }

    if let Some(entries) = plugins.get("entries") {
        let Some(entries_obj) = entries.as_object() else {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins.entries".to_string(),
                message: "plugins.entries must be an object".to_string(),
            });
            return;
        };

        for (name, entry) in entries_obj {
            if is_reserved_plugin_id(name) {
                // SECURITY/SCHEMA-RUNTIME-PARITY: the bootstrap reader
                // at `managed_plugin_config_entries`
                // (src/server/plugin_bootstrap.rs) returns `None` for
                // reserved names and the WS install-list builder at
                // `handlers::plugins::collect_managed_plugin_entries`
                // does the same. Both runtime paths silently skip
                // such entries. A schema-level Error here would
                // FAIL_FAST at config load on a state the runtime
                // tolerates, breaking the schema/runtime parity
                // pattern enforced by Batches 87/88. Demote to
                // Warning so operators see the diagnostic without
                // an unrelated entry blocking startup.
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: format!(".plugins.entries.{name}"),
                    message: format!(
                        "managed plugin name '{}' is reserved for plugin configuration; runtime will ignore this entry",
                        name
                    ),
                });
            }

            let Some(entry_obj) = entry.as_object() else {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: format!(".plugins.entries.{name}"),
                    message: "managed plugin entry must be an object".to_string(),
                });
                continue;
            };

            for field in entry_obj.keys() {
                if !matches!(field.as_str(), "enabled" | "installId" | "requestedAt") {
                    // SCHEMA-RUNTIME-PARITY: the bootstrap reader and
                    // the WS install-list builder both `tracing::warn!`
                    // and then `return None` (skip the entry) when an
                    // unknown field is present. Schema must match —
                    // Warning, not Error — so a forward-compat field
                    // shipped by a newer daemon does not brick startup
                    // when an operator downgrades.
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!(".plugins.entries.{name}.{field}"),
                        message: format!(
                            "unknown managed plugin field '{}'; plugin runtime config belongs under plugins.<plugin-id>.*, and reserved top-level plugin keys are {}. Runtime will skip this entry until the field is removed.",
                            field,
                            RESERVED_PLUGIN_CONFIG_KEYS.join(", ")
                        ),
                    });
                }
            }
        }
    }

    let Some(load) = plugins.get("load") else {
        return;
    };
    let Some(load_obj) = load.as_object() else {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".plugins.load".to_string(),
            message: "plugins.load must be an object".to_string(),
        });
        return;
    };

    if let Some(paths) = load_obj.get("paths") {
        let Some(array) = paths.as_array() else {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins.load.paths".to_string(),
                message: "plugins.load.paths must be an array".to_string(),
            });
            return;
        };
        if array.iter().any(|value| !value.is_string()) {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".plugins.load.paths".to_string(),
                message: "plugins.load.paths entries must be strings".to_string(),
            });
        }
    }
}

fn validate_session_integrity(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let integrity = match obj
        .get("sessions")
        .and_then(|s| s.get("integrity"))
        .and_then(|v| v.as_object())
    {
        Some(i) => i,
        None => return,
    };

    if let Some(enabled) = integrity.get("enabled") {
        if !enabled.is_boolean() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".sessions.integrity.enabled".to_string(),
                message: "enabled must be a boolean".to_string(),
            });
        }
    }

    if let Some(action) = integrity.get("action").and_then(|v| v.as_str()) {
        let valid = ["warn", "reject"];
        if !valid.contains(&action) {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".sessions.integrity.action".to_string(),
                message: format!("action should be one of warn/reject, got \"{}\"", action),
            });
        }
    }
}

fn validate_session_encryption(
    obj: &serde_json::Map<String, Value>,
    issues: &mut Vec<SchemaIssue>,
) {
    let Some(encryption_value) = obj.get("sessions").and_then(|s| s.get("encryption")) else {
        return;
    };

    let encryption = match encryption_value.as_object() {
        Some(value) => value,
        None => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".sessions.encryption".to_string(),
                message: "encryption must be an object".to_string(),
            });
            return;
        }
    };

    if let Some(mode) = encryption.get("mode").and_then(|v| v.as_str()) {
        let valid = ["off", "if_password", "required"];
        if !valid.contains(&mode) {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".sessions.encryption.mode".to_string(),
                message: format!(
                    "mode should be one of off/if_password/required, got \"{}\"",
                    mode
                ),
            });
        }
    } else if encryption.contains_key("mode") {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".sessions.encryption.mode".to_string(),
            message: "mode must be a string".to_string(),
        });
    }
}

fn validate_usage(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let usage = match obj.get("usage").and_then(|v| v.as_object()) {
        Some(u) => u,
        None => return,
    };

    let pricing = match usage.get("pricing") {
        Some(value) => match value.as_object() {
            Some(p) => p,
            None => {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".usage.pricing".to_string(),
                    message: "pricing must be an object".to_string(),
                });
                return;
            }
        },
        None => return,
    };

    if let Some(default) = pricing.get("default") {
        match default.as_object() {
            Some(obj) => {
                if let Some(input) = obj.get("inputCostPerMTok") {
                    check_non_negative_number(
                        input,
                        ".usage.pricing.default.inputCostPerMTok",
                        issues,
                    );
                }
                if let Some(output) = obj.get("outputCostPerMTok") {
                    check_non_negative_number(
                        output,
                        ".usage.pricing.default.outputCostPerMTok",
                        issues,
                    );
                }
            }
            None => issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".usage.pricing.default".to_string(),
                message: "default must be an object".to_string(),
            }),
        }
    }

    if let Some(overrides) = pricing.get("overrides") {
        let list = match overrides.as_array() {
            Some(list) => list,
            None => {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: ".usage.pricing.overrides".to_string(),
                    message: "overrides must be an array".to_string(),
                });
                return;
            }
        };

        for (idx, entry) in list.iter().enumerate() {
            let entry_obj = match entry.as_object() {
                Some(obj) => obj,
                None => {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!(".usage.pricing.overrides[{}]", idx),
                        message: "override entry must be an object".to_string(),
                    });
                    continue;
                }
            };

            match entry_obj.get("match").and_then(|v| v.as_str()) {
                Some(value) if !value.trim().is_empty() => {}
                _ => issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: format!(".usage.pricing.overrides[{}].match", idx),
                    message: "match must be a non-empty string".to_string(),
                }),
            }

            if let Some(match_type) = entry_obj.get("matchType").and_then(|v| v.as_str()) {
                if !matches!(match_type, "contains" | "exact") {
                    issues.push(SchemaIssue {
                        severity: Severity::Warning,
                        path: format!(".usage.pricing.overrides[{}].matchType", idx),
                        message: "matchType should be \"contains\" or \"exact\"".to_string(),
                    });
                }
            }

            if let Some(input) = entry_obj.get("inputCostPerMTok") {
                check_non_negative_number(
                    input,
                    &format!(".usage.pricing.overrides[{}].inputCostPerMTok", idx),
                    issues,
                );
            }

            if let Some(output) = entry_obj.get("outputCostPerMTok") {
                check_non_negative_number(
                    output,
                    &format!(".usage.pricing.overrides[{}].outputCostPerMTok", idx),
                    issues,
                );
            }
        }
    }
}

fn validate_vertex(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let vertex = match obj.get("vertex").and_then(|v| v.as_object()) {
        Some(v) => v,
        None => return,
    };

    if let Some(project_id) = vertex.get("projectId") {
        if !project_id.is_string() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".vertex.projectId".to_string(),
                message: "projectId must be a string".to_string(),
            });
        }
    }

    if let Some(location) = vertex.get("location") {
        if !location.is_string() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".vertex.location".to_string(),
                message: "location must be a string".to_string(),
            });
        }
    }

    if let Some(model) = vertex.get("model") {
        if !model.is_string() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".vertex.model".to_string(),
                message: "model must be a string".to_string(),
            });
        }
    }

    if let Some(timeout) = vertex.get("gcloudTokenTimeoutMs") {
        check_vertex_gcloud_token_timeout_ms(timeout, issues);
    }

    if let Some(global_models) = vertex.get("globalModels") {
        check_vertex_global_models(global_models, issues);
    }
}

fn check_vertex_gcloud_token_timeout_ms(value: &Value, issues: &mut Vec<SchemaIssue>) {
    match value.as_u64() {
        Some(n)
            if (crate::agent::vertex::MIN_GCLOUD_TOKEN_TIMEOUT_MS
                ..=crate::agent::vertex::MAX_GCLOUD_TOKEN_TIMEOUT_MS)
                .contains(&n) => {}
        Some(_) => issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: ".vertex.gcloudTokenTimeoutMs".to_string(),
            message: format!(
                "gcloudTokenTimeoutMs must be between {} and {}",
                crate::agent::vertex::MIN_GCLOUD_TOKEN_TIMEOUT_MS,
                crate::agent::vertex::MAX_GCLOUD_TOKEN_TIMEOUT_MS
            ),
        }),
        None => issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".vertex.gcloudTokenTimeoutMs".to_string(),
            message: "gcloudTokenTimeoutMs must be a positive integer".to_string(),
        }),
    }
}

fn check_vertex_global_models(value: &Value, issues: &mut Vec<SchemaIssue>) {
    const EXAMPLES: &str = r#"examples: "gemini-3*", "google/gemini-3.0-flash", "publishers/anthropic/models/claude-sonnet-4*""#;

    let Some(models) = value.as_array() else {
        issues.push(SchemaIssue {
            severity: Severity::Error,
            path: ".vertex.globalModels".to_string(),
            message: format!(
                "globalModels must be an array of strings, got {}; {EXAMPLES}",
                json_type_label(value)
            ),
        });
        return;
    };

    if models.len() >= crate::agent::vertex::VERTEX_GLOBAL_MODELS_WARNING_THRESHOLD {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: ".vertex.globalModels".to_string(),
            message: format!(
                "globalModels has {} entries; large lists may add per-request routing overhead",
                models.len()
            ),
        });
    }

    for (index, model) in models.iter().enumerate() {
        let path = format!(".vertex.globalModels[{index}]");
        let Some(model) = model.as_str() else {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path,
                message: format!(
                    "globalModels entries must be strings, got {} ({})",
                    json_type_label(model),
                    schema_value_snippet(model)
                ),
            });
            continue;
        };

        if crate::agent::vertex::normalize_global_model_rule(model).is_err() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path,
                message: format!(
                    "globalModels entry {:?} must be a valid Vertex global model rule; {EXAMPLES}",
                    schema_value_snippet(model)
                ),
            });
        }
    }
}

fn schema_value_snippet(value: impl ToString) -> String {
    let redacted = crate::logging::redact::redact_string(&value.to_string());
    crate::logging::audit::truncate_audit_free_text_field(&redacted, 128)
}

fn validate_filesystem(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let filesystem = match obj.get("filesystem") {
        Some(value) => value,
        None => return,
    };
    let fs_cfg = match filesystem.as_object() {
        Some(f) => f,
        None => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".filesystem".to_string(),
                message: format!(
                    "filesystem configuration must be an object, got {}",
                    json_type_label(filesystem)
                ),
            });
            return;
        }
    };

    let enabled = match fs_cfg.get("enabled") {
        Some(Value::Bool(enabled)) => *enabled,
        Some(other) => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".filesystem.enabled".to_string(),
                message: format!(
                    "filesystem enabled must be a boolean, got {}",
                    json_type_label(other)
                ),
            });
            false
        }
        None => false,
    };

    // Known keys inside filesystem block
    let known_keys = [
        "enabled",
        "roots",
        "writeAccess",
        "maxReadBytes",
        "excludePatterns",
    ];
    for key in fs_cfg.keys() {
        if !known_keys.contains(&key.as_str()) {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: format!(".filesystem.{}", key),
                message: format!("Unknown filesystem configuration key: {}", key),
            });
        }
    }

    if let Some(write_access) = fs_cfg.get("writeAccess") {
        if !write_access.is_boolean() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".filesystem.writeAccess".to_string(),
                message: format!(
                    "filesystem writeAccess must be a boolean, got {}",
                    json_type_label(write_access)
                ),
            });
        }
    }

    if let Some(max_read_bytes) = fs_cfg.get("maxReadBytes") {
        if !max_read_bytes.is_u64() {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".filesystem.maxReadBytes".to_string(),
                message: format!(
                    "filesystem maxReadBytes must be a non-negative integer, got {}",
                    json_type_label(max_read_bytes)
                ),
            });
        }
    }

    // Skip deeper validation if disabled
    if !enabled {
        return;
    }

    let roots = match fs_cfg.get("roots") {
        Some(value) if !value.is_array() => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".filesystem.roots".to_string(),
                message: format!(
                    "filesystem roots must be an array, got {}",
                    json_type_label(value)
                ),
            });
            None
        }
        Some(value) => value.as_array(),
        None => None,
    };

    let is_empty = roots.is_none_or(|a| a.is_empty());
    if is_empty {
        issues.push(SchemaIssue {
            severity: Severity::Warning,
            path: ".filesystem.roots".to_string(),
            message: "filesystem is enabled but roots is empty; all paths will be denied"
                .to_string(),
        });
    }

    if let Some(arr) = roots {
        for (i, root) in arr.iter().enumerate() {
            if let Some(s) = root.as_str() {
                let path = std::path::Path::new(s);
                if !path.is_absolute() {
                    issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: format!(".filesystem.roots[{}]", i),
                        message: format!("filesystem root must be an absolute path, got \"{}\"", s),
                    });
                } else if !path.exists() {
                    issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: format!(".filesystem.roots[{}]", i),
                        message: format!("filesystem root does not exist: \"{}\"", s),
                    });
                }
            } else {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: format!(".filesystem.roots[{}]", i),
                    message: format!(
                        "filesystem root must be a string, got {}",
                        json_type_label(root)
                    ),
                });
            }
        }
    }

    // Validate excludePatterns syntax
    let patterns = match fs_cfg.get("excludePatterns") {
        Some(value) if !value.is_array() => {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: ".filesystem.excludePatterns".to_string(),
                message: format!(
                    "filesystem excludePatterns must be an array, got {}",
                    json_type_label(value)
                ),
            });
            None
        }
        Some(value) => value.as_array(),
        None => None,
    };
    if let Some(patterns) = patterns {
        for (i, pat) in patterns.iter().enumerate() {
            if let Some(s) = pat.as_str() {
                if glob::Pattern::new(s).is_err() {
                    issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: format!(".filesystem.excludePatterns[{}]", i),
                        message: format!(
                            "invalid glob pattern \"{}\"; fix or remove it (invalid \
                             patterns cause startup failure to prevent silent access widening)",
                            s
                        ),
                    });
                }
            } else {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: format!(".filesystem.excludePatterns[{}]", i),
                    message: format!(
                        "exclude pattern must be a string, got {}",
                        json_type_label(pat)
                    ),
                });
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Return a human-readable label for a JSON value's type.
fn json_type_label(v: &Value) -> &'static str {
    match v {
        Value::Null => "null",
        Value::Bool(_) => "boolean",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}

/// Check that a value is a positive integer (> 0).
fn check_positive_integer(value: &Value, path: &str, issues: &mut Vec<SchemaIssue>) {
    match value.as_u64() {
        Some(n) if n > 0 => {}
        Some(0) => {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: path.to_string(),
                message: "value must be a positive integer (> 0)".to_string(),
            });
        }
        _ => {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: path.to_string(),
                message: "value must be a positive integer".to_string(),
            });
        }
    }
}

fn check_typing_interval_seconds(value: &Value, path: &str, issues: &mut Vec<SchemaIssue>) {
    check_positive_integer(value, path, issues);
    if let Some(n) = value.as_u64() {
        if n > MAX_REASONABLE_TYPING_INTERVAL_SECONDS {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: path.to_string(),
                message: format!(
                    "typing interval above {} seconds is unusually large and may delay or suppress visible typing feedback",
                    MAX_REASONABLE_TYPING_INTERVAL_SECONDS
                ),
            });
        }
    }
}

/// Check that a value is a positive number (> 0).
fn check_positive_number(value: &Value, path: &str, issues: &mut Vec<SchemaIssue>) {
    match value.as_f64() {
        Some(n) if n > 0.0 => {}
        Some(_) => {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: path.to_string(),
                message: "value must be a positive number (> 0)".to_string(),
            });
        }
        None => {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: path.to_string(),
                message: "value must be a number".to_string(),
            });
        }
    }
}

/// Check that a value is a non-negative number (>= 0).
fn check_non_negative_number(value: &Value, path: &str, issues: &mut Vec<SchemaIssue>) {
    match value.as_f64() {
        Some(n) if n >= 0.0 => {}
        Some(_) => {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: path.to_string(),
                message: "value must be a non-negative number (>= 0)".to_string(),
            });
        }
        None => {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: path.to_string(),
                message: "value must be a number".to_string(),
            });
        }
    }
}

// ---------------------------------------------------------------------------
// Route validation
// ---------------------------------------------------------------------------

/// Validate the `routes` map: each key must be a valid route ID and each
/// value must contain a non-empty `model` field with a recognised provider prefix.
fn validate_routes_map(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let routes = match obj.get("routes").and_then(|v| v.as_object()) {
        Some(r) => r,
        None => return,
    };

    static ROUTE_ID_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
    let id_re = ROUTE_ID_RE
        .get_or_init(|| regex::Regex::new(r"^[a-z][a-z0-9-]*$").expect("route id regex"));

    for (key, entry) in routes {
        let path_prefix = format!(".routes.{key}");

        // Key must match ^[a-z][a-z0-9-]*$
        if !id_re.is_match(key) {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: path_prefix.clone(),
                message: format!(
                    "route id \"{key}\" is invalid; \
                     must start with a lowercase letter and contain only \
                     lowercase letters, digits, and hyphens"
                ),
            });
        }

        let Some(entry_obj) = entry.as_object() else {
            issues.push(SchemaIssue {
                severity: Severity::Error,
                path: path_prefix,
                message: format!(
                    "route entry must be an object, got {}",
                    json_type_label(entry)
                ),
            });
            continue;
        };

        // model field — required, non-empty, valid provider prefix
        match entry_obj.get("model").and_then(|v| v.as_str()) {
            Some(model) if !model.trim().is_empty() => {
                check_model_has_provider_prefix(model, &format!("{path_prefix}.model"), issues);
            }
            Some(_) => {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: format!("{path_prefix}.model"),
                    message: "route model must not be empty".to_string(),
                });
            }
            None => {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: format!("{path_prefix}.model"),
                    message: "route entry is missing required `model` field".to_string(),
                });
            }
        }
    }
}

/// Check that `agents.defaults.route` and `agents.list[].route` reference
/// keys that exist in the `routes` map. If `routes` is absent but a route
/// reference is present, that is an error.
fn validate_route_references(obj: &serde_json::Map<String, Value>, issues: &mut Vec<SchemaIssue>) {
    let routes_map = obj.get("routes").and_then(|v| v.as_object());

    let agents = match obj.get("agents").and_then(|v| v.as_object()) {
        Some(a) => a,
        None => return,
    };

    // Helper: validate a single route reference string.
    let check_ref = |route_str: &str, path: &str, issues: &mut Vec<SchemaIssue>| {
        let route_str = route_str.trim();
        if route_str.is_empty() {
            return;
        }
        match routes_map {
            None => {
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: path.to_string(),
                    message: format!(
                        "references route \"{route_str}\" but no `routes` map is defined; \
                         add a top-level `routes` section"
                    ),
                });
            }
            Some(map) if !map.contains_key(route_str) => {
                let available: Vec<&String> = map.keys().collect();
                issues.push(SchemaIssue {
                    severity: Severity::Error,
                    path: path.to_string(),
                    message: format!(
                        "references unknown route \"{route_str}\"; \
                         defined routes are: {available:?}"
                    ),
                });
            }
            _ => {}
        }
    };

    // agents.defaults.route
    if let Some(defaults) = agents.get("defaults").and_then(|v| v.as_object()) {
        if let Some(route_val) = defaults.get("route") {
            match route_val.as_str() {
                Some(s) => check_ref(s, ".agents.defaults.route", issues),
                None => {
                    issues.push(SchemaIssue {
                        severity: Severity::Error,
                        path: ".agents.defaults.route".to_string(),
                        message: "route must be a string".to_string(),
                    });
                }
            }
        }
    }

    // agents.list[].route
    if let Some(list) = agents.get("list").and_then(|v| v.as_array()) {
        for (i, entry) in list.iter().enumerate() {
            if let Some(route_val) = entry.get("route") {
                let path = format!(".agents.list[{i}].route");
                match route_val.as_str() {
                    Some(s) => check_ref(s, &path, issues),
                    None => {
                        issues.push(SchemaIssue {
                            severity: Severity::Error,
                            path,
                            message: "route must be a string".to_string(),
                        });
                    }
                }
            }
        }
    }
}

/// Warn when both `route` and `model` are set on the same scope level,
/// since `route` silently takes precedence.
fn validate_route_model_both_set(
    obj: &serde_json::Map<String, Value>,
    issues: &mut Vec<SchemaIssue>,
) {
    let agents = match obj.get("agents").and_then(|v| v.as_object()) {
        Some(a) => a,
        None => return,
    };

    // agents.defaults
    if let Some(defaults) = agents.get("defaults").and_then(|v| v.as_object()) {
        if defaults.contains_key("route") && defaults.contains_key("model") {
            issues.push(SchemaIssue {
                severity: Severity::Warning,
                path: ".agents.defaults".to_string(),
                message: "both `route` and `model` are set; `route` takes precedence \
                          and `model` will be ignored"
                    .to_string(),
            });
        }
    }

    // agents.list[]
    if let Some(list) = agents.get("list").and_then(|v| v.as_array()) {
        for (i, entry) in list.iter().enumerate() {
            if entry.get("route").is_some() && entry.get("model").is_some() {
                issues.push(SchemaIssue {
                    severity: Severity::Warning,
                    path: format!(".agents.list[{i}]"),
                    message: "both `route` and `model` are set; `route` takes precedence \
                              and `model` will be ignored"
                        .to_string(),
                });
            }
        }
    }
}

/// Basic plausibility check for cron expressions.
/// Accepts 5-field (standard) or 6-field (with seconds) expressions.
/// Each field should be `*`, a number, or a more complex expression.
fn is_plausible_cron(s: &str) -> bool {
    let fields: Vec<&str> = s.split_whitespace().collect();
    if !(5..=6).contains(&fields.len()) {
        return false;
    }
    // Each field must be non-empty and contain only valid cron characters
    fields.iter().all(|f| {
        !f.is_empty()
            && f.chars().all(|c| {
                c.is_ascii_digit() || matches!(c, '*' | ',' | '-' | '/' | '?' | '#' | 'L' | 'W')
            })
    })
}

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

    fn nested_config(path: &[&str], value: serde_json::Value) -> serde_json::Value {
        path.iter().rev().fold(value, |child, key| {
            let mut object = serde_json::Map::new();
            object.insert((*key).to_string(), child);
            serde_json::Value::Object(object)
        })
    }

    // --- valid config passes ---

    #[test]
    fn test_valid_config_no_issues() {
        let cfg = json!({
            "gateway": {
                "port": 18789,
                "bind": "loopback",
                "hooks": { "enabled": true, "maxBodyBytes": 262144 }
            },
            "logging": { "level": "info", "format": "json" }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.is_empty(), "expected no issues, got: {:?}", issues);
    }

    #[test]
    fn test_matrix_valid_config_no_issues() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "accessToken": "token",
                "deviceId": "DEVICE",
                "storePassphrase": "store-secret",
                "encrypted": true,
                "autoJoin": {
                    "allowUsers": ["@alice:example.com"],
                    "allowServerNames": ["example.org"]
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues.is_empty(),
            "unexpected Matrix config issues: {:?}",
            issues
        );
    }

    #[test]
    fn test_matrix_encrypted_schema_accepts_state_dir_store_passphrase_file() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        let temp = tempfile::tempdir().expect("tempdir");
        let passphrase_path =
            crate::channels::matrix::matrix_store_passphrase_file_path(temp.path());
        std::fs::create_dir_all(passphrase_path.parent().unwrap()).expect("create matrix dir");
        std::fs::write(&passphrase_path, "file-passphrase").expect("write passphrase file");
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.unset("MATRIX_STORE_PASSPHRASE");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");

        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "encrypted": true,
            }
        });

        let context =
            SchemaValidationContext::runtime_readiness_for_state_dir(temp.path().to_path_buf());
        let issues = validate_schema_with_context(&cfg, &context);
        assert!(
            !issues
                .iter()
                .any(|i| { i.path == ".matrix.encrypted" && i.severity == Severity::Error }),
            "schema must match runtime passphrase-file source contract; got: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_encrypted_schema_rejects_whitespace_only_passphrase_file() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.unset("MATRIX_STORE_PASSPHRASE");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let passphrase_path =
            crate::channels::matrix::matrix_store_passphrase_file_path(temp.path());
        std::fs::create_dir_all(passphrase_path.parent().unwrap()).expect("create matrix dir");
        std::fs::write(&passphrase_path, "\n\t ").expect("write whitespace passphrase file");
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "encrypted": true,
            }
        });

        let context =
            SchemaValidationContext::runtime_readiness_for_state_dir(temp.path().to_path_buf());
        let issues = validate_schema_with_context(&cfg, &context);

        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.encrypted"
                    && i.severity == Severity::Error
                    && i.message.contains("empty after trimming")
            }),
            "schema must match runtime trim/non-empty passphrase-file behavior; got: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_static_validation_does_not_read_ambient_env_or_state_dir() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        let temp = tempfile::tempdir().expect("tempdir");
        let passphrase_path =
            crate::channels::matrix::matrix_store_passphrase_file_path(temp.path());
        std::fs::create_dir_all(passphrase_path.parent().unwrap()).expect("create matrix dir");
        std::fs::write(&passphrase_path, "file-passphrase").expect("write passphrase file");
        env_guard.set("CARAPACE_STATE_DIR", temp.path().as_os_str());
        env_guard.set("MATRIX_STORE_PASSPHRASE", "ambient-store-passphrase");
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "ambient-config-password");
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "encrypted": true,
            }
        });

        let issues = validate_schema(&cfg);

        assert!(
            issues
                .iter()
                .any(|i| i.path == ".matrix.encrypted" && i.severity == Severity::Error),
            "deterministic schema validation must ignore ambient env/state-dir readiness; got: {issues:?}"
        );
    }

    /// The passphrase file is read with a bounded cap so a multi-GB
    /// file (or a misconfigured FIFO) at `<state_dir>/matrix/store_passphrase`
    /// cannot hang the validator. Reaching the cap surfaces a typed
    /// schema error instead of streaming the file into memory on
    /// every WS `config.get` poll.
    #[test]
    fn test_matrix_passphrase_file_oversize_is_rejected_at_cap() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.unset("MATRIX_STORE_PASSPHRASE");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let passphrase_path =
            crate::channels::matrix::matrix_store_passphrase_file_path(temp.path());
        std::fs::create_dir_all(passphrase_path.parent().unwrap()).expect("create matrix dir");
        // Cap is 64 KiB; write 1 byte over so the size pre-check fires.
        let oversize = vec![b'x'; (SCHEMA_PASSPHRASE_FILE_MAX_BYTES as usize) + 1];
        std::fs::write(&passphrase_path, &oversize).expect("write oversize passphrase");

        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "encrypted": true,
            }
        });

        let context =
            SchemaValidationContext::runtime_readiness_for_state_dir(temp.path().to_path_buf());
        let issues = validate_schema_with_context(&cfg, &context);

        assert!(
            issues.iter().any(|i| i.path == ".matrix.encrypted"
                && i.severity == Severity::Error
                && i.message.contains("at most")
                && i.message.contains("bytes")),
            "oversize passphrase file must surface a typed validator error; got: {issues:?}"
        );
    }

    /// Regression for R58 H-CS1: the validator and the runtime
    /// resolver must agree on whether a symlink-to-regular-file is
    /// acceptable, otherwise an operator's secret-management setup
    /// (1Password / `pass` / secret-volume) passes the daemon
    /// startup but fails the WS `config.get` poll. Both sides
    /// follow symlinks; the regular-file check applies to the
    /// resolved target so a symlink to a FIFO is still caught.
    #[test]
    fn test_matrix_passphrase_path_accepts_symlink_to_regular_file() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.unset("MATRIX_STORE_PASSPHRASE");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let passphrase_path =
            crate::channels::matrix::matrix_store_passphrase_file_path(temp.path());
        std::fs::create_dir_all(passphrase_path.parent().unwrap()).expect("matrix dir");
        let real_target = temp.path().join("real-passphrase");
        std::fs::write(&real_target, "real-passphrase\n").expect("write real target");
        #[cfg(unix)]
        std::os::unix::fs::symlink(&real_target, &passphrase_path).expect("symlink");
        #[cfg(not(unix))]
        std::fs::copy(&real_target, &passphrase_path).expect("copy stand-in");

        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "encrypted": true,
            }
        });

        let context =
            SchemaValidationContext::runtime_readiness_for_state_dir(temp.path().to_path_buf());
        let issues = validate_schema_with_context(&cfg, &context);

        assert!(
            !issues.iter().any(|i| i.path == ".matrix.encrypted"
                && i.severity == Severity::Error
                && (i.message.contains("regular file")
                    || i.message.contains("symlink"))),
            "symlink-to-regular-file passphrase must not surface a Severity::Error; got: {issues:?}"
        );
    }

    /// A non-regular file at the passphrase path (directory used as a
    /// stand-in for any non-file: FIFO, socket, etc.) is rejected
    /// without attempting a read — guards the validator against
    /// FIFO-blocking attacks on the WS hot path.
    #[test]
    fn test_matrix_passphrase_path_must_be_regular_file() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.unset("MATRIX_STORE_PASSPHRASE");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        let temp = tempfile::tempdir().expect("tempdir");
        let passphrase_path =
            crate::channels::matrix::matrix_store_passphrase_file_path(temp.path());
        // Create a directory at the passphrase path so symlink_metadata
        // reports a non-file.
        std::fs::create_dir_all(&passphrase_path).expect("create passphrase as directory");

        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "encrypted": true,
            }
        });

        let context =
            SchemaValidationContext::runtime_readiness_for_state_dir(temp.path().to_path_buf());
        let issues = validate_schema_with_context(&cfg, &context);

        assert!(
            issues.iter().any(|i| i.path == ".matrix.encrypted"
                && i.severity == Severity::Error
                && i.message.contains("regular file")),
            "non-regular passphrase path must surface a typed validator error; got: {issues:?}"
        );
    }

    /// Runtime env values are sampled exactly once at context
    /// construction. Mutating the process env BETWEEN context
    /// construction and validation must NOT change the validator's
    /// view; otherwise a concurrent `config.set` reload could splice
    /// half-old, half-new env into one validation run.
    #[test]
    fn test_runtime_env_snapshot_is_atomic_per_context() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        let temp = tempfile::tempdir().expect("tempdir");

        // Sample 1: every env key set up front. The runtime-readiness
        // context captures this state.
        env_guard.set("MATRIX_HOMESERVER_URL", "https://snapshot.example.com");
        env_guard.set("MATRIX_USER_ID", "@snapshot:example.com");
        env_guard.set("MATRIX_ACCESS_TOKEN", "snapshot-token");
        env_guard.set("MATRIX_STORE_PASSPHRASE", "snapshot-passphrase");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        let context =
            SchemaValidationContext::runtime_readiness_for_state_dir(temp.path().to_path_buf());

        // Mutate the env AFTER context construction. None of these
        // changes should leak into the captured snapshot.
        env_guard.set("MATRIX_HOMESERVER_URL", "https://changed.example.com");
        env_guard.unset("MATRIX_ACCESS_TOKEN");
        env_guard.set("MATRIX_STORE_PASSPHRASE", "");

        assert_eq!(
            context.runtime_env_snapshot.get("MATRIX_HOMESERVER_URL"),
            Some(&"https://snapshot.example.com".to_string()),
            "snapshot must not observe post-construction env mutations"
        );
        assert_eq!(
            context.runtime_env_snapshot.get("MATRIX_ACCESS_TOKEN"),
            Some(&"snapshot-token".to_string()),
            "snapshot must retain values captured at construction even after later unset"
        );
        assert_eq!(
            context.runtime_env_snapshot.get("MATRIX_STORE_PASSPHRASE"),
            Some(&"snapshot-passphrase".to_string()),
            "snapshot must retain values captured at construction even after later overwrite"
        );

        // An env key that was missing at construction is still missing
        // even if the test set it before this point — the snapshot is
        // strictly the state at runtime_readiness_for_state_dir time.
        assert!(
            !context.runtime_env_snapshot.contains_key("MATRIX_PASSWORD"),
            "snapshot must not observe keys absent at construction"
        );
    }

    /// Regression for R58 M-CS6: empty / whitespace-only env values
    /// must be treated as missing AT THE SNAPSHOT SITE so a future
    /// direct reader of `runtime_env_snapshot.get(key)` cannot
    /// observe the empty string and mis-route as "value set." The
    /// consumer `config_env_value_for_validation` already filters
    /// empties, but the contract was implicit — filtering at
    /// insertion enforces it by construction.
    #[test]
    fn test_runtime_env_snapshot_filters_empty_at_insertion() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        let temp = tempfile::tempdir().expect("tempdir");
        env_guard.set("MATRIX_STORE_PASSPHRASE", "");
        env_guard.set("MATRIX_ACCESS_TOKEN", "   \t  ");
        env_guard.set("MATRIX_HOMESERVER_URL", "https://present.example.com");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");

        let context =
            SchemaValidationContext::runtime_readiness_for_state_dir(temp.path().to_path_buf());

        assert!(
            !context
                .runtime_env_snapshot
                .contains_key("MATRIX_STORE_PASSPHRASE"),
            "empty-string env value must be filtered at the snapshot site"
        );
        assert!(
            !context
                .runtime_env_snapshot
                .contains_key("MATRIX_ACCESS_TOKEN"),
            "whitespace-only env value must be filtered at the snapshot site"
        );
        assert_eq!(
            context.runtime_env_snapshot.get("MATRIX_HOMESERVER_URL"),
            Some(&"https://present.example.com".to_string()),
            "non-empty env values must still be captured"
        );
    }

    #[test]
    fn test_matrix_empty_runtime_env_matches_missing_source() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.set("MATRIX_STORE_PASSPHRASE", "");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "encrypted": true,
            }
        });

        let issues =
            validate_schema_with_context(&cfg, &SchemaValidationContext::runtime_readiness());

        assert!(
            issues
                .iter()
                .any(|i| i.path == ".matrix.encrypted" && i.severity == Severity::Error),
            "empty MATRIX_STORE_PASSPHRASE must not count as a passphrase source; got: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_inbound_dlq_legacy_policy_schema_accepts_known_values() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "encrypted": false,
                "inboundDlq": {
                    "legacyEnvelopePolicy": " refuse "
                }
            }
        });

        let issues = validate_schema(&cfg);

        assert!(
            !issues
                .iter()
                .any(|i| i.path.starts_with(".matrix.inboundDlq")),
            "valid legacyEnvelopePolicy must not produce issues; got: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_inbound_dlq_legacy_policy_schema_rejects_invalid_shapes() {
        let base = json!({
            "enabled": true,
            "homeserverUrl": "https://matrix.example.com",
            "userId": "@cara:example.com",
            "password": "secret",
            "encrypted": false
        });

        let mut non_object = base.clone();
        non_object["inboundDlq"] = json!(["refuse"]);
        non_object["autoJoin"] = json!(["not-an-object"]);
        let cfg = json!({ "matrix": non_object });
        let issues = validate_schema(&cfg);
        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.inboundDlq"
                    && i.severity == Severity::Error
                    && i.message.contains("must be an object")
            }),
            "non-object inboundDlq must be rejected; got: {issues:?}"
        );
        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.autoJoin"
                    && i.severity == Severity::Error
                    && i.message.contains("must be an object")
            }),
            "non-object inboundDlq must not suppress sibling Matrix validation; got: {issues:?}"
        );

        let mut bad_policy = base;
        bad_policy["inboundDlq"] = json!({ "legacyEnvelopePolicy": "drop" });
        let cfg = json!({ "matrix": bad_policy });
        let issues = validate_schema(&cfg);
        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.inboundDlq.legacyEnvelopePolicy"
                    && i.severity == Severity::Error
                    && i.message.contains("accept")
                    && i.message.contains("refuse")
            }),
            "unknown legacyEnvelopePolicy must be rejected; got: {issues:?}"
        );

        let unknown_key = json!({
            "enabled": true,
            "homeserverUrl": "https://matrix.example.com",
            "userId": "@cara:example.com",
            "password": "secret",
            "encrypted": false,
            "inboundDlq": {
                "legacyEnvelopePolicy": "accept",
                "unknown": true
            }
        });
        let cfg = json!({ "matrix": unknown_key });
        let issues = validate_schema(&cfg);
        // Schema-runtime contract: unknown inboundDlq keys must be
        // SURFACED to the operator at validation time, but only at
        // Warning severity — the runtime intentionally accepts them
        // for forward-compat (`resolve_matrix_legacy_envelope_policy`
        // in src/channels/matrix.rs). Hard-erroring here would refuse
        // to start a daemon whose runtime would otherwise accept the
        // config.
        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.inboundDlq.unknown"
                    && i.severity == Severity::Warning
                    && i.message.contains("unknown inboundDlq key")
            }),
            "unknown inboundDlq keys must surface as Warning; got: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_errors_on_env_only_plaintext_homeserver_url() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.set("MATRIX_HOMESERVER_URL", "http://matrix.example.com");
        env_guard.set("MATRIX_STORE_PASSPHRASE", "env-passphrase");
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": " ",
                "userId": "@cara:example.com",
                "password": "secret",
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| { i.path == ".matrix.homeserverUrl" && i.severity == Severity::Error }),
            "env-only plaintext non-loopback homeserverUrl must error; got: {issues:?}"
        );
        env_guard.unset("MATRIX_HOMESERVER_URL");
        env_guard.unset("MATRIX_STORE_PASSPHRASE");
    }

    #[test]
    fn test_matrix_warns_on_credential_and_env_precedence() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.set("MATRIX_STORE_PASSPHRASE", "env-passphrase");
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "accessToken": "token",
                "password": "password",
                "deviceId": "DEVICE",
                "storePassphrase": "config-passphrase",
            }
        });
        let issues =
            validate_schema_with_context(&cfg, &SchemaValidationContext::runtime_readiness());
        assert!(
            issues
                .iter()
                .any(|i| { i.path == ".matrix.accessToken" && i.severity == Severity::Error }),
            "accessToken+password ambiguity must error; got: {issues:?}"
        );
        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.storePassphrase"
                    && i.severity == Severity::Warning
                    && i.message.contains("takes precedence")
            }),
            "config/env storePassphrase precedence must warn; got: {issues:?}"
        );
        env_guard.unset("MATRIX_STORE_PASSPHRASE");
    }

    #[test]
    fn test_matrix_allowlist_validates_shape_and_duplicates() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "autoJoin": {
                    "allowUsers": ["@alice:example.com", "@alice:example.com", "alice@example.com"],
                    "allowServerNames": ["example.org", "https://evil.example.org"]
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.autoJoin.allowUsers[1]" && i.severity == Severity::Warning
            }),
            "duplicate allowUsers entry must warn; got: {issues:?}"
        );
        // Post-Batch-88: runtime `read_string_set` accepts any
        // non-empty trimmed string up to MATRIX_ALLOWLIST_ENTRY_MAX_BYTES
        // (no mxid/server-name shape check). Schema previously
        // hard-errored on shape mismatch, breaking the schema/runtime
        // contract (anything runtime accepts must validate). Demoted
        // to Warning — shape-mismatched entries are dead config (will
        // never match), not security risks.
        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.autoJoin.allowUsers[2]" && i.severity == Severity::Warning
            }),
            "invalid allowUsers MXID must warn (not error — schema/runtime contract); got: {issues:?}"
        );
        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.autoJoin.allowServerNames[1]"
                    && i.severity == Severity::Warning
            }),
            "URL-shaped allowServerNames entry must warn (not error — schema/runtime contract); got: {issues:?}"
        );
    }

    /// Schema and runtime resolver must agree on the
    /// accessToken-without-deviceId rejection. The resolver fails the
    /// runtime; the schema validator must emit Severity::Error so
    /// `cara setup` (and any "config valid?" check) surfaces the
    /// problem at write time, not at first daemon start.
    #[test]
    fn test_matrix_rejects_access_token_without_device_id() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "accessToken": "tok",
                "password": "doesnt-rescue",
                // no deviceId
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| { i.path == ".matrix.deviceId" && i.severity == Severity::Error }),
            "schema must error on accessToken-without-deviceId regardless of password presence; \
             got: {issues:?}"
        );
    }

    /// Regression: the accessToken-without-deviceId schema check must
    /// consult the same env/env.vars/runtime-env resolution chain that
    /// `resolve_matrix_config` uses. Previously it read only the
    /// in-config matrix.* fields, so an operator providing
    /// MATRIX_ACCESS_TOKEN via env with no matrix.deviceId saw "schema
    /// OK" but the daemon refused to start with
    /// MatrixError::MissingDeviceIdForTokenRestore.
    #[test]
    fn test_matrix_rejects_env_access_token_without_device_id() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        // ScopedEnv only records keys it sets — inherited env values
        // pass through unchanged. Explicitly unset MATRIX_DEVICE_ID
        // (and every other MATRIX_* env this test depends on being
        // absent) so a developer/CI runner that happens to export
        // these for unrelated reasons doesn't silently make the test
        // pass for the wrong reason (or fail spuriously).
        env_guard.unset("MATRIX_DEVICE_ID");
        env_guard.unset("MATRIX_PASSWORD");
        env_guard.set("MATRIX_ACCESS_TOKEN", "env-tok");
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                // accessToken comes from env above
            }
        });
        let issues =
            validate_schema_with_context(&cfg, &SchemaValidationContext::runtime_readiness());
        assert!(
            issues
                .iter()
                .any(|i| { i.path == ".matrix.deviceId" && i.severity == Severity::Error }),
            "schema must error on env-only accessToken without deviceId; got: {issues:?}"
        );
        env_guard.unset("MATRIX_ACCESS_TOKEN");
    }

    /// Companion: env-supplied deviceId is accepted even when
    /// accessToken comes from config (mirror of the resolver fallback).
    #[test]
    fn test_matrix_accepts_env_device_id_paired_with_config_access_token() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        // Defense-in-depth: see sibling test's comment about
        // ScopedEnv only recording the keys it sets. We want this
        // test to exercise the env-deviceId-paired-with-config-token
        // path specifically, so explicitly unset MATRIX_ACCESS_TOKEN
        // (and other absent-keys we depend on) to defeat any inherited
        // env that would silently change which code path runs.
        env_guard.unset("MATRIX_ACCESS_TOKEN");
        env_guard.unset("MATRIX_PASSWORD");
        env_guard.set("MATRIX_DEVICE_ID", "ENV_DEVICE");
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "accessToken": "tok",
                // deviceId comes from env above
            }
        });
        let issues =
            validate_schema_with_context(&cfg, &SchemaValidationContext::runtime_readiness());
        assert!(
            !issues
                .iter()
                .any(|i| { i.path == ".matrix.deviceId" && i.severity == Severity::Error }),
            "env-supplied deviceId must satisfy the accessToken pairing check; got: {issues:?}"
        );
        env_guard.unset("MATRIX_DEVICE_ID");
    }

    /// `matrix.storePassphrase` set with `encrypted=false` is a
    /// silent-discard hazard at the resolver. Schema flags it as a
    /// warning so operators see "this value will be ignored."
    #[test]
    fn test_matrix_warns_on_unused_store_passphrase() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "encrypted": false,
                "storePassphrase": "ignored-because-encrypted-false",
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues.iter().any(|i| {
                i.path == ".matrix.storePassphrase" && i.severity == Severity::Warning
            }),
            "schema must warn when storePassphrase is set with encrypted=false; got: {issues:?}"
        );
    }

    /// Round 23: pin every userId-shape branch in `validate_matrix`.
    /// The previous regex had three bypass cases — `@:example.com`
    /// (empty localpart), `@cara:` (empty server), and any leading
    /// or trailing whitespace (the trim ran before the whitespace
    /// check, so trailing whitespace was always invisible). Each
    /// case now produces a `Severity::Error` issue at
    /// `.matrix.userId`. Canonical MXIDs still pass.
    #[test]
    fn test_matrix_validates_user_id_shape() {
        let canonical = |user_id: &str| -> Vec<SchemaIssue> {
            let cfg = json!({
                "matrix": {
                    "enabled": true,
                    "homeserverUrl": "https://matrix.example.com",
                    "userId": user_id,
                    "password": "p",
                    "deviceId": "D",
                }
            });
            validate_schema(&cfg)
        };
        let any_user_id_error = |issues: &[SchemaIssue]| -> bool {
            issues.iter().any(|issue| {
                issue.path == ".matrix.userId" && matches!(issue.severity, Severity::Error)
            })
        };

        let issues = canonical("@cara:example.com");
        assert!(
            !any_user_id_error(&issues),
            "canonical MXID must not error; got: {issues:?}"
        );
        let issues = canonical("@cara:matrix.example.org:8448");
        assert!(
            !any_user_id_error(&issues),
            "MXID with explicit port must not error; got: {issues:?}"
        );

        for (label, value) in [
            ("empty localpart", "@:example.com"),
            ("empty server", "@cara:"),
            ("missing colon", "@caraexample.com"),
            ("missing @", "cara:example.com"),
            ("email-form", "cara@example.com"),
            ("trailing whitespace", "@cara:example.com "),
            ("leading whitespace", " @cara:example.com"),
            ("internal whitespace", "@cara :example.com"),
            ("empty string", ""),
        ] {
            let issues = canonical(value);
            assert!(
                any_user_id_error(&issues),
                "{label} ({value:?}) must produce a Severity::Error at .matrix.userId; got: {issues:?}"
            );
        }
    }

    #[test]
    fn test_matrix_rejects_enabled_as_string() {
        // "enabled": "false" silently parses as enabled-anyway via
        // unwrap_or(true). Must error so the operator notices the typo.
        let cfg = json!({
            "matrix": {
                "enabled": "false",
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "p",
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| i.path == ".matrix.enabled" && i.severity == Severity::Error),
            "non-boolean enabled must error: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_empty_object_rejects_missing_runtime_required_fields() {
        let cfg = json!({ "matrix": {} });
        let issues = validate_schema(&cfg);

        for path in [
            ".matrix.homeserverUrl",
            ".matrix.userId",
            ".matrix.accessToken",
        ] {
            assert!(
                issues
                    .iter()
                    .any(|issue| issue.path == path && issue.severity == Severity::Error),
                "{path} must error for matrix: {{}}; got {issues:?}"
            );
        }
    }

    #[test]
    fn test_matrix_rejects_encrypted_as_string() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "encrypted": "false",
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "p",
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| i.path == ".matrix.encrypted" && i.severity == Severity::Error),
            "non-boolean encrypted must error: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_rejects_non_object_root() {
        let cfg = json!({
            "matrix": true
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| i.path == ".matrix" && i.severity == Severity::Error),
            "non-object matrix must error: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_rejects_secret_and_identity_non_strings() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": 42,
                "userId": ["@cara:example.com"],
                "accessToken": 12345,
                "password": false,
                "deviceId": {"id": "DEVICE"},
                "storePassphrase": ["secret"]
            }
        });
        let issues = validate_schema(&cfg);
        for field in [
            "homeserverUrl",
            "userId",
            "accessToken",
            "password",
            "deviceId",
            "storePassphrase",
        ] {
            assert!(
                issues.iter().any(|i| {
                    i.path == format!(".matrix.{field}") && i.severity == Severity::Error
                }),
                "non-string matrix.{field} must error: {issues:?}"
            );
        }
    }

    #[test]
    fn test_matrix_rejects_null_secret_and_identity_deletions_as_full_config() {
        let cfg = json!({
            "matrix": {
                "homeserverUrl": null,
                "userId": null,
                "accessToken": null,
                "password": null,
                "deviceId": null,
                "storePassphrase": null,
                "encrypted": false
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| i.path == ".matrix.homeserverUrl" && i.severity == Severity::Error),
            "null Matrix deletion markers are valid only inside a patch adapter; full configs must error: {issues:?}"
        );
        assert!(
            issues
                .iter()
                .any(|i| i.path == ".matrix.userId" && i.severity == Severity::Error),
            "full configs must validate the merged runtime candidate, not the patch marker: {issues:?}"
        );
    }

    #[test]
    fn test_all_structured_secret_config_paths_reject_non_strings_and_scope_null_markers() {
        let structured_secret_paths: Vec<(Vec<&str>, String)> = super::super::CONFIG_SECRET_PATHS
            .iter()
            .copied()
            .filter(|path| !path.starts_with("/env/"))
            .map(|pointer| {
                let segments: Vec<&str> = pointer
                    .trim_start_matches('/')
                    .split('/')
                    .filter(|segment| !segment.is_empty())
                    .collect();
                let dot_path = format!(".{}", segments.join("."));
                (segments, dot_path)
            })
            .collect();
        let mut structured_secret_pointers: Vec<String> = structured_secret_paths
            .iter()
            .map(|(path, _)| format!("/{}", path.join("/")))
            .collect();
        structured_secret_pointers.sort();
        let mut config_secret_pointers: Vec<String> = super::super::CONFIG_SECRET_PATHS
            .iter()
            .copied()
            .filter(|path| !path.starts_with("/env/"))
            .map(str::to_string)
            .collect();
        config_secret_pointers.sort();
        assert_eq!(
            structured_secret_pointers, config_secret_pointers,
            "every non-env CONFIG_SECRET_PATHS entry must have a structured schema boundary"
        );

        for (path, dot_path) in structured_secret_paths {
            let issues = validate_schema(&nested_config(&path, json!({ "not": "a string" })));
            assert!(
                issues
                    .iter()
                    .any(|issue| issue.path == dot_path && issue.severity == Severity::Error),
                "{dot_path} must reject non-string secret values: {issues:?}"
            );

            let null_issues = validate_schema(&nested_config(&path, serde_json::Value::Null));
            if dot_path.starts_with(".matrix.") {
                assert!(
                    null_issues.iter().any(|issue| {
                        issue.path.starts_with(".matrix.") && issue.severity == Severity::Error
                    }),
                    "{dot_path} null belongs to the config patch adapter; full Matrix runtime \
                     candidates must still validate after merge: {null_issues:?}"
                );
            } else {
                assert!(
                    !null_issues
                        .iter()
                        .any(|issue| issue.path == dot_path && issue.severity == Severity::Error),
                    "{dot_path} must accept null as a deletion marker: {null_issues:?}"
                );
            }
        }
    }

    #[test]
    fn test_secret_bearing_non_string_fields_are_errors() {
        let cfg = json!({
            "gateway": {
                "auth": {
                    "token": ["not", "a", "token"],
                    "password": { "secret": true }
                },
                "hooks": {
                    "token": ["not", "a", "token"]
                }
            },
            "anthropic": {
                "apiKey": false
            },
            "google": {
                "apiKey": { "secret": true }
            },
            "auth": {
                "profiles": {
                    "providers": {
                        "google": {
                            "clientSecret": 123
                        },
                        "github": {
                            "clientSecret": false
                        },
                        "discord": {
                            "clientSecret": []
                        },
                        "openai": {
                            "clientSecret": {}
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        for path in [
            ".gateway.auth.token",
            ".gateway.auth.password",
            ".gateway.hooks.token",
            ".anthropic.apiKey",
            ".google.apiKey",
            ".auth.profiles.providers.google.clientSecret",
            ".auth.profiles.providers.github.clientSecret",
            ".auth.profiles.providers.discord.clientSecret",
            ".auth.profiles.providers.openai.clientSecret",
        ] {
            assert!(
                issues
                    .iter()
                    .any(|issue| issue.path == path && issue.severity == Severity::Error),
                "{path} must be a Severity::Error for non-string secret-bearing values: {issues:?}"
            );
        }
    }

    #[test]
    fn test_secret_bearing_null_fields_are_deletion_markers() {
        let cfg = json!({
            "gateway": {
                "auth": { "token": null, "password": null },
                "hooks": { "token": null }
            },
            "anthropic": { "apiKey": null },
            "google": { "apiKey": null },
            "auth": {
                "profiles": {
                    "providers": {
                        "google": { "clientSecret": null },
                        "github": { "clientSecret": null },
                        "discord": { "clientSecret": null },
                        "openai": { "clientSecret": null }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        for path in [
            ".gateway.auth.token",
            ".gateway.auth.password",
            ".gateway.hooks.token",
            ".anthropic.apiKey",
            ".google.apiKey",
            ".auth.profiles.providers.google.clientSecret",
            ".auth.profiles.providers.github.clientSecret",
            ".auth.profiles.providers.discord.clientSecret",
            ".auth.profiles.providers.openai.clientSecret",
        ] {
            assert!(
                !issues
                    .iter()
                    .any(|issue| issue.path == path && issue.severity == Severity::Error),
                "{path} null must be accepted as a deletion marker: {issues:?}"
            );
        }
    }

    #[test]
    fn test_provider_secret_bearing_non_string_fields_are_errors() {
        let cfg = json!({
            "openai": { "apiKey": 1 },
            "nearai": { "apiKey": false },
            "telegram": { "botToken": false, "webhookSecret": [] },
            "discord": { "botToken": {} },
            "slack": { "botToken": 2, "signingSecret": false },
            "bedrock": {
                "accessKeyId": [],
                "secretAccessKey": {},
                "sessionToken": 3
            },
            "venice": { "apiKey": false },
            "ollama": { "apiKey": [] },
            "models": { "providers": { "openai": { "apiKey": {} } } },
            "providers": { "ollama": { "apiKey": 7 } }
        });
        let issues = validate_schema(&cfg);
        for path in PROVIDER_SECRET_STRING_PATHS
            .iter()
            .map(|(_, dot_path)| *dot_path)
        {
            assert!(
                issues
                    .iter()
                    .any(|issue| issue.path == path && issue.severity == Severity::Error),
                "{path} must be a Severity::Error for non-string secret-bearing values: {issues:?}"
            );
        }
    }

    #[test]
    fn test_provider_secret_bearing_null_fields_are_deletion_markers() {
        let cfg = json!({
            "openai": { "apiKey": null },
            "nearai": { "apiKey": null },
            "telegram": { "botToken": null, "webhookSecret": null },
            "discord": { "botToken": null },
            "slack": { "botToken": null, "signingSecret": null },
            "bedrock": {
                "accessKeyId": null,
                "secretAccessKey": null,
                "sessionToken": null
            },
            "venice": { "apiKey": null },
            "ollama": { "apiKey": null },
            "models": { "providers": { "openai": { "apiKey": null } } },
            "providers": { "ollama": { "apiKey": null } }
        });
        let issues = validate_schema(&cfg);
        for path in PROVIDER_SECRET_STRING_PATHS
            .iter()
            .map(|(_, dot_path)| *dot_path)
        {
            assert!(
                !issues
                    .iter()
                    .any(|issue| issue.path == path && issue.severity == Severity::Error),
                "{path} null must be accepted as a deletion marker: {issues:?}"
            );
        }
    }

    #[test]
    fn test_matrix_rejects_auto_join_not_object() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "p",
                "autoJoin": ["not", "an", "object"],
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| i.path == ".matrix.autoJoin" && i.severity == Severity::Error),
            "non-object autoJoin must error: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_rejects_allow_users_not_array() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "p",
                "autoJoin": {
                    "allowUsers": "@alice:example.com"
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| i.path == ".matrix.autoJoin.allowUsers" && i.severity == Severity::Error),
            "non-array allowUsers must error: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_skip_validation_when_disabled() {
        // When matrix.enabled = false, downstream validation (homeserver,
        // accessToken/deviceId, autoJoin) should not run.
        let cfg = json!({
            "matrix": {
                "enabled": false,
                "accessToken": "tok",
                // deviceId missing — would error if enabled.
                "autoJoin": "garbage", // would error if enabled.
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues.iter().any(|i| i.path == ".matrix.deviceId"),
            "disabled matrix must not check deviceId: {issues:?}"
        );
        assert!(
            !issues.iter().any(|i| i.path == ".matrix.autoJoin"),
            "disabled matrix must not check autoJoin: {issues:?}"
        );
    }

    #[test]
    fn test_matrix_rejects_invalid_auto_join_entries() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "password": "secret",
                "autoJoin": {
                    "allowUsers": ["@alice:example.com", 42]
                }
            }
        });
        let issues = validate_schema(&cfg);
        // Round 23: promoted from Warning to Error to match the
        // runtime behaviour — `resolve_matrix_config` rejects this
        // shape with `MatrixError::InvalidStringArray` and
        // `register_matrix_channel_if_configured` propagates it as
        // a startup error.
        assert!(issues.iter().any(|i| {
            i.path == ".matrix.autoJoin.allowUsers[1]" && i.severity == Severity::Error
        }));
    }

    // --- port validation ---

    #[test]
    fn test_invalid_port_string() {
        let cfg = json!({ "gateway": { "port": "not-a-number" } });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".gateway.port" && i.severity == Severity::Error));
    }

    #[test]
    fn test_port_zero_rejected() {
        let cfg = json!({ "gateway": { "port": 0 } });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".gateway.port" && i.severity == Severity::Error));
    }

    #[test]
    fn test_port_too_high() {
        let cfg = json!({ "gateway": { "port": 70000 } });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".gateway.port" && i.severity == Severity::Error));
    }

    #[test]
    fn test_port_valid_boundary() {
        let cfg = json!({ "gateway": { "port": 1 } });
        let issues = validate_schema(&cfg);
        assert!(!issues.iter().any(|i| i.path == ".gateway.port"));

        let cfg = json!({ "gateway": { "port": 65535 } });
        let issues = validate_schema(&cfg);
        assert!(!issues.iter().any(|i| i.path == ".gateway.port"));
    }

    // --- bind validation ---

    #[test]
    fn test_bind_valid_known_values() {
        for val in &["loopback", "lan", "auto", "tailnet"] {
            let cfg = json!({ "gateway": { "bind": val } });
            let issues = validate_schema(&cfg);
            assert!(
                !issues.iter().any(|i| i.path == ".gateway.bind"),
                "bind={} should be valid",
                val
            );
        }
    }

    #[test]
    fn test_bind_valid_ip() {
        let cfg = json!({ "gateway": { "bind": "192.168.1.1" } });
        let issues = validate_schema(&cfg);
        assert!(!issues.iter().any(|i| i.path == ".gateway.bind"));
    }

    #[test]
    fn test_bind_invalid() {
        let cfg = json!({ "gateway": { "bind": "banana" } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".gateway.bind"));
    }

    // --- reload mode ---

    #[test]
    fn test_reload_mode_valid() {
        for mode in &["hot", "hybrid", "off"] {
            let cfg = json!({ "gateway": { "reload": { "mode": mode } } });
            let issues = validate_schema(&cfg);
            assert!(!issues.iter().any(|i| i.path == ".gateway.reload.mode"));
        }
    }

    #[test]
    fn test_reload_mode_invalid() {
        let cfg = json!({ "gateway": { "reload": { "mode": "invalid" } } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".gateway.reload.mode"));
    }

    // --- debounce ---

    #[test]
    fn test_debounce_valid() {
        let cfg = json!({ "gateway": { "reload": { "debounceMs": 300 } } });
        let issues = validate_schema(&cfg);
        assert!(!issues.iter().any(|i| i.path.contains("debounceMs")));
    }

    #[test]
    fn test_debounce_zero_warns() {
        let cfg = json!({ "gateway": { "reload": { "debounceMs": 0 } } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path.contains("debounceMs")));
    }

    // --- hooks ---

    #[test]
    fn test_hooks_root_is_error() {
        let cfg = json!({ "hooks": { "enabled": true } });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".hooks" && i.severity == Severity::Error));
    }

    #[test]
    fn test_vertex_config_valid() {
        let cfg = json!({
            "vertex": {
                "projectId": "my-project",
                "location": "us-central1",
                "model": "gemini-2.5-flash",
                "gcloudTokenTimeoutMs": 10_000
            }
        });
        let issues = validate_schema(&cfg);
        assert!(!issues.iter().any(|i| i.path.starts_with(".vertex")));
    }

    #[test]
    fn test_vertex_project_id_must_be_string() {
        let cfg = json!({ "vertex": { "projectId": 123 } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".vertex.projectId"));
    }

    #[test]
    fn test_vertex_location_must_be_string() {
        let cfg = json!({ "vertex": { "location": 123 } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".vertex.location"));
    }

    #[test]
    fn test_vertex_model_must_be_string() {
        let cfg = json!({ "vertex": { "model": 123 } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".vertex.model"));
    }

    #[test]
    fn test_vertex_gcloud_token_timeout_must_be_integer() {
        let cfg = json!({ "vertex": { "gcloudTokenTimeoutMs": "10s" } });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".vertex.gcloudTokenTimeoutMs" && i.severity == Severity::Error));
    }

    #[test]
    fn test_vertex_gcloud_token_timeout_must_be_in_range() {
        for value in [
            0,
            crate::agent::vertex::MIN_GCLOUD_TOKEN_TIMEOUT_MS - 1,
            crate::agent::vertex::MAX_GCLOUD_TOKEN_TIMEOUT_MS + 1,
        ] {
            let cfg = json!({ "vertex": { "gcloudTokenTimeoutMs": value } });
            let issues = validate_schema(&cfg);
            assert!(
                issues.iter().any(|i| {
                    i.path == ".vertex.gcloudTokenTimeoutMs" && i.severity == Severity::Warning
                }),
                "expected warning for value {value}"
            );
        }
    }

    #[test]
    fn test_vertex_global_models_accepts_valid_rules() {
        let cfg = json!({
            "vertex": {
                "globalModels": [
                    "gemini-3*",
                    "google/gemini-2.5-flash",
                    "publishers/anthropic/models/claude-sonnet-4*"
                ]
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues
                .iter()
                .any(|i| i.path.starts_with(".vertex.globalModels")),
            "{issues:?}"
        );
    }

    #[test]
    fn test_vertex_global_models_must_be_array_of_valid_strings() {
        for (cfg, expected_path, expected_message) in [
            (
                json!({ "vertex": { "globalModels": "gemini-3*" } }),
                ".vertex.globalModels",
                "array of strings",
            ),
            (
                json!({ "vertex": { "globalModels": [123] } }),
                ".vertex.globalModels[0]",
                "got number",
            ),
            (
                json!({ "vertex": { "globalModels": ["publishers/openai/models/gpt-5"] } }),
                ".vertex.globalModels[0]",
                "publishers/anthropic/models/claude-sonnet-4*",
            ),
            (
                json!({ "vertex": { "globalModels": ["*"] } }),
                ".vertex.globalModels[0]",
                "gemini-3*",
            ),
        ] {
            let issues = validate_schema(&cfg);
            assert!(
                issues.iter().any(|i| {
                    i.path == expected_path
                        && i.severity == Severity::Error
                        && i.message.contains(expected_message)
                }),
                "expected error at {expected_path} containing {expected_message:?} for {cfg:?}; got {issues:?}"
            );
        }
    }

    #[test]
    fn test_vertex_global_models_large_list_warns() {
        let cfg = json!({
            "vertex": {
                "globalModels": vec![
                    "gemini-3*";
                    crate::agent::vertex::VERTEX_GLOBAL_MODELS_WARNING_THRESHOLD
                ]
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues.iter().any(|i| {
                i.path == ".vertex.globalModels"
                    && i.severity == Severity::Warning
                    && i.message.contains("per-request routing overhead")
            }),
            "expected large globalModels warning, got {issues:?}"
        );
    }

    #[test]
    fn test_anthropic_auth_profile_requires_auth_profiles_enabled() {
        let cfg = json!({
            "anthropic": { "authProfile": "anthropic:default" },
            "auth": { "profiles": { "enabled": false } }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".anthropic.authProfile"
                && i.message.contains("auth.profiles.enabled")));
    }

    #[test]
    fn test_anthropic_api_key_and_auth_profile_warn() {
        let cfg = json!({
            "anthropic": {
                "apiKey": "${ANTHROPIC_API_KEY}",
                "authProfile": "anthropic:default"
            },
            "auth": { "profiles": { "enabled": true } }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".anthropic"
            && i.message
                .contains("either anthropic.apiKey or anthropic.authProfile")));
    }

    #[test]
    fn test_anthropic_auth_profile_must_be_string() {
        let cfg = json!({
            "anthropic": { "authProfile": 123 }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".anthropic.authProfile"));
    }

    #[test]
    fn test_google_auth_profile_requires_auth_profiles_enabled() {
        let cfg = json!({
            "google": { "authProfile": "google-abc123" },
            "auth": { "profiles": { "enabled": false } }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(
            |i| i.path == ".google.authProfile" && i.message.contains("auth.profiles.enabled")
        ));
    }

    #[test]
    fn test_google_api_key_and_auth_profile_warn() {
        let cfg = json!({
            "google": {
                "apiKey": "${GOOGLE_API_KEY}",
                "authProfile": "google-abc123"
            },
            "auth": { "profiles": { "enabled": true } }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".google"
            && i.message
                .contains("either google.apiKey or google.authProfile")));
    }

    #[test]
    fn test_codex_auth_profile_requires_auth_profiles_enabled() {
        let cfg = json!({
            "codex": { "authProfile": "openai-abc123" },
            "auth": { "profiles": { "enabled": false } }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| i.path == ".codex.authProfile"
                    && i.message.contains("auth.profiles.enabled"))
        );
    }

    #[test]
    fn test_codex_auth_profile_must_be_string() {
        let cfg = json!({
            "codex": { "authProfile": 123 }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".codex.authProfile"));
    }

    #[test]
    fn test_auth_profiles_provider_secret_must_be_string() {
        let cfg = json!({
            "auth": {
                "profiles": {
                    "enabled": true,
                    "providers": {
                        "google": {
                            "clientId": "abc",
                            "clientSecret": 123
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".auth.profiles.providers.google.clientSecret"));
    }

    #[test]
    fn test_auth_profiles_openai_provider_secret_must_be_string() {
        let cfg = json!({
            "auth": {
                "profiles": {
                    "enabled": true,
                    "providers": {
                        "openai": {
                            "clientId": "abc",
                            "clientSecret": 123
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".auth.profiles.providers.openai.clientSecret"));
    }

    // --- logging ---

    #[test]
    fn test_logging_level_invalid() {
        let cfg = json!({ "logging": { "level": "verbose" } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".logging.level"));
    }

    #[test]
    fn test_logging_format_invalid() {
        let cfg = json!({ "logging": { "format": "yaml" } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".logging.format"));
    }

    #[test]
    fn test_logging_valid() {
        let cfg = json!({ "logging": { "level": "debug", "format": "text" } });
        let issues = validate_schema(&cfg);
        assert!(!issues.iter().any(|i| i.path.starts_with(".logging")));
    }

    // --- agents defaults ---

    #[test]
    fn test_agents_defaults_valid() {
        let cfg = json!({ "agents": { "defaults": { "maxConcurrent": 5, "timeoutSeconds": 60, "contextTokens": 8000 } } });
        let issues = validate_schema(&cfg);
        assert!(
            !issues
                .iter()
                .any(|i| i.path.starts_with(".agents.defaults")),
            "Expected no issues for agents.defaults, but found: {:?}",
            issues
        );
    }

    /// `max_processes: 0` parses cleanly as u64 but blocks every
    /// fork inside the sandboxed tool, hiding behind opaque exec
    /// errors. Schema validation must reject the zero/negative/
    /// non-integer cases at load time so the operator gets an
    /// actionable error instead of mysterious tool failures.
    #[test]
    fn test_agents_defaults_sandbox_rejects_invalid_numeric_fields() {
        for (field, bad_value) in [
            ("max_processes", json!(0)),
            ("max_processes", json!(-1)),
            ("max_processes", json!("infinity")),
            ("max_cpu_seconds", json!(0)),
            ("max_memory_mb", json!(0)),
            ("max_fds", json!(0)),
        ] {
            let cfg = json!({
                "agents": {
                    "defaults": {
                        "sandbox": { field: bad_value }
                    }
                }
            });
            let issues = validate_schema(&cfg);
            let path = format!(".agents.defaults.sandbox.{field}");
            assert!(
                issues.iter().any(|i| i.path == path),
                "{field} = {bad_value} must be rejected at {path}, got: {issues:?}"
            );
        }
    }

    #[test]
    fn test_agents_list_sandbox_validated_per_entry() {
        let cfg = json!({
            "agents": {
                "list": [
                    { "name": "bad", "sandbox": { "max_processes": 0 } }
                ]
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            issues
                .iter()
                .any(|i| i.path == ".agents.list[0].sandbox.max_processes"),
            "per-entry sandbox numeric fields must be validated, got: {issues:?}"
        );
    }

    #[test]
    fn test_agents_defaults_sandbox_accepts_valid_numerics() {
        let cfg = json!({
            "agents": {
                "defaults": {
                    "sandbox": {
                        "enabled": true,
                        "max_cpu_seconds": 30,
                        "max_memory_mb": 512,
                        "max_fds": 256,
                        "max_processes": 32,
                        "allowed_paths": ["/tmp"],
                        "network_access": false
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues
                .iter()
                .any(|i| i.path.starts_with(".agents.defaults.sandbox")),
            "valid sandbox config must not surface issues, got: {issues:?}"
        );
    }

    fn assert_alias_rejected(issues: &[SchemaIssue], path: &str, canonical: &str) {
        let issue = issues
            .iter()
            .find(|issue| issue.path == path)
            .unwrap_or_else(|| panic!("missing issue at {path}; got {issues:?}"));
        assert_eq!(issue.severity, Severity::Error);
        assert!(issue.message.contains(canonical), "got: {:?}", issue);
    }

    #[test]
    fn test_agents_defaults_timeout_alias_rejected() {
        let cfg = json!({ "agents": { "defaults": { "timeout": 60 } } });
        let issues = validate_schema(&cfg);
        assert_alias_rejected(
            &issues,
            ".agents.defaults.timeout",
            ".agents.defaults.timeoutSeconds",
        );
    }

    #[test]
    fn test_agents_top_level_snake_case_aliases_rejected() {
        let cfg = json!({
            "agents": {
                "prompt_guard": { "enabled": true },
                "output_sanitizer": { "sanitize_html": false }
            }
        });
        let issues = validate_schema(&cfg);
        assert_alias_rejected(&issues, ".agents.prompt_guard", ".agents.promptGuard");
        assert_alias_rejected(
            &issues,
            ".agents.output_sanitizer",
            ".agents.outputSanitizer",
        );
    }

    #[test]
    fn test_agent_override_aliases_rejected() {
        let cfg = json!({
            "agents": {
                "defaults": {
                    "max_tokens": 1234,
                    "processSandbox": { "enabled": true }
                },
                "list": [{
                    "id": "main",
                    "max_turns": 7,
                    "exfiltration_guard": true,
                    "prompt_guard": { "enabled": true },
                    "output_sanitizer": { "sanitizeHtml": false },
                    "process_sandbox": { "enabled": true }
                }]
            }
        });
        let issues = validate_schema(&cfg);
        assert_alias_rejected(
            &issues,
            ".agents.defaults.max_tokens",
            ".agents.defaults.maxTokens",
        );
        assert_alias_rejected(
            &issues,
            ".agents.defaults.processSandbox",
            ".agents.defaults.sandbox",
        );
        assert_alias_rejected(
            &issues,
            ".agents.list[0].max_turns",
            ".agents.list[0].maxTurns",
        );
        assert_alias_rejected(
            &issues,
            ".agents.list[0].exfiltration_guard",
            ".agents.list[0].exfiltrationGuard",
        );
        assert_alias_rejected(
            &issues,
            ".agents.list[0].prompt_guard",
            ".agents.list[0].promptGuard",
        );
        assert_alias_rejected(
            &issues,
            ".agents.list[0].output_sanitizer",
            ".agents.list[0].outputSanitizer",
        );
        assert_alias_rejected(
            &issues,
            ".agents.list[0].process_sandbox",
            ".agents.list[0].sandbox",
        );
    }

    #[test]
    fn test_agents_defaults_zero_warns() {
        let cfg = json!({ "agents": { "defaults": { "maxConcurrent": 0 } } });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".agents.defaults.maxConcurrent"));
    }

    #[test]
    fn test_agents_output_sanitizer_valid() {
        let cfg = json!({
            "agents": {
                "outputSanitizer": {
                    "sanitizeHtml": false,
                    "cspPolicy": "default-src 'self'"
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(!issues
            .iter()
            .any(|i| i.path.starts_with(".agents.outputSanitizer")));
    }

    #[test]
    fn test_agents_output_sanitizer_invalid_types() {
        let cfg = json!({
            "agents": {
                "outputSanitizer": {
                    "sanitizeHtml": "false",
                    "cspPolicy": 123
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".agents.outputSanitizer.sanitizeHtml"));
        assert!(issues
            .iter()
            .any(|i| i.path == ".agents.outputSanitizer.cspPolicy"));
    }

    #[test]
    fn test_agents_output_sanitizer_field_aliases_rejected() {
        let cfg = json!({
            "agents": {
                "outputSanitizer": {
                    "sanitize_html": false,
                    "csp_policy": "default-src 'self'"
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert_alias_rejected(
            &issues,
            ".agents.outputSanitizer.sanitize_html",
            ".agents.outputSanitizer.sanitizeHtml",
        );
        assert_alias_rejected(
            &issues,
            ".agents.outputSanitizer.csp_policy",
            ".agents.outputSanitizer.cspPolicy",
        );
    }

    #[test]
    fn test_prompt_guard_config_lint_snake_case_valid() {
        let cfg = json!({
            "agents": {
                "promptGuard": {
                    "config_lint": {
                        "enabled": true
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues
                .iter()
                .any(|i| i.path.starts_with(".agents.promptGuard.config_lint")),
            "Expected no config_lint issues, but found: {:?}",
            issues
        );
    }

    #[test]
    fn test_prompt_guard_config_lint_snake_case_enabled_must_be_bool() {
        let cfg = json!({
            "agents": {
                "promptGuard": {
                    "config_lint": {
                        "enabled": "true"
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".agents.promptGuard.config_lint.enabled"));
    }

    #[test]
    fn test_prompt_guard_config_lint_camel_case_alias_rejected() {
        let cfg = json!({
            "agents": {
                "promptGuard": {
                    "configLint": {
                        "enabled": true
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert_alias_rejected(
            &issues,
            ".agents.promptGuard.configLint",
            ".agents.promptGuard.config_lint",
        );
    }

    #[test]
    fn test_prompt_guard_config_lint_snake_case_triggers_lint_checks() {
        let cfg = json!({
            "agents": {
                "promptGuard": {
                    "config_lint": {
                        "enabled": true
                    }
                },
                "list": [{
                    "toolPolicy": "AllowAll"
                }]
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".agents.list[0].toolPolicy"));
        assert!(issues.iter().any(|i| i.path == ".agents.list[0].maxTokens"));
        assert!(issues.iter().any(|i| i.path == ".agents.list[0].model"));
    }

    // --- session retention ---

    #[test]
    fn test_session_retention_valid() {
        let cfg = json!({ "sessions": { "retention": { "days": 30, "enabled": true } } });
        let issues = validate_schema(&cfg);
        assert!(!issues.iter().any(|i| i.path.starts_with(".sessions")));
    }

    #[test]
    fn test_session_retention_enabled_non_bool() {
        let cfg = json!({ "sessions": { "retention": { "enabled": "true" } } });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".sessions.retention.enabled"));
    }

    #[test]
    fn test_session_retention_days_alias_rejected() {
        let cfg = json!({ "sessions": { "retentionDays": 30 } });
        let issues = validate_schema(&cfg);
        assert_alias_rejected(
            &issues,
            ".sessions.retentionDays",
            ".sessions.retention.days",
        );
    }

    #[test]
    fn test_session_encryption_mode_valid() {
        for mode in ["off", "if_password", "required"] {
            let cfg = json!({ "sessions": { "encryption": { "mode": mode } } });
            let issues = validate_schema(&cfg);
            assert!(
                !issues
                    .iter()
                    .any(|i| i.path.starts_with(".sessions.encryption")),
                "expected mode {mode} to validate without session encryption issues"
            );
        }
    }

    #[test]
    fn test_session_encryption_mode_invalid() {
        let cfg = json!({ "sessions": { "encryption": { "mode": "sometimes" } } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".sessions.encryption.mode"));
    }

    #[test]
    fn test_session_encryption_must_be_object() {
        let cfg = json!({ "sessions": { "encryption": "required" } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path == ".sessions.encryption"));
    }

    #[test]
    fn test_channels_features_valid() {
        let cfg = json!({
            "channels": {
                "defaults": {
                    "features": {
                        "typing": {
                            "enabled": true,
                            "mode": "thinking",
                            "intervalSeconds": 3
                        },
                        "readReceipts": {
                            "enabled": false
                        }
                    }
                },
                "signal": {
                    "features": {
                        "typing": {
                            "enabled": false,
                            "intervalSeconds": 7
                        },
                        "readReceipts": {
                            "enabled": true
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues
                .iter()
                .any(|issue| issue.path.starts_with(".channels")),
            "unexpected channel issues: {:?}",
            issues
        );
    }

    #[test]
    fn test_channels_features_invalid_values_warn() {
        let cfg = json!({
            "channels": {
                "signal": {
                    "features": {
                        "typing": {
                            "enabled": "yes",
                            "mode": "forever",
                            "intervalSeconds": 0
                        },
                        "readReceipts": {
                            "enabled": "yes",
                            "mode": "on-receive"
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|issue| issue.path == ".channels.signal.features.typing.enabled"));
        assert!(issues
            .iter()
            .any(|issue| issue.path == ".channels.signal.features.typing.mode"));
        assert!(issues
            .iter()
            .any(|issue| issue.path == ".channels.signal.features.typing.intervalSeconds"));
        assert!(issues
            .iter()
            .any(|issue| issue.path == ".channels.signal.features.readReceipts.enabled"));
        assert!(issues
            .iter()
            .any(|issue| issue.path == ".channels.signal.features.readReceipts.mode"));
    }

    #[test]
    fn test_channels_features_typing_interval_warns_when_unusually_large() {
        let cfg = json!({
            "channels": {
                "signal": {
                    "features": {
                        "typing": {
                            "enabled": true,
                            "intervalSeconds": 9999999
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|issue| {
            issue.path == ".channels.signal.features.typing.intervalSeconds"
                && issue
                    .message
                    .contains("typing interval above 3600 seconds is unusually large")
        }));
    }

    #[test]
    fn test_plugin_channel_config_entry_is_accepted() {
        let cfg = json!({
            "channels": {
                "matrix": {
                    "features": {
                        "typing": {
                            "enabled": true
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues.iter().any(|issue| issue.path == ".channels.matrix"),
            "unexpected channel-id warning for plugin channel entry: {:?}",
            issues
        );
    }

    #[test]
    fn test_builtin_channel_typo_warns_with_suggestion() {
        let cfg = json!({
            "channels": {
                "singal": {
                    "features": {
                        "typing": {
                            "enabled": true
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|issue| {
            issue.path == ".channels.singal" && issue.message.contains("did you mean 'signal'?")
        }));
    }

    #[test]
    fn test_unknown_channel_entry_key_warns() {
        let cfg = json!({
            "channels": {
                "signal": {
                    "feautres": {
                        "typing": {
                            "enabled": true
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|issue| {
            issue.path == ".channels.signal.feautres"
                && issue.message.contains("unknown channel config key")
        }));
    }

    #[test]
    fn test_channel_session_entry_is_accepted() {
        let cfg = json!({
            "channels": {
                "telegram": {
                    "session": {
                        "scope": "per-channel-peer",
                        "reset": {
                            "mode": "idle",
                            "idleMinutes": 30
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues
                .iter()
                .any(|issue| issue.path.starts_with(".channels.telegram.session")),
            "unexpected session warning for valid per-channel session config: {:?}",
            issues
        );
    }

    #[test]
    fn test_channel_session_unknown_key_warns() {
        let cfg = json!({
            "channels": {
                "telegram": {
                    "session": {
                        "scpoe": "per-sender"
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|issue| {
            issue.path == ".channels.telegram.session.scpoe"
                && issue.message.contains("unknown channel session key")
        }));
    }

    #[test]
    fn test_channels_container_type_warns() {
        let cfg = json!({
            "channels": []
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|issue| {
            issue.path == ".channels" && issue.message.contains("channels must be an object")
        }));
    }

    #[test]
    fn test_channels_features_invalid_typing_shape_still_validates_read_receipts() {
        let cfg = json!({
            "channels": {
                "signal": {
                    "features": {
                        "typing": true,
                        "readReceipts": {
                            "enabled": "yes",
                            "mode": "on-receive"
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|issue| issue.path == ".channels.signal.features.typing"));
        assert!(issues
            .iter()
            .any(|issue| issue.path == ".channels.signal.features.readReceipts.enabled"));
        assert!(issues
            .iter()
            .any(|issue| issue.path == ".channels.signal.features.readReceipts.mode"));
    }

    #[test]
    fn test_channels_features_unknown_keys_warn() {
        let cfg = json!({
            "channels": {
                "signal": {
                    "features": {
                        "readReceipt": {
                            "enabled": true
                        },
                        "typing": {
                            "enabled": true,
                            "intervalSecond": 5
                        },
                        "readReceipts": {
                            "enabled": true,
                            "mode": "after-response",
                            "extra": true
                        }
                    }
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(
            |issue| issue.path == ".channels.signal.features.readReceipt"
                && issue.message.contains("unknown channel feature")
        ));
        assert!(issues.iter().any(|issue| issue.path
            == ".channels.signal.features.typing.intervalSecond"
            && issue.message.contains("unknown typing feature key")));
        assert!(issues.iter().any(|issue| issue.path
            == ".channels.signal.features.readReceipts.mode"
            && issue.message.contains("unknown readReceipts feature key")));
        assert!(issues.iter().any(|issue| issue.path
            == ".channels.signal.features.readReceipts.extra"
            && issue.message.contains("unknown readReceipts feature key")));
    }

    #[test]
    fn test_channels_default_typo_warns_for_reserved_defaults_key() {
        let cfg = json!({
            "channels": {
                "default": {
                    "features": {
                        "typing": {
                            "enabled": true
                        }
                    }
                }
            }
        });

        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|issue| {
            issue.path == ".channels.default"
                && issue
                    .message
                    .contains("reserved global defaults key 'defaults'")
        }));
    }

    #[test]
    fn test_channels_defaults_rejects_channel_only_keys() {
        let cfg = json!({
            "channels": {
                "defaults": {
                    "features": {
                        "typing": {
                            "enabled": true
                        }
                    },
                    "session": {
                        "scope": "dm"
                    }
                }
            }
        });

        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|issue| {
            issue.path == ".channels.defaults.session"
                && issue.message.contains(".channels.defaults.features")
        }));
    }

    // --- cron ---

    #[test]
    fn test_cron_valid_entry() {
        let cfg = json!({ "cron": { "entries": [{ "schedule": "0 * * * *", "payload": {} }] } });
        let issues = validate_schema(&cfg);
        assert!(!issues.iter().any(|i| i.path.starts_with(".cron")));
    }

    #[test]
    fn test_cron_bad_schedule() {
        let cfg = json!({ "cron": { "entries": [{ "schedule": "not a cron" }] } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path.contains("schedule")));
    }

    #[test]
    fn test_cron_payload_not_object() {
        let cfg =
            json!({ "cron": { "entries": [{ "schedule": "0 * * * *", "payload": "string" }] } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path.contains("payload")));
    }

    #[test]
    fn test_cron_entry_not_object() {
        let cfg = json!({ "cron": { "entries": ["bad"] } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path.contains("entries[0]")));
    }

    // --- unknown keys ---

    #[test]
    fn test_unknown_top_level_key() {
        let cfg = json!({ "gateway": {}, "somethingWeird": true });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.path == ".somethingWeird" && i.severity == Severity::Warning));
    }

    // --- root not object ---

    #[test]
    fn test_root_not_object() {
        let cfg = json!("just a string");
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| i.severity == Severity::Error && i.path == "."));
    }

    // --- WS rate limit config ---

    #[test]
    fn test_ws_message_rate_valid() {
        let cfg = json!({ "gateway": { "ws": { "messageRate": 60.0, "messageBurst": 120.0 } } });
        let issues = validate_schema(&cfg);
        assert!(!issues.iter().any(|i| i.path.contains("messageRate")));
    }

    #[test]
    fn test_ws_message_rate_invalid() {
        let cfg = json!({ "gateway": { "ws": { "messageRate": -1 } } });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| i.path.contains("messageRate")));
    }

    // ===== Filesystem validation =====

    fn test_filesystem_root() -> tempfile::TempDir {
        tempfile::TempDir::new().unwrap()
    }

    fn nonexistent_filesystem_root() -> String {
        loop {
            let dir = tempfile::TempDir::new().unwrap();
            let path = dir.path().to_path_buf();
            drop(dir);
            if !path.exists() {
                return path.to_string_lossy().into_owned();
            }
        }
    }

    #[test]
    fn test_filesystem_valid_config() {
        let root = test_filesystem_root();
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": [root.path().to_str().unwrap()],
                "writeAccess": false
            }
        });
        let issues = validate_schema(&config);
        assert!(
            !issues.iter().any(|i| i.severity == Severity::Error),
            "unexpected errors: {:?}",
            issues
        );
    }

    #[test]
    fn test_filesystem_enabled_no_roots_warns() {
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": []
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| i.severity == Severity::Warning
            && i.path.contains("filesystem")
            && i.message.contains("roots")));
    }

    #[test]
    fn test_filesystem_relative_path_is_error() {
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": ["relative/path"]
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| i.severity == Severity::Error
            && i.path.contains("roots")
            && i.message.contains("absolute")));
    }

    #[test]
    fn test_filesystem_nonexistent_path_is_error() {
        let missing_root = nonexistent_filesystem_root();
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": [missing_root]
            }
        });
        let issues = validate_schema(&config);
        assert!(issues
            .iter()
            .any(|i| i.severity == Severity::Error && i.message.contains("does not exist")));
    }

    #[test]
    fn test_filesystem_unknown_key_warns() {
        let root = test_filesystem_root();
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": [root.path().to_str().unwrap()],
                "unknownSetting": true
            }
        });
        let issues = validate_schema(&config);
        assert!(issues
            .iter()
            .any(|i| i.severity == Severity::Warning && i.message.contains("unknownSetting")));
    }

    #[test]
    fn test_filesystem_invalid_exclude_pattern_is_error() {
        let root = test_filesystem_root();
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": [root.path().to_str().unwrap()],
                "excludePatterns": ["[invalid"]
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| i.severity == Severity::Error
            && i.path.contains("excludePatterns")
            && i.message.contains("invalid glob pattern")));
    }

    #[test]
    fn test_filesystem_disabled_skips_validation() {
        let config = json!({
            "filesystem": {
                "enabled": false,
                "roots": ["not-absolute"]
            }
        });
        let issues = validate_schema(&config);
        // Should not produce errors when disabled
        assert!(!issues.iter().any(|i| i.severity == Severity::Error));
    }

    #[test]
    fn test_filesystem_non_string_root_is_error() {
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": [42, true]
            }
        });
        let issues = validate_schema(&config);
        let errors: Vec<_> = issues
            .iter()
            .filter(|i| i.severity == Severity::Error && i.path.contains("roots"))
            .collect();
        assert_eq!(errors.len(), 2, "should flag both non-string roots");
        assert!(errors[0].message.contains("must be a string"));
    }

    #[test]
    fn test_filesystem_non_string_exclude_pattern_is_error() {
        let root = test_filesystem_root();
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": [root.path().to_str().unwrap()],
                "excludePatterns": ["*.log", 123]
            }
        });
        let issues = validate_schema(&config);
        let errors: Vec<_> = issues
            .iter()
            .filter(|i| i.severity == Severity::Error && i.path.contains("excludePatterns"))
            .collect();
        assert_eq!(errors.len(), 1, "should flag the non-string pattern");
        assert!(errors[0].message.contains("must be a string"));
    }

    #[test]
    fn test_filesystem_invalid_enabled_type_is_error() {
        let config = json!({
            "filesystem": {
                "enabled": "yes",
                "roots": []
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".filesystem.enabled"
                && i.message.contains("must be a boolean")
        }));
    }

    #[test]
    fn test_filesystem_invalid_write_access_type_is_error() {
        let root = test_filesystem_root();
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": [root.path().to_str().unwrap()],
                "writeAccess": "yes"
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".filesystem.writeAccess"
                && i.message.contains("must be a boolean")
        }));
    }

    #[test]
    fn test_filesystem_invalid_max_read_bytes_type_is_error() {
        let root = test_filesystem_root();
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": [root.path().to_str().unwrap()],
                "maxReadBytes": "lots"
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".filesystem.maxReadBytes"
                && i.message.contains("must be a non-negative integer")
        }));
    }

    #[test]
    fn test_filesystem_roots_non_array_is_error() {
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": "/tmp"
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".filesystem.roots"
                && i.message.contains("must be an array")
        }));
    }

    #[test]
    fn test_filesystem_exclude_patterns_non_array_is_error() {
        let root = test_filesystem_root();
        let config = json!({
            "filesystem": {
                "enabled": true,
                "roots": [root.path().to_str().unwrap()],
                "excludePatterns": "*.log"
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".filesystem.excludePatterns"
                && i.message.contains("must be an array")
        }));
    }

    #[test]
    fn test_filesystem_non_object_is_error() {
        let config = json!({
            "filesystem": true
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".filesystem"
                && i.message.contains("must be an object")
        }));
    }

    #[test]
    fn test_plugins_config_valid() {
        let config = json!({
            "plugins": {
                "enabled": true,
                "load": {
                    "paths": ["/tmp/plugins", "/opt/carapace/plugins"]
                }
            }
        });
        let issues = validate_schema(&config);
        assert!(!issues.iter().any(|i| i.path.starts_with(".plugins")));
    }

    #[test]
    fn test_plugins_enabled_must_be_boolean() {
        let config = json!({
            "plugins": {
                "enabled": "yes"
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".plugins.enabled"
                && i.message.contains("must be a boolean")
        }));
    }

    #[test]
    fn test_plugins_sandbox_allow_tailscale_must_be_boolean() {
        let config = json!({
            "plugins": {
                "sandbox": {
                    "allow_tailscale": "yes"
                }
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".plugins.sandbox.allow_tailscale"
                && i.message.contains("must be a boolean")
        }));
    }

    #[test]
    fn test_plugins_sandbox_allow_tailscale_camel_case_warns() {
        let config = json!({
            "plugins": {
                "sandbox": {
                    "allowTailscale": true
                }
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Warning
                && i.path == ".plugins.sandbox.allowTailscale"
                && i.message.contains("use `allow_tailscale`")
        }));
    }

    #[test]
    fn test_plugins_must_be_object() {
        let config = json!({
            "plugins": "yes"
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".plugins"
                && i.message.contains("must be an object")
        }));
    }

    #[test]
    fn test_plugins_load_paths_must_be_array_of_strings() {
        let config = json!({
            "plugins": {
                "load": {
                    "paths": ["/tmp/plugins", 42]
                }
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".plugins.load.paths"
                && i.message.contains("entries must be strings")
        }));
    }

    #[test]
    fn test_plugins_entries_must_be_managed_entry_objects() {
        let config = json!({
            "plugins": {
                "entries": {
                    "demo": {
                        "apiKey": "${DEMO_API_KEY}"
                    }
                }
            }
        });
        let issues = validate_schema(&config);
        // Batch 97: schema/runtime parity — runtime tolerates
        // unknown fields by tracing-warn + skip, so schema is now
        // Warning rather than Error. Operators should still see the
        // diagnostic but startup must not fail on a forward-compat
        // field a newer daemon shipped.
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Warning
                && i.path == ".plugins.entries.demo.apiKey"
                && i.message
                    .contains("plugin runtime config belongs under plugins.<plugin-id>.*")
        }));
    }

    #[test]
    fn test_plugins_entries_reserved_names_warn_not_error() {
        let config = json!({
            "plugins": {
                "entries": {
                    "entries": {
                        "enabled": true
                    }
                }
            }
        });
        let issues = validate_schema(&config);
        // Batch 97: schema/runtime parity — runtime silently skips
        // entries whose name matches a reserved plugin-config key
        // (return None in `managed_plugin_config_entries` and
        // `collect_managed_plugin_entries`). Schema reports Warning,
        // not Error, so an unrelated stale entry does not brick
        // startup.
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Warning
                && i.path == ".plugins.entries.entries"
                && i.message.contains("reserved for plugin configuration")
                && i.message.contains("runtime will ignore this entry")
        }));
        // Crucially: no Error variant exists for this path; only Warning.
        assert!(!issues
            .iter()
            .any(|i| { i.severity == Severity::Error && i.path == ".plugins.entries.entries" }));
    }

    #[test]
    fn test_plugins_signature_removed_aliases_rejected() {
        let config = json!({
            "plugins": {
                "signature": {
                    "require_signature": false,
                    "trusted_publishers": ["abc123"]
                }
            }
        });
        let issues = validate_schema(&config);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".plugins.signature.require_signature"
                && i.message.contains(".plugins.signature.requireSignature")
        }));
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".plugins.signature.trusted_publishers"
                && i.message.contains(".plugins.signature.trustedPublishers")
        }));
    }

    #[test]
    fn test_plugins_signature_negative_schema_cases_are_errors() {
        let config = json!({
            "plugins": {
                "signature": {
                    "enabled": "yes",
                    "requireSignature": "always",
                    "trustedPublishers": "publisher",
                    "unknownField": true
                }
            }
        });
        let issues = validate_schema(&config);
        for path in [
            ".plugins.signature.enabled",
            ".plugins.signature.requireSignature",
            ".plugins.signature.trustedPublishers",
            ".plugins.signature.unknownField",
        ] {
            assert!(
                issues
                    .iter()
                    .any(|i| i.severity == Severity::Error && i.path == path),
                "{path} must be rejected as a signature-policy schema error: {issues:?}"
            );
        }
    }

    // --- is_plausible_cron ---

    #[test]
    fn test_cron_plausibility() {
        assert!(is_plausible_cron("* * * * *"));
        assert!(is_plausible_cron("0 0 * * *"));
        assert!(is_plausible_cron("*/5 * * * *"));
        assert!(is_plausible_cron("0 0 1 1 *"));
        assert!(is_plausible_cron("0 0 * * 1-5"));
        assert!(is_plausible_cron("0 0 0 * * *")); // 6-field
        assert!(!is_plausible_cron("not a cron"));
        assert!(!is_plausible_cron("* * *")); // too few fields
        assert!(!is_plausible_cron(""));
    }

    // --- routes validation ---

    #[test]
    fn test_routes_valid_map_passes() {
        let cfg = json!({
            "routes": {
                "fast": { "model": "anthropic:claude-sonnet-4-6" },
                "my-route-2": { "model": "openai:gpt-5.5", "label": "GPT" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues.iter().any(|i| i.path.starts_with(".routes")),
            "expected no route issues, got: {:?}",
            issues
                .iter()
                .filter(|i| i.path.starts_with(".routes"))
                .collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_routes_invalid_id_uppercase() {
        let cfg = json!({
            "routes": {
                "Fast": { "model": "anthropic:claude-sonnet-4-6" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".routes.Fast"
                && i.message.contains("invalid")
        }));
    }

    #[test]
    fn test_routes_invalid_id_spaces() {
        let cfg = json!({
            "routes": {
                "my route": { "model": "anthropic:claude-sonnet-4-6" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| { i.severity == Severity::Error && i.path == ".routes.my route" }));
    }

    #[test]
    fn test_routes_invalid_id_starts_with_number() {
        let cfg = json!({
            "routes": {
                "1fast": { "model": "anthropic:claude-sonnet-4-6" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues
            .iter()
            .any(|i| { i.severity == Severity::Error && i.path == ".routes.1fast" }));
    }

    #[test]
    fn test_routes_missing_model_field() {
        let cfg = json!({
            "routes": {
                "fast": { "label": "no model here" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".routes.fast.model"
                && i.message.contains("missing")
        }));
    }

    #[test]
    fn test_routes_empty_model() {
        let cfg = json!({
            "routes": {
                "fast": { "model": "  " }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".routes.fast.model"
                && i.message.contains("must not be empty")
        }));
    }

    #[test]
    fn test_routes_invalid_model_prefix() {
        let cfg = json!({
            "routes": {
                "fast": { "model": "badprovider:some-model" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".routes.fast.model"
                && i.message.contains("unrecognized provider prefix")
        }));
    }

    #[test]
    fn test_routes_slash_model_prefix_gets_canonical_hint() {
        let cfg = json!({
            "routes": {
                "fast": { "model": "models/gemini-2.5-flash" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".routes.fast.model"
                && i.message.contains("uses slash syntax")
                && i.message.contains("gemini:gemini-2.5-flash")
        }));
    }

    #[test]
    fn test_route_ref_defaults_unknown() {
        let cfg = json!({
            "routes": {
                "fast": { "model": "anthropic:claude-sonnet-4-6" }
            },
            "agents": {
                "defaults": { "route": "nonexistent" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".agents.defaults.route"
                && i.message.contains("unknown route")
        }));
    }

    #[test]
    fn test_route_ref_defaults_valid() {
        let cfg = json!({
            "routes": {
                "fast": { "model": "anthropic:claude-sonnet-4-6" }
            },
            "agents": {
                "defaults": { "route": "fast" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues.iter().any(|i| i.path == ".agents.defaults.route"),
            "expected no route reference issue, got: {:?}",
            issues
        );
    }

    #[test]
    fn test_route_ref_agent_list_unknown() {
        let cfg = json!({
            "routes": {
                "fast": { "model": "anthropic:claude-sonnet-4-6" }
            },
            "agents": {
                "defaults": {},
                "list": [
                    { "id": "helper", "route": "doesnt-exist" }
                ]
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".agents.list[0].route"
                && i.message.contains("unknown route")
        }));
    }

    #[test]
    fn test_route_ref_no_routes_map_is_error() {
        let cfg = json!({
            "agents": {
                "defaults": { "route": "fast" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Error
                && i.path == ".agents.defaults.route"
                && i.message.contains("no `routes` map is defined")
        }));
    }

    #[test]
    fn test_route_and_model_both_set_defaults_warns() {
        let cfg = json!({
            "routes": {
                "fast": { "model": "anthropic:claude-sonnet-4-6" }
            },
            "agents": {
                "defaults": {
                    "route": "fast",
                    "model": "openai:gpt-5.5"
                }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Warning
                && i.path == ".agents.defaults"
                && i.message.contains("route")
                && i.message.contains("takes precedence")
        }));
    }

    #[test]
    fn test_route_and_model_both_set_agent_list_warns() {
        let cfg = json!({
            "routes": {
                "fast": { "model": "anthropic:claude-sonnet-4-6" }
            },
            "agents": {
                "defaults": {},
                "list": [
                    {
                        "id": "helper",
                        "route": "fast",
                        "model": "openai:gpt-5.5"
                    }
                ]
            }
        });
        let issues = validate_schema(&cfg);
        assert!(issues.iter().any(|i| {
            i.severity == Severity::Warning
                && i.path == ".agents.list[0]"
                && i.message.contains("route")
                && i.message.contains("takes precedence")
        }));
    }

    /// `matrix.homeserverUrl` using plaintext `http://` against a
    /// non-loopback host produces a `Severity::Error`. Pin the
    /// classification so a future PR doesn't accidentally invert
    /// loopback detection — either spamming dev/test setups with
    /// warnings OR silently accepting plaintext WAN traffic for
    /// E2EE-bearing flows.
    #[test]
    fn test_matrix_homeserver_plaintext_non_loopback_errors() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "http://matrix.example.com",
                "userId": "@cara:example.com",
                "accessToken": "tok",
                "deviceId": "DEVICE",
                "encrypted": false,
            }
        });
        let issues = validate_schema(&cfg);
        let homeserver_warns: Vec<&SchemaIssue> = issues
            .iter()
            .filter(|i| i.path == ".matrix.homeserverUrl")
            .collect();
        assert!(
            !homeserver_warns.is_empty(),
            "plaintext http:// non-loopback homeserverUrl must produce a schema error"
        );
        assert!(homeserver_warns
            .iter()
            .all(|i| i.severity == Severity::Error));
    }

    #[test]
    fn test_matrix_encrypted_true_requires_passphrase_source() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.unset("MATRIX_STORE_PASSPHRASE");
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "accessToken": "tok",
                "deviceId": "DEVICE",
                "encrypted": true,
            }
        });

        let issues = validate_schema(&cfg);

        assert!(
            issues
                .iter()
                .any(|i| { i.path == ".matrix.encrypted" && i.severity == Severity::Error }),
            "encrypted Matrix config without any passphrase source must error; got: {issues:?}"
        );
    }

    /// Loopback hosts (`127.0.0.0/8`, `::1`, literal `localhost`) must
    /// NOT trigger the plaintext warning — dev/test against a local
    /// synapse is the dominant valid use of `http://`.
    #[test]
    fn test_matrix_homeserver_plaintext_loopback_does_not_warn() {
        for host in [
            "http://127.0.0.1:8008",
            "http://127.0.0.2:8008",
            "http://localhost:8008",
            "http://[::1]:8008",
        ] {
            let cfg = json!({
                "matrix": {
                    "enabled": true,
                    "homeserverUrl": host,
                    "userId": "@cara:example.com",
                    "accessToken": "tok",
                    "deviceId": "DEVICE",
                    "encrypted": false,
                }
            });
            let issues = validate_schema(&cfg);
            assert!(
                !issues.iter().any(|i| i.path == ".matrix.homeserverUrl"),
                "loopback homeserverUrl {host} must not produce a schema warning, got: {:?}",
                issues
            );
        }
    }

    /// `https://` produces no warning regardless of host.
    #[test]
    fn test_matrix_homeserver_https_does_not_warn() {
        let cfg = json!({
            "matrix": {
                "enabled": true,
                "homeserverUrl": "https://matrix.example.com",
                "userId": "@cara:example.com",
                "accessToken": "tok",
                "deviceId": "DEVICE",
                "encrypted": false,
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues.iter().any(|i| i.path == ".matrix.homeserverUrl"),
            "https:// homeserverUrl must not produce a schema warning"
        );
    }

    #[test]
    fn test_no_routes_no_refs_no_issues() {
        let cfg = json!({
            "agents": {
                "defaults": { "model": "anthropic:claude-sonnet-4-6" }
            }
        });
        let issues = validate_schema(&cfg);
        assert!(
            !issues.iter().any(|i| i.path.contains("route")),
            "expected no route-related issues, got: {:?}",
            issues
                .iter()
                .filter(|i| i.path.contains("route"))
                .collect::<Vec<_>>()
        );
    }
}
