//! Setup wizard handlers.
//!
//! Manages interactive setup wizards for configuring channels, agents,
//! and other system components.

use parking_lot::RwLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::LazyLock;
use uuid::Uuid;

use super::super::*;
use super::config::{
    has_config_errors, map_validation_issues, try_update_config_file_with_error_shape,
    ConfigUpdateOutcome,
};
use crate::config;
use crate::server::bind::DEFAULT_PORT;

/// Available wizard types
pub const WIZARD_TYPES: [&str; 6] = [
    "setup",
    "channel",
    "agent",
    "provider",
    "plugin",
    "migration",
];

/// Wizard timeout in seconds (30 minutes)
const WIZARD_TIMEOUT_SECS: u64 = 1800;

/// Global wizard state
static WIZARD_STATE: LazyLock<RwLock<WizardManager>> =
    LazyLock::new(|| RwLock::new(WizardManager::default()));

/// Manages active wizard instances
#[derive(Debug, Default)]
pub struct WizardManager {
    /// Active wizard sessions by ID
    wizards: HashMap<String, WizardSession>,
}

/// A wizard session
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WizardSession {
    /// Unique wizard ID
    pub id: String,
    /// Wizard type
    pub wizard_type: String,
    /// Current step index (0-based)
    pub current_step: usize,
    /// Total number of steps
    pub total_steps: usize,
    /// Step definitions
    pub steps: Vec<WizardStep>,
    /// Collected data from completed steps
    pub data: HashMap<String, Value>,
    /// Whether the wizard is complete
    pub complete: bool,
    /// Whether the wizard was cancelled
    pub cancelled: bool,
    /// Created timestamp (ms since epoch)
    pub created_at: u64,
    /// Last activity timestamp (ms since epoch)
    pub last_activity: u64,
    /// Connection ID that started the wizard
    pub conn_id: Option<String>,
}

/// A single wizard step
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WizardStep {
    /// Step identifier
    pub id: String,
    /// Step title
    pub title: String,
    /// Step description
    pub description: Option<String>,
    /// Input type (text, select, confirm, etc.)
    pub input_type: String,
    /// Available options for select/multiselect
    pub options: Option<Vec<WizardOption>>,
    /// Whether this step is required
    pub required: bool,
    /// Default value
    pub default: Option<Value>,
    /// Validation rules
    pub validation: Option<WizardValidation>,
}

/// A wizard option for select inputs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WizardOption {
    pub value: String,
    pub label: String,
    pub description: Option<String>,
}

/// Validation rules for wizard inputs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WizardValidation {
    pub min_length: Option<usize>,
    pub max_length: Option<usize>,
    pub pattern: Option<String>,
}

impl WizardManager {
    /// Create a new wizard session
    fn create_wizard(&mut self, wizard_type: &str, conn_id: Option<String>) -> WizardSession {
        // Clean up expired wizards
        self.cleanup_expired();

        let id = Uuid::new_v4().to_string();
        let now = now_ms();
        let steps = create_wizard_steps(wizard_type);
        let total_steps = steps.len();

        let session = WizardSession {
            id: id.clone(),
            wizard_type: wizard_type.to_string(),
            current_step: 0,
            total_steps,
            steps,
            data: HashMap::new(),
            complete: false,
            cancelled: false,
            created_at: now,
            last_activity: now,
            conn_id,
        };

        self.wizards.insert(id, session.clone());
        session
    }

    /// Get an active wizard by ID
    fn get_wizard(&self, id: &str) -> Option<&WizardSession> {
        self.wizards.get(id)
    }

    /// Get a mutable wizard by ID
    fn get_wizard_mut(&mut self, id: &str) -> Option<&mut WizardSession> {
        self.wizards.get_mut(id)
    }

    /// Cancel a wizard
    fn cancel_wizard(&mut self, id: &str) -> Option<WizardSession> {
        if let Some(wizard) = self.wizards.get_mut(id) {
            wizard.cancelled = true;
            wizard.last_activity = now_ms();
            wizard.data.clear();
            Some(wizard.clone())
        } else {
            None
        }
    }

    /// Get the currently active wizard (if any)
    fn active_wizard(&self) -> Option<&WizardSession> {
        self.wizards.values().find(|w| !w.complete && !w.cancelled)
    }

    /// Clean up expired wizard sessions
    fn cleanup_expired(&mut self) {
        let now = now_ms();
        let timeout_ms = WIZARD_TIMEOUT_SECS * 1000;
        self.wizards
            .retain(|_, w| now - w.last_activity < timeout_ms);
    }
}

/// Build the steps for the initial setup wizard.
fn setup_wizard_steps() -> Vec<WizardStep> {
    vec![
        WizardStep {
            id: "welcome".to_string(),
            title: "Welcome to Carapace".to_string(),
            description: Some("Let's get you set up with your AI assistant.".to_string()),
            input_type: "confirm".to_string(),
            options: None,
            required: true,
            default: Some(Value::Bool(true)),
            validation: None,
        },
        WizardStep {
            id: "provider".to_string(),
            title: "Select AI Provider".to_string(),
            description: Some("Choose your preferred AI model provider.".to_string()),
            input_type: "select".to_string(),
            options: Some(vec![
                WizardOption {
                    value: "anthropic".to_string(),
                    label: "Anthropic (Claude)".to_string(),
                    description: Some("Recommended for most users".to_string()),
                },
                WizardOption {
                    value: "openai".to_string(),
                    label: "OpenAI (GPT)".to_string(),
                    description: None,
                },
            ]),
            required: true,
            default: Some(Value::String("anthropic".to_string())),
            validation: None,
        },
        WizardStep {
            id: "api_key".to_string(),
            title: "Enter API Key".to_string(),
            description: Some("Enter your API key for the selected provider.".to_string()),
            input_type: "password".to_string(),
            options: None,
            required: true,
            default: None,
            validation: Some(WizardValidation {
                min_length: Some(10),
                max_length: Some(256),
                pattern: None,
            }),
        },
        WizardStep {
            id: "auth_mode".to_string(),
            title: "Gateway Auth Mode".to_string(),
            description: Some("Choose token or password authentication for gateway access."
                .to_string()),
            input_type: "select".to_string(),
            options: Some(vec![
                WizardOption {
                    value: "token".to_string(),
                    label: "Token (recommended)".to_string(),
                    description: Some("Bearer token auth for CLI/API access.".to_string()),
                },
                WizardOption {
                    value: "password".to_string(),
                    label: "Password".to_string(),
                    description: Some("Password auth for gateway access.".to_string()),
                },
            ]),
            required: true,
            default: Some(Value::String("token".to_string())),
            validation: None,
        },
        WizardStep {
            id: "auth_secret".to_string(),
            title: "Gateway Secret".to_string(),
            description: Some(
                "Optional. Leave blank to auto-generate a strong secret for the selected auth mode."
                    .to_string(),
            ),
            input_type: "password".to_string(),
            options: None,
            required: false,
            default: None,
            validation: Some(WizardValidation {
                min_length: Some(10),
                max_length: Some(256),
                pattern: None,
            }),
        },
        WizardStep {
            id: "bind_mode".to_string(),
            title: "Gateway Bind Mode".to_string(),
            description: Some(
                "Loopback keeps access local. LAN exposes the service on your local network."
                    .to_string(),
            ),
            input_type: "select".to_string(),
            options: Some(vec![
                WizardOption {
                    value: "loopback".to_string(),
                    label: "Loopback (recommended)".to_string(),
                    description: Some("Local access only.".to_string()),
                },
                WizardOption {
                    value: "lan".to_string(),
                    label: "LAN".to_string(),
                    description: Some("Reachable from devices on your local network.".to_string()),
                },
            ]),
            required: true,
            default: Some(Value::String("loopback".to_string())),
            validation: None,
        },
        WizardStep {
            id: "port".to_string(),
            title: "Gateway Port".to_string(),
            description: Some("TCP port for the gateway service.".to_string()),
            input_type: "text".to_string(),
            options: None,
            required: true,
            default: Some(Value::String(DEFAULT_PORT.to_string())),
            validation: Some(WizardValidation {
                min_length: Some(1),
                max_length: Some(5),
                pattern: Some("^[0-9]{1,5}$".to_string()),
            }),
        },
        WizardStep {
            id: "first_outcome".to_string(),
            title: "First-Run Outcome".to_string(),
            description: Some(
                "Choose what you want to do first (local chat, Discord, Telegram, or hooks)."
                    .to_string(),
            ),
            input_type: "select".to_string(),
            options: Some(vec![
                WizardOption {
                    value: "local-chat".to_string(),
                    label: "Local chat (recommended)".to_string(),
                    description: Some("Get the first response in the CLI REPL.".to_string()),
                },
                WizardOption {
                    value: "discord".to_string(),
                    label: "Discord assistant".to_string(),
                    description: Some("Configure Discord bot credentials.".to_string()),
                },
                WizardOption {
                    value: "telegram".to_string(),
                    label: "Telegram assistant".to_string(),
                    description: Some("Configure Telegram bot credentials.".to_string()),
                },
                WizardOption {
                    value: "matrix".to_string(),
                    label: "Matrix / Element assistant".to_string(),
                    description: Some(
                        "Acknowledge intent only — Matrix requires homeserverUrl, userId, and a UIA-protected first login that doesn't fit a single token. Finish by editing your config file and running `cara verify --outcome matrix`."
                            .to_string(),
                    ),
                },
                WizardOption {
                    value: "hooks".to_string(),
                    label: "Hooks automation".to_string(),
                    description: Some("Configure /hooks for automations.".to_string()),
                },
            ]),
            required: true,
            default: Some(Value::String("local-chat".to_string())),
            validation: None,
        },
        WizardStep {
            id: "channel_token".to_string(),
            title: "Channel Bot Token".to_string(),
            description: Some(
                "Optional. Used when first outcome is Discord or Telegram. Leave blank to configure later."
                    .to_string(),
            ),
            input_type: "password".to_string(),
            options: None,
            required: false,
            default: None,
            validation: Some(WizardValidation {
                min_length: Some(10),
                max_length: Some(256),
                pattern: None,
            }),
        },
        WizardStep {
            id: "hooks_enabled".to_string(),
            title: "Enable Hooks API".to_string(),
            description: Some("Expose /hooks endpoints for automations.".to_string()),
            input_type: "confirm".to_string(),
            options: None,
            required: false,
            default: Some(Value::Bool(false)),
            validation: None,
        },
        WizardStep {
            id: "hooks_token".to_string(),
            title: "Hooks Token".to_string(),
            description: Some(
                "Optional. If hooks are enabled and this is empty, a strong token is generated."
                    .to_string(),
            ),
            input_type: "password".to_string(),
            options: None,
            required: false,
            default: None,
            validation: Some(WizardValidation {
                min_length: Some(10),
                max_length: Some(256),
                pattern: None,
            }),
        },
        WizardStep {
            id: "control_ui_enabled".to_string(),
            title: "Enable Control UI".to_string(),
            description: Some("Enable the local web dashboard.".to_string()),
            input_type: "confirm".to_string(),
            options: None,
            required: false,
            default: Some(Value::Bool(false)),
            validation: None,
        },
        WizardStep {
            id: "complete".to_string(),
            title: "Setup Complete".to_string(),
            description: Some("You're all set. Run your first assistant workflow.".to_string()),
            input_type: "confirm".to_string(),
            options: None,
            required: true,
            default: Some(Value::Bool(true)),
            validation: None,
        },
    ]
}

/// Build the steps for the channel configuration wizard.
fn channel_wizard_steps() -> Vec<WizardStep> {
    vec![
        WizardStep {
            id: "channel_type".to_string(),
            title: "Select Channel".to_string(),
            description: Some("Choose a messaging channel to configure.".to_string()),
            input_type: "select".to_string(),
            options: Some(vec![
                WizardOption {
                    value: "telegram".to_string(),
                    label: "Telegram".to_string(),
                    description: None,
                },
                WizardOption {
                    value: "discord".to_string(),
                    label: "Discord".to_string(),
                    description: None,
                },
                WizardOption {
                    value: "slack".to_string(),
                    label: "Slack".to_string(),
                    description: None,
                },
                WizardOption {
                    value: "matrix".to_string(),
                    label: "Matrix / Element".to_string(),
                    description: Some(
                        "Native Matrix runtime with E2EE; finish setup via `cara matrix verify` after the wizard.".to_string(),
                    ),
                },
            ]),
            required: true,
            default: None,
            validation: None,
        },
        WizardStep {
            id: "channel_token".to_string(),
            title: "Enter Bot Token".to_string(),
            description: Some(
                "Enter the bot token for your channel. \
                 (Skip with empty input if you selected Matrix — Matrix uses a \
                 different login flow that's completed via `cara matrix verify` \
                 after the wizard finishes.)"
                    .to_string(),
            ),
            input_type: "password".to_string(),
            options: None,
            // Validation enforces a 10-char min token for the
            // bot-token channels (telegram/discord/slack); Matrix
            // doesn't read this field, so an empty submission is
            // accepted at the apply layer (see `apply_wizard_config`
            // channel match — the matrix arm doesn't call
            // `require_wizard_string` for the token). The required
            // flag is `false` so a Matrix-selecting operator can
            // proceed without typing a dummy 10-char string.
            required: false,
            default: None,
            validation: Some(WizardValidation {
                min_length: Some(10),
                max_length: None,
                pattern: None,
            }),
        },
    ]
}

/// Build the steps for the agent configuration wizard.
fn agent_wizard_steps() -> Vec<WizardStep> {
    vec![
        WizardStep {
            id: "agent_name".to_string(),
            title: "Agent Name".to_string(),
            description: Some("Give your agent a name.".to_string()),
            input_type: "text".to_string(),
            options: None,
            required: true,
            default: Some(Value::String("Assistant".to_string())),
            validation: Some(WizardValidation {
                min_length: Some(1),
                max_length: Some(50),
                pattern: None,
            }),
        },
        WizardStep {
            id: "agent_personality".to_string(),
            title: "Agent Personality".to_string(),
            description: Some("Describe how your agent should behave.".to_string()),
            input_type: "textarea".to_string(),
            options: None,
            required: false,
            default: None,
            validation: Some(WizardValidation {
                min_length: None,
                max_length: Some(1000),
                pattern: None,
            }),
        },
    ]
}

/// Create steps for a wizard type
fn create_wizard_steps(wizard_type: &str) -> Vec<WizardStep> {
    match wizard_type {
        "setup" => setup_wizard_steps(),
        "channel" => channel_wizard_steps(),
        "agent" => agent_wizard_steps(),
        _ => vec![WizardStep {
            id: "generic".to_string(),
            title: "Configuration".to_string(),
            description: None,
            input_type: "confirm".to_string(),
            options: None,
            required: true,
            default: Some(Value::Bool(true)),
            validation: None,
        }],
    }
}

fn now_ms() -> u64 {
    crate::time::unix_now_ms_u64()
}

/// Set a value at a dot-notation path. Returns `false` if root /
/// intermediate isn't an Object — fail-soft to avoid panic on
/// non-Object base. See `src/server/control.rs::set_value_at_path`
/// for the same fix rationale.
#[must_use]
fn set_value_at_path(root: &mut Value, path: &str, value: Value) -> bool {
    let parts: Vec<&str> = path.split('.').collect();
    let mut current = root;

    for (i, part) in parts.iter().enumerate() {
        if i == parts.len() - 1 {
            if let Value::Object(map) = current {
                map.insert(part.to_string(), value);
                return true;
            }
            return false;
        }

        if !current.get(*part).is_some_and(|v| v.is_object()) {
            if let Value::Object(map) = current {
                map.insert(part.to_string(), Value::Object(serde_json::Map::new()));
            } else {
                return false;
            }
        }
        current = match current.get_mut(*part) {
            Some(v) => v,
            None => return false,
        };
    }
    true
}

fn get_value_at_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
    let mut current = root;
    for part in path.split('.') {
        current = current.get(part)?;
    }
    Some(current)
}

fn get_bool_at_path(root: &Value, path: &str) -> Option<bool> {
    get_value_at_path(root, path).and_then(Value::as_bool)
}

fn get_string_at_path<'a>(root: &'a Value, path: &str) -> Option<&'a str> {
    get_value_at_path(root, path).and_then(Value::as_str)
}

fn apply_channel_token(
    config_value: &mut Value,
    channel_name: &str,
    token: &str,
) -> Result<(), ErrorShape> {
    let stored_token =
        encrypt_secret_inline_if_password_set(token, &format!("{channel_name}.botToken"))?;
    let _ = set_value_at_path(
        config_value,
        &format!("{channel_name}.botToken"),
        json!(stored_token),
    );
    let _ = set_value_at_path(
        config_value,
        &format!("{channel_name}.enabled"),
        json!(true),
    );
    Ok(())
}

/// Encrypt a secret inline at the wizard layer so it lands in the
/// candidate config as an `enc:v2:` envelope BEFORE the persist sink
/// runs `seal_config_secrets`. Mirrors the CLI-side B114 discipline:
///
/// - If `CARAPACE_CONFIG_PASSWORD` is set, encrypt with the
///   snapshotted password. This closes the TOCTOU window where the
///   env var could vanish between the wizard's write and the seal
///   layer's independent read — without inline encryption the seal
///   layer would silently no-op and leave plaintext on disk.
/// - If `CARAPACE_CONFIG_PASSWORD` is unset, fall through to
///   plaintext. This matches the documented unencrypted first-run
///   setup contract that applies to `gateway.auth.token` /
///   `gateway.auth.password` / `gateway.hooks.token` /
///   `discord.botToken` / `telegram.botToken` / `slack.botToken`
///   — the same trade-off the CLI's `prompt_and_configure_bot_channel`
///   takes (B114).
/// - Already-`enc:v2:` values pass through unchanged so a re-run
///   of the wizard does not double-encrypt.
fn encrypt_secret_inline_if_password_set(
    value: &str,
    path_label: &str,
) -> Result<String, ErrorShape> {
    let Some(password) = crate::config::config_password() else {
        return Ok(value.to_string());
    };
    if crate::config::secrets::is_encrypted(value) {
        return Ok(value.to_string());
    }
    let store = crate::config::secrets::SecretStore::new(password.as_ref()).map_err(|err| {
        error_shape(
            ERROR_UNAVAILABLE,
            &format!("failed to initialize config secret store: {err}"),
            None,
        )
    })?;
    store.encrypt(value).map_err(|err| {
        error_shape(
            ERROR_UNAVAILABLE,
            &format!("failed to encrypt {path_label}: {err}"),
            None,
        )
    })
}

fn normalize_string(value: &Value) -> Option<String> {
    value
        .as_str()
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
}

fn validate_step_input(step: &WizardStep, input: Option<&Value>) -> Result<(), ErrorShape> {
    if step.required && input.is_none() {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "input is required for this step",
            None,
        ));
    }

    let Some(input) = input else {
        return Ok(());
    };

    match step.input_type.as_str() {
        "confirm" => {
            if !input.is_boolean() {
                return Err(error_shape(
                    ERROR_INVALID_REQUEST,
                    "confirm input must be a boolean",
                    None,
                ));
            }
        }
        "select" => {
            let Some(selection) = input.as_str() else {
                return Err(error_shape(
                    ERROR_INVALID_REQUEST,
                    "select input must be a string",
                    None,
                ));
            };
            if let Some(options) = &step.options {
                if !options.iter().any(|option| option.value == selection) {
                    return Err(error_shape(
                        ERROR_INVALID_REQUEST,
                        "input is not a valid option",
                        None,
                    ));
                }
            }
        }
        _ => {
            let Some(value) = input.as_str() else {
                return Err(error_shape(
                    ERROR_INVALID_REQUEST,
                    "input must be a string",
                    None,
                ));
            };

            if let Some(validation) = &step.validation {
                if let Some(min) = validation.min_length {
                    if value.len() < min {
                        return Err(error_shape(
                            ERROR_INVALID_REQUEST,
                            "input is shorter than the minimum length",
                            None,
                        ));
                    }
                }
                if let Some(max) = validation.max_length {
                    if value.len() > max {
                        return Err(error_shape(
                            ERROR_INVALID_REQUEST,
                            "input exceeds the maximum length",
                            None,
                        ));
                    }
                }
                if let Some(pattern) = &validation.pattern {
                    let regex = Regex::new(pattern).map_err(|err| {
                        error_shape(
                            ERROR_INVALID_REQUEST,
                            &format!("invalid validation pattern: {}", err),
                            None,
                        )
                    })?;
                    if !regex.is_match(value) {
                        return Err(error_shape(
                            ERROR_INVALID_REQUEST,
                            "input does not match the required pattern",
                            None,
                        ));
                    }
                }
            }
        }
    }

    Ok(())
}

fn require_wizard_string(
    data: &HashMap<String, Value>,
    key: &str,
    label: &str,
) -> Result<String, ErrorShape> {
    data.get(key).and_then(normalize_string).ok_or_else(|| {
        error_shape(
            ERROR_INVALID_REQUEST,
            &format!("{} is required", label),
            None,
        )
    })
}

fn parse_wizard_port(
    data: &HashMap<String, Value>,
    key: &str,
    default_port: u16,
) -> Result<u16, ErrorShape> {
    let Some(raw) = data.get(key).and_then(normalize_string) else {
        return Ok(default_port);
    };

    let port = raw.parse::<u16>().map_err(|_| {
        error_shape(
            ERROR_INVALID_REQUEST,
            "port must be a valid integer between 1 and 65535",
            None,
        )
    })?;
    if port == 0 {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "port must be between 1 and 65535",
            None,
        ));
    }
    Ok(port)
}

fn generate_wizard_secret_hex(byte_len: usize) -> Result<String, ErrorShape> {
    crate::crypto::generate_hex_secret(byte_len).map_err(|_| {
        error_shape(
            ERROR_INVALID_REQUEST,
            "failed to generate authentication secret",
            None,
        )
    })
}

fn resolve_wizard_auth_secret(
    auth_secret: Option<&String>,
    generated_byte_len: usize,
) -> Result<String, ErrorShape> {
    match auth_secret {
        Some(secret) => Ok(secret.clone()),
        None => generate_wizard_secret_hex(generated_byte_len),
    }
}

fn apply_agent_wizard(config_value: &mut Value, name: Option<&str>, description: Option<&str>) {
    let Value::Object(root) = config_value else {
        return;
    };

    let agents = root
        .entry("agents")
        .or_insert_with(|| Value::Object(serde_json::Map::new()));
    if !agents.is_object() {
        *agents = Value::Object(serde_json::Map::new());
    }
    let agents_map = agents.as_object_mut().expect("just ensured object");
    let list_value = agents_map
        .entry("list")
        .or_insert_with(|| Value::Array(Vec::new()));
    if !list_value.is_array() {
        *list_value = Value::Array(Vec::new());
    }
    let list = list_value.as_array_mut().expect("just ensured array");

    let mut target_index = list
        .iter()
        .position(|agent| {
            agent
                .get("default")
                .and_then(Value::as_bool)
                .unwrap_or(false)
        })
        .or_else(|| {
            list.iter()
                .position(|agent| agent.get("id").and_then(Value::as_str) == Some("default"))
        });

    if target_index.is_none() {
        if list.is_empty() {
            list.push(json!({
                "id": "default",
                "default": true,
                "identity": {}
            }));
            target_index = Some(0);
        } else {
            target_index = Some(0);
        }
    }

    let Some(index) = target_index else {
        return;
    };

    let agent = &mut list[index];
    if !agent.is_object() {
        *agent = Value::Object(serde_json::Map::new());
    }
    let agent_map = agent.as_object_mut().expect("just ensured object");
    let identity = agent_map
        .entry("identity")
        .or_insert_with(|| Value::Object(serde_json::Map::new()));
    if !identity.is_object() {
        *identity = Value::Object(serde_json::Map::new());
    }
    let identity_map = identity.as_object_mut().expect("just ensured object");
    if let Some(name) = name {
        identity_map.insert("name".to_string(), Value::String(name.to_string()));
    }
    if let Some(description) = description {
        identity_map.insert(
            "description".to_string(),
            Value::String(description.to_string()),
        );
    }
}

fn apply_wizard_config(
    wizard_type: &str,
    data: &HashMap<String, Value>,
    config_value: &mut Value,
) -> Result<bool, ErrorShape> {
    if !config_value.is_object() {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "config root must be an object",
            None,
        ));
    }

    match wizard_type {
        "setup" => {
            let provider = require_wizard_string(data, "provider", "provider")?;
            let api_key = require_wizard_string(data, "api_key", "api_key")?;
            let auth_mode = data
                .get("auth_mode")
                .and_then(normalize_string)
                .unwrap_or_else(|| "token".to_string())
                .to_lowercase();
            let auth_secret = data.get("auth_secret").and_then(normalize_string);
            let bind_mode = data
                .get("bind_mode")
                .and_then(normalize_string)
                .unwrap_or_else(|| "loopback".to_string())
                .to_lowercase();
            let port = parse_wizard_port(data, "port", DEFAULT_PORT)?;
            let first_outcome = data
                .get("first_outcome")
                .and_then(normalize_string)
                .unwrap_or_else(|| "local-chat".to_string())
                .to_lowercase();
            let channel_token = data.get("channel_token").and_then(normalize_string);
            let hooks_enabled = data
                .get("hooks_enabled")
                .and_then(Value::as_bool)
                .unwrap_or_else(|| {
                    if matches!(first_outcome.as_str(), "hooks") {
                        true
                    } else {
                        get_bool_at_path(config_value, "gateway.hooks.enabled").unwrap_or(false)
                    }
                });
            let hooks_token = data.get("hooks_token").and_then(normalize_string);
            let control_ui_enabled = data
                .get("control_ui_enabled")
                .and_then(Value::as_bool)
                .unwrap_or_else(|| {
                    get_bool_at_path(config_value, "gateway.controlUi.enabled").unwrap_or(false)
                });

            match provider.as_str() {
                "anthropic" => {
                    let stored =
                        encrypt_secret_inline_if_password_set(&api_key, "anthropic.apiKey")?;
                    let _ = set_value_at_path(config_value, "anthropic.apiKey", json!(stored));
                    let _ = set_value_at_path(
                        config_value,
                        "agents.defaults.model",
                        json!("anthropic:claude-sonnet-4-6"),
                    );
                }
                "openai" => {
                    let stored = encrypt_secret_inline_if_password_set(&api_key, "openai.apiKey")?;
                    let _ = set_value_at_path(config_value, "openai.apiKey", json!(stored));
                    let _ = set_value_at_path(
                        config_value,
                        "agents.defaults.model",
                        json!("openai:gpt-5.5"),
                    );
                }
                _ => return Err(error_shape(ERROR_INVALID_REQUEST, "unknown provider", None)),
            }

            match auth_mode.as_str() {
                "token" => {
                    let token = resolve_wizard_auth_secret(auth_secret.as_ref(), 32)?;
                    let stored_token =
                        encrypt_secret_inline_if_password_set(&token, "gateway.auth.token")?;
                    let _ = set_value_at_path(
                        config_value,
                        "gateway.auth",
                        json!({
                            "mode": "token",
                            "token": stored_token
                        }),
                    );
                }
                "password" => {
                    let password = resolve_wizard_auth_secret(auth_secret.as_ref(), 24)?;
                    let stored_password =
                        encrypt_secret_inline_if_password_set(&password, "gateway.auth.password")?;
                    let _ = set_value_at_path(
                        config_value,
                        "gateway.auth",
                        json!({
                            "mode": "password",
                            "password": stored_password
                        }),
                    );
                }
                _ => {
                    return Err(error_shape(
                        ERROR_INVALID_REQUEST,
                        "auth_mode must be token or password",
                        None,
                    ));
                }
            }

            match bind_mode.as_str() {
                "loopback" | "lan" => {
                    let _ = set_value_at_path(config_value, "gateway.bind", json!(bind_mode));
                }
                _ => {
                    return Err(error_shape(
                        ERROR_INVALID_REQUEST,
                        "bind_mode must be loopback or lan",
                        None,
                    ));
                }
            }
            let _ = set_value_at_path(config_value, "gateway.port", json!(port));
            match first_outcome.as_str() {
                "local-chat" => {}
                "discord" => {
                    if let Some(token) = channel_token.as_deref() {
                        apply_channel_token(config_value, "discord", token)?;
                    }
                }
                "telegram" => {
                    if let Some(token) = channel_token.as_deref() {
                        apply_channel_token(config_value, "telegram", token)?;
                    }
                }
                "matrix" => {
                    // Setup-wizard Matrix path is intentionally
                    // minimal: we do NOT collect homeserverUrl /
                    // userId / password / accessToken / deviceId
                    // from the wizard payload. Those fields require
                    // operator decisions (homeserver choice, MXID
                    // format, device-id reuse vs fresh) and a
                    // UIA-protected first-login flow that doesn't
                    // fit the single-token shape the other channel
                    // arms use.
                    //
                    // We also do NOT set `matrix.enabled = true`:
                    // that would brick the daemon on next start
                    // because `resolve_matrix_config` requires
                    // homeserverUrl + userId, and the wizard hasn't
                    // collected them. The operator follows up by
                    // editing `~/.config/carapace/carapace.json5`
                    // directly and running
                    // `cara verify --outcome matrix` (NOT
                    // `cara matrix verify`, which is the SAS
                    // device-verification command — different flow
                    // entirely). Procedure documented in
                    // docs/channels.md#matrix--element.
                    //
                    // The setup wizard ALSO mutates
                    // `gateway.hooks.enabled` and
                    // `gateway.controlUi.enabled` regardless of
                    // first_outcome; those land below this match.
                    // We can't return Ok(false) here without
                    // skipping that legitimate downstream config.
                }
                "hooks" => {}
                _ => {
                    return Err(error_shape(
                        ERROR_INVALID_REQUEST,
                        "first_outcome must be local-chat, discord, telegram, matrix, or hooks",
                        None,
                    ));
                }
            }

            let _ = set_value_at_path(config_value, "gateway.hooks.enabled", json!(hooks_enabled));
            if hooks_enabled {
                let token = if let Some(token) = hooks_token.as_deref() {
                    token.to_string()
                } else if let Some(existing_token) =
                    get_string_at_path(config_value, "gateway.hooks.token")
                {
                    existing_token.to_string()
                } else {
                    resolve_wizard_auth_secret(None, 32)?
                };
                let stored_token =
                    encrypt_secret_inline_if_password_set(&token, "gateway.hooks.token")?;
                let _ = set_value_at_path(config_value, "gateway.hooks.token", json!(stored_token));
            }

            let _ = set_value_at_path(
                config_value,
                "gateway.controlUi.enabled",
                json!(control_ui_enabled),
            );
            Ok(true)
        }
        "channel" => {
            let channel = require_wizard_string(data, "channel_type", "channel_type")?;
            match channel.as_str() {
                "telegram" => {
                    let token = require_wizard_string(data, "channel_token", "channel_token")?;
                    apply_channel_token(config_value, "telegram", &token)?;
                }
                "discord" => {
                    let token = require_wizard_string(data, "channel_token", "channel_token")?;
                    apply_channel_token(config_value, "discord", &token)?;
                }
                "slack" => {
                    let token = require_wizard_string(data, "channel_token", "channel_token")?;
                    apply_channel_token(config_value, "slack", &token)?;
                }
                "matrix" => {
                    // Matrix doesn't fit the single-token wizard
                    // shape: it needs homeserverUrl + userId + a
                    // first-login password (UIA). The wizard
                    // intentionally does NOT mutate config —
                    // setting `matrix.enabled = true` would brick
                    // the daemon on next start
                    // (resolve_matrix_config requires homeserverUrl
                    // + userId). The operator follows up by editing
                    // `~/.config/carapace/carapace.json5` directly
                    // then running `cara verify --outcome matrix`
                    // (the read-only outcome verifier — NOT
                    // `cara matrix verify`, which is the SAS
                    // device-verification command). Procedure
                    // documented in
                    // docs/channels.md#matrix--element.
                    //
                    // Return Ok(false) → ConfigUpdateOutcome::NoOp
                    // so the outer flow skips the file rewrite.
                    // Avoids `seal_config_secrets`'s AEAD-nonce
                    // churn for sealed secrets unrelated to
                    // matrix.* on every wizard submission. The
                    // channel wizard, unlike the setup wizard, has
                    // no other config to mutate after this match,
                    // so early-return is safe here.
                    return Ok(false);
                }
                _ => {
                    return Err(error_shape(
                        ERROR_INVALID_REQUEST,
                        &format!(
                            "unknown channel type: {channel}; must be telegram, discord, slack, or matrix"
                        ),
                        None,
                    ))
                }
            }
            Ok(true)
        }
        "agent" => {
            let name = data.get("agent_name").and_then(normalize_string);
            let description = data.get("agent_personality").and_then(normalize_string);
            apply_agent_wizard(config_value, name.as_deref(), description.as_deref());
            Ok(true)
        }
        // Unknown wizard_type — surface explicitly so the wizard
        // doesn't silently report `applied=false` to the client. The
        // round-19 ConfigUpdateOutcome refactor moved no-op semantics
        // from "did the closure return Ok(false)?" to "did the
        // closure say Changed or NoOp?". An unrecognised wizard
        // type is neither — it's a request error and the caller
        // needs to know the wizard_type was wrong, not interpret
        // the success-but-no-change response as "valid wizard, no
        // fields applied yet".
        other => Err(error_shape(
            ERROR_INVALID_REQUEST,
            &format!("unknown wizard type: {other}"),
            None,
        )),
    }
}

fn persist_wizard_config(
    wizard_type: &str,
    data: &HashMap<String, Value>,
) -> Result<Option<String>, ErrorShape> {
    let path = config::get_config_path();
    // Use the try_-variant so a wizard that reports "no fields to
    // apply" (`applied = false`) does NOT trigger a config-file
    // rewrite or creation. The non-try variant always persists
    // regardless of the closure's return.
    let outcome = try_update_config_file_with_error_shape(&path, |config_value| {
        let applied = apply_wizard_config(wizard_type, data, config_value)?;
        if !applied {
            return Ok(ConfigUpdateOutcome::NoOp);
        }
        let issues = config::validate_runtime_config_candidate(config_value)
            .map(|(_, issues)| map_validation_issues(issues))
            .map_err(|err| error_shape(ERROR_INVALID_REQUEST, &err.to_string(), None))?;
        if has_config_errors(&issues) {
            return Err(error_shape(
                ERROR_INVALID_REQUEST,
                "invalid config",
                Some(json!({ "issues": issues })),
            ));
        }
        Ok(ConfigUpdateOutcome::Changed)
    })?;
    if !outcome.is_changed() {
        return Ok(None);
    }
    Ok(Some(path.display().to_string()))
}

/// Start a new wizard
pub(super) fn handle_wizard_start(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let wizard_type = params
        .and_then(|v| v.get("type"))
        .and_then(|v| v.as_str())
        .unwrap_or("setup");

    if !WIZARD_TYPES.contains(&wizard_type) {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            &format!("unknown wizard type: {}", wizard_type),
            Some(json!({ "validTypes": WIZARD_TYPES })),
        ));
    }

    let conn_id = params
        .and_then(|v| v.get("connId"))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let mut manager = WIZARD_STATE.write();

    // Check for existing active wizard
    if let Some(active) = manager.active_wizard() {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "a wizard is already active",
            Some(json!({
                "activeWizardId": active.id,
                "type": active.wizard_type
            })),
        ));
    }

    let session = manager.create_wizard(wizard_type, conn_id);
    let current_step = session.steps.first().cloned();

    Ok(json!({
        "ok": true,
        "wizardId": session.id,
        "type": session.wizard_type,
        "step": session.current_step,
        "totalSteps": session.total_steps,
        "currentStep": current_step,
        "complete": false
    }))
}

/// Advance to the next wizard step
pub(super) fn handle_wizard_next(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let wizard_id = params
        .and_then(|v| v.get("wizardId"))
        .and_then(|v| v.as_str())
        .ok_or_else(|| error_shape(ERROR_INVALID_REQUEST, "wizardId is required", None))?;

    let input = params.and_then(|v| v.get("input")).cloned();

    let (wizard_type, wizard_data, total_steps, next_step) = {
        let mut manager = WIZARD_STATE.write();
        let wizard = manager
            .get_wizard_mut(wizard_id)
            .ok_or_else(|| error_shape(ERROR_INVALID_REQUEST, "wizard not found", None))?;

        if wizard.complete {
            return Err(error_shape(
                ERROR_INVALID_REQUEST,
                "wizard is already complete",
                None,
            ));
        }

        if wizard.cancelled {
            return Err(error_shape(
                ERROR_INVALID_REQUEST,
                "wizard was cancelled",
                None,
            ));
        }

        // Validate and store current step input
        if let Some(current_step) = wizard.steps.get(wizard.current_step) {
            validate_step_input(current_step, input.as_ref())?;
            let step_id = current_step.id.clone();
            if let Some(value) = input {
                wizard.data.insert(step_id, value);
            }
        }

        wizard.last_activity = now_ms();
        let next_step = wizard.current_step + 1;

        if next_step < wizard.total_steps {
            wizard.current_step = next_step;
            let current_step = wizard.steps.get(wizard.current_step).cloned();
            return Ok(json!({
                "ok": true,
                "wizardId": wizard.id,
                "step": wizard.current_step,
                "totalSteps": wizard.total_steps,
                "currentStep": current_step,
                "complete": false
            }));
        }

        (
            wizard.wizard_type.clone(),
            wizard.data.clone(),
            wizard.total_steps,
            next_step,
        )
    };

    let applied_path = persist_wizard_config(&wizard_type, &wizard_data)?;

    {
        let mut manager = WIZARD_STATE.write();
        if let Some(wizard) = manager.get_wizard_mut(wizard_id) {
            wizard.complete = true;
            wizard.current_step = next_step;
            wizard.data.clear();
            wizard.last_activity = now_ms();
        }
    }

    Ok(json!({
        "ok": true,
        "wizardId": wizard_id,
        "step": next_step,
        "totalSteps": total_steps,
        "complete": true,
        "applied": applied_path.is_some(),
        "configPath": applied_path
    }))
}

/// Go back to the previous wizard step
pub(super) fn handle_wizard_back(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let wizard_id = params
        .and_then(|v| v.get("wizardId"))
        .and_then(|v| v.as_str())
        .ok_or_else(|| error_shape(ERROR_INVALID_REQUEST, "wizardId is required", None))?;

    let mut manager = WIZARD_STATE.write();
    let wizard = manager
        .get_wizard_mut(wizard_id)
        .ok_or_else(|| error_shape(ERROR_INVALID_REQUEST, "wizard not found", None))?;

    if wizard.current_step == 0 {
        return Err(error_shape(
            ERROR_INVALID_REQUEST,
            "already at first step",
            None,
        ));
    }

    wizard.current_step -= 1;
    wizard.last_activity = now_ms();

    let current_step = wizard.steps.get(wizard.current_step).cloned();
    let step_id = current_step.as_ref().map(|s| s.id.clone());
    let previous_input = step_id.and_then(|id| wizard.data.get(&id).cloned());

    Ok(json!({
        "ok": true,
        "wizardId": wizard.id,
        "step": wizard.current_step,
        "totalSteps": wizard.total_steps,
        "currentStep": current_step,
        "previousInput": previous_input,
        "complete": false
    }))
}

/// Cancel an active wizard
pub(super) fn handle_wizard_cancel(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let wizard_id = params
        .and_then(|v| v.get("wizardId"))
        .and_then(|v| v.as_str());

    let mut manager = WIZARD_STATE.write();

    // If wizard_id provided, cancel that specific wizard
    // Otherwise, cancel the active wizard
    let id_to_cancel = if let Some(id) = wizard_id {
        Some(id.to_string())
    } else {
        manager.active_wizard().map(|w| w.id.clone())
    };

    if let Some(id) = id_to_cancel {
        if let Some(wizard) = manager.cancel_wizard(&id) {
            return Ok(json!({
                "ok": true,
                "cancelled": true,
                "wizardId": wizard.id,
                "type": wizard.wizard_type
            }));
        }
    }

    Ok(json!({
        "ok": true,
        "cancelled": false,
        "reason": "no active wizard"
    }))
}

/// Get wizard status
pub(super) fn handle_wizard_status(params: Option<&Value>) -> Result<Value, ErrorShape> {
    let wizard_id = params
        .and_then(|v| v.get("wizardId"))
        .and_then(|v| v.as_str());

    let manager = WIZARD_STATE.read();

    // If wizard_id provided, get that specific wizard
    // Otherwise, get the active wizard
    let wizard = if let Some(id) = wizard_id {
        manager.get_wizard(id)
    } else {
        manager.active_wizard()
    };

    match wizard {
        Some(w) => {
            let current_step = w.steps.get(w.current_step).cloned();
            Ok(json!({
                "active": !w.complete && !w.cancelled,
                "wizardId": w.id,
                "type": w.wizard_type,
                "step": w.current_step,
                "totalSteps": w.total_steps,
                "currentStep": current_step,
                "complete": w.complete,
                "cancelled": w.cancelled,
                "createdAt": w.created_at,
                "lastActivity": w.last_activity
            }))
        }
        None => Ok(json!({
            "active": false,
            "wizardId": null
        })),
    }
}

/// List all active wizards
pub(super) fn handle_wizard_list() -> Result<Value, ErrorShape> {
    let manager = WIZARD_STATE.read();

    let wizards: Vec<Value> = manager
        .wizards
        .values()
        .filter(|w| !w.complete && !w.cancelled)
        .map(|w| {
            json!({
                "wizardId": w.id,
                "type": w.wizard_type,
                "step": w.current_step,
                "totalSteps": w.total_steps,
                "createdAt": w.created_at
            })
        })
        .collect();

    Ok(json!({
        "wizards": wizards,
        "count": wizards.len()
    }))
}

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

    /// Mutex to serialize tests that modify global state
    static TEST_LOCK: Mutex<()> = Mutex::new(());

    fn reset_state() {
        let mut manager = WIZARD_STATE.write();
        manager.wizards.clear();
    }

    #[test]
    fn test_wizard_start() {
        let _lock = TEST_LOCK.lock().unwrap();
        reset_state();
        let params = json!({ "type": "setup" });
        let result = handle_wizard_start(Some(&params)).unwrap();
        assert_eq!(result["ok"], true);
        assert_eq!(result["type"], "setup");
        assert_eq!(result["step"], 0);
        assert!(result["totalSteps"].as_i64().unwrap() > 0);
        assert_eq!(result["complete"], false);
    }

    #[test]
    fn test_wizard_start_invalid_type() {
        let _lock = TEST_LOCK.lock().unwrap();
        reset_state();
        let params = json!({ "type": "invalid" });
        let result = handle_wizard_start(Some(&params));
        assert!(result.is_err());
    }

    #[test]
    fn test_wizard_next() {
        let _lock = TEST_LOCK.lock().unwrap();
        reset_state();

        // Start wizard
        let start_params = json!({ "type": "setup" });
        let start_result = handle_wizard_start(Some(&start_params)).unwrap();
        let wizard_id = start_result["wizardId"].as_str().unwrap();

        // Advance to next step
        let next_params = json!({
            "wizardId": wizard_id,
            "input": true
        });
        let result = handle_wizard_next(Some(&next_params)).unwrap();
        assert_eq!(result["ok"], true);
        assert_eq!(result["step"], 1);
    }

    #[test]
    fn test_wizard_cancel() {
        let _lock = TEST_LOCK.lock().unwrap();
        reset_state();

        // Start wizard
        let start_params = json!({ "type": "setup" });
        let start_result = handle_wizard_start(Some(&start_params)).unwrap();
        let wizard_id = start_result["wizardId"].as_str().unwrap();

        // Cancel wizard
        let cancel_params = json!({ "wizardId": wizard_id });
        let result = handle_wizard_cancel(Some(&cancel_params)).unwrap();
        assert_eq!(result["ok"], true);
        assert_eq!(result["cancelled"], true);
    }

    #[test]
    fn test_wizard_status() {
        let _lock = TEST_LOCK.lock().unwrap();
        reset_state();
        let result = handle_wizard_status(None).unwrap();
        assert_eq!(result["active"], false);
        assert!(result["wizardId"].is_null());
    }

    #[test]
    fn test_wizard_status_with_active() {
        let _lock = TEST_LOCK.lock().unwrap();
        reset_state();

        // Start wizard
        let start_params = json!({ "type": "setup" });
        handle_wizard_start(Some(&start_params)).unwrap();

        // Check status
        let result = handle_wizard_status(None).unwrap();
        assert_eq!(result["active"], true);
        assert_eq!(result["type"], "setup");
    }

    #[test]
    fn test_wizard_cannot_start_when_active() {
        let _lock = TEST_LOCK.lock().unwrap();
        reset_state();

        // Start first wizard
        let start_params = json!({ "type": "setup" });
        handle_wizard_start(Some(&start_params)).unwrap();

        // Try to start another
        let result = handle_wizard_start(Some(&start_params));
        assert!(result.is_err());
    }

    #[test]
    fn test_wizard_list() {
        let _lock = TEST_LOCK.lock().unwrap();
        reset_state();

        // Start wizard
        let start_params = json!({ "type": "setup" });
        handle_wizard_start(Some(&start_params)).unwrap();

        // List wizards
        let result = handle_wizard_list().unwrap();
        assert_eq!(result["count"], 1);
    }

    #[test]
    fn test_apply_setup_wizard_updates_config() {
        let mut data = HashMap::new();
        data.insert("provider".to_string(), json!("anthropic"));
        data.insert("api_key".to_string(), json!("sk-test"));
        let mut config_value = json!({});

        let applied = apply_wizard_config("setup", &data, &mut config_value).unwrap();
        assert!(applied);
        assert_eq!(
            config_value["anthropic"]["apiKey"],
            Value::String("sk-test".to_string())
        );
        assert_eq!(
            config_value["gateway"]["auth"]["mode"],
            Value::String("token".to_string())
        );
        assert_eq!(
            config_value["agents"]["defaults"]["model"],
            Value::String("anthropic:claude-sonnet-4-6".to_string())
        );
        assert_eq!(
            config_value["gateway"]["bind"],
            Value::String("loopback".to_string())
        );
        assert_eq!(
            config_value["gateway"]["port"],
            Value::Number(DEFAULT_PORT.into())
        );
        assert_eq!(
            config_value["gateway"]["hooks"]["enabled"],
            Value::Bool(false)
        );
        assert_eq!(
            config_value["gateway"]["controlUi"]["enabled"],
            Value::Bool(false)
        );
        assert!(config_value["gateway"]["auth"]["token"]
            .as_str()
            .map(|v| !v.is_empty())
            .unwrap_or(false));
    }

    #[test]
    fn test_apply_setup_wizard_respects_auth_and_bind_overrides() {
        let mut data = HashMap::new();
        data.insert("provider".to_string(), json!("openai"));
        data.insert("api_key".to_string(), json!("sk-openai"));
        data.insert("auth_mode".to_string(), json!("password"));
        data.insert("auth_secret".to_string(), json!("my-password"));
        data.insert("bind_mode".to_string(), json!("lan"));
        data.insert("port".to_string(), json!("19001"));
        data.insert("first_outcome".to_string(), json!("discord"));
        data.insert("channel_token".to_string(), json!("discord-bot-token"));
        data.insert("hooks_enabled".to_string(), json!(true));
        data.insert("hooks_token".to_string(), json!("hooks-secret-token"));
        data.insert("control_ui_enabled".to_string(), json!(true));
        let mut config_value = json!({});

        let applied = apply_wizard_config("setup", &data, &mut config_value).unwrap();
        assert!(applied);
        assert_eq!(
            config_value["openai"]["apiKey"],
            Value::String("sk-openai".to_string())
        );
        assert_eq!(
            config_value["gateway"]["auth"]["mode"],
            Value::String("password".to_string())
        );
        assert_eq!(
            config_value["agents"]["defaults"]["model"],
            Value::String("openai:gpt-5.5".to_string())
        );
        assert_eq!(
            config_value["gateway"]["auth"]["password"],
            Value::String("my-password".to_string())
        );
        assert_eq!(
            config_value["gateway"]["bind"],
            Value::String("lan".to_string())
        );
        assert_eq!(config_value["gateway"]["port"], Value::Number(19001.into()));
        assert_eq!(
            config_value["discord"]["botToken"],
            Value::String("discord-bot-token".to_string())
        );
        assert_eq!(config_value["discord"]["enabled"], Value::Bool(true));
        assert_eq!(
            config_value["gateway"]["hooks"]["enabled"],
            Value::Bool(true)
        );
        assert_eq!(
            config_value["gateway"]["hooks"]["token"],
            Value::String("hooks-secret-token".to_string())
        );
        assert_eq!(
            config_value["gateway"]["controlUi"]["enabled"],
            Value::Bool(true)
        );
    }

    #[test]
    fn test_apply_setup_wizard_hooks_outcome_enables_hooks_by_default() {
        let mut data = HashMap::new();
        data.insert("provider".to_string(), json!("anthropic"));
        data.insert("api_key".to_string(), json!("sk-test"));
        data.insert("first_outcome".to_string(), json!("hooks"));
        let mut config_value = json!({});

        let applied = apply_wizard_config("setup", &data, &mut config_value).unwrap();
        assert!(applied);
        assert_eq!(
            config_value["gateway"]["hooks"]["enabled"],
            Value::Bool(true)
        );
        assert!(config_value["gateway"]["hooks"]["token"]
            .as_str()
            .map(|v| !v.is_empty())
            .unwrap_or(false));
    }

    #[test]
    fn test_apply_setup_wizard_preserves_existing_optional_toggles_when_unspecified() {
        let mut data = HashMap::new();
        data.insert("provider".to_string(), json!("anthropic"));
        data.insert("api_key".to_string(), json!("sk-test"));
        data.insert("first_outcome".to_string(), json!("local-chat"));

        let mut config_value = json!({
            "gateway": {
                "hooks": {
                    "enabled": true,
                    "token": "existing-hooks-token"
                },
                "controlUi": {
                    "enabled": true
                }
            }
        });

        let applied = apply_wizard_config("setup", &data, &mut config_value).unwrap();
        assert!(applied);
        assert_eq!(
            config_value["gateway"]["hooks"]["enabled"],
            Value::Bool(true)
        );
        assert_eq!(
            config_value["gateway"]["hooks"]["token"],
            Value::String("existing-hooks-token".to_string())
        );
        assert_eq!(
            config_value["gateway"]["controlUi"]["enabled"],
            Value::Bool(true)
        );
    }

    #[test]
    fn test_apply_setup_wizard_hooks_outcome_reuses_existing_token_when_unspecified() {
        let mut data = HashMap::new();
        data.insert("provider".to_string(), json!("anthropic"));
        data.insert("api_key".to_string(), json!("sk-test"));
        data.insert("first_outcome".to_string(), json!("hooks"));

        let mut config_value = json!({
            "gateway": {
                "hooks": {
                    "enabled": false,
                    "token": "existing-hooks-token"
                }
            }
        });

        let applied = apply_wizard_config("setup", &data, &mut config_value).unwrap();
        assert!(applied);
        assert_eq!(
            config_value["gateway"]["hooks"]["enabled"],
            Value::Bool(true)
        );
        assert_eq!(
            config_value["gateway"]["hooks"]["token"],
            Value::String("existing-hooks-token".to_string())
        );
    }

    #[test]
    fn test_apply_channel_wizard_updates_config() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.unset("CARAPACE_CONFIG_PASSWORD");
        crate::config::clear_cache();
        let mut data = HashMap::new();
        data.insert("channel_type".to_string(), json!("telegram"));
        data.insert("channel_token".to_string(), json!("bot:token"));
        let mut config_value = json!({});

        let applied = apply_wizard_config("channel", &data, &mut config_value).unwrap();
        assert!(applied);
        assert_eq!(
            config_value["telegram"]["botToken"],
            Value::String("bot:token".to_string())
        );
        assert_eq!(config_value["telegram"]["enabled"], Value::Bool(true));
        crate::config::clear_cache();
    }

    /// B127 regression: daemon wizard encrypts secrets inline when
    /// CARAPACE_CONFIG_PASSWORD is set. Without this, a transient
    /// unset of the env var between the wizard write and the
    /// `seal_config_secrets` layer's independent read would cause
    /// the seal to no-op and leave plaintext on disk. Same B114-
    /// class TOCTOU on a different surface (control-UI / WS
    /// wizard vs. `cara setup` CLI).
    #[test]
    fn test_apply_channel_wizard_encrypts_token_inline_when_password_set() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-config-password");
        crate::config::clear_cache();
        let mut data = HashMap::new();
        data.insert("channel_type".to_string(), json!("discord"));
        data.insert(
            "channel_token".to_string(),
            json!("discord-bot-token-plaintext"),
        );
        let mut config_value = json!({});

        let applied = apply_wizard_config("channel", &data, &mut config_value).unwrap();
        assert!(applied);
        let stored = config_value
            .pointer("/discord/botToken")
            .and_then(Value::as_str)
            .expect("discord botToken present");
        assert!(
            crate::config::secrets::is_encrypted(stored),
            "discord.botToken must be encrypted inline by the wizard when \
             CARAPACE_CONFIG_PASSWORD is set; got: {stored}"
        );
        crate::config::clear_cache();
    }

    /// B127 regression: daemon wizard `gateway.auth.token` write
    /// also encrypts inline when password is set.
    #[test]
    fn test_apply_setup_wizard_encrypts_gateway_auth_token_inline_when_password_set() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-config-password");
        crate::config::clear_cache();
        let mut data = HashMap::new();
        data.insert("provider".to_string(), json!("anthropic"));
        data.insert("api_key".to_string(), json!("sk-test"));
        data.insert("auth_mode".to_string(), json!("token"));
        data.insert(
            "auth_secret".to_string(),
            json!("test-auth-token-32chars-aaaaaaaa"),
        );
        data.insert("bind_mode".to_string(), json!("loopback"));
        data.insert("port".to_string(), json!(7878));
        data.insert("first_outcome".to_string(), json!("local-chat"));
        let mut config_value = json!({});

        let applied = apply_wizard_config("setup", &data, &mut config_value).unwrap();
        assert!(applied);
        let stored = config_value
            .pointer("/gateway/auth/token")
            .and_then(Value::as_str)
            .expect("gateway.auth.token present");
        assert!(
            crate::config::secrets::is_encrypted(stored),
            "gateway.auth.token must be encrypted inline when password set; got: {stored}"
        );
        crate::config::clear_cache();
    }

    /// B135 regression: daemon wizard encrypts `anthropic.apiKey`
    /// inline when CARAPACE_CONFIG_PASSWORD is set. B127 covered
    /// gateway.auth.* / *.botToken / gateway.hooks.token but
    /// missed the provider keys. Same B114-class TOCTOU bypass —
    /// transient env-var unset between wizard write and seal-layer
    /// read leaves plaintext on disk.
    #[test]
    fn test_apply_setup_wizard_encrypts_anthropic_api_key_inline_when_password_set() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-config-password");
        crate::config::clear_cache();
        let mut data = HashMap::new();
        data.insert("provider".to_string(), json!("anthropic"));
        data.insert("api_key".to_string(), json!("sk-ant-plaintext-key"));
        data.insert("auth_mode".to_string(), json!("token"));
        data.insert(
            "auth_secret".to_string(),
            json!("test-auth-token-32chars-aaaaaaaa"),
        );
        data.insert("bind_mode".to_string(), json!("loopback"));
        data.insert("port".to_string(), json!(7878));
        data.insert("first_outcome".to_string(), json!("local-chat"));
        let mut config_value = json!({});

        let applied = apply_wizard_config("setup", &data, &mut config_value).unwrap();
        assert!(applied);
        let stored = config_value
            .pointer("/anthropic/apiKey")
            .and_then(Value::as_str)
            .expect("anthropic.apiKey present");
        assert!(
            crate::config::secrets::is_encrypted(stored),
            "anthropic.apiKey must be encrypted inline when password set; got: {stored}"
        );
        crate::config::clear_cache();
    }

    /// B135 regression: same shape for openai.apiKey.
    #[test]
    fn test_apply_setup_wizard_encrypts_openai_api_key_inline_when_password_set() {
        let _env_state_guard = crate::config::ScopedEnvStateForTest::new();
        let mut env_guard = crate::test_support::env::ScopedEnv::new();
        env_guard.set("CARAPACE_CONFIG_PASSWORD", "test-config-password");
        crate::config::clear_cache();
        let mut data = HashMap::new();
        data.insert("provider".to_string(), json!("openai"));
        data.insert("api_key".to_string(), json!("sk-openai-plaintext-key"));
        data.insert("auth_mode".to_string(), json!("token"));
        data.insert(
            "auth_secret".to_string(),
            json!("test-auth-token-32chars-aaaaaaaa"),
        );
        data.insert("bind_mode".to_string(), json!("loopback"));
        data.insert("port".to_string(), json!(7878));
        data.insert("first_outcome".to_string(), json!("local-chat"));
        let mut config_value = json!({});

        let applied = apply_wizard_config("setup", &data, &mut config_value).unwrap();
        assert!(applied);
        let stored = config_value
            .pointer("/openai/apiKey")
            .and_then(Value::as_str)
            .expect("openai.apiKey present");
        assert!(
            crate::config::secrets::is_encrypted(stored),
            "openai.apiKey must be encrypted inline when password set; got: {stored}"
        );
        crate::config::clear_cache();
    }

    #[test]
    fn test_apply_agent_wizard_updates_identity() {
        let mut data = HashMap::new();
        data.insert("agent_name".to_string(), json!("Nova"));
        data.insert("agent_personality".to_string(), json!("Helpful and calm."));
        let mut config_value = json!({
            "agents": {
                "list": [{
                    "id": "default",
                    "default": true,
                    "identity": { "name": "Old" }
                }]
            }
        });

        let applied = apply_wizard_config("agent", &data, &mut config_value).unwrap();
        assert!(applied);
        assert_eq!(
            config_value["agents"]["list"][0]["identity"]["name"],
            Value::String("Nova".to_string())
        );
        assert_eq!(
            config_value["agents"]["list"][0]["identity"]["description"],
            Value::String("Helpful and calm.".to_string())
        );
    }
}
