use crate::path_utils::resolve_symlink_write_paths;
use crate::path_utils::write_atomically;
use anyhow::Context;
use codex_config::CONFIG_TOML_FILE;
use codex_config::types::McpServerConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_features::FEATURES;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use tokio::task;
use toml_edit::ArrayOfTables;
use toml_edit::DocumentMut;
use toml_edit::Item as TomlItem;
use toml_edit::Table as TomlTable;
use toml_edit::value;

const NOTICE_TABLE_KEY: &str = "notice";

/// Discrete config mutations supported by the persistence engine.
#[derive(Clone, Debug)]
pub enum ConfigEdit {
    /// Update the active (or default) model selection and optional reasoning effort.
    SetModel {
        model: Option<String>,
        effort: Option<ReasoningEffort>,
    },
    /// Update the service tier preference for future turns.
    SetServiceTier { service_tier: Option<ServiceTier> },
    /// Update the active (or default) model personality.
    SetModelPersonality { personality: Option<Personality> },
    /// Toggle the acknowledgement flag under `[notice]`.
    SetNoticeHideFullAccessWarning(bool),
    /// Toggle the Windows world-writable directories warning acknowledgement flag.
    SetNoticeHideWorldWritableWarning(bool),
    /// Toggle the opt-out marker for Codex-managed fast defaults.
    SetNoticeFastDefaultOptOut(bool),
    /// Toggle the rate limit model nudge acknowledgement flag.
    SetNoticeHideRateLimitModelNudge(bool),
    /// Toggle the Windows onboarding acknowledgement flag.
    SetWindowsWslSetupAcknowledged(bool),
    /// Toggle the model migration prompt acknowledgement flag.
    SetNoticeHideModelMigrationPrompt(String, bool),
    /// Toggle the home external config migration prompt acknowledgement flag.
    SetNoticeHideExternalConfigMigrationPromptHome(bool),
    /// Record when the home external config migration prompt was last shown.
    SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(i64),
    /// Toggle the project external config migration prompt acknowledgement flag.
    SetNoticeHideExternalConfigMigrationPromptProject(String, bool),
    /// Record when the project external config migration prompt was last shown.
    SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(String, i64),
    /// Record that a migration prompt was shown for an old->new model mapping.
    RecordModelMigrationSeen { from: String, to: String },
    /// Replace the entire `[mcp_servers]` table.
    ReplaceMcpServers(BTreeMap<String, McpServerConfig>),
    /// Add a disabled tool suggestion under `[tool_suggest].disabled_tools`.
    AddToolSuggestDisabledTool(ToolSuggestDisabledTool),
    /// Set or clear a skill config entry under `[[skills.config]]` by path.
    SetSkillConfig { path: PathBuf, enabled: bool },
    /// Set or clear a skill config entry under `[[skills.config]]` by name.
    SetSkillConfigByName { name: String, enabled: bool },
    /// Set trust_level under `[projects."<path>"]`,
    /// migrating inline tables to explicit tables.
    SetProjectTrustLevel { path: PathBuf, level: TrustLevel },
    /// Set the value stored at the exact dotted path.
    SetPath {
        segments: Vec<String>,
        value: TomlItem,
    },
    /// Remove the value stored at the exact dotted path.
    ClearPath { segments: Vec<String> },
}

#[derive(Clone, Debug, PartialEq, Eq)]
enum SkillConfigSelector {
    Name(String),
    Path(PathBuf),
}

/// Produces a config edit that sets `[tui].theme = "<name>"`.
pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
    ConfigEdit::SetPath {
        segments: vec!["tui".to_string(), "theme".to_string()],
        value: value(name.to_string()),
    }
}

/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list.
///
/// The array is written even when it is empty so "hide the status line" stays
/// distinct from "unset, so use defaults".
pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
    let array = items.iter().cloned().collect::<toml_edit::Array>();

    ConfigEdit::SetPath {
        segments: vec!["tui".to_string(), "status_line".to_string()],
        value: TomlItem::Value(array.into()),
    }
}

/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list.
///
/// The array is written even when it is empty so "disabled title updates" stays
/// distinct from "unset, so use defaults".
pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit {
    let array = items.iter().cloned().collect::<toml_edit::Array>();

    ConfigEdit::SetPath {
        segments: vec!["tui".to_string(), "terminal_title".to_string()],
        value: TomlItem::Value(array.into()),
    }
}

fn keymap_binding_value(keys: &[String]) -> TomlItem {
    if let [key] = keys {
        value(key.to_string())
    } else {
        let array = keys.iter().cloned().collect::<toml_edit::Array>();
        TomlItem::Value(array.into())
    }
}

/// Produces a config edit that replaces one root-level TUI keymap binding list.
pub fn keymap_bindings_edit(context: &str, action: &str, keys: &[String]) -> ConfigEdit {
    ConfigEdit::SetPath {
        segments: vec![
            "tui".to_string(),
            "keymap".to_string(),
            context.to_string(),
            action.to_string(),
        ],
        value: keymap_binding_value(keys),
    }
}

/// Produces a config edit that replaces one root-level TUI keymap binding.
pub fn keymap_binding_edit(context: &str, action: &str, key: &str) -> ConfigEdit {
    keymap_bindings_edit(context, action, &[key.to_string()])
}

/// Produces a config edit that removes one root-level TUI keymap binding.
pub fn keymap_binding_clear_edit(context: &str, action: &str) -> ConfigEdit {
    ConfigEdit::ClearPath {
        segments: vec![
            "tui".to_string(),
            "keymap".to_string(),
            context.to_string(),
            action.to_string(),
        ],
    }
}

pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) -> Vec<ConfigEdit> {
    let mut shown_count_entries: Vec<_> = shown_count.iter().collect();
    shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));

    let mut edits = vec![ConfigEdit::ClearPath {
        segments: vec!["tui".to_string(), "model_availability_nux".to_string()],
    }];
    for (model_slug, count) in shown_count_entries {
        edits.push(ConfigEdit::SetPath {
            segments: vec![
                "tui".to_string(),
                "model_availability_nux".to_string(),
                model_slug.clone(),
            ],
            value: value(i64::from(*count)),
        });
    }

    edits
}

// TODO(jif) move to a dedicated file
mod document_helpers {
    use codex_config::types::AppToolApproval;
    use codex_config::types::McpServerConfig;
    use codex_config::types::McpServerEnvVar;
    use codex_config::types::McpServerToolConfig;
    use codex_config::types::McpServerTransportConfig;
    use codex_config::types::ToolSuggestDisabledTool;
    use codex_config::types::ToolSuggestDiscoverableType;
    use toml_edit::Array as TomlArray;
    use toml_edit::InlineTable;
    use toml_edit::Item as TomlItem;
    use toml_edit::Table as TomlTable;
    use toml_edit::Value as TomlValue;
    use toml_edit::value;

    pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> {
        match item {
            TomlItem::Table(table) => Some(table),
            TomlItem::Value(value) => {
                if let Some(inline) = value.as_inline_table() {
                    *item = TomlItem::Table(table_from_inline(inline));
                    item.as_table_mut()
                } else {
                    *item = TomlItem::Table(new_implicit_table());
                    item.as_table_mut()
                }
            }
            TomlItem::None => {
                *item = TomlItem::Table(new_implicit_table());
                item.as_table_mut()
            }
            _ => None,
        }
    }

    pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> {
        match item {
            TomlItem::Table(table) => Some(table),
            TomlItem::Value(value) => {
                let inline = value.as_inline_table()?;
                *item = TomlItem::Table(table_from_inline(inline));
                item.as_table_mut()
            }
            _ => None,
        }
    }

    fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
        let mut entry = TomlTable::new();
        entry.set_implicit(false);

        match &config.transport {
            McpServerTransportConfig::Stdio {
                command,
                args,
                env,
                env_vars,
                cwd,
            } => {
                entry["command"] = value(command.clone());
                if !args.is_empty() {
                    entry["args"] = array_from_iter(args.iter().cloned());
                }
                if let Some(env) = env
                    && !env.is_empty()
                {
                    entry["env"] = table_from_pairs(env.iter());
                }
                if !env_vars.is_empty() {
                    entry["env_vars"] = array_from_env_vars(env_vars);
                }
                if let Some(cwd) = cwd {
                    entry["cwd"] = value(cwd.to_string_lossy().to_string());
                }
            }
            McpServerTransportConfig::StreamableHttp {
                url,
                bearer_token_env_var,
                http_headers,
                env_http_headers,
            } => {
                entry["url"] = value(url.clone());
                if let Some(env_var) = bearer_token_env_var {
                    entry["bearer_token_env_var"] = value(env_var.clone());
                }
                if let Some(headers) = http_headers
                    && !headers.is_empty()
                {
                    entry["http_headers"] = table_from_pairs(headers.iter());
                }
                if let Some(headers) = env_http_headers
                    && !headers.is_empty()
                {
                    entry["env_http_headers"] = table_from_pairs(headers.iter());
                }
            }
        }

        if !config.enabled {
            entry["enabled"] = value(false);
        }
        if let Some(environment) = &config.experimental_environment {
            entry["experimental_environment"] = value(environment.clone());
        }
        if config.required {
            entry["required"] = value(true);
        }
        if config.supports_parallel_tool_calls {
            entry["supports_parallel_tool_calls"] = value(true);
        }
        if let Some(timeout) = config.startup_timeout_sec {
            entry["startup_timeout_sec"] = value(timeout.as_secs_f64());
        }
        if let Some(timeout) = config.tool_timeout_sec {
            entry["tool_timeout_sec"] = value(timeout.as_secs_f64());
        }
        if let Some(approval_mode) = config.default_tools_approval_mode {
            entry["default_tools_approval_mode"] = value(match approval_mode {
                AppToolApproval::Auto => "auto",
                AppToolApproval::Prompt => "prompt",
                AppToolApproval::Approve => "approve",
            });
        }
        if let Some(enabled_tools) = &config.enabled_tools
            && !enabled_tools.is_empty()
        {
            entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned());
        }
        if let Some(disabled_tools) = &config.disabled_tools
            && !disabled_tools.is_empty()
        {
            entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned());
        }
        if let Some(scopes) = &config.scopes
            && !scopes.is_empty()
        {
            entry["scopes"] = array_from_iter(scopes.iter().cloned());
        }
        if let Some(resource) = &config.oauth_resource
            && !resource.is_empty()
        {
            entry["oauth_resource"] = value(resource.clone());
        }
        if !config.tools.is_empty() {
            let mut tools = new_implicit_table();
            let mut tool_entries: Vec<_> = config.tools.iter().collect();
            tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right));
            for (name, tool_config) in tool_entries {
                tools.insert(name, serialize_mcp_server_tool(tool_config));
            }
            entry.insert("tools", TomlItem::Table(tools));
        }

        entry
    }

    fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
        let mut entry = TomlTable::new();
        entry.set_implicit(false);
        if let Some(approval_mode) = config.approval_mode {
            entry["approval_mode"] = value(match approval_mode {
                AppToolApproval::Auto => "auto",
                AppToolApproval::Prompt => "prompt",
                AppToolApproval::Approve => "approve",
            });
        }
        TomlItem::Table(entry)
    }

    pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
        TomlItem::Table(serialize_mcp_server_table(config))
    }

    pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable {
        serialize_mcp_server_table(config).into_inline_table()
    }

    pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) {
        existing.retain(|key, _| replacement.get(key).is_some());

        for (key, value) in replacement.iter() {
            if let Some(existing_value) = existing.get_mut(key) {
                let mut updated_value = value.clone();
                *updated_value.decor_mut() = existing_value.decor().clone();
                *existing_value = updated_value;
            } else {
                existing.insert(key.to_string(), value.clone());
            }
        }
    }

    fn table_from_inline(inline: &InlineTable) -> TomlTable {
        let mut table = new_implicit_table();
        for (key, value) in inline.iter() {
            let mut value = value.clone();
            let decor = value.decor_mut();
            decor.set_suffix("");
            table.insert(key, TomlItem::Value(value));
        }
        table
    }

    pub(super) fn new_implicit_table() -> TomlTable {
        let mut table = TomlTable::new();
        table.set_implicit(true);
        table
    }

    pub(super) fn parse_tool_suggest_disabled_tool(
        value: &TomlValue,
    ) -> Option<ToolSuggestDisabledTool> {
        let table = value.as_inline_table()?;
        let kind = match table.get("type").and_then(TomlValue::as_str) {
            Some("connector") => ToolSuggestDiscoverableType::Connector,
            Some("plugin") => ToolSuggestDiscoverableType::Plugin,
            _ => return None,
        };
        let id = table.get("id").and_then(TomlValue::as_str)?;
        Some(ToolSuggestDisabledTool {
            kind,
            id: id.to_string(),
        })
    }

    pub(super) fn parse_tool_suggest_disabled_tool_table(
        table: &TomlTable,
    ) -> Option<ToolSuggestDisabledTool> {
        let kind = match table.get("type").and_then(TomlItem::as_str) {
            Some("connector") => ToolSuggestDiscoverableType::Connector,
            Some("plugin") => ToolSuggestDiscoverableType::Plugin,
            _ => return None,
        };
        let id = table.get("id").and_then(TomlItem::as_str)?;
        Some(ToolSuggestDisabledTool {
            kind,
            id: id.to_string(),
        })
    }

    pub(super) fn tool_suggest_disabled_tools_value(
        disabled_tools: &[ToolSuggestDisabledTool],
    ) -> TomlItem {
        let mut array = TomlArray::new();
        for disabled_tool in disabled_tools {
            let mut table = InlineTable::new();
            table.insert(
                "type",
                match disabled_tool.kind {
                    ToolSuggestDiscoverableType::Connector => "connector",
                    ToolSuggestDiscoverableType::Plugin => "plugin",
                }
                .into(),
            );
            table.insert("id", disabled_tool.id.clone().into());
            array.push(table);
        }
        TomlItem::Value(array.into())
    }

    fn array_from_iter<I>(iter: I) -> TomlItem
    where
        I: Iterator<Item = String>,
    {
        let mut array = TomlArray::new();
        for value in iter {
            array.push(value);
        }
        TomlItem::Value(array.into())
    }

    fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem {
        let mut array = TomlArray::new();
        for env_var in env_vars {
            match env_var {
                McpServerEnvVar::Name(name) => array.push(name.clone()),
                McpServerEnvVar::Config { name, source } => {
                    let mut table = InlineTable::new();
                    table.insert("name", name.clone().into());
                    if let Some(source) = source {
                        table.insert("source", source.clone().into());
                    }
                    array.push(table);
                }
            }
        }
        TomlItem::Value(array.into())
    }

    fn table_from_pairs<'a, I>(pairs: I) -> TomlItem
    where
        I: IntoIterator<Item = (&'a String, &'a String)>,
    {
        let mut entries: Vec<_> = pairs.into_iter().collect();
        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
        let mut table = TomlTable::new();
        table.set_implicit(false);
        for (key, val) in entries {
            table.insert(key, value(val.clone()));
        }
        TomlItem::Table(table)
    }
}

struct ConfigDocument {
    doc: DocumentMut,
    profile: Option<String>,
}

#[derive(Copy, Clone)]
enum Scope {
    Global,
    Profile,
}

#[derive(Copy, Clone)]
enum TraversalMode {
    Create,
    Existing,
}

impl ConfigDocument {
    fn new(doc: DocumentMut, profile: Option<String>) -> Self {
        Self { doc, profile }
    }

    fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result<bool> {
        match edit {
            ConfigEdit::SetModel { model, effort } => Ok({
                let mut mutated = false;
                mutated |= self.write_profile_value(
                    &["model"],
                    model.as_ref().map(|model_value| value(model_value.clone())),
                );
                mutated |= self.write_profile_value(
                    &["model_reasoning_effort"],
                    effort.map(|effort| value(effort.to_string())),
                );
                mutated
            }),
            ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value(
                &["service_tier"],
                service_tier.map(|service_tier| value(service_tier.to_string())),
            )),
            ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value(
                &["personality"],
                personality.map(|personality| value(personality.to_string())),
            )),
            ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value(
                Scope::Global,
                &[NOTICE_TABLE_KEY, "hide_full_access_warning"],
                value(*acknowledged),
            )),
            ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value(
                Scope::Global,
                &[NOTICE_TABLE_KEY, "hide_world_writable_warning"],
                value(*acknowledged),
            )),
            ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value(
                Scope::Global,
                &[NOTICE_TABLE_KEY, "fast_default_opt_out"],
                value(*opted_out),
            )),
            ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
                Scope::Global,
                &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"],
                value(*acknowledged),
            )),
            ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => {
                Ok(self.write_value(
                    Scope::Global,
                    &[NOTICE_TABLE_KEY, migration_config.as_str()],
                    value(*acknowledged),
                ))
            }
            ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self
                .write_value(
                    Scope::Global,
                    &[
                        NOTICE_TABLE_KEY,
                        "external_config_migration_prompts",
                        "home",
                    ],
                    value(*acknowledged),
                )),
            ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => {
                Ok(self.write_value(
                    Scope::Global,
                    &[
                        NOTICE_TABLE_KEY,
                        "external_config_migration_prompts",
                        "home_last_prompted_at",
                    ],
                    value(*timestamp),
                ))
            }
            ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
                project,
                acknowledged,
            ) => Ok(self.write_value(
                Scope::Global,
                &[
                    NOTICE_TABLE_KEY,
                    "external_config_migration_prompts",
                    "projects",
                    project.as_str(),
                ],
                value(*acknowledged),
            )),
            ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
                project,
                timestamp,
            ) => Ok(self.write_value(
                Scope::Global,
                &[
                    NOTICE_TABLE_KEY,
                    "external_config_migration_prompts",
                    "project_last_prompted_at",
                    project.as_str(),
                ],
                value(*timestamp),
            )),
            ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(
                Scope::Global,
                &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
                value(to.clone()),
            )),
            ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value(
                Scope::Global,
                &["windows_wsl_setup_acknowledged"],
                value(*acknowledged),
            )),
            ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)),
            ConfigEdit::AddToolSuggestDisabledTool(disabled_tool) => {
                Ok(self.add_tool_suggest_disabled_tool(disabled_tool))
            }
            ConfigEdit::SetSkillConfig { path, enabled } => {
                Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled))
            }
            ConfigEdit::SetSkillConfigByName { name, enabled } => {
                Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled))
            }
            ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),
            ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
            ConfigEdit::SetProjectTrustLevel { path, level } => {
                // Delegate to the existing, tested logic in config.rs to
                // ensure tables are explicit and migration is preserved.
                crate::config::set_project_trust_level_inner(
                    &mut self.doc,
                    path.as_path(),
                    *level,
                )?;
                Ok(true)
            }
        }
    }

    fn write_profile_value(&mut self, segments: &[&str], value: Option<TomlItem>) -> bool {
        match value {
            Some(item) => self.write_value(Scope::Profile, segments, item),
            None => self.clear(Scope::Profile, segments),
        }
    }

    fn write_value(&mut self, scope: Scope, segments: &[&str], value: TomlItem) -> bool {
        let resolved = self.scoped_segments(scope, segments);
        self.insert(&resolved, value)
    }

    fn clear(&mut self, scope: Scope, segments: &[&str]) -> bool {
        let resolved = self.scoped_segments(scope, segments);
        self.remove(&resolved)
    }

    fn add_tool_suggest_disabled_tool(&mut self, disabled_tool: &ToolSuggestDisabledTool) -> bool {
        let disabled_tools_item = self
            .doc
            .get("tool_suggest")
            .and_then(|item| item.as_table_like())
            .and_then(|table| table.get("disabled_tools"));
        let existing_from_array = disabled_tools_item
            .and_then(|item| item.as_value())
            .and_then(|value| value.as_array())
            .into_iter()
            .flat_map(|array| array.iter())
            .filter_map(document_helpers::parse_tool_suggest_disabled_tool);
        let existing_from_tables = disabled_tools_item
            .and_then(|item| match item {
                TomlItem::ArrayOfTables(array) => Some(array),
                _ => None,
            })
            .into_iter()
            .flat_map(|array| array.iter())
            .filter_map(document_helpers::parse_tool_suggest_disabled_tool_table);

        let mut seen = HashSet::new();
        let disabled_tools = existing_from_array
            .chain(existing_from_tables)
            .chain(std::iter::once(disabled_tool.clone()))
            .filter_map(|disabled_tool| disabled_tool.normalized())
            .filter(|disabled_tool| seen.insert(disabled_tool.clone()))
            .collect::<Vec<_>>();
        self.write_value(
            Scope::Global,
            &["tool_suggest", "disabled_tools"],
            document_helpers::tool_suggest_disabled_tools_value(&disabled_tools),
        )
    }

    fn clear_owned(&mut self, segments: &[String]) -> bool {
        self.remove(segments)
    }

    fn replace_mcp_servers(&mut self, servers: &BTreeMap<String, McpServerConfig>) -> bool {
        if servers.is_empty() {
            return self.clear(Scope::Global, &["mcp_servers"]);
        }

        let root = self.doc.as_table_mut();
        if !root.contains_key("mcp_servers") {
            root.insert(
                "mcp_servers",
                TomlItem::Table(document_helpers::new_implicit_table()),
            );
        }

        let Some(item) = root.get_mut("mcp_servers") else {
            return false;
        };

        if document_helpers::ensure_table_for_write(item).is_none() {
            *item = TomlItem::Table(document_helpers::new_implicit_table());
        }

        let Some(table) = item.as_table_mut() else {
            return false;
        };

        let keys_to_remove: Vec<String> = table
            .iter()
            .map(|(key, _)| key.to_string())
            .filter(|key| !servers.contains_key(key.as_str()))
            .collect();

        for key in keys_to_remove {
            table.remove(&key);
        }

        for (name, config) in servers {
            if let Some(existing) = table.get_mut(name.as_str()) {
                if let TomlItem::Value(value) = existing
                    && let Some(inline) = value.as_inline_table_mut()
                {
                    let replacement = document_helpers::serialize_mcp_server_inline(config);
                    document_helpers::merge_inline_table(inline, replacement);
                } else {
                    *existing = document_helpers::serialize_mcp_server(config);
                }
            } else {
                table.insert(name, document_helpers::serialize_mcp_server(config));
            }
        }

        true
    }

    fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool {
        let selector = match selector {
            SkillConfigSelector::Name(name) => SkillConfigSelector::Name(name.trim().to_string()),
            SkillConfigSelector::Path(path) => {
                SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(&path)))
            }
        };
        if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) {
            return false;
        }
        let mut remove_skills_table = false;
        let mut mutated = false;

        {
            let root = self.doc.as_table_mut();
            let skills_item = match root.get_mut("skills") {
                Some(item) => item,
                None => {
                    if enabled {
                        return false;
                    }
                    root.insert(
                        "skills",
                        TomlItem::Table(document_helpers::new_implicit_table()),
                    );
                    let Some(item) = root.get_mut("skills") else {
                        return false;
                    };
                    item
                }
            };

            if document_helpers::ensure_table_for_write(skills_item).is_none() {
                if enabled {
                    return false;
                }
                *skills_item = TomlItem::Table(document_helpers::new_implicit_table());
            }
            let Some(skills_table) = skills_item.as_table_mut() else {
                return false;
            };

            let config_item = match skills_table.get_mut("config") {
                Some(item) => item,
                None => {
                    if enabled {
                        return false;
                    }
                    skills_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new()));
                    let Some(item) = skills_table.get_mut("config") else {
                        return false;
                    };
                    item
                }
            };

            if !matches!(config_item, TomlItem::ArrayOfTables(_)) {
                if enabled {
                    return false;
                }
                *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new());
            }

            let TomlItem::ArrayOfTables(overrides) = config_item else {
                return false;
            };

            let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| {
                skill_config_selector_from_table(table)
                    .filter(|value| value == &selector)
                    .map(|_| idx)
            });

            if enabled {
                if let Some(index) = existing_index {
                    overrides.remove(index);
                    mutated = true;
                    if overrides.is_empty() {
                        skills_table.remove("config");
                        if skills_table.is_empty() {
                            remove_skills_table = true;
                        }
                    }
                }
            } else if let Some(index) = existing_index {
                for (idx, table) in overrides.iter_mut().enumerate() {
                    if idx == index {
                        write_skill_config_selector(table, &selector);
                        table["enabled"] = value(false);
                        mutated = true;
                        break;
                    }
                }
            } else {
                let mut entry = TomlTable::new();
                entry.set_implicit(false);
                write_skill_config_selector(&mut entry, &selector);
                entry["enabled"] = value(false);
                overrides.push(entry);
                mutated = true;
            }
        }

        if remove_skills_table {
            let root = self.doc.as_table_mut();
            root.remove("skills");
        }

        mutated
    }

    fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec<String> {
        let resolved: Vec<String> = segments
            .iter()
            .map(|segment| (*segment).to_string())
            .collect();

        if matches!(scope, Scope::Profile)
            && resolved.first().is_none_or(|segment| segment != "profiles")
            && let Some(profile) = self.profile.as_deref()
        {
            let mut scoped = Vec::with_capacity(resolved.len() + 2);
            scoped.push("profiles".to_string());
            scoped.push(profile.to_string());
            scoped.extend(resolved);
            return scoped;
        }

        resolved
    }

    fn insert(&mut self, segments: &[String], value: TomlItem) -> bool {
        let Some((last, parents)) = segments.split_last() else {
            return false;
        };

        let Some(parent) = self.descend(parents, TraversalMode::Create) else {
            return false;
        };

        let mut value = value;
        if let Some(existing) = parent.get(last) {
            Self::preserve_decor(existing, &mut value);
        }
        parent[last] = value;
        true
    }

    fn remove(&mut self, segments: &[String]) -> bool {
        let Some((last, parents)) = segments.split_last() else {
            return false;
        };

        let Some(parent) = self.descend(parents, TraversalMode::Existing) else {
            return false;
        };

        parent.remove(last).is_some()
    }

    fn descend(&mut self, segments: &[String], mode: TraversalMode) -> Option<&mut TomlTable> {
        let mut current = self.doc.as_table_mut();

        for segment in segments {
            match mode {
                TraversalMode::Create => {
                    if !current.contains_key(segment.as_str()) {
                        current.insert(
                            segment.as_str(),
                            TomlItem::Table(document_helpers::new_implicit_table()),
                        );
                    }

                    let item = current.get_mut(segment.as_str())?;
                    current = document_helpers::ensure_table_for_write(item)?;
                }
                TraversalMode::Existing => {
                    let item = current.get_mut(segment.as_str())?;
                    current = document_helpers::ensure_table_for_read(item)?;
                }
            }
        }

        Some(current)
    }

    fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) {
        match (existing, replacement) {
            (TomlItem::Table(existing_table), TomlItem::Table(replacement_table)) => {
                replacement_table
                    .decor_mut()
                    .clone_from(existing_table.decor());
                for (key, existing_item) in existing_table.iter() {
                    if let (Some(existing_key), Some(mut replacement_key)) =
                        (existing_table.key(key), replacement_table.key_mut(key))
                    {
                        replacement_key
                            .leaf_decor_mut()
                            .clone_from(existing_key.leaf_decor());
                        replacement_key
                            .dotted_decor_mut()
                            .clone_from(existing_key.dotted_decor());
                    }
                    if let Some(replacement_item) = replacement_table.get_mut(key) {
                        Self::preserve_decor(existing_item, replacement_item);
                    }
                }
            }
            (TomlItem::Value(existing_value), TomlItem::Value(replacement_value)) => {
                replacement_value
                    .decor_mut()
                    .clone_from(existing_value.decor());
            }
            _ => {}
        }
    }
}

fn normalize_skill_config_path(path: &Path) -> String {
    dunce::canonicalize(path)
        .unwrap_or_else(|_| path.to_path_buf())
        .to_string_lossy()
        .to_string()
}

fn skill_config_selector_from_table(table: &TomlTable) -> Option<SkillConfigSelector> {
    let path = table
        .get("path")
        .and_then(|item| item.as_str())
        .map(Path::new)
        .map(|path| SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(path))));
    let name = table
        .get("name")
        .and_then(|item| item.as_str())
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .map(|name| SkillConfigSelector::Name(name.to_string()));

    match (path, name) {
        (Some(selector), None) | (None, Some(selector)) => Some(selector),
        _ => None,
    }
}

fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSelector) {
    match selector {
        SkillConfigSelector::Name(name) => {
            table.remove("path");
            table["name"] = value(name.clone());
        }
        SkillConfigSelector::Path(path) => {
            table.remove("name");
            table["path"] = value(path.to_string_lossy().to_string());
        }
    }
}

/// Persist edits using a blocking strategy.
pub fn apply_blocking(
    codex_home: &Path,
    profile: Option<&str>,
    edits: &[ConfigEdit],
) -> anyhow::Result<()> {
    if edits.is_empty() {
        return Ok(());
    }

    let config_path = codex_home.join(CONFIG_TOML_FILE);
    let write_paths = resolve_symlink_write_paths(&config_path)?;
    let serialized = match write_paths.read_path {
        Some(path) => match std::fs::read_to_string(&path) {
            Ok(contents) => contents,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
            Err(err) => return Err(err.into()),
        },
        None => String::new(),
    };

    let doc = if serialized.is_empty() {
        DocumentMut::new()
    } else {
        serialized.parse::<DocumentMut>()?
    };

    let profile = profile.map(ToOwned::to_owned).or_else(|| {
        doc.get("profile")
            .and_then(|item| item.as_str())
            .map(ToOwned::to_owned)
    });

    let mut document = ConfigDocument::new(doc, profile);
    let mut mutated = false;

    for edit in edits {
        mutated |= document.apply(edit)?;
    }

    if !mutated {
        return Ok(());
    }

    write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| {
        format!(
            "failed to persist config.toml at {}",
            write_paths.write_path.display()
        )
    })?;

    Ok(())
}

/// Persist edits asynchronously by offloading the blocking writer.
pub async fn apply(
    codex_home: &Path,
    profile: Option<&str>,
    edits: Vec<ConfigEdit>,
) -> anyhow::Result<()> {
    let codex_home = codex_home.to_path_buf();
    let profile = profile.map(ToOwned::to_owned);
    task::spawn_blocking(move || apply_blocking(&codex_home, profile.as_deref(), &edits))
        .await
        .context("config persistence task panicked")?
}

/// Fluent builder to batch config edits and apply them atomically.
#[derive(Default)]
pub struct ConfigEditsBuilder {
    codex_home: PathBuf,
    profile: Option<String>,
    edits: Vec<ConfigEdit>,
}

impl ConfigEditsBuilder {
    pub fn new(codex_home: &Path) -> Self {
        Self {
            codex_home: codex_home.to_path_buf(),
            profile: None,
            edits: Vec::new(),
        }
    }

    pub fn with_profile(mut self, profile: Option<&str>) -> Self {
        self.profile = profile.map(ToOwned::to_owned);
        self
    }

    pub fn set_model(mut self, model: Option<&str>, effort: Option<ReasoningEffort>) -> Self {
        self.edits.push(ConfigEdit::SetModel {
            model: model.map(ToOwned::to_owned),
            effort,
        });
        self
    }

    pub fn set_service_tier(mut self, service_tier: Option<ServiceTier>) -> Self {
        self.edits.push(ConfigEdit::SetServiceTier { service_tier });
        self
    }

    pub fn set_personality(mut self, personality: Option<Personality>) -> Self {
        self.edits
            .push(ConfigEdit::SetModelPersonality { personality });
        self
    }

    pub fn set_hide_full_access_warning(mut self, acknowledged: bool) -> Self {
        self.edits
            .push(ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged));
        self
    }

    pub fn set_hide_world_writable_warning(mut self, acknowledged: bool) -> Self {
        self.edits
            .push(ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged));
        self
    }

    pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self {
        self.edits
            .push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out));
        self
    }

    pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
        self.edits
            .push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
        self
    }

    pub fn set_hide_model_migration_prompt(mut self, model: &str, acknowledged: bool) -> Self {
        self.edits
            .push(ConfigEdit::SetNoticeHideModelMigrationPrompt(
                model.to_string(),
                acknowledged,
            ));
        self
    }

    pub fn set_hide_external_config_migration_prompt_home(mut self, acknowledged: bool) -> Self {
        self.edits
            .push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
                acknowledged,
            ));
        self
    }

    pub fn set_hide_external_config_migration_prompt_project(
        mut self,
        project: &str,
        acknowledged: bool,
    ) -> Self {
        self.edits.push(
            ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
                project.to_string(),
                acknowledged,
            ),
        );
        self
    }

    pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self {
        self.edits.push(ConfigEdit::RecordModelMigrationSeen {
            from: from.to_string(),
            to: to.to_string(),
        });
        self
    }

    pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self {
        self.edits
            .push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged));
        self
    }

    pub fn set_model_availability_nux_count(mut self, shown_count: &HashMap<String, u32>) -> Self {
        self.edits
            .extend(model_availability_nux_count_edits(shown_count));
        self
    }

    pub fn replace_mcp_servers(mut self, servers: &BTreeMap<String, McpServerConfig>) -> Self {
        self.edits
            .push(ConfigEdit::ReplaceMcpServers(servers.clone()));
        self
    }

    pub fn set_project_trust_level<P: Into<PathBuf>>(
        mut self,
        project_path: P,
        trust_level: TrustLevel,
    ) -> Self {
        self.edits.push(ConfigEdit::SetProjectTrustLevel {
            path: project_path.into(),
            level: trust_level,
        });
        self
    }

    /// Enable or disable a feature flag by key under the `[features]` table.
    ///
    /// Disabling a default-false feature clears the root-scoped key instead of
    /// persisting `false`, so the config does not pin the feature once it
    /// graduates to globally enabled. Profile-scoped disables still persist
    /// `false` so they can override an inherited root enable.
    pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self {
        let profile_scoped = self.profile.is_some();
        let segments = if let Some(profile) = self.profile.as_ref() {
            vec![
                "profiles".to_string(),
                profile.clone(),
                "features".to_string(),
                key.to_string(),
            ]
        } else {
            vec!["features".to_string(), key.to_string()]
        };
        let is_default_false_feature = FEATURES
            .iter()
            .find(|spec| spec.key == key)
            .is_some_and(|spec| !spec.default_enabled);
        if enabled || profile_scoped || !is_default_false_feature {
            self.edits.push(ConfigEdit::SetPath {
                segments,
                value: value(enabled),
            });
        } else {
            self.edits.push(ConfigEdit::ClearPath { segments });
        }
        self
    }

    pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self {
        let segments = if let Some(profile) = self.profile.as_ref() {
            vec![
                "profiles".to_string(),
                profile.clone(),
                "windows".to_string(),
                "sandbox".to_string(),
            ]
        } else {
            vec!["windows".to_string(), "sandbox".to_string()]
        };
        self.edits.push(ConfigEdit::SetPath {
            segments,
            value: value(mode),
        });
        self
    }

    pub fn set_realtime_microphone(mut self, microphone: Option<&str>) -> Self {
        let segments = vec!["audio".to_string(), "microphone".to_string()];
        match microphone {
            Some(microphone) => self.edits.push(ConfigEdit::SetPath {
                segments,
                value: value(microphone),
            }),
            None => self.edits.push(ConfigEdit::ClearPath { segments }),
        }
        self
    }

    pub fn set_realtime_speaker(mut self, speaker: Option<&str>) -> Self {
        let segments = vec!["audio".to_string(), "speaker".to_string()];
        match speaker {
            Some(speaker) => self.edits.push(ConfigEdit::SetPath {
                segments,
                value: value(speaker),
            }),
            None => self.edits.push(ConfigEdit::ClearPath { segments }),
        }
        self
    }

    pub fn set_realtime_voice(mut self, voice: Option<&str>) -> Self {
        let segments = vec!["realtime".to_string(), "voice".to_string()];
        match voice {
            Some(voice) => self.edits.push(ConfigEdit::SetPath {
                segments,
                value: value(voice),
            }),
            None => self.edits.push(ConfigEdit::ClearPath { segments }),
        }
        self
    }

    pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self {
        for key in [
            "experimental_windows_sandbox",
            "elevated_windows_sandbox",
            "enable_experimental_windows_sandbox",
        ] {
            let mut segments = vec!["features".to_string(), key.to_string()];
            if let Some(profile) = self.profile.as_ref() {
                segments = vec![
                    "profiles".to_string(),
                    profile.clone(),
                    "features".to_string(),
                    key.to_string(),
                ];
            }
            self.edits.push(ConfigEdit::ClearPath { segments });
        }
        self
    }

    pub fn with_edits<I>(mut self, edits: I) -> Self
    where
        I: IntoIterator<Item = ConfigEdit>,
    {
        self.edits.extend(edits);
        self
    }

    /// Apply edits on a blocking thread.
    pub fn apply_blocking(self) -> anyhow::Result<()> {
        apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits)
    }

    /// Apply edits asynchronously via a blocking offload.
    pub async fn apply(self) -> anyhow::Result<()> {
        task::spawn_blocking(move || {
            apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits)
        })
        .await
        .context("config persistence task panicked")?
    }
}

#[cfg(test)]
#[path = "edit_tests.rs"]
mod tests;
