use std::time::Duration;

use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookHandlerType;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HookSource;
use codex_app_server_protocol::HookTrustStatus;
use codex_app_server_protocol::HooksListEntry;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::skip_if_windows;
use pretty_assertions::assert_eq;
use serde::Serialize;
use tempfile::TempDir;
use tokio::time::timeout;

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

#[derive(Serialize)]
struct NormalizedHookIdentity {
    event_name: &'static str,
    #[serde(flatten)]
    group: codex_config::MatcherGroup,
}

fn command_hook_hash(
    event_name: &'static str,
    matcher: Option<&str>,
    command: &str,
    timeout_sec: u64,
    status_message: Option<&str>,
) -> String {
    let identity = NormalizedHookIdentity {
        event_name,
        group: codex_config::MatcherGroup {
            matcher: matcher.map(ToOwned::to_owned),
            hooks: vec![codex_config::HookHandlerConfig::Command {
                command: command.to_string(),
                command_windows: None,
                timeout_sec: Some(timeout_sec),
                r#async: false,
                status_message: status_message.map(ToOwned::to_owned),
            }],
        },
    };
    let Ok(value) = codex_config::TomlValue::try_from(identity) else {
        unreachable!("normalized hook identity should serialize to TOML");
    };
    codex_config::version_for_toml(&value)
}

fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> {
    std::fs::write(
        codex_home.join("config.toml"),
        r#"[hooks]

[[hooks.PreToolUse]]
matcher = "Bash"

[[hooks.PreToolUse.hooks]]
type = "command"
command = "python3 /tmp/listed-hook.py"
timeout = 5
statusMessage = "running listed hook"
"#,
    )?;
    Ok(())
}

fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> Result<()> {
    let plugin_root = codex_home.join("plugins/cache/test/demo/local");
    std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
    std::fs::create_dir_all(plugin_root.join("hooks"))?;
    std::fs::write(
        plugin_root.join(".codex-plugin/plugin.json"),
        r#"{"name":"demo"}"#,
    )?;
    std::fs::write(plugin_root.join("hooks/hooks.json"), hooks_json)?;
    std::fs::write(
        codex_home.join("config.toml"),
        r#"[features]
plugins = true
hooks = true

[plugins."demo@test"]
enabled = true
"#,
    )?;
    Ok(())
}

fn write_project_hook_config(dot_codex_folder: &std::path::Path, command: &str) -> Result<()> {
    std::fs::create_dir_all(dot_codex_folder)?;
    std::fs::write(
        dot_codex_folder.join("config.toml"),
        format!(
            r#"[features]
hooks = true

[hooks]

[[hooks.PreToolUse]]
matcher = "Bash"

[[hooks.PreToolUse.hooks]]
type = "command"
command = "{command}"
timeout = 5
"#
        ),
    )?;
    Ok(())
}

#[tokio::test]
async fn hooks_list_shows_discovered_hook() -> Result<()> {
    let codex_home = TempDir::new()?;
    let cwd = TempDir::new()?;
    write_user_hook_config(codex_home.path())?;

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

    let request_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![cwd.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    let config_path = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
        codex_home.path().join("config.toml"),
    )?)?;
    assert_eq!(
        data,
        vec![HooksListEntry {
            cwd: cwd.path().to_path_buf(),
            hooks: vec![HookMetadata {
                key: format!("{}:pre_tool_use:0:0", config_path.as_path().display()),
                event_name: HookEventName::PreToolUse,
                handler_type: HookHandlerType::Command,
                matcher: Some("Bash".to_string()),
                command: Some("python3 /tmp/listed-hook.py".to_string()),
                timeout_sec: 5,
                status_message: Some("running listed hook".to_string()),
                source_path: config_path,
                source: HookSource::User,
                plugin_id: None,
                display_order: 0,
                enabled: true,
                is_managed: false,
                current_hash: command_hook_hash(
                    "pre_tool_use",
                    Some("Bash"),
                    "python3 /tmp/listed-hook.py",
                    /*timeout_sec*/ 5,
                    Some("running listed hook"),
                ),
                trust_status: HookTrustStatus::Untrusted,
            }],
            warnings: Vec::new(),
            errors: Vec::new(),
        }]
    );
    Ok(())
}

#[tokio::test]
async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
    let codex_home = TempDir::new()?;
    let cwd = TempDir::new()?;
    write_plugin_hook_config(
        codex_home.path(),
        r#"{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "echo plugin hook",
            "timeout": 7,
            "statusMessage": "running plugin hook"
          }
        ]
      }
    ]
  }
}"#,
    )?;

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

    let request_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![cwd.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    let plugin_hooks_path = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(
        codex_home
            .path()
            .join("plugins/cache/test/demo/local/hooks/hooks.json"),
    )?)?;
    assert_eq!(
        data,
        vec![HooksListEntry {
            cwd: cwd.path().to_path_buf(),
            hooks: vec![HookMetadata {
                key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(),
                event_name: HookEventName::PreToolUse,
                handler_type: HookHandlerType::Command,
                matcher: Some("Bash".to_string()),
                command: Some("echo plugin hook".to_string()),
                timeout_sec: 7,
                status_message: Some("running plugin hook".to_string()),
                source_path: plugin_hooks_path,
                source: HookSource::Plugin,
                plugin_id: Some("demo@test".to_string()),
                display_order: 0,
                enabled: true,
                is_managed: false,
                current_hash: command_hook_hash(
                    "pre_tool_use",
                    Some("Bash"),
                    "echo plugin hook",
                    /*timeout_sec*/ 7,
                    Some("running plugin hook"),
                ),
                trust_status: HookTrustStatus::Untrusted,
            }],
            warnings: Vec::new(),
            errors: Vec::new(),
        }]
    );
    Ok(())
}

#[tokio::test]
async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> {
    let codex_home = TempDir::new()?;
    let cwd = TempDir::new()?;
    write_plugin_hook_config(
        codex_home.path(),
        r#"{
  "hooks": {
    "PreToolUse": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "echo plugin hook"
          }
        ]
      }
    ]
  }
}"#,
    )?;
    let plugin_mcp_path = codex_home
        .path()
        .join("plugins/cache/test/demo/local/.mcp.json");
    std::fs::write(
        &plugin_mcp_path,
        r#"{
  "mcpServers": {
    "plugin-server": {
      "url": "http://127.0.0.1:1/mcp"
    }
  }
}"#,
    )?;

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

    let hooks_list_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![cwd.path().to_path_buf()],
        })
        .await?;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(hooks_list_id)),
    )
    .await??;

    std::fs::remove_file(plugin_mcp_path)?;

    let thread_start_id = mcp
        .send_thread_start_request(ThreadStartParams::default())
        .await?;
    let _: ThreadStartResponse = to_response(
        timeout(
            DEFAULT_TIMEOUT,
            mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
        )
        .await??,
    )?;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_matching_notification("plugin MCP server starting", |notification| {
            notification.method == "mcpServer/startupStatus/updated"
                && notification
                    .params
                    .as_ref()
                    .and_then(|params| params.get("name"))
                    .and_then(serde_json::Value::as_str)
                    == Some("plugin-server")
        }),
    )
    .await??;

    Ok(())
}

#[tokio::test]
async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> {
    let codex_home = TempDir::new()?;
    let cwd = TempDir::new()?;
    write_plugin_hook_config(codex_home.path(), "{ not-json")?;

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

    let request_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![cwd.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;

    assert_eq!(data.len(), 1);
    assert_eq!(data[0].hooks, Vec::new());
    assert_eq!(data[0].warnings.len(), 1);
    assert!(
        data[0].warnings[0].contains("failed to parse plugin hooks config"),
        "unexpected warnings: {:?}",
        data[0].warnings
    );
    Ok(())
}

#[tokio::test]
async fn hooks_list_uses_each_cwds_effective_feature_enablement() -> Result<()> {
    let codex_home = TempDir::new()?;
    let workspace = TempDir::new()?;
    std::fs::write(
        codex_home.path().join("config.toml"),
        r#"[features]
hooks = false
"#,
    )?;
    std::fs::create_dir_all(workspace.path().join(".git"))?;
    std::fs::create_dir_all(workspace.path().join(".codex"))?;
    std::fs::write(
        workspace.path().join(".codex/config.toml"),
        r#"[features]
hooks = true

[hooks]

[[hooks.PreToolUse]]
matcher = "Bash"

[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo project hook"
timeout = 5
"#,
    )?;
    set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;

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

    let request_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![
                codex_home.path().to_path_buf(),
                workspace.path().to_path_buf(),
            ],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    let project_config_path =
        AbsolutePathBuf::try_from(workspace.path().join(".codex/config.toml"))?;
    assert_eq!(
        data,
        vec![
            HooksListEntry {
                cwd: codex_home.path().to_path_buf(),
                hooks: Vec::new(),
                warnings: Vec::new(),
                errors: Vec::new(),
            },
            HooksListEntry {
                cwd: workspace.path().to_path_buf(),
                hooks: vec![HookMetadata {
                    key: format!(
                        "{}:pre_tool_use:0:0",
                        project_config_path.as_path().display()
                    ),
                    event_name: HookEventName::PreToolUse,
                    handler_type: HookHandlerType::Command,
                    matcher: Some("Bash".to_string()),
                    command: Some("echo project hook".to_string()),
                    timeout_sec: 5,
                    status_message: None,
                    source_path: project_config_path,
                    source: HookSource::Project,
                    plugin_id: None,
                    display_order: 0,
                    enabled: true,
                    is_managed: false,
                    current_hash: command_hook_hash(
                        "pre_tool_use",
                        Some("Bash"),
                        "echo project hook",
                        /*timeout_sec*/ 5,
                        /*status_message*/ None,
                    ),
                    trust_status: HookTrustStatus::Untrusted,
                }],
                warnings: Vec::new(),
                errors: Vec::new(),
            },
        ]
    );
    Ok(())
}

#[tokio::test]
async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> {
    let codex_home = TempDir::new()?;
    let workspace = TempDir::new()?;
    let repo_root = workspace.path().join("repo");
    let worktree_root = workspace.path().join("worktree");
    let worktree_git_dir = repo_root.join(".git/worktrees/feature-x");

    std::fs::create_dir_all(&worktree_git_dir)?;
    std::fs::create_dir_all(&worktree_root)?;
    std::fs::write(
        worktree_root.join(".git"),
        format!("gitdir: {}\n", worktree_git_dir.display()),
    )?;
    write_project_hook_config(&repo_root.join(".codex"), "echo root hook")?;
    write_project_hook_config(&worktree_root.join(".codex"), "echo worktree hook")?;
    set_project_trust_level(codex_home.path(), &repo_root, TrustLevel::Trusted)?;

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

    let list_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![repo_root.clone(), worktree_root.clone()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    let repo_hook = data[0].hooks[0].clone();
    let worktree_hook = data[1].hooks[0].clone();
    let repo_config_path =
        AbsolutePathBuf::from_absolute_path(repo_root.join(".codex/config.toml"))?;

    assert_eq!(repo_hook.command.as_deref(), Some("echo root hook"));
    assert_eq!(worktree_hook.command.as_deref(), Some("echo root hook"));
    assert_eq!(repo_hook.key, worktree_hook.key);
    assert_eq!(repo_hook.source_path, repo_config_path);
    assert_eq!(worktree_hook.source_path, repo_config_path);

    let write_id = mcp
        .send_config_batch_write_request(ConfigBatchWriteParams {
            edits: vec![ConfigEdit {
                key_path: "hooks.state".to_string(),
                value: serde_json::json!({
                    repo_hook.key.clone(): {
                        "trusted_hash": repo_hook.current_hash.clone()
                    }
                }),
                merge_strategy: MergeStrategy::Upsert,
            }],
            file_path: None,
            expected_version: None,
            reload_user_config: true,
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
    )
    .await??;
    let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;

    let list_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![worktree_root],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    assert_eq!(data[0].hooks[0].trust_status, HookTrustStatus::Trusted);

    Ok(())
}

#[tokio::test]
async fn config_batch_write_toggles_user_hook() -> Result<()> {
    let codex_home = TempDir::new()?;
    let cwd = TempDir::new()?;
    write_user_hook_config(codex_home.path())?;

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

    let request_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![cwd.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    let hook = &data[0].hooks[0];
    assert_eq!(hook.enabled, true);

    let write_id = mcp
        .send_config_batch_write_request(ConfigBatchWriteParams {
            edits: vec![ConfigEdit {
                key_path: "hooks.state".to_string(),
                value: serde_json::json!({
                    hook.key.clone(): {
                        "enabled": false
                    }
                }),
                merge_strategy: MergeStrategy::Upsert,
            }],
            file_path: None,
            expected_version: None,
            reload_user_config: true,
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
    )
    .await??;
    let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;

    let request_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![cwd.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    assert_eq!(data[0].hooks.len(), 1);
    assert_eq!(data[0].hooks[0].key, hook.key);
    assert_eq!(data[0].hooks[0].enabled, false);

    let write_id = mcp
        .send_config_batch_write_request(ConfigBatchWriteParams {
            edits: vec![ConfigEdit {
                key_path: "hooks.state".to_string(),
                value: serde_json::json!({
                    hook.key.clone(): {
                        "enabled": true
                    }
                }),
                merge_strategy: MergeStrategy::Upsert,
            }],
            file_path: None,
            expected_version: None,
            reload_user_config: true,
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
    )
    .await??;
    let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;

    let request_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![cwd.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    assert_eq!(data[0].hooks[0].enabled, true);
    Ok(())
}

#[tokio::test]
async fn config_batch_write_updates_hook_trust_for_loaded_session() -> Result<()> {
    skip_if_windows!(Ok(()));

    let responses = vec![
        create_final_assistant_message_sse_response("Warmup")?,
        create_final_assistant_message_sse_response("Untrusted turn")?,
        create_final_assistant_message_sse_response("Trusted turn")?,
        create_final_assistant_message_sse_response("Modified turn")?,
    ];
    let server = create_mock_responses_server_sequence_unchecked(responses).await;
    let codex_home = TempDir::new()?;
    let hook_script_path = codex_home.path().join("user_prompt_submit_hook.py");
    let hook_log_path = codex_home.path().join("user_prompt_submit_hook_log.jsonl");
    std::fs::write(
        &hook_script_path,
        format!(
            r#"import json
from pathlib import Path
import sys

payload = json.load(sys.stdin)
with Path(r"{hook_log_path}").open("a", encoding="utf-8") as handle:
    handle.write(json.dumps(payload) + "\n")
"#,
            hook_log_path = hook_log_path.display(),
        ),
    )?;
    std::fs::write(
        codex_home.path().join("config.toml"),
        format!(
            r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"

model_provider = "mock_provider"

[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0

[hooks]

[[hooks.UserPromptSubmit]]

[[hooks.UserPromptSubmit.hooks]]
type = "command"
command = "python3 {hook_script_path}"
"#,
            server_uri = server.uri(),
            hook_script_path = hook_script_path.display(),
        ),
    )?;

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

    let hook_list_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![codex_home.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    let hook = data[0].hooks[0].clone();
    assert_eq!(hook.trust_status, HookTrustStatus::Untrusted);

    let thread_start_id = mcp
        .send_thread_start_request(ThreadStartParams {
            model: Some("mock-model".to_string()),
            ..Default::default()
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
    )
    .await??;
    let ThreadStartResponse { thread, .. } = to_response(response)?;

    let first_turn_id = mcp
        .send_turn_start_request(TurnStartParams {
            thread_id: thread.id.clone(),
            client_user_message_id: None,
            input: vec![V2UserInput::Text {
                text: "first turn".to_string(),
                text_elements: Vec::new(),
            }],
            ..Default::default()
        })
        .await?;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)),
    )
    .await??;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_notification_message("turn/completed"),
    )
    .await??;
    assert!(!std::fs::exists(&hook_log_path)?);

    let write_id = mcp
        .send_config_batch_write_request(ConfigBatchWriteParams {
            edits: vec![ConfigEdit {
                key_path: "hooks.state".to_string(),
                value: serde_json::json!({
                    hook.key.clone(): {
                        "trusted_hash": hook.current_hash.clone()
                    }
                }),
                merge_strategy: MergeStrategy::Upsert,
            }],
            file_path: None,
            expected_version: None,
            reload_user_config: true,
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
    )
    .await??;
    let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;

    let hook_list_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![codex_home.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    let trusted_hook = &data[0].hooks[0];
    assert_eq!(trusted_hook.key, hook.key);
    assert_eq!(trusted_hook.current_hash, hook.current_hash);
    assert_eq!(trusted_hook.trust_status, HookTrustStatus::Trusted);

    let second_turn_id = mcp
        .send_turn_start_request(TurnStartParams {
            thread_id: thread.id.clone(),
            client_user_message_id: None,
            input: vec![V2UserInput::Text {
                text: "second turn".to_string(),
                text_elements: Vec::new(),
            }],
            ..Default::default()
        })
        .await?;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)),
    )
    .await??;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_notification_message("turn/completed"),
    )
    .await??;
    assert_eq!(
        std::fs::read_to_string(&hook_log_path)?
            .lines()
            .filter(|line| !line.is_empty())
            .count(),
        1
    );

    let write_id = mcp
        .send_config_batch_write_request(ConfigBatchWriteParams {
            edits: vec![ConfigEdit {
                key_path: "hooks.UserPromptSubmit".to_string(),
                value: serde_json::json!([{
                    "hooks": [{
                        "type": "command",
                        "command": format!("python3 {}", hook_script_path.display()),
                        "statusMessage": "modified hook",
                    }],
                }]),
                merge_strategy: MergeStrategy::Replace,
            }],
            file_path: None,
            expected_version: None,
            reload_user_config: true,
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
    )
    .await??;
    let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;

    let hook_list_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![codex_home.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    let modified_hook = &data[0].hooks[0];
    assert_eq!(modified_hook.key, hook.key);
    assert_ne!(modified_hook.current_hash, hook.current_hash);
    assert_eq!(modified_hook.trust_status, HookTrustStatus::Modified);

    let third_turn_id = mcp
        .send_turn_start_request(TurnStartParams {
            thread_id: thread.id,
            client_user_message_id: None,
            input: vec![V2UserInput::Text {
                text: "third turn".to_string(),
                text_elements: Vec::new(),
            }],
            ..Default::default()
        })
        .await?;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(third_turn_id)),
    )
    .await??;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_notification_message("turn/completed"),
    )
    .await??;
    assert_eq!(
        std::fs::read_to_string(&hook_log_path)?
            .lines()
            .filter(|line| !line.is_empty())
            .count(),
        1
    );
    Ok(())
}

#[tokio::test]
async fn config_batch_write_disables_hook_for_loaded_session() -> Result<()> {
    skip_if_windows!(Ok(()));

    let responses = vec![
        create_final_assistant_message_sse_response("Warmup")?,
        create_final_assistant_message_sse_response("First turn")?,
        create_final_assistant_message_sse_response("Second turn")?,
    ];
    let server = create_mock_responses_server_sequence_unchecked(responses).await;
    let codex_home = TempDir::new()?;
    let hook_script_path = codex_home.path().join("user_prompt_submit_hook.py");
    let hook_log_path = codex_home.path().join("user_prompt_submit_hook_log.jsonl");
    std::fs::write(
        &hook_script_path,
        format!(
            r#"import json
from pathlib import Path
import sys

payload = json.load(sys.stdin)
with Path(r"{hook_log_path}").open("a", encoding="utf-8") as handle:
    handle.write(json.dumps(payload) + "\n")
"#,
            hook_log_path = hook_log_path.display(),
        ),
    )?;
    std::fs::write(
        codex_home.path().join("config.toml"),
        format!(
            r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"

model_provider = "mock_provider"

[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0

[hooks]

[[hooks.UserPromptSubmit]]

[[hooks.UserPromptSubmit.hooks]]
type = "command"
command = "python3 {hook_script_path}"
"#,
            server_uri = server.uri(),
            hook_script_path = hook_script_path.display(),
        ),
    )?;

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

    let hook_list_id = mcp
        .send_hooks_list_request(HooksListParams {
            cwds: vec![codex_home.path().to_path_buf()],
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)),
    )
    .await??;
    let HooksListResponse { data } = to_response(response)?;
    let hook = &data[0].hooks[0];
    assert_eq!(hook.enabled, true);

    let write_id = mcp
        .send_config_batch_write_request(ConfigBatchWriteParams {
            edits: vec![ConfigEdit {
                key_path: "hooks.state".to_string(),
                value: serde_json::json!({
                    hook.key.clone(): {
                        "trusted_hash": hook.current_hash.clone()
                    }
                }),
                merge_strategy: MergeStrategy::Upsert,
            }],
            file_path: None,
            expected_version: None,
            reload_user_config: true,
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
    )
    .await??;
    let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;

    let thread_start_id = mcp
        .send_thread_start_request(ThreadStartParams {
            model: Some("mock-model".to_string()),
            ..Default::default()
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
    )
    .await??;
    let ThreadStartResponse { thread, .. } = to_response(response)?;

    let first_turn_id = mcp
        .send_turn_start_request(TurnStartParams {
            thread_id: thread.id.clone(),
            client_user_message_id: None,
            input: vec![V2UserInput::Text {
                text: "first turn".to_string(),
                text_elements: Vec::new(),
            }],
            ..Default::default()
        })
        .await?;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)),
    )
    .await??;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_notification_message("turn/completed"),
    )
    .await??;
    assert_eq!(
        std::fs::read_to_string(&hook_log_path)?
            .lines()
            .filter(|line| !line.is_empty())
            .count(),
        1
    );

    let write_id = mcp
        .send_config_batch_write_request(ConfigBatchWriteParams {
            edits: vec![ConfigEdit {
                key_path: "hooks.state".to_string(),
                value: serde_json::json!({
                    hook.key.clone(): {
                        "enabled": false
                    }
                }),
                merge_strategy: MergeStrategy::Upsert,
            }],
            file_path: None,
            expected_version: None,
            reload_user_config: true,
        })
        .await?;
    let response: JSONRPCResponse = timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
    )
    .await??;
    let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;

    let second_turn_id = mcp
        .send_turn_start_request(TurnStartParams {
            thread_id: thread.id,
            client_user_message_id: None,
            input: vec![V2UserInput::Text {
                text: "second turn".to_string(),
                text_elements: Vec::new(),
            }],
            ..Default::default()
        })
        .await?;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)),
    )
    .await??;
    timeout(
        DEFAULT_TIMEOUT,
        mcp.read_stream_until_notification_message("turn/completed"),
    )
    .await??;
    assert_eq!(
        std::fs::read_to_string(&hook_log_path)?
            .lines()
            .filter(|line| !line.is_empty())
            .count(),
        1
    );
    Ok(())
}
