//! Quickstart preset tables and submission shape.

use serde::{Deserialize, Serialize};

use crate::autonomy::AutonomyLevel;
use crate::autonomy::{DelegationMode, DelegationPolicy};
use crate::policy::{default_allowed_commands, default_forbidden_paths};
use crate::schema::{RiskProfileConfig, RuntimeProfileConfig};

// ─────────────────────────────────────────────────────────────────────
// Risk presets
// ─────────────────────────────────────────────────────────────────────

/// One row in the Risk preset table. The Quickstart UI renders the
/// `label`, the runtime writes `values` to
/// `risk-profiles.<preset_name>` on apply.
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct RiskPreset {
    /// Alias key written to `risk-profiles.<preset_name>`. Doubles as
    /// the stable wire identifier (`BuilderSubmission.risk_preset`).
    pub preset_name: &'static str,
    /// Short label rendered in the picker UI.
    pub label: &'static str,
    /// One-line help blurb rendered next to the label.
    pub help: &'static str,
    /// Factory that produces the [`RiskProfileConfig`] this preset
    /// installs. A function (not a const value) because
    /// `RiskProfileConfig` has owned `Vec<String>` fields that cannot
    /// live in a `const`.
    #[serde(skip)]
    pub values: fn() -> RiskProfileConfig,
}

/// Canonical Risk preset table. Order is the order the picker
/// renders. Add or remove a preset by editing one row here; every
/// consumer reads from the slice so nothing else has to change.
pub const RISK_PRESETS: &[RiskPreset] = &[
    RiskPreset {
        preset_name: "locked_down",
        label: "Locked Down",
        help: "Tightest defaults. Workspace-only filesystem access, approval \
               required for medium and high risk, no shell environment passthrough.",
        values: locked_down_risk,
    },
    RiskPreset {
        preset_name: "balanced",
        label: "Balanced",
        help: "Trusted daily driver for a personal dev box. Supervised, \
               workspace-scoped with sensitive paths blocked and the sandbox \
               on. Any command runs without an allowlist, but high-risk \
               commands stay blocked unless explicitly allowlisted. \
               Recommended for most users.",
        values: balanced_risk,
    },
    RiskPreset {
        preset_name: "yolo",
        label: "YOLO",
        help: "Full autonomy. No approval gates, no command denylist, no \
               workspace scoping. Only pick this if you know what you're \
               doing on a machine you don't mind breaking.",
        values: yolo_risk,
    },
];

/// Look up a Risk preset by its `preset_name`. Returns `None` for
/// unknown keys.
#[must_use]
pub fn risk_preset(preset_name: &str) -> Option<&'static RiskPreset> {
    RISK_PRESETS.iter().find(|p| p.preset_name == preset_name)
}

fn locked_down_risk() -> RiskProfileConfig {
    RiskProfileConfig {
        level: AutonomyLevel::Supervised,
        workspace_only: true,
        allowed_commands: default_allowed_commands(),
        forbidden_paths: default_forbidden_paths(),
        require_approval_for_medium_risk: true,
        block_high_risk_commands: true,
        shell_env_passthrough: vec![],
        auto_approve: vec![],
        always_ask: vec![],
        allowed_roots: vec![],
        delegation_policy: DelegationPolicy::default(),
        approval_route: None,
        allowed_tools: vec![],
        excluded_tools: vec![],
        sandbox_enabled: Some(true),
        sandbox_backend: None,
        firejail_args: vec![],
    }
}

fn balanced_risk() -> RiskProfileConfig {
    RiskProfileConfig {
        level: AutonomyLevel::Supervised,
        workspace_only: true,
        allowed_commands: vec!["*".to_string()],
        forbidden_paths: default_forbidden_paths(),
        require_approval_for_medium_risk: false,
        block_high_risk_commands: true,
        shell_env_passthrough: vec![],
        auto_approve: vec![],
        always_ask: vec![],
        allowed_roots: vec![],
        delegation_policy: DelegationPolicy {
            mode: DelegationMode::Allow,
        },
        approval_route: None,
        allowed_tools: vec![],
        excluded_tools: vec![],
        sandbox_enabled: Some(true),
        sandbox_backend: None,
        firejail_args: vec![],
    }
}

fn yolo_risk() -> RiskProfileConfig {
    RiskProfileConfig {
        level: AutonomyLevel::Full,
        workspace_only: false,
        allowed_commands: vec!["*".to_string()],
        forbidden_paths: vec![],
        require_approval_for_medium_risk: false,
        block_high_risk_commands: false,
        shell_env_passthrough: vec![],
        auto_approve: vec!["*".to_string()],
        always_ask: vec![],
        allowed_roots: vec![],
        delegation_policy: DelegationPolicy {
            mode: DelegationMode::Allow,
        },
        approval_route: None,
        allowed_tools: vec![],
        excluded_tools: vec![],
        sandbox_enabled: Some(false),
        sandbox_backend: None,
        firejail_args: vec![],
    }
}

// ─────────────────────────────────────────────────────────────────────
// Runtime presets
// ─────────────────────────────────────────────────────────────────────

/// Canonical fallback used when no provider-specific runtime recommendation exists.
pub const DEFAULT_RUNTIME_PRESET_NAME: &str = "unbounded";

/// Canonical compact runtime recommended by local inference backends.
pub const LOCAL_SMALL_RUNTIME_PRESET_NAME: &str = "local_small";

/// One row in the Runtime preset table. Same shape and contract as
/// [`RiskPreset`] — see its docs for the per-field semantics.
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct RuntimePreset {
    /// Alias key written to `runtime-profiles.<preset_name>`. Doubles
    /// as the stable wire identifier (`BuilderSubmission.runtime_preset`).
    pub preset_name: &'static str,
    /// Short label rendered in the picker UI.
    pub label: &'static str,
    /// One-line help blurb rendered next to the label.
    pub help: &'static str,
    /// Factory that produces the [`RuntimeProfileConfig`] this preset
    /// installs.
    #[serde(skip)]
    pub values: fn() -> RuntimeProfileConfig,
}

/// Canonical Runtime preset table. See [`RISK_PRESETS`] for the
/// ordering / consumer contract.
pub const RUNTIME_PRESETS: &[RuntimePreset] = &[
    RuntimePreset {
        preset_name: "tight",
        label: "Tight",
        help: "Small budgets and short timeouts. Good for cheap models, \
               metered API keys, or tight feedback loops where you want \
               the agent to stop and ask early rather than burn budget.",
        values: tight_runtime,
    },
    RuntimePreset {
        preset_name: LOCAL_SMALL_RUNTIME_PRESET_NAME,
        label: "Local Small",
        help: "Compact no-text-fallback profile for smaller local models. \
               Keeps context and tool results small, disables parallel tool \
               fan-out, and requires native or structured tool calls.",
        values: local_small_runtime,
    },
    RuntimePreset {
        preset_name: "balanced",
        label: "Balanced",
        help: "Middle-of-the-road operational defaults. Suits most users \
               most of the time.",
        values: balanced_runtime,
    },
    RuntimePreset {
        preset_name: DEFAULT_RUNTIME_PRESET_NAME,
        label: "Unbounded",
        help: "Wide-open budgets and long timeouts. Pick this when you're \
               actively driving the agent through a hard task and don't \
               want it to throttle.",
        values: unbounded_runtime,
    },
];

/// Look up a Runtime preset by its `preset_name`. Returns `None` for
/// unknown keys.
#[must_use]
pub fn runtime_preset(preset_name: &str) -> Option<&'static RuntimePreset> {
    RUNTIME_PRESETS
        .iter()
        .find(|p| p.preset_name == preset_name)
}

/// Resolve an optional provider recommendation against the canonical preset table.
/// Unknown or absent recommendations fall back to `unbounded`, then to the first
/// available preset so consumers never duplicate fallback policy.
#[must_use]
pub fn recommended_runtime_preset(recommendation: Option<&str>) -> Option<&'static RuntimePreset> {
    recommendation
        .and_then(runtime_preset)
        .or_else(|| runtime_preset(DEFAULT_RUNTIME_PRESET_NAME))
        .or_else(|| RUNTIME_PRESETS.first())
}

fn tight_runtime() -> RuntimeProfileConfig {
    RuntimeProfileConfig {
        agentic: false,
        max_tool_iterations: 10,
        max_actions_per_hour: 10,
        max_cost_per_day_cents: 100,
        shell_timeout_secs: 30,
        max_delegation_depth: 1,
        delegation_timeout_secs: Some(60),
        agentic_timeout_secs: Some(120),
        max_history_messages: Some(20),
        max_context_tokens: Some(8_000),
        compact_context: Some(true),
        parallel_tools: Some(false),
        tool_dispatcher: None,
        tool_call_dedup_exempt: vec![],
        max_system_prompt_chars: Some(4_000),
        max_tool_result_chars: Some(8_000),
        keep_tool_context_turns: Some(2),
        memory_recall_limit: Some(3),
        ..RuntimeProfileConfig::default()
    }
}

fn local_small_runtime() -> RuntimeProfileConfig {
    RuntimeProfileConfig {
        agentic: true,
        max_tool_iterations: 4,
        max_actions_per_hour: 10,
        max_cost_per_day_cents: 100,
        shell_timeout_secs: 30,
        max_delegation_depth: 1,
        delegation_timeout_secs: Some(60),
        agentic_timeout_secs: Some(120),
        max_history_messages: Some(20),
        max_context_tokens: Some(8_000),
        compact_context: Some(true),
        parallel_tools: Some(false),
        tool_dispatcher: None,
        tool_call_dedup_exempt: vec![],
        max_system_prompt_chars: Some(4_000),
        max_tool_result_chars: Some(4_000),
        keep_tool_context_turns: Some(1),
        memory_recall_limit: Some(3),
        strict_tool_parsing: true,
        ..RuntimeProfileConfig::default()
    }
}

fn balanced_runtime() -> RuntimeProfileConfig {
    // Schema default is already the Balanced shape. Use it directly so
    // the preset can't drift from the schema default.
    RuntimeProfileConfig::default()
}

fn unbounded_runtime() -> RuntimeProfileConfig {
    RuntimeProfileConfig {
        agentic: true,
        max_tool_iterations: 100,
        max_actions_per_hour: u32::MAX,
        max_cost_per_day_cents: u32::MAX,
        shell_timeout_secs: 600,
        max_delegation_depth: 8,
        delegation_timeout_secs: Some(900),
        agentic_timeout_secs: Some(1_800),
        max_history_messages: Some(200),
        max_context_tokens: Some(128_000),
        compact_context: Some(false),
        parallel_tools: Some(true),
        tool_dispatcher: None,
        tool_call_dedup_exempt: vec![],
        max_system_prompt_chars: Some(64_000),
        max_tool_result_chars: Some(64_000),
        keep_tool_context_turns: Some(8),
        memory_recall_limit: Some(10),
        ..RuntimeProfileConfig::default()
    }
}

// ─────────────────────────────────────────────────────────────────────
// BuilderSubmission and dependent choice types
// ─────────────────────────────────────────────────────────────────────

pub use crate::multi_agent::MemoryBackendKind as MemoryChoice;

/// Model provider selection submitted by the Quickstart builder.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ModelProviderChoice {
    /// Provider type identifier (`anthropic`, `openai`, `openrouter`,
    /// `ollama`, etc.). Used as the type segment in the TOML path.
    pub provider_type: String,
    /// User-named alias. Defaults to `"default"` in the UI; users
    /// override when stacking multiple aliases of the same provider
    /// type (e.g. `anthropic-work`, `anthropic-personal`).
    pub alias: String,
    /// Model id written to `providers.models.<type>.<alias>.model` at
    /// apply time.
    pub model: String,
    /// Round-trip of every field the daemon described in
    /// `quickstart/fields`. Surfaces echo back exactly what was
    /// emitted; the daemon writes each entry under `<prefix>.<key>`
    /// using its own schema knowledge.
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    pub fields: std::collections::HashMap<String, String>,
}

/// One channel selection submitted by the Quickstart builder.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ChannelQuickStart {
    /// Channel type identifier (`cli`, `telegram`, `discord`, `web`
    /// in the FTUE-supported set).
    pub channel_type: String,
    /// User-named alias for this channel entry. Defaults to
    /// `channel_type` in the UI; users override when stacking
    /// multiple aliases of the same channel type.
    pub alias: String,
    /// Round-trip of every field the daemon described in
    /// `quickstart/fields`. Surfaces echo back exactly what was
    /// emitted; the daemon writes each entry under `<prefix>.<key>`
    /// using its own schema knowledge.
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    pub fields: std::collections::HashMap<String, String>,
}

/// Agent identity payload from the Agent step. Personality file
/// authoring is handled by the existing `PersonalityEditor` widget;
/// Quickstart passes only the chosen `personality_file` path here.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AgentIdentity {
    /// Agent alias — also the key written to `agents.<name>`. Must
    /// not collide with an existing agent alias; runtime validation
    /// rejects collisions before apply.
    pub name: String,
    /// System prompt text. Sourced from the personality template
    /// picker in the UI (`default` or `blank`); Quickstart does not
    /// pre-fill this field itself.
    pub system_prompt: String,
    /// Optional personality file path written to
    /// `agents.<name>.personality_file`. `None` ships the agent with
    /// no personality file (the existing optional pattern).
    pub personality_file: Option<String>,
    /// Staged personality file contents to write into the agent's
    /// workspace during the atomic apply. Empty list = no files
    /// written. Surfaces validate the filename against the canonical
    /// `EDITABLE_PERSONALITY_FILES` list before staging.
    #[serde(default)]
    pub personality_files: Vec<QuickstartPersonalityFile>,
}

/// One personality file staged for write during Quickstart apply.
/// The runtime writes `<workspace>/<filename>` with `content`,
/// overwriting if the path already exists.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct QuickstartPersonalityFile {
    /// Filename from `EDITABLE_PERSONALITY_FILES`.
    pub filename: String,
    /// File body. Subject to `MAX_FILE_CHARS` at apply time.
    pub content: String,
}

/// Complete builder submission consumed by the shared Quickstart apply path.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct BuilderSubmission {
    /// Model provider step submission. Always a `Create new` shape
    /// in this initial cut — `Use existing` is represented by
    /// [`SelectorChoice::Existing`] in the wrapper enum below.
    pub model_provider: SelectorChoice<ModelProviderChoice>,
    /// Risk profile preset key from [`RISK_PRESETS`], or the alias
    /// of an existing `risk-profiles.<alias>`.
    pub risk_profile: SelectorChoice<String>,
    /// Runtime profile preset key from [`RUNTIME_PRESETS`], or the
    /// alias of an existing `runtime-profiles.<alias>`.
    pub runtime_profile: SelectorChoice<String>,
    /// Memory step. Either a fresh [`MemoryChoice`] or the alias of
    /// an existing `storage.<type>.<alias>` entry.
    pub memory: SelectorChoice<MemoryChoice>,
    /// Channels step. 0..N entries. Each is either a freshly-built
    /// [`ChannelQuickStart`] or the alias of an existing channel.
    /// The agent's `channels` field is auto-bound to every entry in
    /// this vec at apply time.
    pub channels: Vec<SelectorChoice<ChannelQuickStart>>,
    /// Peer groups to materialize. Each entry can reference either a
    /// staged channel from `channels` (above) or an already-configured
    /// channel ref. Empty list = no peer-group rows written.
    #[serde(default)]
    pub peer_groups: Vec<QuickstartPeerGroup>,
    /// Agent identity (always create-new — there's no reuse path).
    pub agent: AgentIdentity,
}

/// Peer-group entry staged in the Quickstart. Maps 1:1 to a
/// `[peer_groups.<name>]` table written at apply time. The `channel`
/// field carries a `<type>.<alias>` ref pointing at either a staged
/// channel from the same submission or a pre-existing one.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct QuickstartPeerGroup {
    /// Map key written to `peer_groups.<name>`. Synthesized by surfaces
    /// from the channel ref so no `match` table is involved.
    pub name: String,
    /// Channel ref (`<type>.<alias>`) the peer group authorizes.
    pub channel: String,
    /// External (non-agent) peer usernames the channel should accept.
    #[serde(default)]
    pub external_peers: Vec<String>,
    /// Per-group blocklist applied to the resolved peer set.
    #[serde(default)]
    pub ignore: Vec<String>,
}

/// Selects either an existing configured value or a fresh builder value.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case", tag = "mode", content = "value")]
pub enum SelectorChoice<T> {
    /// Use an already-configured alias under the corresponding
    /// section. Carries only the alias key — the runtime resolves
    /// against the live config at apply time.
    Existing(String),
    /// Create a new entry from the carried value.
    Fresh(T),
}

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

    #[test]
    fn risk_preset_names_are_unique() {
        let mut seen = std::collections::BTreeSet::new();
        for p in RISK_PRESETS {
            assert!(
                seen.insert(p.preset_name),
                "duplicate risk preset_name: {}",
                p.preset_name
            );
        }
    }

    #[test]
    fn runtime_preset_names_are_unique() {
        let mut seen = std::collections::BTreeSet::new();
        for p in RUNTIME_PRESETS {
            assert!(
                seen.insert(p.preset_name),
                "duplicate runtime preset_name: {}",
                p.preset_name
            );
        }
    }

    #[test]
    fn risk_preset_lookup_round_trips() {
        for p in RISK_PRESETS {
            let found = risk_preset(p.preset_name).expect("preset present");
            assert_eq!(found.preset_name, p.preset_name);
            assert_eq!(found.label, p.label);
        }
        assert!(risk_preset("not-a-real-preset").is_none());
    }

    #[test]
    fn runtime_preset_lookup_round_trips() {
        for p in RUNTIME_PRESETS {
            let found = runtime_preset(p.preset_name).expect("preset present");
            assert_eq!(found.preset_name, p.preset_name);
            assert_eq!(found.label, p.label);
        }
        assert!(runtime_preset("not-a-real-preset").is_none());
    }

    #[test]
    fn no_preset_uses_default_alias() {
        for p in RISK_PRESETS {
            assert_ne!(
                p.preset_name, "default",
                "risk preset alias must never be `default`",
            );
        }
        for p in RUNTIME_PRESETS {
            assert_ne!(
                p.preset_name, "default",
                "runtime preset alias must never be `default`",
            );
        }
    }

    #[test]
    fn preset_names_are_valid_alias_keys() {
        for p in RISK_PRESETS {
            crate::helpers::validate_alias_key(p.preset_name).unwrap_or_else(|e| {
                panic!(
                    "risk preset_name `{}` is not a valid alias key: {e}",
                    p.preset_name
                )
            });
        }
        for p in RUNTIME_PRESETS {
            crate::helpers::validate_alias_key(p.preset_name).unwrap_or_else(|e| {
                panic!(
                    "runtime preset_name `{}` is not a valid alias key: {e}",
                    p.preset_name
                )
            });
        }
    }

    #[test]
    fn balanced_risk_is_trusted_local_shape() {
        let preset = risk_preset("balanced").unwrap();
        let v = (preset.values)();
        // Supervised, workspace-scoped, sandbox on: a trusted personal dev box.
        assert_eq!(v.level, AutonomyLevel::Supervised);
        assert!(v.workspace_only);
        assert_eq!(v.sandbox_enabled, Some(true));
        // Any command runs without an allowlist, but high-risk is blocked, not
        // prompted: the `*` wildcard is not an explicit exemption, so
        // block_high_risk_commands rejects high-risk commands outright while
        // medium-risk friction is off.
        assert_eq!(v.allowed_commands, vec!["*".to_string()]);
        assert!(v.block_high_risk_commands);
        assert!(!v.require_approval_for_medium_risk);
    }

    #[test]
    fn balanced_risk_allows_routine_commands_but_blocks_high_risk() {
        let preset = risk_preset("balanced").unwrap();
        let values = (preset.values)();
        let policy = crate::policy::SecurityPolicy {
            autonomy: values.level,
            allowed_commands: values.allowed_commands.clone(),
            block_high_risk_commands: values.block_high_risk_commands,
            require_approval_for_medium_risk: values.require_approval_for_medium_risk,
            ..crate::policy::SecurityPolicy::default()
        };
        for cmd in ["ls", "cat README.md", "git status"] {
            assert!(
                policy.is_command_allowed(cmd),
                "balanced must allow routine command `{cmd}` without an allowlist",
            );
        }
        // High-risk command passes the allowlist but is blocked outright at
        // execution: the `*` wildcard is not an explicit allowlist entry, so
        // block_high_risk_commands rejects it even when approved=true. This is
        // a hard block, not an approval prompt.
        assert!(policy.is_command_allowed("rm -rf node_modules"));
        let err_unapproved = policy
            .validate_command_execution("rm -rf node_modules", false)
            .expect_err("balanced must block a wildcard-matched high-risk command");
        let err_approved = policy
            .validate_command_execution("rm -rf node_modules", true)
            .expect_err("blocked even with approved=true: not an approval prompt");
        assert!(
            err_approved.contains("high-risk command is disallowed"),
            "must be the hard-block error, not the approval-required one: {err_approved}",
        );
        assert_eq!(
            err_unapproved, err_approved,
            "approval state must not change the outcome for a wildcard-matched high-risk command",
        );
    }

    #[test]
    fn balanced_runtime_matches_schema_default() {
        let preset = runtime_preset("balanced").unwrap();
        let preset_values = (preset.values)();
        let schema_default = RuntimeProfileConfig::default();
        assert_eq!(format!("{preset_values:?}"), format!("{schema_default:?}"),);
    }

    #[test]
    fn local_small_runtime_matches_documented_small_model_shape() {
        let preset = runtime_preset("local_small").expect("local_small preset");
        let values = (preset.values)();

        assert!(values.agentic);
        assert_eq!(values.max_tool_iterations, 4);
        assert_eq!(values.max_actions_per_hour, 10);
        assert_eq!(values.max_cost_per_day_cents, 100);
        assert_eq!(values.shell_timeout_secs, 30);
        assert_eq!(values.max_delegation_depth, 1);
        assert_eq!(values.delegation_timeout_secs, Some(60));
        assert_eq!(values.agentic_timeout_secs, Some(120));
        assert_eq!(values.max_history_messages, Some(20));
        assert_eq!(values.max_context_tokens, Some(8_000));
        assert_eq!(values.compact_context, Some(true));
        assert_eq!(values.parallel_tools, Some(false));
        assert_eq!(values.max_system_prompt_chars, Some(4_000));
        assert_eq!(values.max_tool_result_chars, Some(4_000));
        assert_eq!(values.keep_tool_context_turns, Some(1));
        assert_eq!(values.memory_recall_limit, Some(3));
        assert!(values.strict_tool_parsing);
    }

    #[test]
    fn runtime_recommendation_resolution_uses_canonical_fallback() {
        assert_eq!(
            recommended_runtime_preset(Some(LOCAL_SMALL_RUNTIME_PRESET_NAME))
                .map(|preset| preset.preset_name),
            Some(LOCAL_SMALL_RUNTIME_PRESET_NAME),
        );
        for recommendation in [None, Some("unknown-runtime-profile")] {
            assert_eq!(
                recommended_runtime_preset(recommendation).map(|preset| preset.preset_name),
                Some(DEFAULT_RUNTIME_PRESET_NAME),
            );
        }
    }

    #[test]
    fn local_small_runtime_resolves_to_strict_compact_agent_policy() {
        let preset = runtime_preset("local_small").expect("local_small preset");
        let mut config = crate::schema::Config::default();
        config
            .runtime_profiles
            .insert("local_small".into(), (preset.values)());
        config.agents.insert(
            "local_agent".into(),
            crate::schema::AliasedAgentConfig {
                runtime_profile: crate::providers::RuntimeProfileRef::new("local_small"),
                ..crate::schema::AliasedAgentConfig::default()
            },
        );

        let resolved = config
            .resolved_agent_config("local_agent")
            .expect("agent should resolve");

        assert!(resolved.resolved.strict_tool_parsing);
        assert_eq!(resolved.resolved.max_tool_iterations, 4);
        assert_eq!(resolved.resolved.max_history_messages, 20);
        assert_eq!(resolved.resolved.max_context_tokens, 8_000);
        assert!(resolved.resolved.compact_context);
        assert!(!resolved.resolved.parallel_tools);
        assert_eq!(resolved.resolved.max_system_prompt_chars, 4_000);
        assert_eq!(resolved.resolved.max_tool_result_chars, 4_000);
        assert_eq!(resolved.resolved.keep_tool_context_turns, 1);
        assert_eq!(config.effective_memory_recall_limit("local_agent"), 3);
    }

    #[test]
    fn unbounded_runtime_does_not_block_all_actions() {
        let preset = runtime_preset("unbounded").unwrap();
        let values = (preset.values)();
        assert_ne!(
            values.max_actions_per_hour, 0,
            "unbounded must not use 0 — 0 means a hard zero action budget, not unlimited",
        );
        let policy = crate::policy::SecurityPolicy {
            max_actions_per_hour: values.max_actions_per_hour,
            ..crate::policy::SecurityPolicy::default()
        };
        assert!(
            policy.record_action(),
            "an unbounded-profile agent must be allowed to take actions",
        );
    }

    #[test]
    fn yolo_risk_allows_shell_commands() {
        let preset = risk_preset("yolo").unwrap();
        let values = (preset.values)();
        let policy = crate::policy::SecurityPolicy {
            autonomy: values.level,
            allowed_commands: values.allowed_commands.clone(),
            block_high_risk_commands: values.block_high_risk_commands,
            ..crate::policy::SecurityPolicy::default()
        };
        for cmd in ["ls", "pwd", "cat README.md", "rm -rf node_modules"] {
            assert!(
                policy.is_command_allowed(cmd),
                "yolo profile must allow `{cmd}` — it grants full autonomy with no denylist",
            );
        }
    }

    #[test]
    fn yolo_risk_auto_approves_everything_with_no_forbidden_paths() {
        let preset = risk_preset("yolo").unwrap();
        let values = (preset.values)();
        assert_eq!(
            values.auto_approve,
            vec!["*".to_string()],
            "yolo must auto-approve every tool via the `*` wildcard",
        );
        assert!(
            values.forbidden_paths.is_empty(),
            "yolo must have no forbidden paths",
        );
        assert!(
            values.always_ask.is_empty(),
            "yolo must never force an approval prompt",
        );
        assert!(
            values.delegation_policy.permits(),
            "yolo must permit delegation",
        );
    }

    #[test]
    fn yolo_and_balanced_permit_delegation() {
        for name in ["yolo", "balanced"] {
            let preset = risk_preset(name).unwrap();
            let values = (preset.values)();
            assert!(
                values.delegation_policy.permits(),
                "{name} must permit delegation",
            );
        }
    }

    #[test]
    fn builder_submission_round_trips_through_json() {
        let submission = BuilderSubmission {
            model_provider: SelectorChoice::Fresh(ModelProviderChoice {
                provider_type: "anthropic".into(),
                alias: "anthropic".into(),
                model: "claude-sonnet-4-5".into(),
                fields: std::collections::HashMap::from([(
                    "api-key".to_string(),
                    "sk-test".to_string(),
                )]),
            }),
            risk_profile: SelectorChoice::Fresh("balanced".into()),
            runtime_profile: SelectorChoice::Fresh("balanced".into()),
            memory: SelectorChoice::Fresh(MemoryChoice::Sqlite),
            channels: vec![SelectorChoice::Fresh(ChannelQuickStart {
                channel_type: "cli".into(),
                alias: "cli".into(),
                fields: std::collections::HashMap::new(),
            })],
            peer_groups: vec![],
            agent: AgentIdentity {
                name: "my-bot".into(),
                system_prompt: "You are a helpful assistant.".into(),
                personality_file: None,
                personality_files: vec![],
            },
        };
        let json = serde_json::to_string(&submission).expect("serialize");
        let parsed: BuilderSubmission = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(parsed, submission);
    }

    #[test]
    fn channel_quickstart_round_trips_schema_fields() {
        let channel = ChannelQuickStart {
            channel_type: "telegram".into(),
            alias: "ops".into(),
            fields: std::collections::HashMap::from([(
                "bot_token".to_string(),
                "123:ABC".to_string(),
            )]),
        };

        let value = serde_json::to_value(&channel).expect("serialize channel");
        assert_eq!(value["fields"]["bot_token"], "123:ABC");
        assert!(value.get("token").is_none());
        assert_eq!(
            serde_json::from_value::<ChannelQuickStart>(value).expect("deserialize channel"),
            channel
        );
    }

    #[test]
    fn channel_quickstart_defaults_missing_fields_to_empty() {
        let channel: ChannelQuickStart = serde_json::from_value(serde_json::json!({
            "channel_type": "cli",
            "alias": "cli"
        }))
        .expect("deserialize legacy channel without fields");

        assert!(channel.fields.is_empty());
    }
}
