use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
pub use crate::cli_agent_runtime::AgentStreamRequest;
use std::path::{Path, PathBuf};
use std::process::Stdio;

pub fn check_cli() -> AiAgentAvailability {
    codex_availability_from_binary_result(find_codex_binary())
}

pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
    F: FnMut(AiAgentStreamEvent),
{
    let binary = find_codex_binary()?;
    run_agent_stream_with_binary(&binary, request, emit)
}

fn find_codex_binary() -> Result<PathBuf, String> {
    if let Some(binary) = find_codex_binary_on_path() {
        return Ok(binary);
    }

    if let Some(binary) = find_codex_binary_in_user_shell() {
        return Ok(binary);
    }

    if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate(
        codex_binary_candidates(),
        "Codex CLI",
    )? {
        return Ok(binary);
    }

    Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
}

fn codex_availability_from_binary_result(
    binary_result: Result<PathBuf, String>,
) -> AiAgentAvailability {
    match binary_result {
        Ok(binary) => AiAgentAvailability {
            installed: true,
            version: crate::cli_agent_runtime::version_for_binary(&binary),
        },
        Err(_) => AiAgentAvailability {
            installed: false,
            version: None,
        },
    }
}

fn find_codex_binary_on_path() -> Option<PathBuf> {
    crate::hidden_command(codex_path_lookup_command())
        .arg("codex")
        .output()
        .ok()
        .and_then(|output| path_from_successful_output(&output))
}

fn codex_path_lookup_command() -> &'static str {
    if cfg!(windows) {
        "where"
    } else {
        "which"
    }
}

fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
    user_shell_candidates()
        .into_iter()
        .filter(|shell| shell.exists())
        .find_map(|shell| codex_path_from_shell(&shell))
}

fn user_shell_candidates() -> Vec<PathBuf> {
    let mut shells = Vec::new();
    if let Some(shell) = std::env::var_os("SHELL") {
        if !shell.is_empty() {
            shells.push(PathBuf::from(shell));
        }
    }
    shells.push(PathBuf::from("/bin/zsh"));
    shells.push(PathBuf::from("/bin/bash"));
    shells
}

fn codex_path_from_shell(shell: &Path) -> Option<PathBuf> {
    crate::hidden_command(shell)
        .arg("-lc")
        .arg("command -v codex")
        .output()
        .ok()
        .and_then(|output| path_from_successful_output(&output))
}

fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
    if output.status.success() {
        first_existing_path(&String::from_utf8_lossy(&output.stdout))
    } else {
        None
    }
}

fn first_existing_path(stdout: &str) -> Option<PathBuf> {
    first_existing_path_for_platform(stdout, cfg!(windows))
}

fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
    let mut paths = stdout.lines().filter_map(existing_path);
    if windows {
        return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
    }

    paths.next()
}

fn existing_path(line: &str) -> Option<PathBuf> {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return None;
    }
    let candidate = PathBuf::from(trimmed);
    candidate.exists().then_some(candidate)
}

fn codex_binary_candidates() -> Vec<PathBuf> {
    dirs::home_dir()
        .map(|home| codex_binary_candidates_for_home(&home))
        .unwrap_or_default()
}

fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
    let mut candidates = vec![
        home.join(".local/bin/codex"),
        home.join(".local/bin/codex.exe"),
        home.join(".local/bin/codex.cmd"),
        home.join(".codex/bin/codex"),
        home.join(".codex/bin/codex.exe"),
        home.join(".codex/bin/codex.cmd"),
        home.join(".local/share/mise/shims/codex"),
        home.join(".local/share/mise/shims/codex.exe"),
        home.join(".local/share/mise/shims/codex.cmd"),
        home.join(".asdf/shims/codex"),
        home.join(".asdf/shims/codex.exe"),
        home.join(".asdf/shims/codex.cmd"),
        home.join(".npm-global/bin/codex"),
        home.join(".npm-global/bin/codex.cmd"),
        home.join(".npm-global/bin/codex.exe"),
        home.join(".npm/bin/codex"),
        home.join(".npm/bin/codex.cmd"),
        home.join(".npm/bin/codex.exe"),
        home.join(".bun/bin/codex"),
        home.join(".bun/bin/codex.exe"),
        home.join(".bun/bin/codex.cmd"),
        home.join(".linuxbrew/bin/codex"),
        home.join("AppData/Roaming/npm/codex.cmd"),
        home.join("AppData/Roaming/npm/codex.exe"),
        home.join("AppData/Local/pnpm/codex.cmd"),
        home.join("AppData/Local/pnpm/codex.exe"),
        home.join("scoop/shims/codex.cmd"),
        home.join("scoop/shims/codex.exe"),
        PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
        PathBuf::from("/usr/local/bin/codex"),
        PathBuf::from("/opt/homebrew/bin/codex"),
        PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
    ];
    candidates.extend(nvm_codex_binary_candidates_for_home(home));
    candidates
}

fn nvm_codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
    let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
        return Vec::new();
    };

    let mut candidates = entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| path.is_dir())
        .map(|path| path.join("bin").join("codex"))
        .collect::<Vec<_>>();
    candidates.sort();
    candidates
}

fn run_agent_stream_with_binary<F>(
    binary: &Path,
    request: AgentStreamRequest,
    emit: F,
) -> Result<String, String>
where
    F: FnMut(AiAgentStreamEvent),
{
    let last_message_dir = tempfile::Builder::new()
        .prefix("tolaria-codex-last-message-")
        .tempdir()
        .map_err(|error| format!("Failed to create Codex output directory: {error}"))?;
    let last_message_path = last_message_dir.path().join("last-message.txt");
    let args = build_codex_args(&request, Some(&last_message_path))?;
    let prompt = build_codex_prompt(&request);
    let command = build_codex_command(binary, args, prompt, &request.vault_path)?;
    let emit = with_codex_last_message_fallback(emit, last_message_path);

    crate::cli_agent_runtime::run_ai_agent_json_stream(
        command,
        "codex",
        emit,
        codex_session_id,
        dispatch_codex_event,
        |stderr_output, status| {
            format_codex_error(CodexProcessError {
                stderr_output,
                status,
            })
        },
    )
}

fn with_codex_last_message_fallback<F>(
    mut emit: F,
    last_message_path: PathBuf,
) -> impl FnMut(AiAgentStreamEvent)
where
    F: FnMut(AiAgentStreamEvent),
{
    let mut text_emitted = false;

    move |event| {
        match &event {
            AiAgentStreamEvent::TextDelta { text } if !text.trim().is_empty() => {
                text_emitted = true;
            }
            AiAgentStreamEvent::Done if !text_emitted => {
                if let Some(text) = read_codex_last_message(&last_message_path) {
                    text_emitted = true;
                    emit(AiAgentStreamEvent::TextDelta { text });
                }
            }
            _ => {}
        }

        emit(event);
    }
}

fn build_codex_command(
    binary: &Path,
    args: Vec<String>,
    prompt: String,
    vault_path: &str,
) -> Result<std::process::Command, String> {
    let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?;
    let mut command = crate::hidden_command(&target.program);
    crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
    if let Some(first_arg) = target.first_arg {
        command.arg(first_arg);
    }
    command
        .args(args)
        .arg(prompt)
        .current_dir(vault_path)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    Ok(command)
}

fn build_codex_args(
    request: &AgentStreamRequest,
    last_message_path: Option<&Path>,
) -> Result<Vec<String>, String> {
    let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
    let node_path = crate::mcp::find_node()?;

    let mut args = vec![
        "--sandbox".into(),
        codex_sandbox(request.permission_mode).into(),
        "--ask-for-approval".into(),
        codex_approval_policy(request.permission_mode).into(),
        "exec".into(),
        "--json".into(),
        "-C".into(),
        request.vault_path.clone(),
        "-c".into(),
        codex_config_string("mcp_servers.tolaria.command", &node_path.to_string_lossy()),
        "-c".into(),
        codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]),
        "-c".into(),
        codex_mcp_env_config(request),
    ];

    if let Some(path) = last_message_path {
        args.push("--output-last-message".into());
        args.push(path.to_string_lossy().into_owned());
    }

    Ok(args)
}

fn codex_config_string(key: &str, value: &str) -> String {
    format!(r#"{key}="{}""#, toml_escape(value))
}

fn codex_config_string_list(key: &str, values: &[&str]) -> String {
    let values = values
        .iter()
        .map(|value| format!(r#""{}""#, toml_escape(value)))
        .collect::<Vec<_>>()
        .join(",");
    format!("{key}=[{values}]")
}

fn codex_mcp_env_config(request: &AgentStreamRequest) -> String {
    let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(
        &request.vault_path,
        &request.vault_paths,
    );
    format!(
        r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",VAULT_PATHS="{}",WS_UI_PORT="9711"}}"#,
        toml_escape(&request.vault_path),
        toml_escape(&vault_paths)
    )
}

fn toml_escape(value: &str) -> String {
    value.replace('\\', r#"\\"#).replace('"', r#"\""#)
}

fn codex_sandbox(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str {
    match permission_mode {
        crate::ai_agents::AiAgentPermissionMode::Safe => "read-only",
        crate::ai_agents::AiAgentPermissionMode::PowerUser => "workspace-write",
    }
}

fn codex_approval_policy(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str {
    match permission_mode {
        crate::ai_agents::AiAgentPermissionMode::Safe => "untrusted",
        crate::ai_agents::AiAgentPermissionMode::PowerUser => "never",
    }
}

fn build_codex_prompt(request: &AgentStreamRequest) -> String {
    crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
}

fn codex_session_id(json: &serde_json::Value) -> Option<&str> {
    json["thread_id"].as_str()
}

fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
where
    F: FnMut(AiAgentStreamEvent),
{
    match json["type"].as_str().unwrap_or_default() {
        "thread.started" => {
            if let Some(thread_id) = json["thread_id"].as_str() {
                emit(AiAgentStreamEvent::Init {
                    session_id: thread_id.to_string(),
                });
            }
        }
        "item.started" => emit_codex_item_event(json, false, emit),
        "item.completed" => emit_codex_item_event(json, true, emit),
        _ => {}
    }
}

fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
where
    F: FnMut(AiAgentStreamEvent),
{
    let item = &json["item"];
    let item_type = item["type"].as_str().unwrap_or_default();
    let item_id = item["id"].as_str().unwrap_or_default();

    match item_type {
        "command_execution" => {
            if completed {
                emit(AiAgentStreamEvent::ToolDone {
                    tool_id: item_id.to_string(),
                    output: item["aggregated_output"]
                        .as_str()
                        .map(|output| output.to_string()),
                });
            } else {
                emit(AiAgentStreamEvent::ToolStart {
                    tool_name: "Bash".into(),
                    tool_id: item_id.to_string(),
                    input: item["command"]
                        .as_str()
                        .map(|command| serde_json::json!({ "command": command }).to_string()),
                });
            }
        }
        "mcp_tool_call" => emit_codex_mcp_tool_event(item, item_id, completed, emit),
        "agent_message" if completed => {
            if let Some(text) = item["text"].as_str() {
                emit(AiAgentStreamEvent::TextDelta {
                    text: text.to_string(),
                });
            }
        }
        _ => {}
    }
}

fn emit_codex_mcp_tool_event<F>(
    item: &serde_json::Value,
    item_id: &str,
    completed: bool,
    emit: &mut F,
) where
    F: FnMut(AiAgentStreamEvent),
{
    if completed {
        emit(AiAgentStreamEvent::ToolDone {
            tool_id: item_id.to_string(),
            output: codex_tool_output(item),
        });
        return;
    }

    let tool_name = item["tool"].as_str().unwrap_or("MCP tool");
    let input = json_field_to_string(&item["arguments"]);
    emit(AiAgentStreamEvent::ToolStart {
        tool_name: tool_name.to_string(),
        tool_id: item_id.to_string(),
        input,
    });
}

fn codex_tool_output(item: &serde_json::Value) -> Option<String> {
    item["error"]["message"]
        .as_str()
        .map(|message| format!("Error: {message}"))
        .or_else(|| json_field_to_string(&item["result"]))
}

fn json_field_to_string(value: &serde_json::Value) -> Option<String> {
    if value.is_null() {
        None
    } else {
        value
            .as_str()
            .map(str::to_string)
            .or_else(|| Some(value.to_string()))
    }
}

fn read_codex_last_message(path: &Path) -> Option<String> {
    std::fs::read_to_string(path)
        .ok()
        .map(|text| text.trim().to_string())
        .filter(|text| !text.is_empty())
}

struct CodexProcessError {
    stderr_output: String,
    status: String,
}

fn format_codex_error(error: CodexProcessError) -> String {
    let lower = error.stderr_output.to_ascii_lowercase();
    if is_codex_auth_error(&lower) {
        return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
    }

    if is_codex_write_permission_error(&lower) {
        return "Codex could not write to the active vault. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or verify the selected vault folder is writable and retry. Writes outside the active vault remain blocked.".into();
    }

    if error.stderr_output.trim().is_empty() {
        format!("codex exited with status {}", error.status)
    } else {
        error
            .stderr_output
            .lines()
            .take(3)
            .collect::<Vec<_>>()
            .join("\n")
    }
}

fn is_codex_auth_error(lower: &str) -> bool {
    ["auth", "login", "sign in"]
        .iter()
        .any(|pattern| lower.contains(pattern))
}

fn is_codex_write_permission_error(lower: &str) -> bool {
    [
        "read-only sandbox",
        "writing is blocked",
        "rejected by user approval",
        "rejected by the environment",
    ]
    .iter()
    .any(|pattern| lower.contains(pattern))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ai_agents::AiAgentPermissionMode;
    use std::ffi::OsStr;

    #[cfg(target_os = "linux")]
    fn current_test_binary() -> PathBuf {
        std::fs::read_link("/proc/self/exe").unwrap()
    }

    #[cfg(target_os = "macos")]
    fn current_test_binary() -> PathBuf {
        let pid = std::process::id().to_string();
        let output = std::process::Command::new("/bin/ps")
            .args(["-p", pid.as_str(), "-o", "comm="])
            .output()
            .unwrap();
        let path = String::from_utf8(output.stdout).unwrap();
        PathBuf::from(path.trim())
    }

    #[cfg(unix)]
    fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
        use std::os::unix::fs::PermissionsExt;

        let script = dir.join(name);
        std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
        script
    }

    fn codex_request(
        vault_path: &Path,
        permission_mode: AiAgentPermissionMode,
    ) -> AgentStreamRequest {
        AgentStreamRequest {
            message: "Summarize".into(),
            system_prompt: None,
            vault_path: vault_path.to_string_lossy().into_owned(),
            vault_paths: Vec::new(),
            permission_mode,
        }
    }

    fn assert_codex_permission_contract(args: &[String], permission_mode: AiAgentPermissionMode) {
        let sandbox = codex_sandbox(permission_mode);
        let approval = codex_approval_policy(permission_mode);
        let prefix = ["--sandbox", sandbox, "--ask-for-approval", approval];

        assert_eq!(&args[..prefix.len()], prefix);
        assert!(!args.iter().any(|arg| arg == "danger-full-access"));
        assert!(!args
            .iter()
            .any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox"));
    }

    #[cfg(unix)]
    fn run_codex_script(body: &str) -> (String, Vec<AiAgentStreamEvent>) {
        let dir = tempfile::tempdir().unwrap();
        let vault = tempfile::tempdir().unwrap();
        let binary = executable_script(dir.path(), "codex", body);
        let mut events = Vec::new();
        let thread_id = run_agent_stream_with_binary(
            &binary,
            codex_request(vault.path(), AiAgentPermissionMode::Safe),
            |event| events.push(event),
        )
        .unwrap();

        (thread_id, events)
    }

    fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) {
        assert!(matches!(
            &events[0],
            AiAgentStreamEvent::Init { session_id } if session_id == session
        ));
        assert!(matches!(
            &events[1],
            AiAgentStreamEvent::TextDelta { text } if text == text_delta
        ));
        assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
    }

    #[test]
    fn build_codex_prompt_keeps_system_prompt_first() {
        let prompt = build_codex_prompt(&AgentStreamRequest {
            message: "Rename the note".into(),
            system_prompt: Some("Be concise".into()),
            vault_path: "/tmp/vault".into(),
            vault_paths: Vec::new(),
            permission_mode: AiAgentPermissionMode::Safe,
        });

        assert!(prompt.starts_with("System instructions:\nBe concise"));
        assert!(prompt.contains("User request:\nRename the note"));
    }

    #[test]
    fn build_codex_args_uses_safe_default_permissions() {
        if let Ok(args) = build_codex_args(
            &AgentStreamRequest {
                message: "Rename the note".into(),
                system_prompt: None,
                vault_path: "/tmp/vault".into(),
                vault_paths: Vec::new(),
                permission_mode: AiAgentPermissionMode::Safe,
            },
            None,
        ) {
            assert_eq!(args[4], "exec");
            assert_codex_permission_contract(&args, AiAgentPermissionMode::Safe);
            assert!(args.contains(&"--json".to_string()));
            assert!(args.contains(&"-C".to_string()));
        }
    }

    #[test]
    fn codex_power_user_keeps_workspace_write_without_dangerous_bypass() {
        if let Ok(args) = build_codex_args(
            &AgentStreamRequest {
                message: "Rename the note".into(),
                system_prompt: None,
                vault_path: "/tmp/vault".into(),
                vault_paths: Vec::new(),
                permission_mode: AiAgentPermissionMode::PowerUser,
            },
            None,
        ) {
            assert_codex_permission_contract(&args, AiAgentPermissionMode::PowerUser);
        }
    }

    #[test]
    fn build_codex_args_can_request_last_message_output_file() {
        if let Ok(args) = build_codex_args(
            &AgentStreamRequest {
                message: "Rename the note".into(),
                system_prompt: None,
                vault_path: "/tmp/vault".into(),
                vault_paths: Vec::new(),
                permission_mode: AiAgentPermissionMode::Safe,
            },
            Some(Path::new("/tmp/tolaria-codex-last-message.txt")),
        ) {
            assert!(args.windows(2).any(|window| window
                == [
                    "--output-last-message",
                    "/tmp/tolaria-codex-last-message.txt",
                ]));
        }
    }

    #[test]
    fn build_codex_args_uses_resolved_mcp_node_and_ui_bridge_env() {
        let args = build_codex_args(
            &AgentStreamRequest {
                message: "Read [[Test note]]".into(),
                system_prompt: None,
                vault_path: "/tmp/vault".into(),
                vault_paths: Vec::new(),
                permission_mode: AiAgentPermissionMode::Safe,
            },
            None,
        )
        .unwrap();

        let command_override = args
            .iter()
            .find(|arg| arg.starts_with("mcp_servers.tolaria.command="))
            .expect("Codex should receive a transient Tolaria MCP command");

        assert!(
            !command_override.ends_with(r#""node""#),
            "Codex MCP command should use Tolaria's resolved Node path, got {command_override}"
        );
        assert!(
            command_override.contains('/'),
            "Codex MCP command should be an absolute Node path, got {command_override}"
        );
        assert!(args.iter().any(|arg| arg.contains(r#"WS_UI_PORT="9711""#)));
    }

    #[test]
    fn build_codex_command_keeps_agent_process_contract() {
        let binary = PathBuf::from("codex");
        let args = vec!["exec".to_string(), "--json".to_string()];
        let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault").unwrap();
        let actual_args: Vec<&OsStr> = command.get_args().collect();

        assert_eq!(command.get_program(), OsStr::new("codex"));
        assert_eq!(
            actual_args,
            vec![
                OsStr::new("exec"),
                OsStr::new("--json"),
                OsStr::new("Summarize")
            ]
        );
        assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
    }

    #[test]
    fn build_codex_command_extends_path_with_resolved_homebrew_bin() {
        let binary = PathBuf::from("/opt/homebrew/bin/codex");
        let command = build_codex_command(
            &binary,
            vec!["exec".to_string(), "--json".to_string()],
            "Summarize".into(),
            "/tmp/vault",
        )
        .unwrap();
        let path_value = command
            .get_envs()
            .find(|(key, _)| *key == OsStr::new("PATH"))
            .and_then(|(_, value)| value)
            .expect("PATH should be set");
        let paths = std::env::split_paths(path_value).collect::<Vec<_>>();

        assert!(
            paths.contains(&PathBuf::from("/opt/homebrew/bin")),
            "PATH should include the resolved Codex binary directory, got {paths:?}"
        );
    }

    #[test]
    fn build_codex_command_avoids_windows_cmd_shim_for_complex_args() {
        let dir = tempfile::tempdir().unwrap();
        let shim = dir.path().join("codex.cmd");
        let script = dir
            .path()
            .join("node_modules")
            .join("@openai")
            .join("codex")
            .join("bin")
            .join("codex.js");
        std::fs::create_dir_all(script.parent().unwrap()).unwrap();
        std::fs::write(&script, "console.log('codex')\n").unwrap();
        std::fs::write(
            &shim,
            r#"@ECHO off
"%_prog%" "%dp0%\node_modules\@openai\codex\bin\codex.js" %*
"#,
        )
        .unwrap();

        let command = build_codex_command(
            &shim,
            vec![
                "exec".to_string(),
                "-c".to_string(),
                r#"mcp_servers.tolaria.command="C:\\Program Files\\node.exe""#.to_string(),
            ],
            "Summarize".into(),
            "/tmp/vault",
        )
        .unwrap();

        assert_ne!(
            command.get_program(),
            shim.as_os_str(),
            "Codex npm .cmd shims cannot safely receive quoted -c args directly"
        );
        let actual_args = command.get_args().collect::<Vec<_>>();
        assert_eq!(actual_args.first().copied(), Some(script.as_os_str()));
        assert!(actual_args
            .iter()
            .any(|arg| *arg == OsStr::new("Summarize")));
    }

    #[cfg(unix)]
    #[test]
    fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {
        let (thread_id, events) = run_codex_script(
            r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}'
"#,
        );

        assert_eq!(thread_id, "thread_1");
        assert_codex_text_flow(&events, "thread_1", "Done");
    }

    #[cfg(unix)]
    #[test]
    fn run_codex_agent_stream_uses_last_message_file_when_stream_has_no_text() {
        let (thread_id, events) = run_codex_script(
            r#"last_message=""
while [ "$#" -gt 0 ]; do
  if [ "$1" = "--output-last-message" ]; then
    shift
    last_message="$1"
  fi
  shift
done
printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s' 'Recovered final answer' > "$last_message"
"#,
        );

        assert_eq!(thread_id, "thread_1");
        assert_codex_text_flow(&events, "thread_1", "Recovered final answer");
    }

    #[cfg(unix)]
    #[test]
    fn run_codex_agent_stream_does_not_duplicate_last_message_file_after_text_event() {
        let (thread_id, events) = run_codex_script(
            r#"last_message=""
while [ "$#" -gt 0 ]; do
  if [ "$1" = "--output-last-message" ]; then
    shift
    last_message="$1"
  fi
  shift
done
printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Streamed answer"}}'
printf '%s' 'Recovered final answer' > "$last_message"
"#,
        );

        let text_events = events
            .iter()
            .filter(|event| matches!(event, AiAgentStreamEvent::TextDelta { .. }))
            .count();

        assert_eq!(thread_id, "thread_1");
        assert_eq!(text_events, 1);
        assert_codex_text_flow(&events, "thread_1", "Streamed answer");
    }

    #[cfg(unix)]
    #[test]
    fn run_codex_agent_stream_reports_nonzero_exit_errors() {
        let (thread_id, events) = run_codex_script(
            r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' 'login required' >&2
exit 2
"#,
        );

        assert_eq!(thread_id, "thread_1");
        assert!(events.iter().any(|event| matches!(
            event,
            AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
        )));
        assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
    }

    #[cfg(unix)]
    #[test]
    fn run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open() {
        use std::io::Read;
        use std::time::{Duration, Instant};

        let mut child = std::process::Command::new(current_test_binary())
            .arg("codex_stdin_probe_parent_child")
            .arg("--ignored")
            .arg("--nocapture")
            .env("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD", "1")
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();
        let child_stdin = child.stdin.take().unwrap();
        let mut stdout = child.stdout.take().unwrap();
        let mut stderr = child.stderr.take().unwrap();
        let deadline = Instant::now() + Duration::from_secs(5);

        let status = loop {
            if let Some(status) = child.try_wait().unwrap() {
                break status;
            }
            if Instant::now() >= deadline {
                child.kill().unwrap();
                drop(child_stdin);
                panic!("Codex stdin probe child timed out");
            }
            std::thread::sleep(Duration::from_millis(10));
        };

        drop(child_stdin);
        let mut stdout_text = String::new();
        let mut stderr_text = String::new();
        stdout.read_to_string(&mut stdout_text).unwrap();
        stderr.read_to_string(&mut stderr_text).unwrap();

        assert!(
            status.success(),
            "Codex stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}"
        );
    }

    #[cfg(unix)]
    #[ignore = "spawned by run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open"]
    #[test]
    fn codex_stdin_probe_parent_child() {
        if std::env::var_os("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD").is_none() {
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let vault = tempfile::tempdir().unwrap();
        let binary = executable_script(
            dir.path(),
            "codex",
            r#"stdin="$(cat)"
if [ -n "$stdin" ]; then
  echo "stdin was not closed" >&2
  exit 9
fi
printf '%s\n' '{"type":"thread.started","thread_id":"stdin-ok"}'
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"stdin closed"}}'
"#,
        );
        let mut events = Vec::new();
        let result = run_agent_stream_with_binary(
            &binary,
            codex_request(vault.path(), AiAgentPermissionMode::Safe),
            |event| events.push(event),
        );

        assert_eq!(result.unwrap(), "stdin-ok");
        assert!(events.iter().any(|event| matches!(
            event,
            AiAgentStreamEvent::TextDelta { text } if text == "stdin closed"
        )));
        assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
    }

    #[test]
    fn codex_binary_candidates_include_supported_macos_installs() {
        let home = PathBuf::from("/Users/alex");
        let candidates = codex_binary_candidates_for_home(&home);
        let expected = [
            home.join(".local/bin/codex"),
            home.join(".codex/bin/codex"),
            home.join(".local/share/mise/shims/codex"),
            home.join(".asdf/shims/codex"),
            home.join(".npm-global/bin/codex"),
            home.join(".bun/bin/codex"),
            PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
        ];

        for candidate in expected {
            assert!(
                candidates.contains(&candidate),
                "missing {}",
                candidate.display()
            );
        }
    }

    #[test]
    fn codex_binary_candidates_include_linuxbrew_installs() {
        let home = PathBuf::from("/home/alex");
        let candidates = codex_binary_candidates_for_home(&home);
        let expected = [
            home.join(".linuxbrew/bin/codex"),
            PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
        ];

        for candidate in expected {
            assert!(
                candidates.contains(&candidate),
                "missing {}",
                candidate.display()
            );
        }
    }

    #[test]
    fn codex_binary_candidates_include_windows_npm_and_toolchain_shims() {
        let home = PathBuf::from("C:/Users/alex");
        let candidates = codex_binary_candidates_for_home(&home);
        let expected = [
            home.join(".local/bin/codex.exe"),
            home.join(".local/bin/codex.cmd"),
            home.join(".local/share/mise/shims/codex.exe"),
            home.join(".local/share/mise/shims/codex.cmd"),
            home.join(".asdf/shims/codex.exe"),
            home.join(".asdf/shims/codex.cmd"),
            home.join(".codex/bin/codex.cmd"),
            home.join(".npm-global/bin/codex.cmd"),
            home.join(".npm-global/bin/codex.exe"),
            home.join(".npm/bin/codex.cmd"),
            home.join(".npm/bin/codex.exe"),
            home.join(".bun/bin/codex.cmd"),
            home.join("AppData/Roaming/npm/codex.cmd"),
            home.join("AppData/Roaming/npm/codex.exe"),
            home.join("AppData/Local/pnpm/codex.cmd"),
            home.join("AppData/Local/pnpm/codex.exe"),
            home.join("scoop/shims/codex.cmd"),
            home.join("scoop/shims/codex.exe"),
        ];

        for candidate in expected {
            assert!(
                candidates.contains(&candidate),
                "missing {}",
                candidate.display()
            );
        }
    }

    #[test]
    fn codex_availability_reports_installed_even_when_version_probe_fails() {
        let binary = PathBuf::from("C:/Users/alex/AppData/Roaming/npm/codex.cmd");

        let availability = codex_availability_from_binary_result(Ok(binary));

        assert!(availability.installed);
        assert_eq!(availability.version, None);
    }

    #[test]
    fn codex_binary_candidates_include_nvm_managed_node_installs() {
        let home = tempfile::tempdir().unwrap();
        let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex");
        std::fs::create_dir_all(codex.parent().unwrap()).unwrap();
        std::fs::write(&codex, "#!/bin/sh\n").unwrap();

        let candidates = codex_binary_candidates_for_home(home.path());

        assert!(candidates.contains(&codex), "missing {}", codex.display());
    }

    #[test]
    fn first_existing_path_skips_empty_and_missing_lines() {
        let dir = tempfile::tempdir().unwrap();
        let missing = dir.path().join("missing-codex");
        let codex = dir.path().join("codex");
        std::fs::write(&codex, "#!/bin/sh\n").unwrap();

        let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());

        assert_eq!(first_existing_path(&stdout), Some(codex));
    }

    #[test]
    fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
        let dir = tempfile::tempdir().unwrap();
        let shell_script = dir.path().join("codex");
        let cmd_shim = dir.path().join("codex.cmd");
        std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
        std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();

        let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());

        assert_eq!(
            first_existing_path_for_platform(&stdout, true),
            Some(cmd_shim)
        );
    }

    #[cfg(unix)]
    #[test]
    fn command_path_from_shell_finds_codex_from_login_shell() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().unwrap();
        let codex = dir.path().join("codex");
        std::fs::write(&codex, "#!/bin/sh\n").unwrap();
        std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();

        let shell = dir.path().join("shell");
        std::fs::write(
            &shell,
            format!(
                "#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
                codex.display()
            ),
        )
        .unwrap();
        std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();

        assert_eq!(codex_path_from_shell(&shell), Some(codex));
    }

    #[test]
    fn dispatch_codex_command_events_maps_to_bash_events() {
        let mut events = Vec::new();
        let started = serde_json::json!({
            "type": "item.started",
            "item": {
                "id": "item_1",
                "type": "command_execution",
                "command": "/bin/zsh -lc pwd"
            }
        });
        let completed = serde_json::json!({
            "type": "item.completed",
            "item": {
                "id": "item_1",
                "type": "command_execution",
                "aggregated_output": "/private/tmp\n"
            }
        });

        dispatch_codex_event(&started, &mut |event| events.push(event));
        dispatch_codex_event(&completed, &mut |event| events.push(event));

        assert!(matches!(
            &events[0],
            AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
                if tool_name == "Bash" && tool_id == "item_1"
        ));
        assert!(matches!(
            &events[1],
            AiAgentStreamEvent::ToolDone { tool_id, output }
                if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
        ));
    }

    #[test]
    fn dispatch_codex_mcp_tool_call_maps_to_tool_events() {
        let mut events = Vec::new();
        let started = serde_json::json!({
            "type": "item.started",
            "item": {
                "id": "item_1",
                "type": "mcp_tool_call",
                "server": "tolaria",
                "tool": "search_notes",
                "arguments": { "query": "meeting", "limit": 5 },
                "status": "in_progress"
            }
        });
        let completed = serde_json::json!({
            "type": "item.completed",
            "item": {
                "id": "item_1",
                "type": "mcp_tool_call",
                "server": "tolaria",
                "tool": "search_notes",
                "arguments": { "query": "meeting", "limit": 5 },
                "result": [{ "title": "Meeting notes" }],
                "status": "completed"
            }
        });

        dispatch_codex_event(&started, &mut |event| events.push(event));
        dispatch_codex_event(&completed, &mut |event| events.push(event));

        assert!(matches!(
            &events[0],
            AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
                if tool_name == "search_notes"
                    && tool_id == "item_1"
                    && input.as_deref().is_some_and(|value| value.contains("meeting"))
        ));
        assert!(matches!(
            &events[1],
            AiAgentStreamEvent::ToolDone { tool_id, output }
                if tool_id == "item_1"
                    && output.as_deref().is_some_and(|value| value.contains("Meeting notes"))
        ));
    }

    #[test]
    fn dispatch_codex_agent_message_maps_to_text_delta() {
        let mut events = Vec::new();
        let completed = serde_json::json!({
            "type": "item.completed",
            "item": {
                "id": "item_2",
                "type": "agent_message",
                "text": "All set"
            }
        });

        dispatch_codex_event(&completed, &mut |event| events.push(event));

        assert!(matches!(
            &events[0],
            AiAgentStreamEvent::TextDelta { text } if text == "All set"
        ));
    }

    #[test]
    fn format_codex_error_explains_vault_write_permission_failures() {
        let message = format_codex_error(CodexProcessError {
            stderr_output: "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
            status: "exit status: 1".into(),
        });

        assert!(message.contains("active vault"));
        assert!(message.contains("writable"));
        assert!(message.contains("outside"));
    }
}
