#![allow(clippy::unwrap_used, clippy::expect_used)]
#![cfg(target_os = "macos")]

use anyhow::Result;
use codex_core::config::Constrained;
use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::responses::ev_apply_patch_custom_tool_call;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::path::Path;

fn absolute_path(path: &Path) -> AbsolutePathBuf {
    AbsolutePathBuf::try_from(path).expect("absolute path")
}

fn request_permissions_tool_event(
    call_id: &str,
    reason: &str,
    permissions: &RequestPermissionProfile,
) -> Result<Value> {
    let args = json!({
        "reason": reason,
        "permissions": permissions,
    });
    let args_str = serde_json::to_string(&args)?;
    Ok(ev_function_call(call_id, "request_permissions", &args_str))
}

fn exec_command_event(call_id: &str, command: &str) -> Result<Value> {
    let args = json!({
        "cmd": command,
        "yield_time_ms": 1_000_u64,
    });
    let args_str = serde_json::to_string(&args)?;
    Ok(ev_function_call(call_id, "exec_command", &args_str))
}

fn build_add_file_patch(patch_path: &Path, content: &str) -> String {
    format!(
        "*** Begin Patch\n*** Add File: {}\n+{}\n*** End Patch\n",
        patch_path.display(),
        content
    )
}

fn workspace_write_excluding_tmp() -> PermissionProfile {
    PermissionProfile::workspace_write_with(
        &[],
        NetworkSandboxPolicy::Restricted,
        /*exclude_tmpdir_env_var*/ true,
        /*exclude_slash_tmp*/ true,
    )
}

fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfile {
    RequestPermissionProfile {
        file_system: Some(FileSystemPermissions::from_read_write_roots(
            Some(vec![]),
            Some(vec![absolute_path(path)]),
        )),
        ..RequestPermissionProfile::default()
    }
}

fn normalized_directory_write_permissions(path: &Path) -> Result<RequestPermissionProfile> {
    Ok(RequestPermissionProfile {
        file_system: Some(FileSystemPermissions::from_read_write_roots(
            Some(vec![]),
            Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]),
        )),
        ..RequestPermissionProfile::default()
    })
}

fn parse_result(item: &Value) -> (Option<i64>, String) {
    let output_str = item
        .get("output")
        .and_then(Value::as_str)
        .expect("shell output payload");
    match serde_json::from_str::<Value>(output_str) {
        Ok(parsed) => {
            let exit_code = parsed["metadata"]["exit_code"].as_i64();
            let stdout = parsed["output"].as_str().unwrap_or_default().to_string();
            (exit_code, stdout)
        }
        Err(_) => {
            let structured = Regex::new(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$").unwrap();
            let regex =
                Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap();
            if let Some(captures) = structured.captures(output_str) {
                let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap();
                let output = captures.get(2).unwrap().as_str();
                (Some(exit_code), output.to_string())
            } else if let Some(captures) = regex.captures(output_str) {
                let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap();
                let output = captures.get(2).unwrap().as_str();
                (Some(exit_code), output.to_string())
            } else {
                (None, output_str.to_string())
            }
        }
    }
}

async fn submit_turn(
    test: &TestCodex,
    prompt: &str,
    approval_policy: AskForApproval,
    permission_profile: PermissionProfile,
    approvals_reviewer: Option<ApprovalsReviewer>,
) -> Result<()> {
    let session_model = test.session_configured.model.clone();
    let (sandbox_policy, permission_profile) =
        turn_permission_fields(permission_profile, test.config.cwd.as_path());
    test.codex
        .submit(Op::UserInput {
            items: vec![UserInput::Text {
                text: prompt.into(),
                text_elements: Vec::new(),
            }],
            final_output_json_schema: None,
            responsesapi_client_metadata: None,
            additional_context: Default::default(),
            thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
                environments: Some(local_selections(test.config.cwd.clone())),
                approval_policy: Some(approval_policy),
                approvals_reviewer,
                sandbox_policy: Some(sandbox_policy),
                permission_profile,
                collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
                    mode: codex_protocol::config_types::ModeKind::Default,
                    settings: codex_protocol::config_types::Settings {
                        model: session_model,
                        reasoning_effort: None,
                        developer_instructions: None,
                    },
                }),
                ..Default::default()
            },
        })
        .await?;
    Ok(())
}

async fn wait_for_completion(test: &TestCodex) {
    wait_for_event(&test.codex, |event| {
        matches!(event, EventMsg::TurnComplete(_))
    })
    .await;
}

async fn expect_request_permissions_event(
    test: &TestCodex,
    expected_call_id: &str,
) -> RequestPermissionProfile {
    let event = wait_for_event(&test.codex, |event| {
        matches!(
            event,
            EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
        )
    })
    .await;

    match event {
        EventMsg::RequestPermissions(request) => {
            assert_eq!(request.call_id, expected_call_id);
            request.permissions
        }
        EventMsg::TurnComplete(_) => panic!("expected request_permissions before completion"),
        other => panic!("unexpected event: {other:?}"),
    }
}

#[tokio::test(flavor = "current_thread")]
#[cfg(target_os = "macos")]
async fn approved_folder_write_request_permissions_unblocks_later_exec_without_sandbox_args()
-> Result<()> {
    skip_if_no_network!(Ok(()));
    skip_if_sandbox!(Ok(()));

    let server = start_mock_server().await;
    let approval_policy = AskForApproval::OnRequest;
    let permission_profile = workspace_write_excluding_tmp();
    let permission_profile_for_config = permission_profile.clone();

    let mut builder = test_codex().with_config(move |config| {
        config.permissions.approval_policy = Constrained::allow_any(approval_policy);
        config
            .permissions
            .set_permission_profile(permission_profile_for_config)
            .expect("set permission profile");
        config
            .features
            .enable(Feature::ExecPermissionApprovals)
            .expect("test config should allow feature update");
        config
            .features
            .enable(Feature::RequestPermissionsTool)
            .expect("test config should allow feature update");
    });
    let test = builder.build(&server).await?;

    let requested_dir = tempfile::tempdir()?;
    let requested_file = requested_dir.path().join("allowed-write.txt");
    let command = format!(
        "printf {:?} > {:?} && cat {:?}",
        "folder-grant-ok", requested_file, requested_file
    );
    let requested_permissions = requested_directory_write_permissions(requested_dir.path());
    let normalized_requested_permissions =
        normalized_directory_write_permissions(requested_dir.path())?;

    let responses = mount_sse_sequence(
        &server,
        vec![
            sse(vec![
                ev_response_created("resp-request-permissions-1"),
                request_permissions_tool_event(
                    "permissions-call",
                    "Allow writing outside the workspace",
                    &requested_permissions,
                )?,
                ev_completed("resp-request-permissions-1"),
            ]),
            sse(vec![
                ev_response_created("resp-request-permissions-2"),
                exec_command_event("exec-call", &command)?,
                ev_completed("resp-request-permissions-2"),
            ]),
            sse(vec![
                ev_response_created("resp-request-permissions-3"),
                ev_assistant_message("msg-request-permissions-1", "done"),
                ev_completed("resp-request-permissions-3"),
            ]),
        ],
    )
    .await;

    submit_turn(
        &test,
        "write outside the workspace",
        approval_policy,
        permission_profile,
        Some(ApprovalsReviewer::User),
    )
    .await?;

    let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await;
    assert_eq!(
        granted_permissions,
        normalized_requested_permissions.clone()
    );
    test.codex
        .submit(Op::RequestPermissionsResponse {
            id: "permissions-call".to_string(),
            response: RequestPermissionsResponse {
                permissions: normalized_requested_permissions,
                scope: PermissionGrantScope::Turn,
                strict_auto_review: false,
            },
        })
        .await?;

    let completion_event = wait_for_event(&test.codex, |event| {
        matches!(
            event,
            EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
        )
    })
    .await;
    if let EventMsg::ExecApprovalRequest(approval) = completion_event {
        test.codex
            .submit(Op::ExecApproval {
                id: approval.effective_approval_id(),
                turn_id: None,
                decision: ReviewDecision::Approved,
            })
            .await?;
        wait_for_event(&test.codex, |event| {
            matches!(event, EventMsg::TurnComplete(_))
        })
        .await;
    }

    let exec_output = responses
        .function_call_output_text("exec-call")
        .map(|output| json!({ "output": output }))
        .unwrap_or_else(|| panic!("expected exec-call output"));
    let (exit_code, stdout) = parse_result(&exec_output);
    assert!(exit_code.is_none() || exit_code == Some(0));
    assert!(stdout.contains("folder-grant-ok"));
    assert!(
        requested_file.exists(),
        "touch command should create the file"
    );
    assert_eq!(fs::read_to_string(&requested_file)?, "folder-grant-ok");

    Ok(())
}

#[tokio::test(flavor = "current_thread")]
#[cfg(target_os = "macos")]
async fn approved_folder_write_request_permissions_unblocks_later_apply_patch() -> Result<()> {
    skip_if_no_network!(Ok(()));
    skip_if_sandbox!(Ok(()));

    apply_patch_after_request_permissions(/*strict_auto_review*/ false).await?;
    apply_patch_after_request_permissions(/*strict_auto_review*/ true).await?;

    Ok(())
}

async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Result<()> {
    let server = start_mock_server().await;
    let approval_policy = AskForApproval::OnRequest;
    let permission_profile = workspace_write_excluding_tmp();
    let permission_profile_for_config = permission_profile.clone();

    let mut builder = test_codex().with_config(move |config| {
        config.permissions.approval_policy = Constrained::allow_any(approval_policy);
        config
            .permissions
            .set_permission_profile(permission_profile_for_config)
            .expect("set permission profile");
        config
            .features
            .enable(Feature::ExecPermissionApprovals)
            .expect("test config should allow feature update");
        config
            .features
            .enable(Feature::RequestPermissionsTool)
            .expect("test config should allow feature update");
    });
    let test = builder.build(&server).await?;

    let requested_dir = tempfile::tempdir()?;
    let requested_file_name = if strict_auto_review {
        "strict-allowed-patch.txt"
    } else {
        "allowed-patch.txt"
    };
    let patch_content = if strict_auto_review {
        "patched-after-strict-review"
    } else {
        "patched-via-request-permissions"
    };
    let requested_file = requested_dir
        .path()
        .canonicalize()?
        .join(requested_file_name);
    let requested_permissions = requested_directory_write_permissions(requested_dir.path());
    let normalized_requested_permissions =
        normalized_directory_write_permissions(requested_dir.path())?;
    let patch = build_add_file_patch(&requested_file, patch_content);

    let response_prefix = if strict_auto_review {
        "resp-strict-request-permissions-patch"
    } else {
        "resp-request-permissions-patch"
    };
    let mut sse_sequence = vec![
        sse(vec![
            ev_response_created(&format!("{response_prefix}-1")),
            request_permissions_tool_event(
                "permissions-call",
                "Allow patching outside the workspace",
                &requested_permissions,
            )?,
            ev_completed(&format!("{response_prefix}-1")),
        ]),
        sse(vec![
            ev_response_created(&format!("{response_prefix}-2")),
            ev_apply_patch_custom_tool_call("apply-patch-call", &patch),
            ev_completed(&format!("{response_prefix}-2")),
        ]),
    ];
    if strict_auto_review {
        sse_sequence.push(sse(vec![
            ev_response_created(&format!("{response_prefix}-guardian")),
            ev_assistant_message(
                "msg-strict-request-permissions-patch-guardian",
                &serde_json::json!({
                    "risk_level": "low",
                    "user_authorization": "high",
                    "outcome": "allow",
                    "rationale": "The patch stays within the strict turn grant.",
                })
                .to_string(),
            ),
            ev_completed(&format!("{response_prefix}-guardian")),
        ]));
    }
    sse_sequence.push(sse(vec![
        ev_response_created(&format!("{response_prefix}-3")),
        ev_assistant_message("msg-request-permissions-patch-1", "done"),
        ev_completed(&format!("{response_prefix}-3")),
    ]));
    let responses = mount_sse_sequence(&server, sse_sequence).await;

    submit_turn(
        &test,
        "patch outside the workspace",
        approval_policy,
        permission_profile,
        Some(ApprovalsReviewer::User),
    )
    .await?;

    let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await;
    assert_eq!(
        granted_permissions,
        normalized_requested_permissions.clone()
    );
    test.codex
        .submit(Op::RequestPermissionsResponse {
            id: "permissions-call".to_string(),
            response: RequestPermissionsResponse {
                permissions: normalized_requested_permissions,
                scope: PermissionGrantScope::Turn,
                strict_auto_review,
            },
        })
        .await?;

    if strict_auto_review {
        wait_for_completion(&test).await;
        let guardian_request = responses
            .requests()
            .into_iter()
            .find(|request| request.body_contains_text(requested_file_name))
            .expect("expected guardian request for strict apply_patch");
        assert!(guardian_request.body_contains_text(requested_file_name));
        assert!(guardian_request.body_contains_text(patch_content));
    } else {
        let event = wait_for_event(&test.codex, |event| {
            matches!(
                event,
                EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_)
            )
        })
        .await;
        match event {
            EventMsg::TurnComplete(_) => {}
            EventMsg::ApplyPatchApprovalRequest(approval) => {
                panic!(
                    "unexpected apply_patch approval request after granted permissions: {approval:?}",
                )
            }
            other => panic!("unexpected event: {other:?}"),
        }
    }

    let patch_output = responses
        .requests()
        .into_iter()
        .find_map(|request| {
            request
                .input()
                .into_iter()
                .find(|item| {
                    item.get("type").and_then(Value::as_str) == Some("custom_tool_call_output")
                        && item.get("call_id").and_then(Value::as_str) == Some("apply-patch-call")
                })
                .and_then(|item| {
                    item.get("output")
                        .and_then(Value::as_str)
                        .map(str::to_string)
                })
        })
        .map(|output| json!({ "output": output }))
        .unwrap_or_else(|| panic!("expected apply-patch-call output"));
    let (exit_code, stdout) = parse_result(&patch_output);
    assert!(exit_code.is_none() || exit_code == Some(0));
    assert!(
        stdout.contains("Success."),
        "unexpected patch output: {stdout}"
    );
    assert_eq!(
        fs::read_to_string(&requested_file)?,
        format!("{patch_content}\n")
    );

    Ok(())
}
