//! Partial-form of [`crate::WorkerManifest`] used as cascade layers.
//!
//! `WorkerManifestConfig` mirrors `WorkerManifest` but every field is optional
//! so individual layers (builtin defaults, user manifest, project
//! manifest, programmatic overlay) can be partial. Layers are combined
//! via [`WorkerManifestConfig::merge`] and the final config is converted to
//! a validated [`WorkerManifest`] via `TryFrom`.

use std::collections::{BTreeSet, HashMap};
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};

use serde::de::Error as _;
use serde::{Deserialize, Serialize};

use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{
    CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
    McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig,
    MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
    ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
    WorkerManifest, WorkerMeta,
};

/// Partial-form Worker manifest. Every field is optional; one or more
/// instances merge via [`WorkerManifestConfig::merge`] before being
/// converted to a validated [`WorkerManifest`] via `TryFrom`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerManifestConfig {
    #[serde(default)]
    pub worker: WorkerMetaConfig,
    /// `[model]` セクションは partial でも完成形でも同じ
    /// [`ModelManifest`] を使う。ref / inline の両形を受け入れるための
    /// 全 Optional 構造なので、カスケード層と最終マニフェストで型を
    /// 分ける必要がない。
    #[serde(default)]
    pub model: ModelManifest,
    #[serde(default)]
    pub engine: EngineManifestConfig,
    #[serde(default)]
    pub scope: ScopeConfig,
    /// Scope that may be subdelegated to spawned child Workers. Defaults empty.
    #[serde(default)]
    pub delegation_scope: ScopeConfig,
    #[serde(default)]
    pub session: Option<SessionConfigPartial>,
    /// Optional `[permissions]` section. `None` means the permission layer
    /// is disabled; `Some` requires `default_action` during final resolve.
    #[serde(default)]
    pub permissions: Option<PermissionConfigPartial>,
    /// Explicit built-in feature/tool-surface enablement. Absent flags resolve
    /// disabled after cascade merge.
    #[serde(default)]
    pub feature: FeatureConfigPartial,
    /// Explicit plugin package enablement entries. Discovery/resolution is a
    /// separate step and does not run during config merge.
    #[serde(default)]
    pub plugins: PluginConfig,
    /// Explicit Model Context Protocol provider declarations. Config parsing
    /// never starts a local MCP subprocess.
    #[serde(default)]
    pub mcp: McpConfig,
    #[serde(default)]
    pub compaction: Option<CompactionConfigPartial>,
    /// First-class web tool opt-in. See [`WebConfig`].
    #[serde(default)]
    pub web: Option<WebConfig>,
    /// Memory subsystem opt-in. See [`MemoryConfig`].
    #[serde(default)]
    pub memory: Option<MemoryConfig>,
    /// External Agent Skills directories. See [`crate::SkillsConfig`].
    #[serde(default)]
    pub skills: Option<SkillsConfig>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FeatureConfigPartial {
    #[serde(default)]
    pub task: Option<FeatureFlagConfigPartial>,
    #[serde(default)]
    pub memory: Option<MemoryFeatureConfigPartial>,
    #[serde(default)]
    pub web: Option<FeatureFlagConfigPartial>,
    #[serde(default)]
    pub image: Option<FeatureFlagConfigPartial>,
    #[serde(default)]
    pub sub_worker: Option<FeatureFlagConfigPartial>,
    #[serde(default)]
    pub flow: Option<FeatureFlagConfigPartial>,
    #[serde(default)]
    pub worker: Option<WorkerFeatureConfigPartial>,
    #[serde(default)]
    pub objective: Option<FeatureFlagConfigPartial>,
    #[serde(default)]
    pub manage_workdir: Option<FeatureFlagConfigPartial>,
    #[serde(default)]
    pub ticket: Option<TicketFeatureConfigPartial>,
    #[serde(default)]
    pub merge_request: Option<MergeRequestFeatureConfigPartial>,
    #[serde(default)]
    pub orchestration: Option<FeatureFlagConfigPartial>,
    #[serde(default)]
    pub plugins: Option<FeatureFlagConfigPartial>,
}

impl FeatureConfigPartial {
    fn merge(self, other: Self) -> Self {
        Self {
            task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
            memory: merge_option(self.memory, other.memory, MemoryFeatureConfigPartial::merge),
            web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
            image: merge_option(self.image, other.image, FeatureFlagConfigPartial::merge),
            sub_worker: merge_option(
                self.sub_worker,
                other.sub_worker,
                FeatureFlagConfigPartial::merge,
            ),
            flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
            worker: merge_option(self.worker, other.worker, WorkerFeatureConfigPartial::merge),
            objective: merge_option(
                self.objective,
                other.objective,
                FeatureFlagConfigPartial::merge,
            ),
            manage_workdir: merge_option(
                self.manage_workdir,
                other.manage_workdir,
                FeatureFlagConfigPartial::merge,
            ),
            ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
            merge_request: merge_option(
                self.merge_request,
                other.merge_request,
                MergeRequestFeatureConfigPartial::merge,
            ),
            orchestration: merge_option(
                self.orchestration,
                other.orchestration,
                FeatureFlagConfigPartial::merge,
            ),
            plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FeatureFlagConfigPartial {
    #[serde(default)]
    pub enabled: Option<bool>,
}

impl FeatureFlagConfigPartial {
    fn merge(self, other: Self) -> Self {
        Self {
            enabled: other.enabled.or(self.enabled),
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerFeatureConfigPartial {
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(default)]
    pub direct_spawn: Option<bool>,
}

impl WorkerFeatureConfigPartial {
    fn merge(self, other: Self) -> Self {
        Self {
            enabled: other.enabled.or(self.enabled),
            direct_spawn: other.direct_spawn.or(self.direct_spawn),
        }
    }
}

impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
    fn from(value: WorkerFeatureConfigPartial) -> Self {
        Self {
            enabled: value.enabled.unwrap_or(false),
            direct_spawn: value.direct_spawn.unwrap_or(true),
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryFeatureConfigPartial {
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(default)]
    pub staging: Option<bool>,
}

impl MemoryFeatureConfigPartial {
    fn merge(self, other: Self) -> Self {
        Self {
            enabled: other.enabled.or(self.enabled),
            staging: other.staging.or(self.staging),
        }
    }
}

#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct TicketFeatureConfigPartial {
    pub enabled: Option<bool>,
    pub authoring: Option<bool>,
    pub thread: Option<bool>,
    pub intake: Option<bool>,
    pub workflow: Option<bool>,
}

impl TicketFeatureConfigPartial {
    fn merge(self, other: Self) -> Self {
        Self {
            enabled: other.enabled.or(self.enabled),
            authoring: other.authoring.or(self.authoring),
            thread: other.thread.or(self.thread),
            intake: other.intake.or(self.intake),
            workflow: other.workflow.or(self.workflow),
        }
    }
}

#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct MergeRequestFeatureConfigPartial {
    pub show: Option<bool>,
    pub open: Option<bool>,
    pub review: Option<bool>,
    pub readiness_check: Option<bool>,
    pub complete: Option<bool>,
}

impl MergeRequestFeatureConfigPartial {
    fn merge(self, other: Self) -> Self {
        Self {
            show: other.show.or(self.show),
            open: other.open.or(self.open),
            review: other.review.or(self.review),
            readiness_check: other.readiness_check.or(self.readiness_check),
            complete: other.complete.or(self.complete),
        }
    }
}

impl From<FeatureConfigPartial> for FeatureConfig {
    fn from(value: FeatureConfigPartial) -> Self {
        Self {
            task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
            memory: value
                .memory
                .map(MemoryFeatureConfig::from)
                .unwrap_or_default(),
            web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
            image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
            sub_worker: value
                .sub_worker
                .map(FeatureFlagConfig::from)
                .unwrap_or_default(),
            flow: value.flow.map(FeatureFlagConfig::from).unwrap_or_default(),
            worker: value
                .worker
                .map(WorkerFeatureConfig::from)
                .unwrap_or_default(),
            objective: value
                .objective
                .map(FeatureFlagConfig::from)
                .unwrap_or_default(),
            manage_workdir: value
                .manage_workdir
                .map(FeatureFlagConfig::from)
                .unwrap_or_default(),
            ticket: value
                .ticket
                .map(TicketFeatureConfig::from)
                .unwrap_or_default(),
            merge_request: value
                .merge_request
                .map(MergeRequestFeatureConfig::from)
                .unwrap_or_default(),
            orchestration: value
                .orchestration
                .map(FeatureFlagConfig::from)
                .unwrap_or_default(),
            plugins: value
                .plugins
                .map(FeatureFlagConfig::from)
                .unwrap_or_default(),
        }
    }
}

impl From<FeatureFlagConfigPartial> for FeatureFlagConfig {
    fn from(value: FeatureFlagConfigPartial) -> Self {
        Self {
            enabled: value.enabled.unwrap_or_default(),
        }
    }
}

impl From<FeatureFlagConfig> for FeatureFlagConfigPartial {
    fn from(value: FeatureFlagConfig) -> Self {
        Self {
            enabled: Some(value.enabled),
        }
    }
}

impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
    fn from(value: WorkerFeatureConfig) -> Self {
        Self {
            enabled: Some(value.enabled),
            direct_spawn: Some(value.direct_spawn),
        }
    }
}

impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
    fn from(value: MemoryFeatureConfigPartial) -> Self {
        Self {
            enabled: value.enabled.unwrap_or_default(),
            staging: value.staging.unwrap_or_default(),
        }
    }
}

impl From<MemoryFeatureConfig> for MemoryFeatureConfigPartial {
    fn from(value: MemoryFeatureConfig) -> Self {
        Self {
            enabled: Some(value.enabled),
            staging: Some(value.staging),
        }
    }
}

impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
    fn from(value: TicketFeatureConfigPartial) -> Self {
        Self {
            enabled: value.enabled.unwrap_or_default(),
            authoring: value.authoring.unwrap_or_default(),
            thread: value.thread.unwrap_or_default(),
            intake: value.intake.unwrap_or_default(),
            workflow: value.workflow.unwrap_or_default(),
        }
    }
}

impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
    fn from(value: TicketFeatureConfig) -> Self {
        Self {
            enabled: Some(value.enabled),
            authoring: Some(value.authoring),
            thread: Some(value.thread),
            intake: Some(value.intake),
            workflow: Some(value.workflow),
        }
    }
}

impl From<MergeRequestFeatureConfigPartial> for MergeRequestFeatureConfig {
    fn from(value: MergeRequestFeatureConfigPartial) -> Self {
        Self {
            show: value.show.unwrap_or_default(),
            open: value.open.unwrap_or_default(),
            review: value.review.unwrap_or_default(),
            readiness_check: value.readiness_check.unwrap_or_default(),
            complete: value.complete.unwrap_or_default(),
        }
    }
}

impl From<MergeRequestFeatureConfig> for MergeRequestFeatureConfigPartial {
    fn from(value: MergeRequestFeatureConfig) -> Self {
        Self {
            show: Some(value.show),
            open: Some(value.open),
            review: Some(value.review),
            readiness_check: Some(value.readiness_check),
            complete: Some(value.complete),
        }
    }
}

impl From<FeatureConfig> for FeatureConfigPartial {
    fn from(value: FeatureConfig) -> Self {
        Self {
            task: Some(value.task.into()),
            memory: Some(value.memory.into()),
            web: Some(value.web.into()),
            image: Some(value.image.into()),
            sub_worker: Some(value.sub_worker.into()),
            flow: Some(value.flow.into()),
            worker: Some(value.worker.into()),
            objective: Some(value.objective.into()),
            manage_workdir: Some(value.manage_workdir.into()),
            ticket: Some(value.ticket.into()),
            merge_request: Some(value.merge_request.into()),
            orchestration: Some(value.orchestration.into()),
            plugins: Some(value.plugins.into()),
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerMetaConfig {
    #[serde(default)]
    pub name: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EngineManifestConfig {
    #[serde(default)]
    pub instruction: Option<String>,
    #[serde(default)]
    pub language: Option<String>,
    #[serde(default)]
    pub max_tokens: Option<u32>,
    #[serde(default)]
    pub max_turns: Option<NonZeroU32>,
    #[serde(default)]
    pub temperature: Option<f32>,
    #[serde(default)]
    pub top_p: Option<f32>,
    #[serde(default)]
    pub top_k: Option<u32>,
    #[serde(default)]
    pub stop_sequences: Option<Vec<String>>,
    #[serde(default)]
    pub reasoning: Option<ReasoningControl>,
    #[serde(default)]
    pub tool_output: ToolOutputLimitsPartial,
    #[serde(default)]
    pub file_upload: FileUploadLimitsPartial,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolOutputLimitsPartial {
    #[serde(default)]
    pub default_max_bytes: Option<usize>,
    #[serde(default)]
    pub per_tool: HashMap<String, usize>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FileUploadLimitsPartial {
    #[serde(default)]
    pub max_bytes: Option<usize>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionConfigPartial {
    #[serde(default)]
    pub record_event_trace: Option<bool>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionConfigPartial {
    #[serde(default)]
    pub default_action: Option<crate::ToolPermissionAction>,
    #[serde(default, rename = "rule")]
    pub rules: Vec<ToolPermissionRule>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompactionConfigPartial {
    #[serde(default)]
    pub prune_protected_tokens: Option<u64>,
    #[serde(default)]
    pub prune_min_savings: Option<u64>,
    #[serde(default, alias = "compact_threshold")]
    pub threshold: Option<u64>,
    #[serde(default, alias = "compact_request_threshold")]
    pub request_threshold: Option<u64>,
    #[serde(default, alias = "compact_retained_tokens")]
    pub retained_tokens: Option<u64>,
    #[serde(default)]
    pub overview_target_tokens: Option<u64>,
    #[serde(default)]
    pub overview_warning_tokens: Option<u64>,
    #[serde(default)]
    pub overview_deadline_tokens: Option<u64>,
    #[serde(default, alias = "compact_worker_max_input_tokens")]
    pub worker_context_max_tokens: Option<u64>,
    #[serde(default)]
    pub finish_warning_remaining_tokens: Option<u64>,
    #[serde(default)]
    pub final_reserve_tokens: Option<u64>,
    #[serde(default, alias = "compact_worker_max_turns")]
    pub worker_max_turns: Option<u32>,
    #[serde(default)]
    pub summary_target_tokens: Option<u64>,
    #[serde(default)]
    pub summary_max_tokens: Option<u64>,
    #[serde(default, alias = "compact_auto_read_budget")]
    pub auto_read_budget_tokens: Option<u64>,
    #[serde(default)]
    pub result_context_max_tokens: Option<u64>,
    #[serde(default)]
    pub model: Option<ModelManifest>,
}

/// Errors raised when converting a [`WorkerManifestConfig`] to a validated
/// [`WorkerManifest`] via `TryFrom`.
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
    #[error("missing required field: {0}")]
    MissingField(&'static str),
    #[error("path must be absolute ({field}): {}", .path.display())]
    RelativePath { field: &'static str, path: PathBuf },
    #[error("invalid MCP config ({field}): {message}")]
    InvalidMcpConfig {
        field: &'static str,
        message: String,
    },
}

/// Reject manifest fields that were intentionally removed and must not be
/// silently swallowed by the general warn-and-ignore unknown-field policy.
pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Error> {
    let value: toml::Value = toml::from_str(s)?;
    if value
        .get("compaction")
        .and_then(toml::Value::as_table)
        .is_some_and(|table| table.contains_key("prune_protected_turns"))
    {
        return Err(toml::de::Error::custom(
            "unknown field in manifest: compaction.prune_protected_turns \
             (removed; use compaction.prune_protected_tokens)",
        ));
    }
    if value
        .get("memory")
        .and_then(toml::Value::as_table)
        .is_some_and(|table| table.contains_key("extract_worker_max_input_tokens"))
    {
        return Err(toml::de::Error::custom(
            "unknown field in manifest: memory.extract_worker_max_input_tokens (removed)",
        ));
    }
    if value
        .get("feature")
        .and_then(toml::Value::as_table)
        .is_some_and(|table| table.contains_key("workers"))
    {
        return Err(toml::de::Error::custom(
            "unknown field in manifest: feature.workers (removed; use feature.sub_worker)",
        ));
    }
    Ok(())
}

impl WorkerManifestConfig {
    /// Parse a partial manifest from a TOML string. Unknown top-level or
    /// nested fields emit a `tracing::warn!` and are ignored; use
    /// `tracing_subscriber` with `WARN` enabled to surface them to the
    /// operator. Removed fields with an explicit replacement (including
    /// `feature.workers`) are rejected before deserialization.
    pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
        reject_removed_manifest_fields(s)?;
        let de = toml::Deserializer::parse(s)?;
        serde_ignored::deserialize(de, |path| {
            tracing::warn!("unknown field in manifest: {}", path);
        })
    }

    /// Base config populated with the in-code defaults listed in
    /// [`crate::defaults`]. Profile and one-file Manifest resolvers start
    /// from this layer so every per-field default lives at exactly one
    /// call site (the `defaults` module).
    ///
    /// `TryFrom<WorkerManifestConfig>` also reads the same constants as a
    /// belt-and-suspenders fallback, so a manually-constructed config
    /// that skips this layer still resolves to the same values.
    pub fn builtin_defaults() -> Self {
        Self {
            engine: EngineManifestConfig {
                tool_output: ToolOutputLimitsPartial {
                    default_max_bytes: Some(defaults::TOOL_OUTPUT_MAX_BYTES),
                    per_tool: HashMap::new(),
                },
                file_upload: FileUploadLimitsPartial {
                    max_bytes: Some(defaults::FILE_UPLOAD_MAX_BYTES),
                },
                ..Default::default()
            },
            ..Default::default()
        }
    }

    /// Resolve every relative path inside this partial config against
    /// `base` (assumed absolute). Paths that are already absolute are
    /// left untouched. This is the only place per-layer path resolution
    /// happens — cascade merge runs against fully absolute paths so
    /// rules from different layers do not accidentally inherit another
    /// layer's base.
    ///
    /// Affected fields: `model.auth.file`,
    /// `scope.allow[].target`, `scope.deny[].target`,
    /// `delegation_scope.allow[].target`, `delegation_scope.deny[].target`,
    /// `compaction.model.auth.file`.
    pub fn resolve_paths(mut self, base: &Path) -> Self {
        debug_assert!(
            base.is_absolute(),
            "resolve_paths base must be absolute: {}",
            base.display()
        );
        resolve_auth_file(&mut self.model.auth, base);
        for rule in &mut self.scope.allow {
            rule.target = join_if_relative(base, &rule.target);
        }
        for rule in &mut self.scope.deny {
            rule.target = join_if_relative(base, &rule.target);
        }
        for rule in &mut self.delegation_scope.allow {
            rule.target = join_if_relative(base, &rule.target);
        }
        for rule in &mut self.delegation_scope.deny {
            rule.target = join_if_relative(base, &rule.target);
        }
        if let Some(ref mut memory) = self.memory
            && let Some(ref mut root) = memory.workspace_root
        {
            *root = join_if_relative(base, root);
        }
        if let Some(ref mut compaction) = self.compaction
            && let Some(ref mut cp) = compaction.model
        {
            resolve_auth_file(&mut cp.auth, base);
        }
        if let Some(ref mut skills) = self.skills {
            for dir in &mut skills.directories {
                *dir = join_if_relative(base, dir);
            }
        }
        for server in &mut self.mcp.stdio_servers {
            if let Some(McpStdioCwdPolicy::Path { path }) = &mut server.cwd {
                *path = join_if_relative(base, path);
            }
        }
        self
    }

    /// Merge `upper` into `self`. Fields present in `upper` override
    /// fields from `self`. Map entries merge key-wise with `upper`
    /// winning on conflict. Scope rules from both layers accumulate
    /// (see [`ScopeConfig`] semantics).
    pub fn merge(self, upper: WorkerManifestConfig) -> Self {
        Self {
            worker: self.worker.merge(upper.worker),
            model: self.model.merge(upper.model),
            engine: self.engine.merge(upper.engine),
            scope: merge_scope(self.scope, upper.scope),
            delegation_scope: merge_scope(self.delegation_scope, upper.delegation_scope),
            session: merge_option(self.session, upper.session, SessionConfigPartial::merge),
            permissions: merge_option(
                self.permissions,
                upper.permissions,
                PermissionConfigPartial::merge,
            ),
            feature: self.feature.merge(upper.feature),
            plugins: merge_plugin_config(self.plugins, upper.plugins),
            mcp: merge_mcp_config(self.mcp, upper.mcp),
            compaction: merge_option(
                self.compaction,
                upper.compaction,
                CompactionConfigPartial::merge,
            ),
            web: merge_option(self.web, upper.web, WebConfig::merge),
            memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
            skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
        }
    }
}

impl SkillsConfig {
    fn merge(mut self, upper: Self) -> Self {
        self.directories.extend(upper.directories);
        self
    }
}

fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
    let upper_has_resolved_plan = upper.has_resolved_plan();
    base.enabled.extend(upper.enabled);
    if upper_has_resolved_plan {
        base.resolved = upper.resolved;
        base.diagnostics = upper.diagnostics;
    }
    base
}

fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
    base.stdio_servers.extend(upper.stdio_servers);
    base
}

impl WebConfig {
    fn merge(self, upper: Self) -> Self {
        Self {
            enabled: upper.enabled.or(self.enabled),
            allow_private_addresses: upper
                .allow_private_addresses
                .or(self.allow_private_addresses),
            search: merge_option(self.search, upper.search, crate::WebSearchConfig::merge),
            fetch: merge_option(self.fetch, upper.fetch, crate::WebFetchConfig::merge),
        }
    }
}

impl crate::WebSearchConfig {
    fn merge(self, upper: Self) -> Self {
        Self {
            enabled: upper.enabled.or(self.enabled),
            provider: upper.provider.or(self.provider),
            api_key_secret: upper.api_key_secret.or(self.api_key_secret),
            timeout_secs: upper.timeout_secs.or(self.timeout_secs),
            base_url: upper.base_url.or(self.base_url),
            country: upper.country.or(self.country),
            search_lang: upper.search_lang.or(self.search_lang),
            ui_lang: upper.ui_lang.or(self.ui_lang),
            safesearch: upper.safesearch.or(self.safesearch),
        }
    }
}

impl crate::WebFetchConfig {
    fn merge(self, upper: Self) -> Self {
        Self {
            enabled: upper.enabled.or(self.enabled),
            timeout_secs: upper.timeout_secs.or(self.timeout_secs),
            redirect_limit: upper.redirect_limit.or(self.redirect_limit),
            max_response_bytes: upper.max_response_bytes.or(self.max_response_bytes),
            max_output_bytes: upper.max_output_bytes.or(self.max_output_bytes),
            allow_private_addresses: upper
                .allow_private_addresses
                .or(self.allow_private_addresses),
        }
    }
}

impl MemoryConfig {
    fn merge(self, upper: Self) -> Self {
        Self {
            workspace_root: upper.workspace_root.or(self.workspace_root),
            query_result_limit: upper.query_result_limit.or(self.query_result_limit),
            query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
            inject_summary: upper.inject_summary.or(self.inject_summary),
            workspace_id: upper.workspace_id.or(self.workspace_id),
            settings_revision: upper.settings_revision.or(self.settings_revision),
            language: upper.language.or(self.language),
            extract_model: upper.extract_model.or(self.extract_model),
            extract_threshold: upper.extract_threshold.or(self.extract_threshold),
            extract_worker_max_turns: upper
                .extract_worker_max_turns
                .or(self.extract_worker_max_turns),
            consolidation_model: upper.consolidation_model.or(self.consolidation_model),
            consolidation_threshold_files: upper
                .consolidation_threshold_files
                .or(self.consolidation_threshold_files),
            consolidation_threshold_bytes: upper
                .consolidation_threshold_bytes
                .or(self.consolidation_threshold_bytes),
        }
    }
}

impl WorkerMetaConfig {
    fn merge(self, upper: Self) -> Self {
        Self {
            name: upper.name.or(self.name),
        }
    }
}

impl EngineManifestConfig {
    fn merge(self, upper: Self) -> Self {
        Self {
            instruction: upper.instruction.or(self.instruction),
            language: upper.language.or(self.language),
            max_tokens: upper.max_tokens.or(self.max_tokens),
            max_turns: upper.max_turns.or(self.max_turns),
            temperature: upper.temperature.or(self.temperature),
            top_p: upper.top_p.or(self.top_p),
            top_k: upper.top_k.or(self.top_k),
            stop_sequences: upper.stop_sequences.or(self.stop_sequences),
            reasoning: upper.reasoning.or(self.reasoning),
            tool_output: self.tool_output.merge(upper.tool_output),
            file_upload: self.file_upload.merge(upper.file_upload),
        }
    }
}

impl ToolOutputLimitsPartial {
    fn merge(self, upper: Self) -> Self {
        let mut per_tool = self.per_tool;
        per_tool.extend(upper.per_tool);
        Self {
            default_max_bytes: upper.default_max_bytes.or(self.default_max_bytes),
            per_tool,
        }
    }
}

impl FileUploadLimitsPartial {
    fn merge(self, upper: Self) -> Self {
        Self {
            max_bytes: upper.max_bytes.or(self.max_bytes),
        }
    }
}

impl SessionConfigPartial {
    fn merge(self, upper: Self) -> Self {
        Self {
            record_event_trace: upper.record_event_trace.or(self.record_event_trace),
        }
    }
}

impl PermissionConfigPartial {
    fn merge(mut self, upper: Self) -> Self {
        self.rules.extend(upper.rules);
        Self {
            default_action: upper.default_action.or(self.default_action),
            rules: self.rules,
        }
    }
}

impl CompactionConfigPartial {
    fn merge(self, upper: Self) -> Self {
        Self {
            prune_protected_tokens: upper.prune_protected_tokens.or(self.prune_protected_tokens),
            prune_min_savings: upper.prune_min_savings.or(self.prune_min_savings),
            threshold: upper.threshold.or(self.threshold),
            request_threshold: upper.request_threshold.or(self.request_threshold),
            retained_tokens: upper.retained_tokens.or(self.retained_tokens),
            overview_target_tokens: upper.overview_target_tokens.or(self.overview_target_tokens),
            overview_warning_tokens: upper
                .overview_warning_tokens
                .or(self.overview_warning_tokens),
            overview_deadline_tokens: upper
                .overview_deadline_tokens
                .or(self.overview_deadline_tokens),
            worker_context_max_tokens: upper
                .worker_context_max_tokens
                .or(self.worker_context_max_tokens),
            finish_warning_remaining_tokens: upper
                .finish_warning_remaining_tokens
                .or(self.finish_warning_remaining_tokens),
            final_reserve_tokens: upper.final_reserve_tokens.or(self.final_reserve_tokens),
            worker_max_turns: upper.worker_max_turns.or(self.worker_max_turns),
            summary_target_tokens: upper.summary_target_tokens.or(self.summary_target_tokens),
            summary_max_tokens: upper.summary_max_tokens.or(self.summary_max_tokens),
            auto_read_budget_tokens: upper
                .auto_read_budget_tokens
                .or(self.auto_read_budget_tokens),
            result_context_max_tokens: upper
                .result_context_max_tokens
                .or(self.result_context_max_tokens),
            model: merge_option(self.model, upper.model, ModelManifest::merge),
        }
    }
}

fn merge_scope(mut lower: ScopeConfig, upper: ScopeConfig) -> ScopeConfig {
    lower.allow.extend(upper.allow);
    lower.deny.extend(upper.deny);
    lower
}

fn merge_option<T>(lower: Option<T>, upper: Option<T>, merge: fn(T, T) -> T) -> Option<T> {
    match (lower, upper) {
        (Some(l), Some(u)) => Some(merge(l, u)),
        (l, u) => u.or(l),
    }
}

fn join_if_relative(base: &Path, p: &Path) -> PathBuf {
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        base.join(p)
    }
}

/// Invariant check: every path in a fully-resolved [`WorkerManifestConfig`]
/// must be absolute. Relative paths are resolved per-layer via
/// [`WorkerManifestConfig::resolve_paths`]; if one reaches `TryFrom` it
/// indicates a caller skipped the per-layer resolve step.
fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> {
    if path.is_absolute() {
        Ok(())
    } else {
        Err(ResolveError::RelativePath {
            field,
            path: path.to_path_buf(),
        })
    }
}

/// `AuthRef::ApiKey { file }` が相対パスのとき `base` を前置する。
fn resolve_auth_file(auth: &mut Option<AuthRef>, base: &Path) {
    if let Some(AuthRef::ApiKey { file: Some(p), .. }) = auth.as_mut() {
        *p = join_if_relative(base, p);
    }
}

/// モデル宣言に含まれる `auth.file` が絶対パスであることを検証する。
/// ref / scheme / model_id 等の論理的な有効性（ref があるか、inline が
/// 揃っているか）の検証は [`crate::model_catalog`] で行う。
fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<(), ResolveError> {
    if let Some(AuthRef::ApiKey { file: Some(p), .. }) = &model.auth {
        ensure_absolute(field, p)?;
    }
    Ok(())
}

pub(crate) fn validate_mcp_config(mcp: &McpConfig) -> Result<(), ResolveError> {
    let mut names = BTreeSet::new();
    for server in &mcp.stdio_servers {
        if server.name.trim().is_empty() {
            return Err(invalid_mcp(
                "mcp.stdio_server.name",
                "server name must not be empty",
            ));
        }
        if contains_nul(&server.name) {
            return Err(invalid_mcp(
                "mcp.stdio_server.name",
                "server name must not contain NUL",
            ));
        }
        if !names.insert(server.name.as_str()) {
            return Err(invalid_mcp(
                "mcp.stdio_server.name",
                format!(
                    "duplicate stdio server name `{}`",
                    bounded_label(&server.name)
                ),
            ));
        }

        if server.command.trim().is_empty() {
            return Err(invalid_mcp(
                "mcp.stdio_server.command",
                "command must not be empty",
            ));
        }
        if contains_nul(&server.command) {
            return Err(invalid_mcp(
                "mcp.stdio_server.command",
                "command must not contain NUL",
            ));
        }
        for arg in &server.args {
            if contains_nul(arg) {
                return Err(invalid_mcp(
                    "mcp.stdio_server.args",
                    "argument must not contain NUL",
                ));
            }
        }

        if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd {
            if path.as_os_str().is_empty() {
                return Err(invalid_mcp(
                    "mcp.stdio_server.cwd.path",
                    "cwd path must not be empty",
                ));
            }
            if !path.is_absolute() {
                return Err(invalid_mcp(
                    "mcp.stdio_server.cwd.path",
                    "cwd path must be absolute after profile/manifest path resolution",
                ));
            }
        }

        for name in &server.env.inherit {
            validate_env_name("mcp.stdio_server.env.inherit", name)?;
        }
        for (name, value) in &server.env.set {
            validate_env_name("mcp.stdio_server.env.set", name)?;
            match value {
                McpEnvValue::Literal { value } => {
                    if contains_nul(value) {
                        return Err(invalid_mcp(
                            "mcp.stdio_server.env.set",
                            "literal env value must not contain NUL",
                        ));
                    }
                }
                McpEnvValue::SecretRef { ref_ } => {
                    if secrets::validate_id(ref_).is_err() {
                        return Err(invalid_mcp(
                            "mcp.stdio_server.env.set.secret_ref",
                            "secret_ref must be a valid local secret id",
                        ));
                    }
                }
                McpEnvValue::EnvRef { name } => {
                    validate_env_name("mcp.stdio_server.env.set.env_ref", name)?;
                }
            }
        }
    }
    Ok(())
}

fn validate_env_name(field: &'static str, name: &str) -> Result<(), ResolveError> {
    let mut chars = name.chars();
    let Some(first) = chars.next() else {
        return Err(invalid_mcp(
            field,
            "environment variable name must not be empty",
        ));
    };
    if !(first == '_' || first.is_ascii_alphabetic()) {
        return Err(invalid_mcp(
            field,
            "environment variable name must start with ASCII letter or underscore",
        ));
    }
    if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
        return Err(invalid_mcp(
            field,
            "environment variable name must contain only ASCII letters, digits, and underscore",
        ));
    }
    Ok(())
}

fn invalid_mcp(field: &'static str, message: impl Into<String>) -> ResolveError {
    ResolveError::InvalidMcpConfig {
        field,
        message: message.into(),
    }
}

fn contains_nul(value: &str) -> bool {
    value.as_bytes().contains(&0)
}

fn bounded_label(value: &str) -> String {
    const MAX: usize = 80;
    let mut out = String::new();
    for (idx, ch) in value.chars().enumerate() {
        if idx >= MAX {
            out.push('…');
            break;
        }
        if ch.is_control() {
            out.push('?');
        } else {
            out.push(ch);
        }
    }
    out
}

impl TryFrom<WorkerManifestConfig> for WorkerManifest {
    type Error = ResolveError;

    fn try_from(cfg: WorkerManifestConfig) -> Result<Self, Self::Error> {
        let name = cfg
            .worker
            .name
            .ok_or(ResolveError::MissingField("worker.name"))?;

        validate_model_paths(&cfg.model, "model.auth.file")?;

        let engine = EngineManifest {
            instruction: cfg
                .engine
                .instruction
                .unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()),
            language: cfg
                .engine
                .language
                .unwrap_or_else(|| defaults::WORKER_LANGUAGE.to_string()),
            max_tokens: cfg.engine.max_tokens,
            max_turns: cfg.engine.max_turns,
            temperature: cfg.engine.temperature,
            top_p: cfg.engine.top_p,
            top_k: cfg.engine.top_k,
            stop_sequences: cfg.engine.stop_sequences.unwrap_or_default(),
            reasoning: cfg.engine.reasoning,
            tool_output: ToolOutputLimits {
                default_max_bytes: cfg
                    .engine
                    .tool_output
                    .default_max_bytes
                    .unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES),
                per_tool: cfg.engine.tool_output.per_tool,
            },
            file_upload: FileUploadLimits {
                max_bytes: cfg
                    .engine
                    .file_upload
                    .max_bytes
                    .unwrap_or(defaults::FILE_UPLOAD_MAX_BYTES),
            },
        };

        for rule in &cfg.scope.allow {
            ensure_absolute("scope.allow.target", &rule.target)?;
        }
        for rule in &cfg.scope.deny {
            ensure_absolute("scope.deny.target", &rule.target)?;
        }
        for rule in &cfg.delegation_scope.allow {
            ensure_absolute("delegation_scope.allow.target", &rule.target)?;
        }
        for rule in &cfg.delegation_scope.deny {
            ensure_absolute("delegation_scope.deny.target", &rule.target)?;
        }
        let session = SessionConfig {
            record_event_trace: cfg
                .session
                .and_then(|s| s.record_event_trace)
                .unwrap_or(false),
        };

        let permissions = cfg
            .permissions
            .map(|p| {
                Ok(ToolPermissionConfig {
                    default_action: p
                        .default_action
                        .ok_or(ResolveError::MissingField("permissions.default_action"))?,
                    rules: p.rules,
                })
            })
            .transpose()?;

        let compaction = cfg
            .compaction
            .map(|c| -> Result<CompactionConfig, ResolveError> {
                if let Some(ref cm) = c.model {
                    validate_model_paths(cm, "compaction.model.auth.file")?;
                }
                Ok(CompactionConfig {
                    prune_protected_tokens: c
                        .prune_protected_tokens
                        .unwrap_or(defaults::PRUNE_PROTECTED_TOKENS),
                    prune_min_savings: c.prune_min_savings.unwrap_or(defaults::PRUNE_MIN_SAVINGS),
                    threshold: c.threshold,
                    request_threshold: c.request_threshold,
                    retained_tokens: c
                        .retained_tokens
                        .unwrap_or(defaults::COMPACT_RETAINED_TOKENS),
                    overview_target_tokens: c
                        .overview_target_tokens
                        .unwrap_or(defaults::COMPACT_OVERVIEW_TARGET_TOKENS),
                    overview_warning_tokens: c
                        .overview_warning_tokens
                        .unwrap_or(defaults::COMPACT_OVERVIEW_WARNING_TOKENS),
                    overview_deadline_tokens: c
                        .overview_deadline_tokens
                        .unwrap_or(defaults::COMPACT_OVERVIEW_DEADLINE_TOKENS),
                    worker_context_max_tokens: c
                        .worker_context_max_tokens
                        .unwrap_or(defaults::COMPACT_WORKER_MAX_INPUT_TOKENS),
                    finish_warning_remaining_tokens: c
                        .finish_warning_remaining_tokens
                        .unwrap_or(defaults::COMPACT_FINISH_WARNING_REMAINING_TOKENS),
                    final_reserve_tokens: c
                        .final_reserve_tokens
                        .unwrap_or(defaults::COMPACT_FINAL_RESERVE_TOKENS),
                    worker_max_turns: c.worker_max_turns.or(defaults::COMPACT_WORKER_MAX_TURNS),
                    summary_target_tokens: c
                        .summary_target_tokens
                        .unwrap_or(defaults::COMPACT_SUMMARY_TARGET_TOKENS),
                    summary_max_tokens: c
                        .summary_max_tokens
                        .unwrap_or(defaults::COMPACT_SUMMARY_MAX_TOKENS),
                    auto_read_budget_tokens: c
                        .auto_read_budget_tokens
                        .unwrap_or(defaults::COMPACT_AUTO_READ_BUDGET),
                    result_context_max_tokens: c
                        .result_context_max_tokens
                        .unwrap_or(defaults::COMPACT_RESULT_CONTEXT_MAX_TOKENS),
                    model: c.model,
                })
            })
            .transpose()?;

        if let Some(ref skills) = cfg.skills {
            for dir in &skills.directories {
                ensure_absolute("skills.directories", dir)?;
            }
        }

        validate_mcp_config(&cfg.mcp)?;

        Ok(WorkerManifest {
            worker: WorkerMeta { name },
            model: cfg.model,
            engine,
            scope: cfg.scope,
            delegation_scope: cfg.delegation_scope,
            session,
            permissions,
            feature: FeatureConfig::from(cfg.feature),
            plugins: cfg.plugins,
            mcp: cfg.mcp,
            compaction,
            web: cfg.web,
            memory: cfg.memory,
            skills: cfg.skills,
            profile: None,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::SchemeKind;
    use crate::{Permission, ReasoningEffort, ScopeRule};

    fn abs(path: &str) -> PathBuf {
        PathBuf::from(format!("/tmp/yoi-test{path}"))
    }

    fn api_key_file_auth(path: PathBuf) -> AuthRef {
        AuthRef::ApiKey { file: Some(path) }
    }

    fn minimal_valid() -> WorkerManifestConfig {
        WorkerManifestConfig {
            worker: WorkerMetaConfig {
                name: Some("test".into()),
            },
            model: ModelManifest {
                scheme: Some(SchemeKind::Anthropic),
                model_id: Some("claude-sonnet-4-20250514".into()),
                ..Default::default()
            },
            engine: EngineManifestConfig::default(),
            scope: ScopeConfig {
                allow: vec![ScopeRule {
                    target: abs("/worker"),
                    permission: Permission::Write,
                    recursive: true,
                }],
                deny: Vec::new(),
            },
            delegation_scope: ScopeConfig::default(),
            permissions: None,
            feature: FeatureConfigPartial::default(),
            plugins: PluginConfig::default(),
            mcp: McpConfig::default(),
            session: None,
            compaction: None,
            web: None,
            memory: None,
            skills: None,
        }
    }

    #[test]
    fn resolve_minimal_succeeds() {
        let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
        assert_eq!(manifest.worker.name, "test");
        assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
        assert!(manifest.permissions.is_none());
    }

    #[test]
    fn resolve_mcp_stdio_config_preserves_explicit_policy() {
        let mut cfg = minimal_valid();
        cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
            name: "filesystem".into(),
            command: "node".into(),
            args: vec!["server.js".into(), "--root".into()],
            cwd: Some(McpStdioCwdPolicy::Path { path: abs("/mcp") }),
            env: crate::McpEnvConfig {
                inherit: vec!["PATH".into()],
                set: std::collections::BTreeMap::from([
                    (
                        "SAFE_MODE".into(),
                        McpEnvValue::Literal { value: "1".into() },
                    ),
                    (
                        "TOKEN".into(),
                        McpEnvValue::SecretRef {
                            ref_: "providers/mcp-token".into(),
                        },
                    ),
                    (
                        "UPSTREAM".into(),
                        McpEnvValue::EnvRef {
                            name: "MCP_UPSTREAM_TOKEN".into(),
                        },
                    ),
                ]),
            },
        });

        let manifest: WorkerManifest = cfg.try_into().unwrap();

        assert_eq!(manifest.mcp.stdio_servers.len(), 1);
        let server = &manifest.mcp.stdio_servers[0];
        assert_eq!(server.name, "filesystem");
        assert_eq!(server.command, "node");
        assert_eq!(server.env.inherit, ["PATH"]);
        assert!(matches!(
            server.env.set["TOKEN"],
            McpEnvValue::SecretRef { .. }
        ));
    }

    #[test]
    fn resolve_mcp_rejects_empty_command_and_duplicates() {
        let mut cfg = minimal_valid();
        cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
            name: "dup".into(),
            command: "".into(),
            args: Vec::new(),
            cwd: None,
            env: crate::McpEnvConfig::default(),
        });

        let err = WorkerManifest::try_from(cfg).unwrap_err();
        assert!(matches!(
            err,
            ResolveError::InvalidMcpConfig {
                field: "mcp.stdio_server.command",
                ..
            }
        ));

        let mut cfg = minimal_valid();
        for command in ["one", "two"] {
            cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
                name: "dup".into(),
                command: command.into(),
                args: Vec::new(),
                cwd: None,
                env: crate::McpEnvConfig::default(),
            });
        }

        let err = WorkerManifest::try_from(cfg).unwrap_err();
        assert!(matches!(
            err,
            ResolveError::InvalidMcpConfig {
                field: "mcp.stdio_server.name",
                ..
            }
        ));
    }

    #[test]
    fn resolve_mcp_rejects_invalid_env_and_secret_ref_without_leaking_values() {
        let mut cfg = minimal_valid();
        cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
            name: "secret".into(),
            command: "no-such-command-is-not-started".into(),
            args: Vec::new(),
            cwd: None,
            env: crate::McpEnvConfig {
                inherit: Vec::new(),
                set: std::collections::BTreeMap::from([(
                    "TOKEN".into(),
                    McpEnvValue::SecretRef {
                        ref_: "bad secret id with spaces".into(),
                    },
                )]),
            },
        });

        let err = WorkerManifest::try_from(cfg).unwrap_err();
        let rendered = err.to_string();
        assert!(rendered.contains("secret_ref"));
        assert!(!rendered.contains("bad secret id with spaces"));

        let value = McpEnvValue::Literal {
            value: "plaintext-secret-value".into(),
        };
        assert!(!format!("{value:?}").contains("plaintext-secret-value"));
    }

    #[test]
    fn resolve_mcp_accepts_nonexistent_command_without_autostart() {
        let mut cfg = minimal_valid();
        cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
            name: "later".into(),
            command: "definitely-not-a-command-yoi-must-spawn".into(),
            args: Vec::new(),
            cwd: None,
            env: crate::McpEnvConfig::default(),
        });

        let manifest: WorkerManifest = cfg.try_into().unwrap();
        assert_eq!(
            manifest.mcp.stdio_servers[0].command,
            "definitely-not-a-command-yoi-must-spawn"
        );
    }

    #[test]
    fn resolve_session_record_event_trace() {
        let mut cfg = minimal_valid();
        cfg.session = Some(SessionConfigPartial {
            record_event_trace: Some(true),
        });

        let manifest: WorkerManifest = cfg.try_into().unwrap();
        assert!(manifest.session.record_event_trace);
    }

    #[test]
    fn resolve_permissions_requires_default_action_when_present() {
        let mut cfg = minimal_valid();
        cfg.permissions = Some(PermissionConfigPartial {
            default_action: None,
            rules: Vec::new(),
        });

        let err = WorkerManifest::try_from(cfg).unwrap_err();

        assert!(matches!(
            err,
            ResolveError::MissingField("permissions.default_action")
        ));
    }

    #[test]
    fn resolve_permissions_preserves_actions_and_rule_order() {
        let mut cfg = minimal_valid();
        cfg.permissions = Some(PermissionConfigPartial {
            default_action: Some(crate::ToolPermissionAction::Ask),
            rules: vec![
                ToolPermissionRule {
                    tool: "Bash".into(),
                    pattern: "rm *".into(),
                    action: crate::ToolPermissionAction::Deny,
                },
                ToolPermissionRule {
                    tool: "Read".into(),
                    pattern: "*".into(),
                    action: crate::ToolPermissionAction::Allow,
                },
            ],
        });

        let manifest: WorkerManifest = cfg.try_into().unwrap();
        let permissions = manifest.permissions.unwrap();

        assert_eq!(permissions.default_action, crate::ToolPermissionAction::Ask);
        assert_eq!(permissions.rules.len(), 2);
        assert_eq!(permissions.rules[0].tool, "Bash");
        assert_eq!(permissions.rules[1].tool, "Read");
    }

    #[test]
    fn resolve_paths_joins_relative_auth_file() {
        let mut cfg = minimal_valid();
        cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/anthropic")));
        let resolved = cfg.resolve_paths(Path::new("/home/user/.config/yoi"));
        let file = match resolved.model.auth {
            Some(AuthRef::ApiKey { file, .. }) => file,
            _ => panic!("expected ApiKey"),
        };
        assert_eq!(
            file.as_deref(),
            Some(Path::new("/home/user/.config/yoi/keys/anthropic"))
        );
    }

    #[test]
    fn resolve_paths_leaves_absolute_paths_untouched() {
        let mut cfg = minimal_valid();
        cfg.model.auth = Some(api_key_file_auth(PathBuf::from("/etc/already/abs")));
        let resolved = cfg.resolve_paths(Path::new("/home/user"));
        let file = match resolved.model.auth {
            Some(AuthRef::ApiKey { file, .. }) => file,
            _ => panic!("expected ApiKey"),
        };
        assert_eq!(file.as_deref(), Some(Path::new("/etc/already/abs")));
    }

    #[test]
    fn resolve_paths_joins_relative_scope_targets() {
        let mut cfg = minimal_valid();
        cfg.scope.allow[0].target = PathBuf::from(".");
        cfg.scope.deny.push(ScopeRule {
            target: PathBuf::from("secrets"),
            permission: Permission::Write,
            recursive: true,
        });
        let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
        assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
        assert_eq!(
            resolved.scope.deny[0].target,
            Path::new("/workspace/proj/secrets")
        );
    }

    #[test]
    fn try_from_invariant_rejects_lingering_relative_auth_file() {
        let mut cfg = minimal_valid();
        cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/relative")));
        let err = WorkerManifest::try_from(cfg).unwrap_err();
        assert!(matches!(
            err,
            ResolveError::RelativePath {
                field: "model.auth.file",
                ..
            }
        ));
    }

    #[test]
    fn try_from_invariant_rejects_lingering_relative_scope_target() {
        let mut cfg = minimal_valid();
        cfg.scope.allow[0].target = PathBuf::from("docs");
        let err = WorkerManifest::try_from(cfg).unwrap_err();
        assert!(matches!(
            err,
            ResolveError::RelativePath {
                field: "scope.allow.target",
                ..
            }
        ));
    }

    #[test]
    fn resolve_rejects_missing_worker_name() {
        let mut cfg = minimal_valid();
        cfg.worker.name = None;
        let err = WorkerManifest::try_from(cfg).unwrap_err();
        assert!(matches!(err, ResolveError::MissingField("worker.name")));
    }

    #[test]
    fn resolve_accepts_empty_scope_for_profile_launch_policy() {
        let mut cfg = minimal_valid();
        cfg.scope.allow.clear();
        let manifest = WorkerManifest::try_from(cfg).unwrap();
        assert!(manifest.scope.allow.is_empty());
    }

    #[test]
    fn merge_scalar_upper_wins() {
        let lower = WorkerManifestConfig {
            worker: WorkerMetaConfig {
                name: Some("lower".into()),
            },
            model: ModelManifest {
                model_id: Some("lower-model".into()),
                ..Default::default()
            },
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            worker: WorkerMetaConfig {
                name: Some("upper".into()),
            },
            ..Default::default()
        };
        let merged = lower.merge(upper);
        assert_eq!(merged.worker.name.as_deref(), Some("upper"));
        // model_id not present in upper — retain lower
        assert_eq!(merged.model.model_id.as_deref(), Some("lower-model"));
    }

    #[test]
    fn merge_worker_reasoning_upper_wins() {
        let lower = WorkerManifestConfig {
            engine: EngineManifestConfig {
                reasoning: Some(ReasoningControl::Effort(ReasoningEffort::Low)),
                ..Default::default()
            },
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            engine: EngineManifestConfig {
                reasoning: Some(ReasoningControl::BudgetTokens(4096)),
                ..Default::default()
            },
            ..Default::default()
        };

        let merged = lower.merge(upper);

        assert_eq!(
            merged.engine.reasoning,
            Some(ReasoningControl::BudgetTokens(4096))
        );
    }

    #[test]
    fn merge_worker_generation_settings_upper_wins() {
        let lower = WorkerManifestConfig {
            engine: EngineManifestConfig {
                top_p: Some(0.8),
                top_k: Some(20),
                stop_sequences: Some(vec!["lower".into()]),
                ..Default::default()
            },
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            engine: EngineManifestConfig {
                top_p: Some(0.9),
                stop_sequences: Some(vec!["upper".into()]),
                ..Default::default()
            },
            ..Default::default()
        };

        let merged = lower.merge(upper);

        assert_eq!(merged.engine.top_p, Some(0.9));
        assert_eq!(merged.engine.top_k, Some(20));
        assert_eq!(merged.engine.stop_sequences, Some(vec!["upper".into()]));
    }

    #[test]
    fn merge_scope_accumulates_allow_and_deny() {
        let lower = WorkerManifestConfig {
            scope: ScopeConfig {
                allow: vec![ScopeRule {
                    target: abs("/a"),
                    permission: Permission::Read,
                    recursive: true,
                }],
                deny: Vec::new(),
            },
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            scope: ScopeConfig {
                allow: vec![ScopeRule {
                    target: abs("/b"),
                    permission: Permission::Write,
                    recursive: true,
                }],
                deny: vec![ScopeRule {
                    target: abs("/a/secret"),
                    permission: Permission::Read,
                    recursive: false,
                }],
            },
            ..Default::default()
        };
        let merged = lower.merge(upper);
        assert_eq!(merged.scope.allow.len(), 2);
        assert_eq!(merged.scope.deny.len(), 1);
    }

    #[test]
    fn merge_permissions_accumulates_rules_and_upper_default_wins() {
        let lower = WorkerManifestConfig {
            permissions: Some(PermissionConfigPartial {
                default_action: Some(crate::ToolPermissionAction::Allow),
                rules: vec![ToolPermissionRule {
                    tool: "Bash".into(),
                    pattern: "git *".into(),
                    action: crate::ToolPermissionAction::Allow,
                }],
            }),
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            permissions: Some(PermissionConfigPartial {
                default_action: Some(crate::ToolPermissionAction::Deny),
                rules: vec![ToolPermissionRule {
                    tool: "Bash".into(),
                    pattern: "rm *".into(),
                    action: crate::ToolPermissionAction::Deny,
                }],
            }),
            ..Default::default()
        };

        let merged = lower.merge(upper).permissions.unwrap();

        assert_eq!(
            merged.default_action,
            Some(crate::ToolPermissionAction::Deny)
        );
        assert_eq!(merged.rules.len(), 2);
        assert_eq!(merged.rules[0].pattern, "git *");
        assert_eq!(merged.rules[1].pattern, "rm *");
    }

    #[test]
    fn merge_tool_output_per_tool_keywise() {
        let lower = WorkerManifestConfig {
            engine: EngineManifestConfig {
                tool_output: ToolOutputLimitsPartial {
                    default_max_bytes: Some(8192),
                    per_tool: [("Read".to_string(), 1024)].into_iter().collect(),
                },
                ..Default::default()
            },
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            engine: EngineManifestConfig {
                tool_output: ToolOutputLimitsPartial {
                    default_max_bytes: None,
                    per_tool: [("Read".to_string(), 2048), ("Grep".to_string(), 512)]
                        .into_iter()
                        .collect(),
                },
                ..Default::default()
            },
            ..Default::default()
        };
        let merged = lower.merge(upper);
        let to = &merged.engine.tool_output;
        assert_eq!(to.default_max_bytes, Some(8192));
        assert_eq!(to.per_tool.get("Read"), Some(&2048));
        assert_eq!(to.per_tool.get("Grep"), Some(&512));
    }

    #[test]
    fn merge_file_upload_max_bytes_upper_wins() {
        let lower = WorkerManifestConfig {
            engine: EngineManifestConfig {
                file_upload: FileUploadLimitsPartial {
                    max_bytes: Some(8192),
                },
                ..Default::default()
            },
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            engine: EngineManifestConfig {
                file_upload: FileUploadLimitsPartial {
                    max_bytes: Some(54_321),
                },
                ..Default::default()
            },
            ..Default::default()
        };
        let merged = lower.merge(upper);
        assert_eq!(merged.engine.file_upload.max_bytes, Some(54_321));
    }
    #[test]
    fn merge_option_struct_field_wise() {
        let lower = WorkerManifestConfig {
            compaction: Some(CompactionConfigPartial {
                threshold: Some(50_000),
                prune_protected_tokens: Some(5_000),
                ..Default::default()
            }),
            web: Some(WebConfig {
                search: Some(crate::WebSearchConfig {
                    api_key_secret: Some("web/brave/lower".into()),
                    timeout_secs: Some(12),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            compaction: Some(CompactionConfigPartial {
                threshold: Some(80_000),
                ..Default::default()
            }),
            web: Some(WebConfig {
                search: Some(crate::WebSearchConfig {
                    timeout_secs: Some(3),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            ..Default::default()
        };
        let merged = lower.merge(upper);
        let c = merged.compaction.unwrap();
        assert_eq!(c.threshold, Some(80_000));
        // field from lower retained when upper has None
        assert_eq!(c.prune_protected_tokens, Some(5_000));
        let search = merged.web.unwrap().search.unwrap();
        assert_eq!(search.timeout_secs, Some(3));
        assert_eq!(search.api_key_secret.as_deref(), Some("web/brave/lower"));
    }

    #[test]
    fn from_toml_type_mismatch_is_hard_error() {
        let bad = r#"
[worker]
name = "x"

[engine]
max_tokens = "not-a-number"
"#;
        assert!(WorkerManifestConfig::from_toml(bad).is_err());
    }

    #[test]
    fn from_toml_accepts_unknown_field() {
        // Unknown keys are warn-and-ignored, not hard errors.
        // `worker.pwd` specifically is silently dropped after the
        // path-resolution ticket — keep it in the fixture to exercise
        // that code path.
        let ok = r#"
[worker]
name = "x"
pwd = "/obsolete"

[engine]
max_tokens = 1000
unknown_future_field = "tolerated"
"#;
        let cfg = WorkerManifestConfig::from_toml(ok).unwrap();
        assert_eq!(cfg.engine.max_tokens, Some(1000));
    }

    #[test]
    fn from_toml_rejects_removed_prune_protected_turns_field() {
        let bad = r#"
[compaction]
prune_protected_turns = 3
"#;
        let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
        assert!(
            err.to_string().contains("compaction.prune_protected_turns"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() {
        let bad = r#"
[memory]
extract_worker_max_input_tokens = 30000
"#;
        let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
        assert!(
            err.to_string()
                .contains("memory.extract_worker_max_input_tokens"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn from_toml_accepts_extract_worker_max_turns() {
        let cfg = WorkerManifestConfig::from_toml(
            r#"
[memory]
extract_worker_max_turns = 2
"#,
        )
        .unwrap();
        assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2));
    }

    #[test]
    fn from_toml_accepts_worker_reasoning_string_or_integer() {
        let effort = WorkerManifestConfig::from_toml(
            r#"
[engine]
reasoning = "xhigh"
"#,
        )
        .unwrap();
        assert_eq!(
            effort.engine.reasoning,
            Some(ReasoningControl::Effort(ReasoningEffort::XHigh))
        );

        let budget = WorkerManifestConfig::from_toml(
            r#"
[engine]
reasoning = -1
"#,
        )
        .unwrap();
        assert_eq!(
            budget.engine.reasoning,
            Some(ReasoningControl::BudgetTokens(-1))
        );
    }

    #[test]
    fn from_toml_accepts_worker_generation_settings() {
        let cfg = WorkerManifestConfig::from_toml(
            r#"
[engine]
top_p = 0.9
top_k = 40
stop_sequences = ["\n\n", "</stop>"]
"#,
        )
        .unwrap();

        assert_eq!(cfg.engine.top_p, Some(0.9));
        assert_eq!(cfg.engine.top_k, Some(40));
        assert_eq!(
            cfg.engine.stop_sequences,
            Some(vec!["\n\n".into(), "</stop>".into()])
        );
    }

    #[test]
    fn from_toml_accepts_worker_max_turns() {
        let cfg = WorkerManifestConfig::from_toml(
            r#"
[compaction]
worker_max_turns = 7
"#,
        )
        .unwrap();

        assert_eq!(cfg.compaction.unwrap().worker_max_turns, Some(7));
    }

    #[test]
    fn try_from_compaction_defaults_worker_max_turns() {
        let mut cfg = minimal_valid();
        cfg.compaction = Some(CompactionConfigPartial::default());

        let manifest = WorkerManifest::try_from(cfg).unwrap();

        assert_eq!(
            manifest.compaction.unwrap().worker_max_turns,
            defaults::COMPACT_WORKER_MAX_TURNS
        );
    }

    #[test]
    fn feature_flags_default_disabled_in_resolved_manifest() {
        let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
        assert!(!manifest.feature.task.enabled);
        assert!(!manifest.feature.memory.enabled);
        assert!(!manifest.feature.web.enabled);
        assert!(!manifest.feature.sub_worker.enabled);
        assert!(!manifest.feature.objective.enabled);
        assert!(!manifest.feature.manage_workdir.enabled);
        assert!(!manifest.feature.ticket.enabled);
        assert!(!manifest.feature.merge_request.any());
    }

    #[test]
    fn from_toml_parses_explicit_feature_flags() {
        let cfg = WorkerManifestConfig::from_toml(
            r#"
[feature.task]
enabled = true

[feature.manage_workdir]
enabled = true

[feature.ticket]
enabled = true
authoring = false
thread = false
intake = false
workflow = false

[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false

[feature.orchestration]
enabled = false
"#,
        )
        .unwrap();
        let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
            .merge(cfg)
            .merge(WorkerManifestConfig {
                worker: WorkerMetaConfig {
                    name: Some("feature-test".into()),
                },
                model: ModelManifest {
                    scheme: Some(SchemeKind::Anthropic),
                    model_id: Some("m".into()),
                    ..Default::default()
                },
                scope: ScopeConfig {
                    allow: vec![ScopeRule {
                        target: abs("/worker"),
                        permission: Permission::Read,
                        recursive: true,
                    }],
                    deny: Vec::new(),
                },
                ..Default::default()
            })
            .try_into()
            .unwrap();
        assert!(manifest.feature.task.enabled);
        assert!(manifest.feature.manage_workdir.enabled);
        assert!(manifest.feature.ticket.enabled);
        assert!(!manifest.feature.ticket.authoring);
        assert!(!manifest.feature.ticket.thread);
        assert!(!manifest.feature.ticket.intake);
        assert!(!manifest.feature.ticket.workflow);
        assert_eq!(
            manifest.feature.merge_request,
            MergeRequestFeatureConfig {
                show: true,
                review: true,
                ..Default::default()
            }
        );
        assert!(!manifest.feature.orchestration.enabled);
        assert!(!manifest.feature.memory.enabled);
        assert!(!manifest.feature.memory.staging);
        assert!(!manifest.feature.objective.enabled);
    }

    #[test]
    fn feature_flags_merge_as_partial_profile_layers() {
        let base = WorkerManifestConfig::from_toml(
            r#"
[feature.memory]
enabled = true

[feature.manage_workdir]
enabled = false

[feature.ticket]
enabled = true
authoring = false
thread = false
intake = false
workflow = false

[feature.merge_request]
show = true
open = false
review = true
readiness_check = false
complete = false

[feature.orchestration]
enabled = false
"#,
        )
        .unwrap();
        let upper = WorkerManifestConfig::from_toml(
            r#"
[feature.ticket]
thread = true
workflow = true

[feature.merge_request]
open = true
review = false
readiness_check = true

[feature.orchestration]
enabled = true

[feature.memory]
staging = true

[feature.manage_workdir]
enabled = true

[feature.objective]
enabled = true

[feature.web]
enabled = true
"#,
        )
        .unwrap();
        let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
            .merge(base)
            .merge(upper)
            .merge(WorkerManifestConfig {
                worker: WorkerMetaConfig {
                    name: Some("feature-merge-test".into()),
                },
                model: ModelManifest {
                    scheme: Some(SchemeKind::Anthropic),
                    model_id: Some("m".into()),
                    ..Default::default()
                },
                scope: ScopeConfig {
                    allow: vec![ScopeRule {
                        target: abs("/worker"),
                        permission: Permission::Read,
                        recursive: true,
                    }],
                    deny: Vec::new(),
                },
                ..Default::default()
            })
            .try_into()
            .unwrap();
        assert!(manifest.feature.memory.enabled);
        assert!(manifest.feature.memory.staging);
        assert!(manifest.feature.manage_workdir.enabled);
        assert!(manifest.feature.ticket.enabled);
        assert!(!manifest.feature.ticket.authoring);
        assert!(manifest.feature.ticket.thread);
        assert!(!manifest.feature.ticket.intake);
        assert!(manifest.feature.ticket.workflow);
        assert_eq!(
            manifest.feature.merge_request,
            MergeRequestFeatureConfig {
                show: true,
                open: true,
                review: false,
                readiness_check: true,
                complete: false,
            }
        );
        assert!(manifest.feature.orchestration.enabled);
        assert!(manifest.feature.objective.enabled);
        assert!(manifest.feature.web.enabled);
        assert!(!manifest.feature.sub_worker.enabled);
    }

    #[test]
    fn from_toml_partial_layer_succeeds() {
        // A project-layer manifest with only scope set must parse fine.
        let toml = r#"
[[scope.allow]]
target = "/abs/project"
permission = "write"
"#;
        let cfg = WorkerManifestConfig::from_toml(toml).unwrap();
        assert!(cfg.worker.name.is_none());
        assert_eq!(cfg.scope.allow.len(), 1);
    }

    #[test]
    fn builtin_defaults_populates_worker_limit_defaults() {
        let cfg = WorkerManifestConfig::builtin_defaults();
        assert_eq!(
            cfg.engine.tool_output.default_max_bytes,
            Some(defaults::TOOL_OUTPUT_MAX_BYTES)
        );
        assert_eq!(
            cfg.engine.file_upload.max_bytes,
            Some(defaults::FILE_UPLOAD_MAX_BYTES)
        );
    }

    #[test]
    fn builtin_defaults_merged_into_minimal_resolves_with_defaults() {
        // Starting from builtin_defaults and overlaying only the
        // required fields must resolve to a WorkerManifest carrying the
        // centralised default values.
        let overlay = WorkerManifestConfig {
            worker: WorkerMetaConfig {
                name: Some("x".into()),
            },
            model: ModelManifest {
                scheme: Some(SchemeKind::Anthropic),
                model_id: Some("m".into()),
                ..Default::default()
            },
            scope: ScopeConfig {
                allow: vec![ScopeRule {
                    target: abs("/worker"),
                    permission: Permission::Write,
                    recursive: true,
                }],
                deny: Vec::new(),
            },
            ..Default::default()
        };
        let merged = WorkerManifestConfig::builtin_defaults().merge(overlay);
        let manifest: WorkerManifest = merged.try_into().unwrap();
        assert_eq!(
            manifest.engine.tool_output.default_max_bytes,
            defaults::TOOL_OUTPUT_MAX_BYTES
        );
        assert_eq!(
            manifest.engine.file_upload.max_bytes,
            defaults::FILE_UPLOAD_MAX_BYTES
        );
    }

    #[test]
    fn end_to_end_cascade() {
        let builtin = WorkerManifestConfig::default();
        let user = WorkerManifestConfig::from_toml(
            r#"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
"#,
        )
        .unwrap();
        let project = WorkerManifestConfig::from_toml(
            r#"
[[scope.allow]]
target = "/abs/project"
permission = "write"
"#,
        )
        .unwrap();
        let overlay = WorkerManifestConfig::from_toml(
            r#"
[worker]
name = "dbg"
"#,
        )
        .unwrap();

        let merged = builtin.merge(user).merge(project).merge(overlay);
        let manifest: WorkerManifest = merged.try_into().unwrap();
        assert_eq!(manifest.worker.name, "dbg");
        assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
        assert_eq!(manifest.scope.allow.len(), 1);
    }

    #[test]
    fn skills_directories_resolved_against_base() {
        let mut cfg = minimal_valid();
        cfg.skills = Some(SkillsConfig {
            directories: vec![
                PathBuf::from(".claude/skills"),
                PathBuf::from("/abs/elsewhere"),
            ],
        });
        let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
        let dirs = resolved.skills.as_ref().unwrap().directories.clone();
        assert_eq!(dirs[0], PathBuf::from("/workspace/proj/.claude/skills"));
        assert_eq!(dirs[1], PathBuf::from("/abs/elsewhere"));
    }

    #[test]
    fn skills_relative_path_rejected_post_resolve() {
        let mut cfg = minimal_valid();
        cfg.skills = Some(SkillsConfig {
            directories: vec![PathBuf::from("relative/skills")],
        });
        let err = WorkerManifest::try_from(cfg).unwrap_err();
        assert!(matches!(
            err,
            ResolveError::RelativePath {
                field: "skills.directories",
                ..
            }
        ));
    }

    #[test]
    fn skills_merge_extends_directories() {
        let lower = WorkerManifestConfig {
            skills: Some(SkillsConfig {
                directories: vec![PathBuf::from("/a")],
            }),
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            skills: Some(SkillsConfig {
                directories: vec![PathBuf::from("/b")],
            }),
            ..Default::default()
        };
        let merged = lower.merge(upper);
        let dirs = merged.skills.unwrap().directories;
        assert_eq!(dirs, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
    }

    #[test]
    fn from_toml_parses_skills_section() {
        let toml = r#"
[worker]
name = "x"

[skills]
directories = [".claude/skills", ".cursor/skills"]
"#;
        let cfg = WorkerManifestConfig::from_toml(toml).unwrap();
        let dirs = cfg.skills.unwrap().directories;
        assert_eq!(
            dirs,
            vec![
                PathBuf::from(".claude/skills"),
                PathBuf::from(".cursor/skills"),
            ]
        );
    }

    #[test]
    fn merge_preserves_ref() {
        let lower = WorkerManifestConfig {
            model: ModelManifest {
                ref_: Some("anthropic/claude-sonnet-4-6".into()),
                ..Default::default()
            },
            ..Default::default()
        };
        let upper = WorkerManifestConfig {
            model: ModelManifest {
                // only override auth
                auth: Some(AuthRef::None),
                ..Default::default()
            },
            ..Default::default()
        };
        let merged = lower.merge(upper);
        assert_eq!(
            merged.model.ref_.as_deref(),
            Some("anthropic/claude-sonnet-4-6")
        );
        assert_eq!(merged.model.auth, Some(AuthRef::None));
    }
}
