use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::test_path_buf_with_windows;
use app_test_support::test_tmp_path_buf;
use app_test_support::to_response;
use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppToolApproval;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ToolsV2;
use codex_app_server_protocol::WriteStatus;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchLocation;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;

// Bazel CI can spend tens of seconds starting app-server subprocesses or
// processing config RPCs under load.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);

fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> {
    Ok(std::fs::write(
        codex_home.path().join("config.toml"),
        contents,
    )?)
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_returns_effective_and_layers() -> Result<()> {
    let codex_home = TempDir::new()?;
    write_config(
        &codex_home,
        r#"
model = "gpt-user"
sandbox_mode = "workspace-write"
"#,
    )?;
    let codex_home_path = codex_home.path().canonicalize()?;
    let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;

    let mut mcp = McpProcess::new(codex_home.path()).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let request_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: true,
            cwd: None,
        })
        .await?;
    let resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let ConfigReadResponse {
        config,
        origins,
        layers,
    } = to_response(resp)?;

    assert_eq!(config.model.as_deref(), Some("gpt-user"));
    assert_eq!(
        origins.get("model").expect("origin").name,
        ConfigLayerSource::User {
            file: user_file.clone(),
        }
    );
    let layers = layers.expect("layers present");
    assert_layers_user_then_optional_system(&layers, user_file)?;

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_tools() -> Result<()> {
    let codex_home = TempDir::new()?;
    write_config(
        &codex_home,
        r#"
model = "gpt-user"

[tools.web_search]
context_size = "low"
allowed_domains = ["example.com"]

[tools]
view_image = false
"#,
    )?;
    let codex_home_path = codex_home.path().canonicalize()?;
    let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;

    let mut mcp = McpProcess::new(codex_home.path()).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let request_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: true,
            cwd: None,
        })
        .await?;
    let resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let ConfigReadResponse {
        config,
        origins,
        layers,
    } = to_response(resp)?;

    let tools = config.tools.expect("tools present");
    assert_eq!(
        tools,
        ToolsV2 {
            web_search: Some(WebSearchToolConfig {
                context_size: Some(WebSearchContextSize::Low),
                allowed_domains: Some(vec!["example.com".to_string()]),
                location: None,
            }),
            view_image: Some(false),
        }
    );
    assert_eq!(
        origins
            .get("tools.web_search.context_size")
            .expect("origin")
            .name,
        ConfigLayerSource::User {
            file: user_file.clone(),
        }
    );
    assert_eq!(
        origins
            .get("tools.web_search.allowed_domains.0")
            .expect("origin")
            .name,
        ConfigLayerSource::User {
            file: user_file.clone(),
        }
    );
    assert_eq!(
        origins.get("tools.view_image").expect("origin").name,
        ConfigLayerSource::User {
            file: user_file.clone(),
        }
    );

    let layers = layers.expect("layers present");
    assert_layers_user_then_optional_system(&layers, user_file)?;

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_nested_web_search_tool_config() -> Result<()> {
    let codex_home = TempDir::new()?;
    write_config(
        &codex_home,
        r#"
web_search = "live"

[tools.web_search]
context_size = "high"
allowed_domains = ["example.com"]
location = { country = "US", city = "New York", timezone = "America/New_York" }
"#,
    )?;

    let mut mcp = McpProcess::new(codex_home.path()).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let request_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: false,
            cwd: None,
        })
        .await?;
    let resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let ConfigReadResponse { config, .. } = to_response(resp)?;

    assert_eq!(
        config.tools.expect("tools present").web_search,
        Some(WebSearchToolConfig {
            context_size: Some(WebSearchContextSize::High),
            allowed_domains: Some(vec!["example.com".to_string()]),
            location: Some(WebSearchLocation {
                country: Some("US".to_string()),
                region: None,
                city: Some("New York".to_string()),
                timezone: Some("America/New_York".to_string()),
            }),
        }),
    );

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_ignores_bool_web_search_tool_config() -> Result<()> {
    let codex_home = TempDir::new()?;
    write_config(
        &codex_home,
        r#"
[tools]
web_search = true
"#,
    )?;

    let mut mcp = McpProcess::new(codex_home.path()).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let request_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: false,
            cwd: None,
        })
        .await?;
    let resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let ConfigReadResponse { config, .. } = to_response(resp)?;

    assert_eq!(config.tools.expect("tools present").web_search, None,);

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_apps() -> Result<()> {
    let codex_home = TempDir::new()?;
    write_config(
        &codex_home,
        r#"
[apps.app1]
enabled = false
destructive_enabled = false
default_tools_approval_mode = "prompt"
"#,
    )?;
    let codex_home_path = codex_home.path().canonicalize()?;
    let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;

    let mut mcp = McpProcess::new(codex_home.path()).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let request_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: true,
            cwd: None,
        })
        .await?;
    let resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let ConfigReadResponse {
        config,
        origins,
        layers,
    } = to_response(resp)?;

    assert_eq!(
        config.apps,
        Some(AppsConfig {
            default: None,
            apps: std::collections::HashMap::from([(
                "app1".to_string(),
                AppConfig {
                    enabled: false,
                    destructive_enabled: Some(false),
                    open_world_enabled: None,
                    default_tools_approval_mode: Some(AppToolApproval::Prompt),
                    default_tools_enabled: None,
                    tools: None,
                },
            )]),
        })
    );
    assert_eq!(
        origins.get("apps.app1.enabled").expect("origin").name,
        ConfigLayerSource::User {
            file: user_file.clone(),
        }
    );
    assert_eq!(
        origins
            .get("apps.app1.destructive_enabled")
            .expect("origin")
            .name,
        ConfigLayerSource::User {
            file: user_file.clone(),
        }
    );
    assert_eq!(
        origins
            .get("apps.app1.default_tools_approval_mode")
            .expect("origin")
            .name,
        ConfigLayerSource::User {
            file: user_file.clone(),
        }
    );

    let layers = layers.expect("layers present");
    assert_layers_user_then_optional_system(&layers, user_file)?;

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_project_layers_for_cwd() -> Result<()> {
    let codex_home = TempDir::new()?;
    write_config(&codex_home, r#"model = "gpt-user""#)?;

    let workspace = TempDir::new()?;
    let project_config_dir = workspace.path().join(".codex");
    std::fs::create_dir_all(&project_config_dir)?;
    std::fs::write(
        project_config_dir.join("config.toml"),
        r#"
model_reasoning_effort = "high"
"#,
    )?;
    set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
    let project_config = AbsolutePathBuf::try_from(project_config_dir)?;

    let mut mcp = McpProcess::new(codex_home.path()).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let request_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: true,
            cwd: Some(workspace.path().to_string_lossy().into_owned()),
        })
        .await?;
    let resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let ConfigReadResponse {
        config, origins, ..
    } = to_response(resp)?;

    assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
    assert_eq!(
        origins.get("model_reasoning_effort").expect("origin").name,
        ConfigLayerSource::Project {
            dot_codex_folder: project_config
        }
    );

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_system_layer_and_overrides() -> Result<()> {
    let codex_home = TempDir::new()?;
    let user_dir = test_path_buf_with_windows("/user", Some(r"C:\Users\user"));
    let system_dir = test_path_buf_with_windows("/system", Some(r"C:\System"));
    write_config(
        &codex_home,
        &format!(
            r#"
model = "gpt-user"
approval_policy = "on-request"
sandbox_mode = "workspace-write"

[sandbox_workspace_write]
writable_roots = [{}]
network_access = true
"#,
            serde_json::json!(user_dir)
        ),
    )?;
    let codex_home_path = codex_home.path().canonicalize()?;
    let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;

    let managed_path = codex_home.path().join("managed_config.toml");
    let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?;
    std::fs::write(
        &managed_path,
        format!(
            r#"
model = "gpt-system"
approval_policy = "never"

[sandbox_workspace_write]
writable_roots = [{}]
"#,
            serde_json::json!(system_dir.clone())
        ),
    )?;

    let managed_path_str = managed_path.display().to_string();

    let mut mcp = McpProcess::new_with_env(
        codex_home.path(),
        &[(
            "CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
            Some(&managed_path_str),
        )],
    )
    .await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let request_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: true,
            cwd: None,
        })
        .await?;
    let resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let ConfigReadResponse {
        config,
        origins,
        layers,
    } = to_response(resp)?;

    assert_eq!(config.model.as_deref(), Some("gpt-system"));
    assert_eq!(
        origins.get("model").expect("origin").name,
        ConfigLayerSource::LegacyManagedConfigTomlFromFile {
            file: managed_file.clone(),
        }
    );

    assert_eq!(config.approval_policy, Some(AskForApproval::Never));
    assert_eq!(
        origins.get("approval_policy").expect("origin").name,
        ConfigLayerSource::LegacyManagedConfigTomlFromFile {
            file: managed_file.clone(),
        }
    );

    assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite));
    assert_eq!(
        origins.get("sandbox_mode").expect("origin").name,
        ConfigLayerSource::User {
            file: user_file.clone(),
        }
    );

    let sandbox = config
        .sandbox_workspace_write
        .as_ref()
        .expect("sandbox workspace write");
    assert_eq!(sandbox.writable_roots, vec![system_dir]);
    assert_eq!(
        origins
            .get("sandbox_workspace_write.writable_roots.0")
            .expect("origin")
            .name,
        ConfigLayerSource::LegacyManagedConfigTomlFromFile {
            file: managed_file.clone(),
        }
    );

    assert!(sandbox.network_access);
    assert_eq!(
        origins
            .get("sandbox_workspace_write.network_access")
            .expect("origin")
            .name,
        ConfigLayerSource::User {
            file: user_file.clone(),
        }
    );

    let layers = layers.expect("layers present");
    assert_layers_managed_user_then_optional_system(&layers, managed_file, user_file)?;

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_replaces_value() -> Result<()> {
    let temp_dir = TempDir::new()?;
    let codex_home = temp_dir.path().canonicalize()?;
    write_config(
        &temp_dir,
        r#"
model = "gpt-old"
"#,
    )?;

    let mut mcp = McpProcess::new(&codex_home).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let read_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: false,
            cwd: None,
        })
        .await?;
    let read_resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
    )
    .await??;
    let read: ConfigReadResponse = to_response(read_resp)?;
    let expected_version = read.origins.get("model").map(|m| m.version.clone());

    let write_id = mcp
        .send_config_value_write_request(ConfigValueWriteParams {
            file_path: None,
            key_path: "model".to_string(),
            value: json!("gpt-new"),
            merge_strategy: MergeStrategy::Replace,
            expected_version,
        })
        .await?;
    let write_resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
    )
    .await??;
    let write: ConfigWriteResponse = to_response(write_resp)?;
    let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home);

    assert_eq!(write.status, WriteStatus::Ok);
    assert_eq!(write.file_path, expected_file_path);
    assert!(write.overridden_metadata.is_none());

    let verify_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: false,
            cwd: None,
        })
        .await?;
    let verify_resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(verify_id)),
    )
    .await??;
    let verify: ConfigReadResponse = to_response(verify_resp)?;
    assert_eq!(verify.config.model.as_deref(), Some("gpt-new"));

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_after_pipelined_write_sees_written_value() -> Result<()> {
    let temp_dir = TempDir::new()?;
    let codex_home = temp_dir.path().canonicalize()?;
    write_config(
        &temp_dir,
        r#"
model = "gpt-old"
"#,
    )?;

    let mut mcp = McpProcess::new(&codex_home).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let write_id = mcp
        .send_config_value_write_request(ConfigValueWriteParams {
            file_path: None,
            key_path: "model".to_string(),
            value: json!("gpt-new"),
            merge_strategy: MergeStrategy::Replace,
            expected_version: None,
        })
        .await?;
    let read_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: false,
            cwd: None,
        })
        .await?;

    let write_resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
    )
    .await??;
    let write: ConfigWriteResponse = to_response(write_resp)?;
    assert_eq!(write.status, WriteStatus::Ok);

    let read_resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
    )
    .await??;
    let read: ConfigReadResponse = to_response(read_resp)?;
    assert_eq!(read.config.model.as_deref(), Some("gpt-new"));

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_rejects_version_conflict() -> Result<()> {
    let codex_home = TempDir::new()?;
    write_config(
        &codex_home,
        r#"
model = "gpt-old"
"#,
    )?;

    let mut mcp = McpProcess::new(codex_home.path()).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let write_id = mcp
        .send_config_value_write_request(ConfigValueWriteParams {
            file_path: Some(codex_home.path().join("config.toml").display().to_string()),
            key_path: "model".to_string(),
            value: json!("gpt-new"),
            merge_strategy: MergeStrategy::Replace,
            expected_version: Some("sha256:stale".to_string()),
        })
        .await?;

    let err: JSONRPCError = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_error_message(RequestId::Integer(write_id)),
    )
    .await??;
    let code = err
        .error
        .data
        .as_ref()
        .and_then(|d| d.get("config_write_error_code"))
        .and_then(|v| v.as_str());
    assert_eq!(code, Some("configVersionConflict"));

    Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_batch_write_applies_multiple_edits() -> Result<()> {
    let tmp_dir = TempDir::new()?;
    let codex_home = tmp_dir.path().canonicalize()?;
    write_config(&tmp_dir, "")?;

    let mut mcp = McpProcess::new(&codex_home).await?;
    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

    let writable_root = test_tmp_path_buf();
    let batch_id = mcp
        .send_config_batch_write_request(ConfigBatchWriteParams {
            file_path: Some(codex_home.join("config.toml").display().to_string()),
            edits: vec![
                ConfigEdit {
                    key_path: "sandbox_mode".to_string(),
                    value: json!("workspace-write"),
                    merge_strategy: MergeStrategy::Replace,
                },
                ConfigEdit {
                    key_path: "sandbox_workspace_write".to_string(),
                    value: json!({
                        "writable_roots": [writable_root.clone()],
                        "network_access": false
                    }),
                    merge_strategy: MergeStrategy::Replace,
                },
            ],
            expected_version: None,
            reload_user_config: false,
        })
        .await?;
    let batch_resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(batch_id)),
    )
    .await??;
    let batch_write: ConfigWriteResponse = to_response(batch_resp)?;
    assert_eq!(batch_write.status, WriteStatus::Ok);
    let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home);
    assert_eq!(batch_write.file_path, expected_file_path);

    let read_id = mcp
        .send_config_read_request(ConfigReadParams {
            include_layers: false,
            cwd: None,
        })
        .await?;
    let read_resp: JSONRPCResponse = timeout(
        DEFAULT_READ_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
    )
    .await??;
    let read: ConfigReadResponse = to_response(read_resp)?;
    assert_eq!(read.config.sandbox_mode, Some(SandboxMode::WorkspaceWrite));
    let sandbox = read
        .config
        .sandbox_workspace_write
        .as_ref()
        .expect("sandbox workspace write");
    assert_eq!(sandbox.writable_roots, vec![writable_root]);
    assert!(!sandbox.network_access);

    Ok(())
}

fn assert_layers_user_then_optional_system(
    layers: &[codex_app_server_protocol::ConfigLayer],
    user_file: AbsolutePathBuf,
) -> Result<()> {
    let mut first_index = 0;
    if matches!(
        layers.first().map(|layer| &layer.name),
        Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm)
    ) {
        first_index = 1;
    }
    assert_eq!(layers.len(), first_index + 2);
    assert_eq!(
        layers[first_index].name,
        ConfigLayerSource::User { file: user_file }
    );
    assert!(matches!(
        layers[first_index + 1].name,
        ConfigLayerSource::System { .. }
    ));
    Ok(())
}

fn assert_layers_managed_user_then_optional_system(
    layers: &[codex_app_server_protocol::ConfigLayer],
    managed_file: AbsolutePathBuf,
    user_file: AbsolutePathBuf,
) -> Result<()> {
    let mut first_index = 0;
    if matches!(
        layers.first().map(|layer| &layer.name),
        Some(ConfigLayerSource::LegacyManagedConfigTomlFromMdm)
    ) {
        first_index = 1;
    }
    assert_eq!(layers.len(), first_index + 3);
    assert_eq!(
        layers[first_index].name,
        ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
    );
    assert_eq!(
        layers[first_index + 1].name,
        ConfigLayerSource::User { file: user_file }
    );
    assert!(matches!(
        layers[first_index + 2].name,
        ConfigLayerSource::System { .. }
    ));
    Ok(())
}
