//! Layered configuration with validation and secret-safe formatting.

use crate::types::{MAX_BODY_BYTES, MAX_CONVERSATION_MESSAGES, MAX_MESSAGE_BYTES, MAX_TOOL_CALLS};
use libremetaverse_types::UUID;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::fmt;
use std::fs;
use std::io::Write as _;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use url::Url;

const MAX_CONFIG_BYTES: u64 = 64 * 1024;
const MAX_SECRET_BYTES: u64 = 16 * 1024;
const MAX_TLS_DER_BYTES: u64 = 1024 * 1024;
const MAX_AUTHORIZED_AVATARS: usize = 1_024;
const MAX_QUEUE_CAPACITY: usize = 8_192;
const MAX_BACKGROUND_TASKS: usize = 2;
pub const CONFIG_SCHEMA_VERSION: u32 = 1;

#[cfg(test)]
const ENV_LLM_ENDPOINT: &str = "METACRATE_AGENT_LLM_ENDPOINT_URL";
#[cfg(test)]
const ENV_LLM_API_KEY: &str = "METACRATE_AGENT_LLM_API_KEY";

/// Wrapper that never reveals its contents through `Debug` or `Display` and
/// deliberately does not implement serialization.
#[derive(Clone, Eq, PartialEq)]
pub struct SecretString(String);

impl SecretString {
    /// Admits one nonempty, single-line secret within the secret-file bound.
    ///
    /// # Errors
    ///
    /// Returns a typed configuration error for empty, multiline, NUL-containing,
    /// or oversized values.
    pub fn new(field: &'static str, value: impl Into<String>) -> Result<Self, ConfigError> {
        let value = value.into();
        validate_secret(field, &value)?;
        Ok(Self(value))
    }

    /// Explicit secret-bearing accessor for authenticated transports.
    #[must_use]
    pub fn expose_secret(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for SecretString {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("SecretString([REDACTED])")
    }
}

impl fmt::Display for SecretString {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("[REDACTED]")
    }
}

/// Exact HTTP(S) URL whose diagnostic representation removes user info and query data.
#[derive(Clone, Eq, PartialEq)]
pub struct EndpointUrl(Url);

impl EndpointUrl {
    /// Parses a network endpoint without rewriting its path, host, or query.
    ///
    /// # Errors
    ///
    /// Rejects malformed, hostless, non-HTTP(S), or fragment-bearing URLs.
    pub fn parse(field: &'static str, value: &str) -> Result<Self, ConfigError> {
        let parsed = Url::parse(value).map_err(|error| ConfigError::InvalidUrl {
            field,
            reason: error.to_string(),
        })?;
        if !matches!(parsed.scheme(), "http" | "https") {
            return Err(ConfigError::InvalidUrl {
                field,
                reason: "scheme must be http or https".into(),
            });
        }
        if parsed.host_str().is_none() {
            return Err(ConfigError::InvalidUrl {
                field,
                reason: "URL must contain a host".into(),
            });
        }
        if parsed.fragment().is_some() {
            return Err(ConfigError::InvalidUrl {
                field,
                reason: "URL fragments are not sent over HTTP and are not allowed".into(),
            });
        }
        Ok(Self(parsed))
    }

    /// Returns the exact configured URL. Callers must not log this value because
    /// it can include user information or secret query parameters.
    #[must_use]
    pub fn expose_url(&self) -> &str {
        self.0.as_str()
    }

    fn redacted(&self) -> String {
        let mut redacted = self.0.clone();
        if !redacted.username().is_empty() {
            let _ = redacted.set_username("[REDACTED]");
        }
        if redacted.password().is_some() {
            let _ = redacted.set_password(Some("[REDACTED]"));
        }
        if redacted.query().is_some() {
            redacted.set_query(Some("[REDACTED]"));
        }
        redacted.to_string()
    }
}

impl fmt::Debug for EndpointUrl {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_tuple("EndpointUrl")
            .field(&self.redacted())
            .finish()
    }
}

impl fmt::Display for EndpointUrl {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.redacted())
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OperatingMode {
    OfflineFake,
    Integrated,
    SplitService,
}

/// The LLM connection identity. These are deliberately the only two fields.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LlmConnection {
    pub endpoint_url: EndpointUrl,
    pub api_key: SecretString,
    pub model: Option<String>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GridConnection {
    pub login_url: EndpointUrl,
    pub avatar_name: String,
    pub password: SecretString,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Timeouts {
    pub startup: Duration,
    pub shutdown: Duration,
    pub request: Duration,
}

/// Runtime allocation and concurrency limits. Each value is validated against
/// a compile-time hard ceiling before service startup.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Limits {
    pub grid_event_queue: usize,
    pub control_queue: usize,
    pub observable_queue: usize,
    pub max_body_bytes: usize,
    pub max_message_bytes: usize,
    pub max_conversation_messages: usize,
    pub max_tool_calls: usize,
    pub max_authorized_avatars: usize,
    pub max_background_tasks: usize,
}

impl Default for Limits {
    fn default() -> Self {
        Self {
            grid_event_queue: 256,
            control_queue: 32,
            observable_queue: 512,
            max_body_bytes: 1024 * 1024,
            max_message_bytes: 16 * 1024,
            max_conversation_messages: 64,
            max_tool_calls: 16,
            max_authorized_avatars: 128,
            max_background_tasks: MAX_BACKGROUND_TASKS,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BehaviorSettings {
    pub heartbeat: Duration,
    pub settle_delay: Duration,
    pub response_delay_min: Duration,
    pub response_delay_max: Duration,
    pub attention_dwell: Duration,
    pub idle_interval: Duration,
    pub action_timeout: Duration,
    pub max_walk_duration: Duration,
    pub stuck_timeout: Duration,
    pub min_action_interval: Duration,
    pub max_walk_distance_meters: u32,
    pub max_attention_distance_meters: u32,
    pub idle_look_enabled: bool,
}

impl BehaviorSettings {
    pub(crate) fn is_valid(&self) -> bool {
        if self.heartbeat.is_zero()
            || self.settle_delay > Duration::from_secs(30)
            || self.response_delay_min > self.response_delay_max
            || self.response_delay_max > Duration::from_secs(30)
            || self.attention_dwell > Duration::from_mins(1)
            || self.idle_interval < Duration::from_secs(15)
            || self.idle_interval > Duration::from_hours(1)
            || self.action_timeout.is_zero()
            || self.action_timeout > Duration::from_mins(1)
            || self.max_walk_duration.is_zero()
            || self.max_walk_duration > self.action_timeout
            || self.stuck_timeout.is_zero()
            || self.stuck_timeout > self.max_walk_duration
            || self.min_action_interval > Duration::from_secs(30)
            || !(1..=20).contains(&self.max_walk_distance_meters)
            || !(1..=256).contains(&self.max_attention_distance_meters)
        {
            return false;
        }
        true
    }
}

/// Conversation-memory settings. Persistence is opt-in and uses
/// `storage_path/conversations`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ConversationSettings {
    pub persistence_enabled: bool,
    pub limits: crate::conversation::ConversationLimits,
}

/// Portable control-plane settings. Plain TCP is loopback-only; a remote
/// listener is admitted only with an explicit Rustls certificate and key.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ControlSettings {
    pub listen: SocketAddr,
    pub operator_token: Option<SecretString>,
    pub observer_token: Option<SecretString>,
    pub limits: crate::control_plane::ControlLimits,
    pub remote_tls: Option<RemoteTlsSettings>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteTlsSettings {
    pub certificate_der: PathBuf,
    pub private_key_der: PathBuf,
}

impl RemoteTlsSettings {
    /// Loads bounded, regular DER files into a portable Rustls server config.
    ///
    /// # Errors
    ///
    /// Returns a configuration error when either file is unavailable, unsafe,
    /// oversized, malformed, or does not form a valid certificate/key pair.
    pub fn server_config(&self) -> Result<Arc<rustls::ServerConfig>, ConfigError> {
        let certificate = read_bounded_regular_file(
            "control.remote_tls_certificate_der",
            &self.certificate_der,
            MAX_TLS_DER_BYTES,
        )?;
        let private_key = read_bounded_regular_file(
            "control.remote_tls_private_key_der",
            &self.private_key_der,
            MAX_TLS_DER_BYTES,
        )?;
        let private_key = rustls::pki_types::PrivateKeyDer::try_from(private_key)
            .map_err(|_| ConfigError::InvalidControl)?;
        let config = rustls::ServerConfig::builder()
            .with_no_client_auth()
            .with_single_cert(
                vec![rustls::pki_types::CertificateDer::from(certificate)],
                private_key,
            )
            .map_err(|_| ConfigError::InvalidControl)?;
        Ok(Arc::new(config))
    }
}

impl Default for ControlSettings {
    fn default() -> Self {
        Self {
            listen: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7943),
            operator_token: None,
            observer_token: None,
            limits: crate::control_plane::ControlLimits::default(),
            remote_tls: None,
        }
    }
}

/// Fully resolved configuration. It cannot be constructed without validation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentConfig {
    pub mode: OperatingMode,
    pub llm: LlmConnection,
    pub grid: Option<GridConnection>,
    pub authorized_avatar_uuids: BTreeSet<UUID>,
    pub timeouts: Timeouts,
    pub limits: Limits,
    pub storage_path: PathBuf,
    pub behavior: BehaviorSettings,
    pub vision: crate::vision::VisionLimits,
    pub reconnect: crate::session::ReconnectPolicy,
    pub conversation: ConversationSettings,
    pub interaction: crate::interaction::InteractionSettings,
    pub control: ControlSettings,
}

impl AgentConfig {
    /// Creates the smallest valid offline configuration with no network side effects.
    ///
    /// # Errors
    ///
    /// Returns a typed error when either required LLM field is invalid.
    pub fn offline(endpoint_url: &str, api_key: &str) -> Result<Self, ConfigError> {
        let raw = FileConfig {
            llm: RawLlm {
                endpoint_url: Some(endpoint_url.into()),
                api_key: Some(api_key.into()),
                ..RawLlm::default()
            },
            ..FileConfig::default()
        };
        resolve(raw, &MapEnvironment::default(), None)
    }

    /// Rechecks all dynamic limits and mode invariants.
    ///
    /// # Errors
    ///
    /// Returns the first invalid field without performing I/O.
    pub fn validate(&self) -> Result<(), ConfigError> {
        validate_limits(&self.limits)?;
        self.reconnect
            .validate()
            .map_err(|_| ConfigError::InvalidReconnect)?;
        self.conversation
            .limits
            .validate()
            .map_err(|_| ConfigError::InvalidConversationMemory)?;
        self.interaction
            .validate()
            .map_err(|_| ConfigError::InvalidInteraction)?;
        if !self.behavior.is_valid() {
            return Err(ConfigError::InvalidBehavior);
        }
        if !(64 * 1024 * 1024..=64 * 1024 * 1024 * 1024)
            .contains(&self.vision.asset_cache_max_bytes)
        {
            return Err(ConfigError::UnsafeLimit {
                field: "vision.asset_cache_max_bytes",
                value: self
                    .vision
                    .asset_cache_max_bytes
                    .try_into()
                    .unwrap_or(usize::MAX),
                minimum: 64 * 1024 * 1024,
                maximum: usize::try_from(64_u64 * 1024 * 1024 * 1024).unwrap_or(usize::MAX),
            });
        }
        if !self.vision.valid() {
            return Err(ConfigError::UnsafeLimit {
                field: "vision.max_distance_meters",
                value: self.vision.max_distance_meters as usize,
                minimum: 1,
                maximum: 1_024,
            });
        }
        if (!self.control.listen.ip().is_loopback() && self.control.remote_tls.is_none())
            || !self.control.limits.is_valid()
        {
            return Err(ConfigError::InvalidControl);
        }
        if self.mode == OperatingMode::SplitService && self.control.operator_token.is_none() {
            return Err(ConfigError::Missing {
                field: "control.operator_token",
                required_for: "split service mode",
            });
        }
        if let Some(operator) = &self.control.operator_token
            && (self
                .control
                .observer_token
                .as_ref()
                .is_some_and(|observer| observer == operator)
                || operator == &self.llm.api_key
                || self
                    .grid
                    .as_ref()
                    .is_some_and(|grid| operator == &grid.password))
        {
            return Err(ConfigError::InvalidControl);
        }
        if self
            .control
            .observer_token
            .as_ref()
            .is_some_and(|observer| {
                observer == &self.llm.api_key
                    || self
                        .grid
                        .as_ref()
                        .is_some_and(|grid| observer == &grid.password)
            })
        {
            return Err(ConfigError::InvalidControl);
        }
        if self.mode != OperatingMode::OfflineFake && self.grid.is_none() {
            return Err(ConfigError::Missing {
                field: "grid",
                required_for: "integrated and split live modes",
            });
        }
        if self.authorized_avatar_uuids.len() > self.limits.max_authorized_avatars {
            return Err(ConfigError::UnsafeLimit {
                field: "authorized_avatar_uuids",
                value: self.authorized_avatar_uuids.len(),
                minimum: 0,
                maximum: self.limits.max_authorized_avatars,
            });
        }
        Ok(())
    }
}

/// Read-only environment boundary, injectable for deterministic precedence tests.
pub trait Environment {
    fn get(&self, name: &str) -> Option<String>;
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PlatformPaths {
    pub config_file: PathBuf,
    pub data_directory: PathBuf,
}

impl PlatformPaths {
    #[must_use]
    pub fn discover() -> Self {
        Self::from_environment(&StdEnvironment)
    }

    #[must_use]
    pub fn from_environment(environment: &impl Environment) -> Self {
        if cfg!(windows) {
            let config = environment
                .get("APPDATA")
                .map_or_else(|| PathBuf::from("config"), PathBuf::from);
            let data = environment
                .get("LOCALAPPDATA")
                .map_or_else(|| PathBuf::from("data"), PathBuf::from);
            return Self {
                config_file: config.join("MetaCrate/config.yml"),
                data_directory: data.join("MetaCrate/grid-agent"),
            };
        }
        if cfg!(target_os = "macos") {
            let home = environment
                .get("HOME")
                .map_or_else(|| PathBuf::from("."), PathBuf::from);
            return Self {
                config_file: home.join("Library/Application Support/MetaCrate/config.yml"),
                data_directory: home.join("Library/Application Support/MetaCrate/grid-agent"),
            };
        }
        let home = environment
            .get("HOME")
            .map_or_else(|| PathBuf::from("."), PathBuf::from);
        let config = environment
            .get("XDG_CONFIG_HOME")
            .map_or_else(|| home.join(".config"), PathBuf::from);
        let data = environment
            .get("XDG_DATA_HOME")
            .map_or_else(|| home.join(".local/share"), PathBuf::from);
        Self {
            config_file: config.join("metacrate/config.yml"),
            data_directory: data.join("metacrate/grid-agent"),
        }
    }
}

#[derive(Clone, Copy, Debug, Default)]
pub struct StdEnvironment;

impl Environment for StdEnvironment {
    fn get(&self, name: &str) -> Option<String> {
        std::env::var(name).ok()
    }
}

#[derive(Clone, Default)]
pub struct MapEnvironment(BTreeMap<String, String>);

impl fmt::Debug for MapEnvironment {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("MapEnvironment")
            .field("variable_count", &self.0.len())
            .field("values", &"[REDACTED]")
            .finish()
    }
}

impl MapEnvironment {
    #[must_use]
    pub fn from_pairs<const N: usize>(pairs: [(&str, &str); N]) -> Self {
        Self(
            pairs
                .into_iter()
                .map(|(name, value)| (name.to_owned(), value.to_owned()))
                .collect(),
        )
    }

    pub fn insert(&mut self, name: impl Into<String>, value: impl Into<String>) {
        self.0.insert(name.into(), value.into());
    }
}

impl Environment for MapEnvironment {
    fn get(&self, name: &str) -> Option<String> {
        self.0.get(name).cloned()
    }
}

/// Resolves built-in defaults, then an optional JSON file, secret files, and
/// finally environment variables (highest precedence).
#[derive(Clone)]
pub struct ConfigLoader<E = StdEnvironment> {
    file: Option<PathBuf>,
    environment: E,
}

impl<E> fmt::Debug for ConfigLoader<E> {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("ConfigLoader")
            .field("file", &self.file)
            .field("environment", &"[REDACTED SOURCE]")
            .finish()
    }
}

impl ConfigLoader<StdEnvironment> {
    #[must_use]
    pub const fn new() -> Self {
        Self {
            file: None,
            environment: StdEnvironment,
        }
    }
}

impl Default for ConfigLoader<StdEnvironment> {
    fn default() -> Self {
        Self::new()
    }
}

impl<E: Environment> ConfigLoader<E> {
    #[must_use]
    pub fn with_environment<T: Environment>(self, environment: T) -> ConfigLoader<T> {
        ConfigLoader {
            file: self.file,
            environment,
        }
    }

    #[must_use]
    pub fn with_file(mut self, path: impl Into<PathBuf>) -> Self {
        self.file = Some(path.into());
        self
    }

    /// Loads and validates configuration before any runtime or network object exists.
    ///
    /// # Errors
    ///
    /// Returns typed I/O, schema, missing-field, secret, URL, UUID, mode, or
    /// resource-limit errors.
    pub fn load(&self) -> Result<AgentConfig, ConfigError> {
        let raw = if let Some(path) = &self.file {
            read_config(path)?
        } else {
            FileConfig::default()
        };
        let config = resolve(raw, &self.environment, self.file.as_deref())?;
        config.validate()?;
        Ok(config)
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ConfigError {
    Io {
        field: &'static str,
        path: PathBuf,
        reason: String,
    },
    InvalidSchema {
        path: PathBuf,
        reason: String,
    },
    UnsupportedSchemaVersion {
        found: u32,
        supported: u32,
    },
    Missing {
        field: &'static str,
        required_for: &'static str,
    },
    InvalidUrl {
        field: &'static str,
        reason: String,
    },
    InvalidUuid {
        value: String,
        reason: &'static str,
    },
    InvalidSecret {
        field: &'static str,
        reason: &'static str,
    },
    InvalidBoolean {
        field: &'static str,
        value: String,
    },
    ConflictingModes,
    UnsafeLimit {
        field: &'static str,
        value: usize,
        minimum: usize,
        maximum: usize,
    },
    InvalidReconnect,
    InvalidConversationMemory,
    InvalidInteraction,
    InvalidBehavior,
    InvalidControl,
}

impl fmt::Display for ConfigError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io {
                field,
                path,
                reason,
            } => write!(
                formatter,
                "cannot read {field} at {}: {reason}",
                path.display()
            ),
            Self::InvalidSchema { path, reason } => {
                write!(
                    formatter,
                    "invalid configuration {}: {reason}",
                    path.display()
                )
            }
            Self::UnsupportedSchemaVersion { found, supported } => write!(
                formatter,
                "configuration schema version {found} is unsupported; this binary accepts version {supported}; migrate a copy before replacing the active configuration"
            ),
            Self::Missing {
                field,
                required_for,
            } => write!(formatter, "missing {field}; required for {required_for}"),
            Self::InvalidUrl { field, reason } => write!(formatter, "invalid {field}: {reason}"),
            Self::InvalidUuid { value, reason } => {
                write!(
                    formatter,
                    "invalid authorized avatar UUID {value:?}: {reason}"
                )
            }
            Self::InvalidSecret { field, reason } => {
                write!(formatter, "invalid secret {field}: {reason}")
            }
            Self::InvalidBoolean { field, value } => {
                write!(formatter, "{field} must be true or false, got {value:?}")
            }
            Self::ConflictingModes => formatter
                .write_str("integrated and split modes are mutually exclusive; enable at most one"),
            Self::UnsafeLimit {
                field,
                value,
                minimum,
                maximum,
            } => write!(
                formatter,
                "unsafe {field}={value}; expected {minimum}..={maximum}"
            ),
            Self::InvalidReconnect => formatter.write_str("invalid reconnect policy bounds"),
            Self::InvalidConversationMemory => {
                formatter.write_str("invalid conversation-memory bounds")
            }
            Self::InvalidInteraction => formatter.write_str("invalid interaction bounds"),
            Self::InvalidBehavior => formatter.write_str("invalid embodied behavior bounds"),
            Self::InvalidControl => formatter.write_str(
                "invalid control plane: use distinct non-credential tokens and loopback TCP or an explicit TLS remote listener",
            ),
        }
    }
}

impl Error for ConfigError {}

/// Editable connection and authorization subset of `config.yml`.
///
/// Unknown settings are retained across saves, allowing the preferences UI to
/// update connection setup without resetting behavior, limits, or storage.
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct AgentPreferences {
    pub schema_version: Option<u32>,
    pub integrated: Option<bool>,
    pub split: Option<bool>,
    pub llm: ConnectionPreferences,
    pub grid: GridPreferences,
    pub authorized_avatar_uuids: Option<Vec<String>>,
    #[serde(flatten)]
    extra: BTreeMap<String, serde_yaml_ng::Value>,
}

impl fmt::Debug for AgentPreferences {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("AgentPreferences")
            .field("schema_version", &self.schema_version)
            .field("integrated", &self.integrated)
            .field("split", &self.split)
            .field("llm_endpoint_configured", &self.llm.endpoint_url.is_some())
            .field("llm_api_key_configured", &self.llm.api_key.is_some())
            .field("grid_login_configured", &self.grid.login_url.is_some())
            .field("grid_avatar_configured", &self.grid.avatar_name.is_some())
            .field("grid_password_configured", &self.grid.password.is_some())
            .field(
                "authorized_avatar_count",
                &self.authorized_avatar_uuids.as_ref().map_or(0, Vec::len),
            )
            .finish_non_exhaustive()
    }
}

#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ConnectionPreferences {
    pub endpoint_url: Option<String>,
    pub api_key: Option<String>,
    pub model: Option<String>,
    #[serde(flatten)]
    extra: BTreeMap<String, serde_yaml_ng::Value>,
}

#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct GridPreferences {
    pub login_url: Option<String>,
    pub avatar_name: Option<String>,
    pub password: Option<String>,
    #[serde(flatten)]
    extra: BTreeMap<String, serde_yaml_ng::Value>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PreferencesSummary {
    pub mode: OperatingMode,
    pub llm_endpoint: Option<String>,
    pub llm_api_key_configured: bool,
    pub llm_model: Option<String>,
    pub grid_login_url: Option<String>,
    pub grid_avatar_name: Option<String>,
    pub grid_password_configured: bool,
    pub privileged_users: Vec<String>,
}

impl AgentPreferences {
    /// Loads editable preferences, or returns initialized defaults when the
    /// configuration file does not exist.
    ///
    /// # Errors
    ///
    /// Returns an error when an existing file cannot be read or parsed.
    pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
        let path = path.as_ref();
        if !path.exists() {
            return Ok(Self {
                schema_version: Some(CONFIG_SCHEMA_VERSION),
                integrated: Some(true),
                ..Self::default()
            });
        }
        let bytes = read_bounded_regular_file("configuration file", path, MAX_CONFIG_BYTES)?;
        parse_document(&bytes, path)
    }

    #[must_use]
    pub fn summary(&self) -> PreferencesSummary {
        let mode = match (
            self.integrated.unwrap_or(false),
            self.split.unwrap_or(false),
        ) {
            (true, false) => OperatingMode::Integrated,
            (false, true) => OperatingMode::SplitService,
            _ => OperatingMode::OfflineFake,
        };
        PreferencesSummary {
            mode,
            llm_endpoint: self.llm.endpoint_url.clone(),
            llm_api_key_configured: self.llm.api_key.is_some(),
            llm_model: self.llm.model.clone(),
            grid_login_url: self.grid.login_url.clone(),
            grid_avatar_name: self.grid.avatar_name.clone(),
            grid_password_configured: self.grid.password.is_some(),
            privileged_users: self.authorized_avatar_uuids.clone().unwrap_or_default(),
        }
    }

    pub fn set_mode(&mut self, mode: OperatingMode) {
        self.integrated = Some(mode == OperatingMode::Integrated);
        self.split = Some(mode == OperatingMode::SplitService);
    }

    pub fn set_llm(&mut self, endpoint_url: String, api_key: Option<String>) {
        self.llm.endpoint_url = Some(endpoint_url);
        if let Some(api_key) = api_key {
            self.llm.api_key = Some(api_key);
        }
    }

    pub fn set_llm_model(&mut self, model: String) {
        self.llm.model = (!model.trim().is_empty()).then_some(model);
    }

    pub fn set_grid(&mut self, login_url: String, avatar_name: String, password: Option<String>) {
        self.grid.login_url = Some(login_url);
        self.grid.avatar_name = Some(avatar_name);
        if let Some(password) = password {
            self.grid.password = Some(password);
        }
    }

    pub fn set_privileged_users(&mut self, users: Vec<String>) {
        self.authorized_avatar_uuids = Some(users);
    }

    /// Validates and securely writes the preferences as YAML.
    ///
    /// # Errors
    ///
    /// Returns an error for invalid values, unsafe paths, serialization
    /// failures, or filesystem failures.
    pub fn save(&mut self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
        let path = path.as_ref();
        self.schema_version = Some(CONFIG_SCHEMA_VERSION);
        validate_preferences(self)?;
        let encoded =
            serde_yaml_ng::to_string(self).map_err(|error| ConfigError::InvalidSchema {
                path: path.to_owned(),
                reason: error.to_string(),
            })?;
        if encoded.len() > usize::try_from(MAX_CONFIG_BYTES).unwrap_or(usize::MAX) {
            return Err(ConfigError::InvalidSchema {
                path: path.to_owned(),
                reason: "serialized configuration exceeds the size limit".into(),
            });
        }
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).map_err(|error| ConfigError::Io {
                field: "configuration directory",
                path: parent.to_owned(),
                reason: error.to_string(),
            })?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt as _;
                fs::set_permissions(parent, fs::Permissions::from_mode(0o700)).map_err(
                    |error| ConfigError::Io {
                        field: "configuration directory permissions",
                        path: parent.to_owned(),
                        reason: error.to_string(),
                    },
                )?;
            }
        }
        if path
            .symlink_metadata()
            .is_ok_and(|metadata| metadata.file_type().is_symlink())
        {
            return Err(ConfigError::InvalidSchema {
                path: path.to_owned(),
                reason: "configuration path must not be a symbolic link".into(),
            });
        }
        let mut options = fs::OpenOptions::new();
        options.write(true).create(true).truncate(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt as _;
            options.mode(0o600);
        }
        let mut file = options.open(path).map_err(|error| ConfigError::Io {
            field: "configuration file",
            path: path.to_owned(),
            reason: error.to_string(),
        })?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            file.set_permissions(fs::Permissions::from_mode(0o600))
                .map_err(|error| ConfigError::Io {
                    field: "configuration file permissions",
                    path: path.to_owned(),
                    reason: error.to_string(),
                })?;
        }
        file.write_all(encoded.as_bytes())
            .map_err(|error| ConfigError::Io {
                field: "configuration file",
                path: path.to_owned(),
                reason: error.to_string(),
            })?;
        file.sync_all().map_err(|error| ConfigError::Io {
            field: "configuration file",
            path: path.to_owned(),
            reason: error.to_string(),
        })
    }

    /// Imports supported values from a legacy `.env` file into memory.
    ///
    /// The caller must invoke [`Self::save`] to persist the result.
    ///
    /// # Errors
    ///
    /// Returns an error when the legacy file is unsafe, unreadable, too large,
    /// non-UTF-8, or contains a malformed assignment.
    pub fn import_dotenv(&mut self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
        let path = path.as_ref();
        let bytes = read_bounded_regular_file("legacy environment file", path, MAX_CONFIG_BYTES)?;
        let text = String::from_utf8(bytes).map_err(|_| ConfigError::InvalidSchema {
            path: path.to_owned(),
            reason: "legacy environment file must be UTF-8".into(),
        })?;
        let mut values = BTreeMap::new();
        for (index, line) in text.lines().enumerate() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let line = line.strip_prefix("export ").unwrap_or(line);
            let Some((name, value)) = line.split_once('=') else {
                return Err(ConfigError::InvalidSchema {
                    path: path.to_owned(),
                    reason: format!("invalid assignment on line {}", index + 1),
                });
            };
            let value = value.trim();
            let value = if value.len() >= 2
                && ((value.starts_with('"') && value.ends_with('"'))
                    || (value.starts_with('\'') && value.ends_with('\'')))
            {
                &value[1..value.len() - 1]
            } else {
                value
            };
            values.insert(name.trim(), value.to_owned());
        }
        if let Some(value) = values.get("OPENAPI_URL") {
            self.llm.endpoint_url = Some(value.clone());
        }
        if let Some(value) = values.get("OPENAPI_KEY") {
            self.llm.api_key = Some(value.clone());
        }
        if let Some(value) = values.get("OPENAPI_MODEL") {
            self.llm.model = Some(value.clone());
        }
        if let Some(value) = values.get("GRID_LOGIN_URL") {
            self.grid.login_url = Some(value.clone());
        }
        if let Some(value) = values.get("GRID_USER") {
            self.grid.avatar_name = Some(value.clone());
        }
        if let Some(value) = values.get("GRID_PASSWORD") {
            self.grid.password = Some(value.clone());
        }
        validate_preferences(self)
    }
}

fn validate_preferences(preferences: &AgentPreferences) -> Result<(), ConfigError> {
    if preferences.integrated.unwrap_or(false) && preferences.split.unwrap_or(false) {
        return Err(ConfigError::ConflictingModes);
    }
    if let Some(endpoint) = &preferences.llm.endpoint_url {
        EndpointUrl::parse("llm.endpoint_url", endpoint)?;
    }
    if let Some(secret) = &preferences.llm.api_key {
        validate_secret("llm.api_key", secret)?;
    }
    if preferences.llm.model.as_ref().is_some_and(|model| {
        model.trim().is_empty() || model.len() > 256 || model.chars().any(char::is_control)
    }) {
        return Err(ConfigError::InvalidSchema {
            path: PathBuf::from("config.yml"),
            reason: "llm.model must be a nonempty printable value of at most 256 bytes".into(),
        });
    }
    if let Some(endpoint) = &preferences.grid.login_url {
        EndpointUrl::parse("grid.login_url", endpoint)?;
    }
    if let Some(secret) = &preferences.grid.password {
        validate_secret("grid.password", secret)?;
    }
    for value in preferences.authorized_avatar_uuids.iter().flatten() {
        UUID::new_with_string(value.clone()).map_err(|_| ConfigError::InvalidUuid {
            value: value.clone(),
            reason: "expected a canonical UUID",
        })?;
    }
    Ok(())
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct FileConfig {
    schema_version: Option<u32>,
    integrated: Option<bool>,
    split: Option<bool>,
    llm: RawLlm,
    grid: RawGrid,
    secret_files: RawSecretFiles,
    authorized_avatar_uuids: Option<Vec<String>>,
    timeouts: RawTimeouts,
    limits: RawLimits,
    storage_path: Option<PathBuf>,
    behavior: RawBehavior,
    vision: RawVision,
    reconnect: RawReconnect,
    conversation: RawConversation,
    interaction: RawInteraction,
    control: RawControl,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawLlm {
    endpoint_url: Option<String>,
    api_key: Option<String>,
    model: Option<String>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawGrid {
    login_url: Option<String>,
    avatar_name: Option<String>,
    password: Option<String>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawSecretFiles {
    llm_api_key: Option<PathBuf>,
    grid_password: Option<PathBuf>,
    control_operator_token: Option<PathBuf>,
    control_observer_token: Option<PathBuf>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawControl {
    listen: Option<String>,
    operator_token: Option<String>,
    observer_token: Option<String>,
    remote_tls_certificate_der: Option<PathBuf>,
    remote_tls_private_key_der: Option<PathBuf>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawTimeouts {
    #[serde(rename = "startup_seconds")]
    startup: Option<u64>,
    #[serde(rename = "shutdown_seconds")]
    shutdown: Option<u64>,
    #[serde(rename = "request_seconds")]
    request: Option<u64>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawLimits {
    grid_event_queue: Option<usize>,
    control_queue: Option<usize>,
    observable_queue: Option<usize>,
    max_body_bytes: Option<usize>,
    max_message_bytes: Option<usize>,
    max_conversation_messages: Option<usize>,
    max_tool_calls: Option<usize>,
    max_authorized_avatars: Option<usize>,
    max_background_tasks: Option<usize>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawBehavior {
    heartbeat_seconds: Option<u64>,
    settle_milliseconds: Option<u64>,
    response_delay_min_milliseconds: Option<u64>,
    response_delay_max_milliseconds: Option<u64>,
    attention_dwell_milliseconds: Option<u64>,
    idle_interval_seconds: Option<u64>,
    action_timeout_seconds: Option<u64>,
    max_walk_duration_seconds: Option<u64>,
    stuck_timeout_seconds: Option<u64>,
    min_action_interval_milliseconds: Option<u64>,
    max_walk_distance_meters: Option<u32>,
    max_attention_distance_meters: Option<u32>,
    idle_look_enabled: Option<bool>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawVision {
    max_distance_meters: Option<u32>,
    asset_cache_max_bytes: Option<u64>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawReconnect {
    initial_delay_milliseconds: Option<u64>,
    maximum_delay_seconds: Option<u64>,
    readiness_timeout_seconds: Option<u64>,
    stable_reset_seconds: Option<u64>,
    jitter_basis_points: Option<u16>,
    offline_work_capacity: Option<usize>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawConversation {
    persistence_enabled: Option<bool>,
    max_active_sessions: Option<usize>,
    max_turns_per_session: Option<usize>,
    max_session_bytes: Option<usize>,
    max_total_bytes: Option<usize>,
    max_tool_results_per_session: Option<usize>,
    max_tool_result_bytes: Option<usize>,
    max_persisted_bytes: Option<usize>,
    max_summary_bytes: Option<usize>,
}

#[derive(Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct RawInteraction {
    aliases: Option<Vec<String>>,
    debounce_milliseconds: Option<u64>,
    model_timeout_seconds: Option<u64>,
    public_rate_milliseconds: Option<u64>,
    im_rate_milliseconds: Option<u64>,
    public_followup_seconds: Option<u64>,
    max_response_bytes: Option<usize>,
    grid_chunk_bytes: Option<usize>,
    max_active_senders: Option<usize>,
    max_queued_per_sender: Option<usize>,
    max_concurrent_inference: Option<usize>,
    max_duplicate_ids: Option<usize>,
    max_debounce_fragments: Option<usize>,
}

fn read_config(path: &Path) -> Result<FileConfig, ConfigError> {
    let bytes = read_bounded_regular_file("configuration file", path, MAX_CONFIG_BYTES)?;
    parse_document(&bytes, path)
}

fn parse_document<T: serde::de::DeserializeOwned>(
    bytes: &[u8],
    path: &Path,
) -> Result<T, ConfigError> {
    let result = if path
        .extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
    {
        serde_json::from_slice(bytes).map_err(|error| error.to_string())
    } else {
        serde_yaml_ng::from_slice(bytes).map_err(|error| error.to_string())
    };
    result.map_err(|reason| ConfigError::InvalidSchema {
        path: path.to_owned(),
        reason,
    })
}

#[allow(clippy::too_many_lines)] // One linear resolver makes the complete precedence order auditable.
fn resolve<E: Environment>(
    raw: FileConfig,
    environment: &E,
    config_path: Option<&Path>,
) -> Result<AgentConfig, ConfigError> {
    if let Some(found) = raw.schema_version
        && found != CONFIG_SCHEMA_VERSION
    {
        return Err(ConfigError::UnsupportedSchemaVersion {
            found,
            supported: CONFIG_SCHEMA_VERSION,
        });
    }
    let integrated = raw.integrated.unwrap_or(false);
    let split = raw.split.unwrap_or(false);
    let mode = match (integrated, split) {
        (true, true) => return Err(ConfigError::ConflictingModes),
        (true, false) => OperatingMode::Integrated,
        (false, true) => OperatingMode::SplitService,
        (false, false) => OperatingMode::OfflineFake,
    };

    let base = config_path
        .and_then(Path::parent)
        .unwrap_or_else(|| Path::new("."));
    let endpoint = raw.llm.endpoint_url.ok_or(ConfigError::Missing {
        field: "llm.endpoint_url",
        required_for: "all modes",
    })?;
    let api_key = secret_from_layers(
        None,
        None,
        raw.llm.api_key,
        raw.secret_files.llm_api_key,
        base,
        "llm.api_key",
    )?;
    let llm = LlmConnection {
        endpoint_url: EndpointUrl::parse("llm.endpoint_url", &endpoint)?,
        api_key,
        model: raw
            .llm
            .model
            .map(|model| model.trim().to_owned())
            .filter(|model| !model.is_empty()),
    };
    if llm
        .model
        .as_ref()
        .is_some_and(|model| model.len() > 256 || model.chars().any(char::is_control))
    {
        return Err(ConfigError::InvalidSchema {
            path: config_path
                .unwrap_or_else(|| Path::new("config.yml"))
                .to_owned(),
            reason: "llm.model must be printable and at most 256 bytes".into(),
        });
    }

    let login_url = raw.grid.login_url;
    let avatar_name = raw.grid.avatar_name;
    let direct_password = raw.grid.password;
    let grid = if mode == OperatingMode::OfflineFake
        && login_url.is_none()
        && avatar_name.is_none()
        && direct_password.is_none()
        && raw.secret_files.grid_password.is_none()
    {
        None
    } else {
        let login_url = login_url.ok_or(ConfigError::Missing {
            field: "grid.login_url",
            required_for: "configured grid connections",
        })?;
        let avatar_name = avatar_name.ok_or(ConfigError::Missing {
            field: "grid.avatar_name",
            required_for: "configured grid connections",
        })?;
        if avatar_name.trim().is_empty() || avatar_name.len() > 256 {
            return Err(ConfigError::InvalidSecret {
                field: "grid.avatar_name",
                reason: "must contain 1..=256 bytes",
            });
        }
        Some(GridConnection {
            login_url: EndpointUrl::parse("grid.login_url", &login_url)?,
            avatar_name,
            password: secret_from_layers(
                direct_password,
                None,
                None,
                raw.secret_files.grid_password,
                base,
                "grid.password",
            )?,
        })
    };

    let authorized_values = raw.authorized_avatar_uuids.unwrap_or_default();
    let authorized_avatar_uuids = parse_authorized_avatars(authorized_values)?;

    let defaults = Limits::default();
    let limits = Limits {
        grid_event_queue: raw
            .limits
            .grid_event_queue
            .unwrap_or(defaults.grid_event_queue),
        control_queue: raw.limits.control_queue.unwrap_or(defaults.control_queue),
        observable_queue: raw
            .limits
            .observable_queue
            .unwrap_or(defaults.observable_queue),
        max_body_bytes: raw.limits.max_body_bytes.unwrap_or(defaults.max_body_bytes),
        max_message_bytes: raw
            .limits
            .max_message_bytes
            .unwrap_or(defaults.max_message_bytes),
        max_conversation_messages: raw
            .limits
            .max_conversation_messages
            .unwrap_or(defaults.max_conversation_messages),
        max_tool_calls: raw.limits.max_tool_calls.unwrap_or(defaults.max_tool_calls),
        max_authorized_avatars: raw
            .limits
            .max_authorized_avatars
            .unwrap_or(defaults.max_authorized_avatars),
        max_background_tasks: raw
            .limits
            .max_background_tasks
            .unwrap_or(defaults.max_background_tasks),
    };
    validate_limits(&limits)?;

    let timeouts = Timeouts {
        startup: checked_duration(
            "timeouts.startup_seconds",
            raw.timeouts.startup.unwrap_or(120),
            1,
            300,
        )?,
        shutdown: checked_duration(
            "timeouts.shutdown_seconds",
            raw.timeouts.shutdown.unwrap_or(10),
            1,
            60,
        )?,
        request: checked_duration(
            "timeouts.request_seconds",
            raw.timeouts.request.unwrap_or(60),
            1,
            300,
        )?,
    };
    let reconnect_defaults = crate::session::ReconnectPolicy::default();
    let reconnect = crate::session::ReconnectPolicy {
        initial_delay: Duration::from_millis(
            raw.reconnect.initial_delay_milliseconds.unwrap_or(1_000),
        ),
        maximum_delay: Duration::from_secs(raw.reconnect.maximum_delay_seconds.unwrap_or(60)),
        readiness_timeout: Duration::from_secs(
            raw.reconnect
                .readiness_timeout_seconds
                .unwrap_or(reconnect_defaults.readiness_timeout.as_secs()),
        ),
        stable_reset_after: Duration::from_secs(raw.reconnect.stable_reset_seconds.unwrap_or(120)),
        shutdown_deadline: timeouts.shutdown,
        jitter_basis_points: raw
            .reconnect
            .jitter_basis_points
            .unwrap_or(reconnect_defaults.jitter_basis_points),
        instance_seed: reconnect_defaults.instance_seed,
        offline_work_capacity: raw
            .reconnect
            .offline_work_capacity
            .unwrap_or(reconnect_defaults.offline_work_capacity),
    };
    let conversation_defaults = ConversationSettings::default();
    let conversation = ConversationSettings {
        persistence_enabled: raw.conversation.persistence_enabled.unwrap_or(false),
        limits: crate::conversation::ConversationLimits {
            max_active_sessions: raw
                .conversation
                .max_active_sessions
                .unwrap_or(conversation_defaults.limits.max_active_sessions),
            max_turns_per_session: raw
                .conversation
                .max_turns_per_session
                .unwrap_or(conversation_defaults.limits.max_turns_per_session),
            max_session_bytes: raw
                .conversation
                .max_session_bytes
                .unwrap_or(conversation_defaults.limits.max_session_bytes),
            max_total_bytes: raw
                .conversation
                .max_total_bytes
                .unwrap_or(conversation_defaults.limits.max_total_bytes),
            max_tool_results_per_session: raw
                .conversation
                .max_tool_results_per_session
                .unwrap_or(conversation_defaults.limits.max_tool_results_per_session),
            max_tool_result_bytes: raw
                .conversation
                .max_tool_result_bytes
                .unwrap_or(conversation_defaults.limits.max_tool_result_bytes),
            max_persisted_bytes: raw
                .conversation
                .max_persisted_bytes
                .unwrap_or(conversation_defaults.limits.max_persisted_bytes),
            max_summary_bytes: raw
                .conversation
                .max_summary_bytes
                .unwrap_or(conversation_defaults.limits.max_summary_bytes),
        },
    };
    let interaction_defaults = crate::interaction::InteractionSettings::default();
    let aliases = raw.interaction.aliases.unwrap_or_else(|| {
        let mut aliases = Vec::new();
        if let Some(connection) = &grid {
            aliases.push(connection.avatar_name.clone());
            if let Some(first) = connection.avatar_name.split_whitespace().next() {
                aliases.push(first.to_owned());
            }
        }
        aliases.push("metacrate".to_owned());
        aliases.sort();
        aliases.dedup();
        aliases
    });
    let interaction = crate::interaction::InteractionSettings {
        aliases,
        debounce: Duration::from_millis(raw.interaction.debounce_milliseconds.unwrap_or(250)),
        model_timeout: Duration::from_secs(
            raw.interaction
                .model_timeout_seconds
                .unwrap_or(interaction_defaults.model_timeout.as_secs()),
        ),
        public_rate_interval: Duration::from_millis(
            raw.interaction.public_rate_milliseconds.unwrap_or(750),
        ),
        im_rate_interval: Duration::from_millis(
            raw.interaction.im_rate_milliseconds.unwrap_or(250),
        ),
        public_followup_window: Duration::from_secs(
            raw.interaction
                .public_followup_seconds
                .unwrap_or(interaction_defaults.public_followup_window.as_secs()),
        ),
        max_response_bytes: raw
            .interaction
            .max_response_bytes
            .unwrap_or(interaction_defaults.max_response_bytes),
        grid_chunk_bytes: raw
            .interaction
            .grid_chunk_bytes
            .unwrap_or(interaction_defaults.grid_chunk_bytes),
        max_active_senders: raw
            .interaction
            .max_active_senders
            .unwrap_or(interaction_defaults.max_active_senders),
        max_queued_per_sender: raw
            .interaction
            .max_queued_per_sender
            .unwrap_or(interaction_defaults.max_queued_per_sender),
        max_concurrent_inference: raw
            .interaction
            .max_concurrent_inference
            .unwrap_or(interaction_defaults.max_concurrent_inference),
        max_duplicate_ids: raw
            .interaction
            .max_duplicate_ids
            .unwrap_or(interaction_defaults.max_duplicate_ids),
        max_debounce_fragments: raw
            .interaction
            .max_debounce_fragments
            .unwrap_or(interaction_defaults.max_debounce_fragments),
    };
    let control_defaults = ControlSettings::default();
    let listen_text = raw
        .control
        .listen
        .unwrap_or_else(|| control_defaults.listen.to_string());
    let listen = listen_text
        .parse::<SocketAddr>()
        .map_err(|_| ConfigError::InvalidControl)?;
    let operator_token = optional_secret_from_layers(
        None,
        None,
        raw.control.operator_token,
        raw.secret_files.control_operator_token,
        base,
        "control.operator_token",
    )?;
    let observer_token = optional_secret_from_layers(
        None,
        None,
        raw.control.observer_token,
        raw.secret_files.control_observer_token,
        base,
        "control.observer_token",
    )?;
    let remote_tls = match (
        raw.control.remote_tls_certificate_der,
        raw.control.remote_tls_private_key_der,
    ) {
        (None, None) => None,
        (Some(certificate_der), Some(private_key_der)) => Some(RemoteTlsSettings {
            certificate_der: resolve_path(base, &certificate_der),
            private_key_der: resolve_path(base, &private_key_der),
        }),
        _ => return Err(ConfigError::InvalidControl),
    };
    let control = ControlSettings {
        listen,
        operator_token,
        observer_token,
        limits: crate::control_plane::ControlLimits {
            command_queue: limits.control_queue,
            request_timeout: timeouts.request.min(Duration::from_mins(2)),
            write_timeout: timeouts.shutdown.min(Duration::from_secs(30)),
            ..crate::control_plane::ControlLimits::default()
        },
        remote_tls,
    };

    let config = AgentConfig {
        mode,
        llm,
        grid,
        authorized_avatar_uuids,
        timeouts,
        limits,
        storage_path: raw
            .storage_path
            .unwrap_or_else(|| PlatformPaths::from_environment(environment).data_directory),
        behavior: BehaviorSettings {
            heartbeat: checked_duration(
                "behavior.heartbeat_seconds",
                raw.behavior.heartbeat_seconds.unwrap_or(30),
                1,
                300,
            )?,
            settle_delay: Duration::from_millis(raw.behavior.settle_milliseconds.unwrap_or(2_000)),
            response_delay_min: Duration::from_millis(
                raw.behavior.response_delay_min_milliseconds.unwrap_or(350),
            ),
            response_delay_max: Duration::from_millis(
                raw.behavior
                    .response_delay_max_milliseconds
                    .unwrap_or(1_200),
            ),
            attention_dwell: Duration::from_millis(
                raw.behavior.attention_dwell_milliseconds.unwrap_or(4_000),
            ),
            idle_interval: checked_duration(
                "behavior.idle_interval_seconds",
                raw.behavior.idle_interval_seconds.unwrap_or(120),
                15,
                3_600,
            )?,
            action_timeout: checked_duration(
                "behavior.action_timeout_seconds",
                raw.behavior.action_timeout_seconds.unwrap_or(15),
                1,
                60,
            )?,
            max_walk_duration: checked_duration(
                "behavior.max_walk_duration_seconds",
                raw.behavior.max_walk_duration_seconds.unwrap_or(10),
                1,
                60,
            )?,
            stuck_timeout: checked_duration(
                "behavior.stuck_timeout_seconds",
                raw.behavior.stuck_timeout_seconds.unwrap_or(3),
                1,
                60,
            )?,
            min_action_interval: Duration::from_millis(
                raw.behavior.min_action_interval_milliseconds.unwrap_or(750),
            ),
            max_walk_distance_meters: raw.behavior.max_walk_distance_meters.unwrap_or(8),
            max_attention_distance_meters: raw.behavior.max_attention_distance_meters.unwrap_or(96),
            idle_look_enabled: raw.behavior.idle_look_enabled.unwrap_or(true),
        },
        vision: crate::vision::VisionLimits {
            max_distance_meters: raw.vision.max_distance_meters.unwrap_or(64),
            asset_cache_max_bytes: raw
                .vision
                .asset_cache_max_bytes
                .unwrap_or(2 * 1024 * 1024 * 1024),
            ..crate::vision::VisionLimits::default()
        },
        reconnect,
        conversation,
        interaction,
        control,
    };
    config.validate()?;
    Ok(config)
}

fn parse_authorized_avatars(values: Vec<String>) -> Result<BTreeSet<UUID>, ConfigError> {
    if values.len() > MAX_AUTHORIZED_AVATARS {
        return Err(ConfigError::UnsafeLimit {
            field: "authorized_avatar_uuids",
            value: values.len(),
            minimum: 0,
            maximum: MAX_AUTHORIZED_AVATARS,
        });
    }
    let mut parsed = BTreeSet::new();
    for value in values {
        if value.trim() == "*" {
            return Err(ConfigError::InvalidUuid {
                value,
                reason: "wildcard authorization is forbidden",
            });
        }
        let id = UUID::new_with_string(value.clone()).map_err(|_| ConfigError::InvalidUuid {
            value: value.clone(),
            reason: "expected a canonical UUID",
        })?;
        if id == UUID::zero() || id.to_string() != value.to_ascii_lowercase() {
            return Err(ConfigError::InvalidUuid {
                value,
                reason: "expected a nonzero canonical lowercase hyphenated UUID",
            });
        }
        if !parsed.insert(id) {
            return Err(ConfigError::InvalidUuid {
                value,
                reason: "duplicate authorization entries are forbidden",
            });
        }
    }
    Ok(parsed)
}

fn secret_from_layers(
    environment_value: Option<String>,
    environment_file: Option<PathBuf>,
    file_value: Option<String>,
    file_path: Option<PathBuf>,
    base: &Path,
    field: &'static str,
) -> Result<SecretString, ConfigError> {
    if let Some(value) = environment_value {
        return SecretString::new(field, value);
    }
    if let Some(path) = environment_file {
        return read_secret(field, &resolve_path(base, &path));
    }
    if let Some(path) = file_path {
        return read_secret(field, &resolve_path(base, &path));
    }
    if let Some(value) = file_value {
        return SecretString::new(field, value);
    }
    Err(ConfigError::Missing {
        field,
        required_for: "the selected connection",
    })
}

fn optional_secret_from_layers(
    environment_value: Option<String>,
    environment_file: Option<PathBuf>,
    file_value: Option<String>,
    file_path: Option<PathBuf>,
    base: &Path,
    field: &'static str,
) -> Result<Option<SecretString>, ConfigError> {
    if let Some(value) = environment_value {
        return SecretString::new(field, value).map(Some);
    }
    if let Some(path) = environment_file {
        return read_secret(field, &resolve_path(base, &path)).map(Some);
    }
    if let Some(path) = file_path {
        return read_secret(field, &resolve_path(base, &path)).map(Some);
    }
    file_value
        .map(|value| SecretString::new(field, value))
        .transpose()
}

fn resolve_path(base: &Path, path: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_owned()
    } else {
        base.join(path)
    }
}

fn read_secret(field: &'static str, path: &Path) -> Result<SecretString, ConfigError> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt as _;
        let metadata = fs::symlink_metadata(path).map_err(|error| ConfigError::Io {
            field,
            path: path.to_owned(),
            reason: error.to_string(),
        })?;
        if metadata.mode() & 0o077 != 0 {
            return Err(ConfigError::InvalidSecret {
                field,
                reason: "secret file must not grant group or other permissions (use mode 0600 or stricter)",
            });
        }
    }
    let bytes = read_bounded_regular_file(field, path, MAX_SECRET_BYTES)?;
    let value = String::from_utf8(bytes).map_err(|_| ConfigError::InvalidSecret {
        field,
        reason: "secret file must be UTF-8",
    })?;
    let value = value
        .strip_suffix("\r\n")
        .or_else(|| value.strip_suffix('\n'))
        .unwrap_or(&value)
        .to_owned();
    SecretString::new(field, value)
}

fn read_bounded_regular_file(
    field: &'static str,
    path: &Path,
    maximum: u64,
) -> Result<Vec<u8>, ConfigError> {
    let metadata = fs::symlink_metadata(path).map_err(|error| ConfigError::Io {
        field,
        path: path.to_owned(),
        reason: error.to_string(),
    })?;
    if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
        return Err(ConfigError::Io {
            field,
            path: path.to_owned(),
            reason: "must be a regular non-symlink file".into(),
        });
    }
    if metadata.len() > maximum {
        return Err(ConfigError::Io {
            field,
            path: path.to_owned(),
            reason: format!("file is {} bytes; maximum is {maximum}", metadata.len()),
        });
    }
    fs::read(path).map_err(|error| ConfigError::Io {
        field,
        path: path.to_owned(),
        reason: error.to_string(),
    })
}

fn validate_secret(field: &'static str, value: &str) -> Result<(), ConfigError> {
    if value.is_empty() {
        return Err(ConfigError::InvalidSecret {
            field,
            reason: "must not be empty",
        });
    }
    if value.len() > usize::try_from(MAX_SECRET_BYTES).unwrap_or(usize::MAX) {
        return Err(ConfigError::InvalidSecret {
            field,
            reason: "exceeds the 16 KiB secret bound",
        });
    }
    if value.contains(['\n', '\r', '\0']) {
        return Err(ConfigError::InvalidSecret {
            field,
            reason: "must be a single line without NUL bytes",
        });
    }
    Ok(())
}

fn checked_duration(
    field: &'static str,
    seconds: u64,
    minimum: usize,
    maximum: usize,
) -> Result<Duration, ConfigError> {
    let value = usize::try_from(seconds).unwrap_or(usize::MAX);
    check_limit(field, value, minimum, maximum)?;
    Ok(Duration::from_secs(seconds))
}

fn validate_limits(limits: &Limits) -> Result<(), ConfigError> {
    check_limit(
        "limits.grid_event_queue",
        limits.grid_event_queue,
        1,
        MAX_QUEUE_CAPACITY,
    )?;
    check_limit("limits.control_queue", limits.control_queue, 1, 256)?;
    check_limit(
        "limits.observable_queue",
        limits.observable_queue,
        1,
        MAX_QUEUE_CAPACITY,
    )?;
    check_limit(
        "limits.max_body_bytes",
        limits.max_body_bytes,
        1_024,
        MAX_BODY_BYTES,
    )?;
    check_limit(
        "limits.max_message_bytes",
        limits.max_message_bytes,
        1,
        MAX_MESSAGE_BYTES,
    )?;
    check_limit(
        "limits.max_conversation_messages",
        limits.max_conversation_messages,
        1,
        MAX_CONVERSATION_MESSAGES,
    )?;
    check_limit(
        "limits.max_tool_calls",
        limits.max_tool_calls,
        1,
        MAX_TOOL_CALLS,
    )?;
    check_limit(
        "limits.max_authorized_avatars",
        limits.max_authorized_avatars,
        1,
        MAX_AUTHORIZED_AVATARS,
    )?;
    check_limit(
        "limits.max_background_tasks",
        limits.max_background_tasks,
        MAX_BACKGROUND_TASKS,
        MAX_BACKGROUND_TASKS,
    )
}

fn check_limit(
    field: &'static str,
    value: usize,
    minimum: usize,
    maximum: usize,
) -> Result<(), ConfigError> {
    if (minimum..=maximum).contains(&value) {
        Ok(())
    } else {
        Err(ConfigError::UnsafeLimit {
            field,
            value,
            minimum,
            maximum,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU64, Ordering};

    static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(1);

    fn offline_environment() -> MapEnvironment {
        MapEnvironment::from_pairs([
            (
                ENV_LLM_ENDPOINT,
                "https://llm.example.invalid/v1/chat/completions",
            ),
            (ENV_LLM_API_KEY, "environment-key"),
        ])
    }

    fn temporary_file(name: &str, contents: &str) -> PathBuf {
        let directory = std::env::temp_dir().join(format!(
            "metacrate-grid-agent-{}-{}",
            NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed),
            name
        ));
        fs::create_dir_all(&directory).expect("create test directory");
        let path = directory.join(name);
        let mut file = fs::File::create(&path).expect("create test file");
        file.write_all(contents.as_bytes())
            .expect("write test file");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
                .expect("restrict test file");
        }
        path
    }

    #[test]
    fn file_configuration_is_authoritative_and_secrets_are_redacted() {
        let path = temporary_file(
            "precedence.json",
            r#"{
              "llm": {"endpoint_url":"https://file-user:file-pass@file.invalid/chat?api_key=file-query", "api_key":"file-key"}
            }"#,
        );
        let loader = ConfigLoader::new()
            .with_file(&path)
            .with_environment(offline_environment());
        let loader_diagnostic = format!("{loader:?}");
        assert!(!loader_diagnostic.contains("environment-key"));
        let config = loader.load().expect("valid layered configuration");
        assert_eq!(config.llm.api_key.expose_secret(), "file-key");
        assert!(
            config
                .llm
                .endpoint_url
                .expose_url()
                .contains("file.invalid")
        );
        let diagnostic = format!("{config:?}");
        assert!(!diagnostic.contains("environment-key"));
        assert!(!diagnostic.contains("file-key"));
        let _ = fs::remove_file(path);
    }

    #[test]
    fn secret_file_overrides_inline_and_environment_is_ignored() {
        let secret = temporary_file("precedence-api-key", "secret-file-key\n");
        let document = serde_json::json!({
            "llm": {
                "endpoint_url": "https://file.invalid/chat",
                "api_key": "inline-key"
            },
            "secret_files": {"llm_api_key": secret.clone()}
        });
        let config_path = temporary_file("all-layers.json", &document.to_string());
        let file_environment =
            MapEnvironment::from_pairs([(ENV_LLM_ENDPOINT, "https://environment.invalid/chat")]);
        let config = ConfigLoader::new()
            .with_file(&config_path)
            .with_environment(file_environment)
            .load()
            .expect("secret file layer resolves");
        assert_eq!(config.llm.api_key.expose_secret(), "secret-file-key");

        let direct_environment = MapEnvironment::from_pairs([
            (ENV_LLM_ENDPOINT, "https://environment.invalid/chat"),
            (ENV_LLM_API_KEY, "direct-environment-key"),
        ]);
        let config = ConfigLoader::new()
            .with_file(&config_path)
            .with_environment(direct_environment)
            .load()
            .expect("direct environment layer resolves");
        assert_eq!(config.llm.api_key.expose_secret(), "secret-file-key");
        let _ = fs::remove_file(secret);
        let _ = fs::remove_file(config_path);
    }

    #[test]
    fn schema_storage_precedence_and_platform_paths_are_explicit() {
        let unsupported = temporary_file(
            "unsupported-schema.json",
            r#"{"schema_version":2,"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"placeholder"}}"#,
        );
        assert!(matches!(
            ConfigLoader::new()
                .with_file(&unsupported)
                .with_environment(MapEnvironment::default())
                .load(),
            Err(ConfigError::UnsupportedSchemaVersion {
                found: 2,
                supported: CONFIG_SCHEMA_VERSION
            })
        ));
        let configured = temporary_file(
            "storage.yml",
            "llm:\n  endpoint_url: https://llm.invalid/chat\n  api_key: placeholder\nstorage_path: operator-data\n",
        );
        let environment = MapEnvironment::from_pairs([("HOME", "/operator")]);
        let config = ConfigLoader::new()
            .with_file(configured)
            .with_environment(environment.clone())
            .load()
            .unwrap();
        assert_eq!(config.storage_path, PathBuf::from("operator-data"));
        assert_eq!(config.timeouts.startup, Duration::from_mins(2));
        let paths = PlatformPaths::from_environment(&environment);
        assert!(paths.config_file.ends_with("config.yml"));
        assert!(paths.data_directory.ends_with("grid-agent"));
    }

    #[test]
    fn vision_distance_is_configurable_and_bounded() {
        let configured = temporary_file(
            "vision-distance.yml",
            "llm:\n  endpoint_url: https://llm.invalid/chat\n  api_key: placeholder\nvision:\n  max_distance_meters: 96\n",
        );
        let config = ConfigLoader::new()
            .with_file(&configured)
            .with_environment(MapEnvironment::default())
            .load()
            .expect("configured vision distance");
        assert_eq!(config.vision.max_distance_meters, 96);

        let unsafe_distance = temporary_file(
            "unsafe-vision-distance.yml",
            "llm:\n  endpoint_url: https://llm.invalid/chat\n  api_key: placeholder\nvision:\n  max_distance_meters: 0\n",
        );
        assert!(matches!(
            ConfigLoader::new()
                .with_file(&unsafe_distance)
                .with_environment(MapEnvironment::default())
                .load(),
            Err(ConfigError::UnsafeLimit {
                field: "vision.max_distance_meters",
                ..
            })
        ));

        let cache = temporary_file(
            "vision-cache.yml",
            "llm:\n  endpoint_url: https://llm.invalid/chat\n  api_key: placeholder\nvision:\n  asset_cache_max_bytes: 1073741824\n",
        );
        let config = ConfigLoader::new()
            .with_file(&cache)
            .with_environment(MapEnvironment::default())
            .load()
            .expect("configured asset cache");
        assert_eq!(config.vision.asset_cache_max_bytes, 1024 * 1024 * 1024);
    }

    #[cfg(unix)]
    #[test]
    fn group_readable_secret_files_fail_closed() {
        use std::os::unix::fs::PermissionsExt as _;
        let secret = temporary_file("insecure-secret", "secret");
        fs::set_permissions(&secret, fs::Permissions::from_mode(0o640)).unwrap();
        assert!(matches!(
            read_secret("test.secret", &secret),
            Err(ConfigError::InvalidSecret { .. })
        ));
    }

    #[test]
    fn endpoint_debug_redacts_credentials_and_query() {
        let endpoint = EndpointUrl::parse(
            "llm.endpoint_url",
            "https://alice:password@example.invalid/chat?api_key=query-secret",
        )
        .expect("valid HTTPS endpoint");
        let diagnostic = format!("{endpoint:?}");
        assert!(!diagnostic.contains("alice"));
        assert!(!diagnostic.contains("password"));
        assert!(!diagnostic.contains("query-secret"));
        assert!(diagnostic.contains("REDACTED"));
    }

    #[test]
    fn invalid_urls_uuids_limits_and_wildcards_fail_fast() {
        assert!(AgentConfig::offline("file:///tmp/socket", "key").is_err());

        assert!(matches!(
            parse_authorized_avatars(vec!["*".into()]),
            Err(ConfigError::InvalidUuid { .. })
        ));

        assert!(matches!(
            parse_authorized_avatars(vec!["not-a-uuid".into()]),
            Err(ConfigError::InvalidUuid { .. })
        ));

        let path = temporary_file(
            "unsafe-limit.json",
            r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"limits":{"control_queue":0}}"#,
        );
        assert!(matches!(
            ConfigLoader::new().with_file(&path).load(),
            Err(ConfigError::UnsafeLimit { .. })
        ));
        let _ = fs::remove_file(path);

        let reconnect = temporary_file(
            "unsafe-reconnect.json",
            r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"reconnect":{"initial_delay_milliseconds":0}}"#,
        );
        assert!(matches!(
            ConfigLoader::new().with_file(&reconnect).load(),
            Err(ConfigError::InvalidReconnect)
        ));
        let _ = fs::remove_file(reconnect);

        let conversation = temporary_file(
            "unsafe-conversation.json",
            r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"conversation":{"max_active_sessions":0}}"#,
        );
        assert!(matches!(
            ConfigLoader::new().with_file(&conversation).load(),
            Err(ConfigError::InvalidConversationMemory)
        ));
        let _ = fs::remove_file(conversation);

        let interaction = temporary_file(
            "unsafe-interaction.json",
            r#"{"llm":{"endpoint_url":"https://llm.invalid/chat","api_key":"key"},"interaction":{"max_concurrent_inference":0}}"#,
        );
        assert!(matches!(
            ConfigLoader::new().with_file(&interaction).load(),
            Err(ConfigError::InvalidInteraction)
        ));
        let _ = fs::remove_file(interaction);
    }

    #[test]
    fn conflicting_live_modes_and_missing_live_fields_are_rejected() {
        let both = temporary_file(
            "both.yml",
            "integrated: true\nsplit: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: key }\n",
        );
        assert_eq!(
            ConfigLoader::new().with_file(both).load(),
            Err(ConfigError::ConflictingModes)
        );

        let missing_grid = temporary_file(
            "missing-grid.yml",
            "integrated: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: key }\n",
        );
        assert!(matches!(
            ConfigLoader::new().with_file(missing_grid).load(),
            Err(ConfigError::Missing {
                field: "grid.login_url",
                ..
            })
        ));
    }

    #[test]
    fn split_control_requires_distinct_tokens_and_tls_for_remote_bind() {
        let split = temporary_file(
            "split.yml",
            "split: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: llm-key }\ngrid: { login_url: https://grid.invalid/login, avatar_name: Control Agent, password: grid-password }\ncontrol: { operator_token: operator-capability, observer_token: observer-capability }\n",
        );
        let config = ConfigLoader::new()
            .with_file(split)
            .load()
            .expect("bounded loopback split control");
        assert_eq!(config.mode, OperatingMode::SplitService);
        let diagnostic = format!("{config:?}");
        for secret in [
            "operator-capability",
            "observer-capability",
            "llm-key",
            "grid-password",
        ] {
            assert!(!diagnostic.contains(secret));
        }

        let reused = temporary_file(
            "reused.yml",
            "split: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: llm-key }\ngrid: { login_url: https://grid.invalid/login, avatar_name: Control Agent, password: grid-password }\ncontrol: { operator_token: llm-key, observer_token: observer-capability }\n",
        );
        assert_eq!(
            ConfigLoader::new().with_file(reused).load(),
            Err(ConfigError::InvalidControl)
        );

        let remote_plaintext = temporary_file(
            "remote.yml",
            "split: true\nllm: { endpoint_url: https://llm.invalid/chat, api_key: llm-key }\ngrid: { login_url: https://grid.invalid/login, avatar_name: Control Agent, password: grid-password }\ncontrol: { listen: '0.0.0.0:7943', operator_token: operator-capability }\n",
        );
        assert_eq!(
            ConfigLoader::new().with_file(remote_plaintext).load(),
            Err(ConfigError::InvalidControl)
        );
    }

    #[test]
    fn secret_file_is_bounded_regular_and_trailing_newline_is_removed() {
        let secret = temporary_file("api-key", "secret-from-file\n");
        let document = serde_json::json!({"llm":{"endpoint_url":"https://llm.example.invalid/chat"},"secret_files":{"llm_api_key":secret}});
        let config_path = temporary_file("secret-file.json", &document.to_string());
        let config = ConfigLoader::new()
            .with_file(config_path)
            .load()
            .expect("secret file loads");
        assert_eq!(config.llm.api_key.expose_secret(), "secret-from-file");
        let _ = fs::remove_file(secret);
    }
}
