use std::path::Path;
use std::sync::Arc;

use anyhow::Result;
use serde_json::Value;
use tokio::sync::Notify;
use vtcode_core::core::interfaces::ui::UiSession;
use vtcode_core::notifications::{NotificationEvent, send_global_notification};
use vtcode_core::sandboxing::SandboxPermissions as CoreSandboxPermissions;
use vtcode_core::utils::ansi::AnsiRenderer;
use vtcode_tui::app::{
    InlineHandle, ListOverlayRequest, TransientHotkey, TransientHotkeyAction, TransientHotkeyKey,
    TransientRequest, TransientSubmission,
};

use crate::agent::runloop::tool_output::format_unified_diff_lines;
use crate::agent::runloop::unified::overlay_prompt::{OverlayWaitOutcome, show_overlay_and_wait};
use crate::agent::runloop::unified::state::CtrlCState;
use crate::agent::runloop::unified::ui_interaction::PlaceholderGuard;

use super::HitlDecision;
use super::shell_approval::{ApprovalLearningTarget, PersistentApprovalTarget};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ToolPermissionPromptKind {
    Standard,
    Mcp,
}

#[cold]
fn cancelled_prompt_decision(prompt_kind: ToolPermissionPromptKind) -> HitlDecision {
    if prompt_kind == ToolPermissionPromptKind::Mcp {
        HitlDecision::DeniedOnce
    } else {
        HitlDecision::Denied
    }
}

fn shell_run_args<'a>(tool_name: &str, tool_args: Option<&'a Value>) -> Option<&'a Value> {
    let args = tool_args?;
    vtcode_core::tools::tool_intent::is_command_run_tool_call(tool_name, args).then_some(args)
}

pub(super) fn tool_permission_prompt_kind(tool_name: &str) -> ToolPermissionPromptKind {
    if vtcode_core::tools::mcp::is_legacy_mcp_tool_name(tool_name)
        || vtcode_core::tools::mcp::parse_canonical_mcp_tool_name(tool_name).is_some()
        || tool_name.starts_with(vtcode_core::tools::mcp::MCP_QUALIFIED_TOOL_PREFIX)
    {
        ToolPermissionPromptKind::Mcp
    } else {
        ToolPermissionPromptKind::Standard
    }
}

fn normalized_shell_command_value(args: &Value) -> Option<Value> {
    let normalized = vtcode_core::tools::command_args::normalize_shell_args(args)
        .ok()
        .unwrap_or_else(|| args.clone());
    vtcode_core::tools::command_args::normalized_command_value(&normalized)
        .ok()
        .flatten()
}

fn extract_shell_command_text_from_run_args(args: &Value) -> Option<String> {
    vtcode_core::tools::command_args::command_text(args)
        .ok()
        .flatten()
}

fn extract_shell_command_words_from_run_args(args: &Value) -> Option<Vec<String>> {
    vtcode_core::tools::command_args::command_words(args)
        .ok()
        .flatten()
}

fn render_shell_command_words(parts: &[String]) -> String {
    shell_words::join(parts.iter().map(|part| part.as_str()))
}

fn shell_command_contains_control_operators(command: &str) -> bool {
    command.contains("&&")
        || command.contains("||")
        || command.contains('|')
        || command.contains(';')
        || command.contains("$(")
        || command.contains('`')
        || command.contains("<<")
        || command.contains('\n')
}

fn shell_run_uses_nested_shell(command_words: &[String]) -> bool {
    let Some(program) = command_words.first() else {
        return false;
    };
    let basename = Path::new(program)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(program)
        .to_ascii_lowercase();
    let args = &command_words[1..];

    match basename.as_str() {
        "sh" | "bash" | "zsh" | "fish" => args.iter().any(|arg| {
            matches!(
                arg.as_str(),
                "-c" | "-ic" | "-lc" | "--command" | "--login" | "--interactive"
            )
        }),
        "pwsh" | "powershell" | "powershell.exe" => args
            .iter()
            .any(|arg| arg.eq_ignore_ascii_case("-command") || arg.eq_ignore_ascii_case("-c")),
        "cmd" | "cmd.exe" => args
            .iter()
            .any(|arg| arg.eq_ignore_ascii_case("/c") || arg.eq_ignore_ascii_case("/k")),
        _ => false,
    }
}

fn shell_command_supports_persistent_approval(args: &Value, command_words: &[String]) -> bool {
    let Some(command_value) = normalized_shell_command_value(args) else {
        return false;
    };

    match command_value {
        Value::String(command) => {
            let trimmed = command.trim();
            !trimmed.is_empty()
                && !shell_command_contains_control_operators(trimmed)
                && !shell_run_uses_nested_shell(command_words)
        }
        Value::Array(_) => !shell_run_uses_nested_shell(command_words),
        _ => false,
    }
}

fn parse_shell_sandbox_permissions(args: &Value) -> CoreSandboxPermissions {
    args.get("sandbox_permissions")
        .cloned()
        .map(serde_json::from_value::<CoreSandboxPermissions>)
        .transpose()
        .ok()
        .flatten()
        .unwrap_or(CoreSandboxPermissions::UseDefault)
}

fn shell_permission_scope_suffix(args: &Value) -> String {
    let sandbox_permissions = parse_shell_sandbox_permissions(args);
    let additional_permissions = args.get("additional_permissions");
    let sandbox_permissions = serde_json::to_string(&sandbox_permissions)
        .unwrap_or_else(|_| "\"use_default\"".to_string());
    let additional_permissions = additional_permissions
        .map(|value| {
            serde_json::to_string(value).unwrap_or_else(|_| "\"<invalid_additional>\"".to_string())
        })
        .unwrap_or_else(|| "null".to_string());

    format!(
        "sandbox_permissions={sandbox_permissions}|additional_permissions={additional_permissions}"
    )
}

fn extract_shell_approval_justification(
    tool_name: &str,
    tool_args: Option<&Value>,
) -> Option<String> {
    let args = shell_run_args(tool_name, tool_args)?;
    args.get("justification")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

pub(super) fn extract_shell_command_text(
    tool_name: &str,
    tool_args: Option<&Value>,
) -> Option<String> {
    let args = shell_run_args(tool_name, tool_args)?;
    extract_shell_command_text_from_run_args(args)
}

pub(super) fn split_command_words_on_operators(parts: &[String]) -> Option<Vec<Vec<String>>> {
    if parts.is_empty() {
        return None;
    }

    let mut segments: Vec<Vec<String>> = Vec::new();
    let mut current: Vec<String> = Vec::new();

    for word in parts {
        match word.as_str() {
            "|" | "||" | "&&" | ";" => {
                if !current.is_empty() {
                    segments.push(std::mem::take(&mut current));
                }
            }
            _ => current.push(word.clone()),
        }
    }

    if !current.is_empty() {
        segments.push(current);
    }

    if segments.is_empty() || segments.iter().all(|s| s.is_empty()) {
        return None;
    }

    Some(segments)
}

pub(super) fn extract_shell_approval_command_words(
    tool_name: &str,
    tool_args: Option<&Value>,
) -> Option<Vec<String>> {
    let args = shell_run_args(tool_name, tool_args)?;
    extract_shell_command_words_from_run_args(args)
}

pub(super) fn extract_shell_approval_command_prefix_words(
    tool_name: &str,
    tool_args: Option<&Value>,
) -> Option<Vec<String>> {
    let args = shell_run_args(tool_name, tool_args)?;
    let command_words = extract_shell_approval_command_words(tool_name, tool_args)?;
    shell_command_supports_persistent_approval(args, &command_words).then_some(command_words)
}

pub(super) fn extract_shell_permission_scope_signature(
    tool_name: &str,
    tool_args: Option<&Value>,
) -> Option<String> {
    let args = shell_run_args(tool_name, tool_args)?;
    Some(shell_permission_scope_suffix(args))
}

pub(super) fn extract_shell_approval_scope_signature(
    tool_name: &str,
    tool_args: Option<&Value>,
) -> Option<String> {
    let args = shell_run_args(tool_name, tool_args)?;
    let command_words = extract_shell_approval_command_words(tool_name, tool_args)?;
    shell_command_supports_persistent_approval(args, &command_words)
        .then(|| shell_permission_scope_suffix(args))
}

pub(super) fn extract_shell_persistent_approval_prefix_rule(
    tool_name: &str,
    tool_args: Option<&Value>,
) -> Option<Vec<String>> {
    let args = shell_run_args(tool_name, tool_args)?;
    let command_words = extract_shell_command_words_from_run_args(args)?;
    if !shell_command_supports_persistent_approval(args, &command_words) {
        return None;
    }

    let prefix_rule = args
        .get("prefix_rule")
        .and_then(Value::as_array)?
        .iter()
        .map(|value| value.as_str().map(ToOwned::to_owned))
        .collect::<Option<Vec<_>>>()?;

    if prefix_rule.is_empty() || prefix_rule.len() > command_words.len() {
        return None;
    }

    prefix_rule
        .iter()
        .zip(command_words.iter())
        .all(|(prefix, command)| prefix == command)
        .then_some(prefix_rule)
}

pub(super) fn render_shell_persistent_approval_prefix_entry(
    tool_name: &str,
    tool_args: Option<&Value>,
    prefix_rule: &[String],
) -> Option<String> {
    let scope_signature = extract_shell_approval_scope_signature(tool_name, tool_args)?;
    Some(format!(
        "{}|{}",
        render_shell_command_words(prefix_rule),
        scope_signature
    ))
}

pub(super) fn render_shell_approval_command_words(parts: &[String]) -> String {
    render_shell_command_words(parts)
}

pub(super) fn shell_permission_cache_suffix(
    tool_name: &str,
    tool_args: Option<&Value>,
) -> Option<String> {
    let args = shell_run_args(tool_name, tool_args)?;
    let command = extract_shell_command_text_from_run_args(args)?;
    let scope_suffix = shell_permission_scope_suffix(args);

    if parse_shell_sandbox_permissions(args) == CoreSandboxPermissions::UseDefault
        && args.get("additional_permissions").is_none()
    {
        return Some(command);
    }

    Some(format!("{command}|{scope_suffix}"))
}

#[cfg(test)]
pub(super) fn shell_allows_persistent_decisions(
    tool_name: &str,
    tool_args: Option<&Value>,
) -> bool {
    extract_shell_command_text(tool_name, tool_args).is_none()
}

fn truncate_arg_preview(value: &str) -> String {
    const MAX_CHARS: usize = 60;
    const TRUNCATED_CHARS: usize = 57;
    if value.chars().nth(MAX_CHARS).is_some() {
        let mut truncated: String = value.chars().take(TRUNCATED_CHARS).collect();
        truncated.push_str("...");
        truncated
    } else {
        value.to_string()
    }
}

fn tool_args_diff_preview(tool_name: &str, tool_args: Option<&Value>) -> Option<Vec<String>> {
    let args = tool_args?.as_object()?;
    let (before, after) = match tool_name {
        "edit_file" => {
            let old_str = args
                .get("old_str")
                .or_else(|| args.get("old_string"))
                .and_then(Value::as_str)?;
            let new_str = args
                .get("new_str")
                .or_else(|| args.get("new_string"))
                .and_then(Value::as_str)?;
            (Some(old_str), new_str)
        }
        "write_file" | "create_file" => {
            let content = args.get("content").and_then(Value::as_str)?;
            (None, content)
        }
        "unified_file" => {
            let action = args
                .get("action")
                .and_then(Value::as_str)
                .or_else(|| {
                    if args.get("old_str").is_some() || args.get("old_string").is_some() {
                        Some("edit")
                    } else if args.get("content").is_some() {
                        Some("write")
                    } else {
                        None
                    }
                })
                .unwrap_or("read");

            match action {
                "edit" => {
                    let old_str = args
                        .get("old_str")
                        .or_else(|| args.get("old_string"))
                        .and_then(Value::as_str)?;
                    let new_str = args
                        .get("new_str")
                        .or_else(|| args.get("new_string"))
                        .and_then(Value::as_str)?;
                    (Some(old_str), new_str)
                }
                "write" | "create" => {
                    let content = args.get("content").and_then(Value::as_str)?;
                    (None, content)
                }
                _ => return None,
            }
        }
        _ => return None,
    };
    let path = args
        .get("path")
        .or_else(|| args.get("file_path"))
        .or_else(|| args.get("target_path"))
        .and_then(Value::as_str)
        .unwrap_or("(unknown file)");

    let diff_preview = vtcode_core::tools::file_ops::build_diff_preview(path, before, after);
    let skipped = diff_preview
        .get("skipped")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    if skipped {
        let reason = diff_preview
            .get("reason")
            .and_then(Value::as_str)
            .unwrap_or("preview unavailable");
        return Some(vec![format!("diff: {}", reason)]);
    }

    let content = diff_preview
        .get("content")
        .and_then(Value::as_str)
        .unwrap_or("");
    if content.is_empty() {
        return Some(vec!["(no changes)".to_string()]);
    }

    let plain = vtcode_commons::ansi::strip_ansi(content);
    let lines = format_unified_diff_lines(&plain);
    if lines.len() <= 80 {
        return Some(lines);
    }

    let mut preview = Vec::with_capacity(61);
    preview.extend(lines.iter().take(40).cloned());
    preview.push(format!("… +{} lines", lines.len().saturating_sub(60)));
    preview.extend(lines.iter().skip(lines.len().saturating_sub(20)).cloned());
    Some(preview)
}

fn build_tool_permission_options(
    prompt_kind: ToolPermissionPromptKind,
    persistent_approval_target: Option<&PersistentApprovalTarget>,
) -> Vec<vtcode_tui::app::InlineListItem> {
    use vtcode_tui::app::{InlineListItem, InlineListSelection};

    let mut options = vec![
        InlineListItem {
            title: "Approve Once".to_string(),
            subtitle: Some("Allow this tool to execute this time only".to_string()),
            badge: None,
            indent: 0,
            selection: Some(InlineListSelection::ToolApproval(true)),
            search_value: Some("approve yes allow once y 1".to_string()),
        },
        InlineListItem {
            title: if prompt_kind == ToolPermissionPromptKind::Mcp {
                "Approve this session".to_string()
            } else {
                "Allow for Session".to_string()
            },
            subtitle: if prompt_kind == ToolPermissionPromptKind::Mcp {
                Some("Run the tool and remember this choice for this session".to_string())
            } else {
                Some("Allow this tool for the current session".to_string())
            },
            badge: Some("Session".to_string()),
            indent: 0,
            selection: Some(InlineListSelection::ToolApprovalSession),
            search_value: Some("session temporary temp 2".to_string()),
        },
    ];

    if let Some(target) = persistent_approval_target {
        let subtitle = match target {
            PersistentApprovalTarget::ToolLevel => {
                "Remember approval for this tool in this workspace".to_string()
            }
            PersistentApprovalTarget::ExactInvocation { display_label } => {
                format!("Remember approval for {} in this workspace", display_label)
            }
            PersistentApprovalTarget::PrefixRule { display_label, .. } => {
                format!("Remember approval for {} in this workspace", display_label)
            }
        };
        options.push(InlineListItem {
            title: "Always approve and save to policy cache".to_string(),
            subtitle: Some(subtitle),
            badge: Some("Permanent".to_string()),
            indent: 0,
            selection: Some(InlineListSelection::ToolApprovalPermanent),
            search_value: Some("always permanent forever save 3".to_string()),
        });
    }

    options.push(InlineListItem {
        title: "".to_string(),
        subtitle: None,
        badge: None,
        indent: 0,
        selection: None,
        search_value: None,
    });

    options.push(InlineListItem {
        title: if prompt_kind == ToolPermissionPromptKind::Mcp {
            "Cancel".to_string()
        } else {
            "Deny Once".to_string()
        },
        subtitle: if prompt_kind == ToolPermissionPromptKind::Mcp {
            Some("Cancel this tool call".to_string())
        } else {
            Some("Reject this tool for now (ask again next time)".to_string())
        },
        badge: None,
        indent: 0,
        selection: Some(InlineListSelection::ToolApprovalDenyOnce),
        search_value: Some(if prompt_kind == ToolPermissionPromptKind::Mcp {
            "cancel stop reject decline 4".to_string()
        } else {
            "deny no reject once temporary 4".to_string()
        }),
    });

    if matches!(
        persistent_approval_target,
        Some(PersistentApprovalTarget::ToolLevel)
    ) && prompt_kind != ToolPermissionPromptKind::Mcp
    {
        options.push(InlineListItem {
            title: "Always Deny".to_string(),
            subtitle: Some("Block this tool until policy is changed".to_string()),
            badge: Some("Persistent".to_string()),
            indent: 0,
            selection: Some(InlineListSelection::ToolApproval(false)),
            search_value: Some("deny no reject cancel never always 5".to_string()),
        });
    }

    options
}

pub(super) async fn prompt_tool_permission<S: UiSession + ?Sized>(
    display_name: &str,
    tool_name: &str,
    tool_args: Option<&Value>,
    learning_target: &ApprovalLearningTarget,
    renderer: &mut AnsiRenderer,
    handle: &InlineHandle,
    session: &mut S,
    ctrl_c_state: &Arc<CtrlCState>,
    ctrl_c_notify: &Arc<Notify>,
    default_placeholder: Option<String>,
    approval_reason: Option<&str>,
    justification: Option<&vtcode_core::tools::ToolJustification>,
    persistent_approval_target: Option<&PersistentApprovalTarget>,
    approval_recorder: Option<&vtcode_core::tools::ApprovalRecorder>,
    hitl_notification_bell: bool,
    source_thread_label: Option<&str>,
) -> Result<HitlDecision> {
    // Auto-approve if the approval recorder indicates high-frequency approval
    // history (≥3 approvals with >80% approval rate) for the exact invocation
    // OR any broader learned pattern (e.g. safe `find <subdir>` family).
    if let Some(recorder) = approval_recorder {
        for (key, _label) in learning_target.iter_keys() {
            if recorder.should_auto_approve(key).await {
                tracing::info!(
                    tool = %tool_name,
                    key = %key,
                    "Auto-approved based on learned approval pattern"
                );
                return Ok(HitlDecision::Approved);
            }
        }
    }
    let approval_learning_key = learning_target.approval_key.as_str();
    let approval_learning_label = learning_target.display_label.as_str();

    let prompt_kind = tool_permission_prompt_kind(tool_name);
    let mut description_lines = vec![
        format!("Tool: {}", tool_name),
        format!("Action: {}", display_name),
    ];

    if let Some(source_label) = source_thread_label {
        description_lines.push(format!("Source: {}", source_label));
    }

    if let Some(args) = tool_args
        && let Some(obj) = args.as_object()
    {
        if let Some(diff_lines) = tool_args_diff_preview(tool_name, tool_args) {
            description_lines.push(String::new());
            description_lines.extend(diff_lines);
        } else {
            for (key, value) in obj.iter().take(3) {
                if let Some(str_val) = value.as_str() {
                    description_lines.push(format!("  {}: {}", key, truncate_arg_preview(str_val)));
                } else if let Some(bool_val) = value.as_bool() {
                    description_lines.push(format!("  {}: {}", key, bool_val));
                } else if let Some(num_val) = value.as_number() {
                    description_lines.push(format!("  {}: {}", key, num_val));
                }
            }
            if obj.len() > 3 {
                description_lines.push(format!("  ... and {} more arguments", obj.len() - 3));
            }
        }
    }

    if let Some(reason) = approval_reason {
        description_lines.push(String::new());
        description_lines.push(format!("Reason: {}", reason));
    }

    if let Some(shell_justification) = extract_shell_approval_justification(tool_name, tool_args) {
        description_lines.push(format!("Justification: {}", shell_justification));
    }

    if let Some(just) = justification {
        let just_lines = just.format_for_dialog();
        description_lines.extend(just_lines);
    }

    if let Some(recorder) = approval_recorder
        && let Some(suggestion) = recorder
            .get_auto_approval_suggestion(approval_learning_key, approval_learning_label)
            .await
    {
        description_lines.push(String::new());
        description_lines.push(format!("Suggestion: {}", suggestion));
    }

    description_lines.push(String::new());
    description_lines.push("Choose how to handle this tool execution:".to_string());
    let mut navigation_hint = if prompt_kind == ToolPermissionPromptKind::Mcp {
        "Use ↑↓ or Tab to navigate • Enter to select • Esc to cancel".to_string()
    } else {
        "Use ↑↓ or Tab to navigate • Enter to select • Esc to deny".to_string()
    };
    if source_thread_label.is_some() {
        navigation_hint.push_str(" • o inspect source thread");
    }
    description_lines.push(navigation_hint);

    let options = build_tool_permission_options(prompt_kind, persistent_approval_target);
    let hotkeys = source_thread_label
        .map(|_| {
            vec![TransientHotkey {
                key: TransientHotkeyKey::Char('o'),
                action: TransientHotkeyAction::OpenSourceThread,
            }]
        })
        .unwrap_or_default();

    use vtcode_tui::app::InlineListSelection;
    let default_selection = InlineListSelection::ToolApproval(true);
    if hitl_notification_bell
        && let Err(err) = send_global_notification(NotificationEvent::PermissionPrompt {
            title: "VT Code approval required".to_string(),
            message: format!("Review the permission prompt for tool `{tool_name}`."),
        })
        .await
    {
        tracing::debug!(error = %err, "Failed to emit HITL notification");
    }
    let _placeholder_guard = PlaceholderGuard::new(handle, default_placeholder);
    let outcome = show_overlay_and_wait(
        handle,
        session,
        TransientRequest::List(ListOverlayRequest {
            title: "Tool Permission Required".to_string(),
            lines: description_lines,
            footer_hint: None,
            items: options,
            selected: Some(default_selection),
            search: None,
            hotkeys,
        }),
        ctrl_c_state,
        ctrl_c_notify,
        |submission| match submission {
            TransientSubmission::Hotkey(TransientHotkeyAction::OpenSourceThread) => {
                handle.set_input("/agent".to_string());
                let _ = renderer.line(
                    vtcode_core::utils::ansi::MessageStyle::Info,
                    "Switched focus to thread selector command. Run /agent to inspect the source thread, then retry the tool action.",
                );
                Some(HitlDecision::DeniedOnce)
            }
            TransientSubmission::Selection(InlineListSelection::ToolApproval(true)) => {
                Some(HitlDecision::Approved)
            }
            TransientSubmission::Selection(InlineListSelection::ToolApprovalSession) => {
                Some(HitlDecision::ApprovedSession)
            }
            TransientSubmission::Selection(InlineListSelection::ToolApprovalPermanent) => {
                Some(HitlDecision::ApprovedPermanent)
            }
            TransientSubmission::Selection(InlineListSelection::ToolApprovalDenyOnce) => {
                Some(HitlDecision::DeniedOnce)
            }
            TransientSubmission::Selection(InlineListSelection::ToolApproval(false)) => {
                Some(HitlDecision::Denied)
            }
            TransientSubmission::Selection(_) => Some(HitlDecision::Denied),
            _ => None,
        },
    )
    .await?;

    match outcome {
        OverlayWaitOutcome::Submitted(decision) => Ok(decision),
        OverlayWaitOutcome::Cancelled => Ok(cancelled_prompt_decision(prompt_kind)),
        OverlayWaitOutcome::Interrupted => Ok(HitlDecision::Interrupt),
        OverlayWaitOutcome::Exit => Ok(HitlDecision::Exit),
    }
}

#[cfg(test)]
mod tests {
    use super::{
        ToolPermissionPromptKind, build_tool_permission_options, cancelled_prompt_decision,
        extract_shell_approval_command_prefix_words, extract_shell_approval_justification,
        extract_shell_approval_scope_signature, extract_shell_command_text,
        extract_shell_persistent_approval_prefix_rule,
        render_shell_persistent_approval_prefix_entry, shell_allows_persistent_decisions,
        shell_permission_cache_suffix, tool_permission_prompt_kind, truncate_arg_preview,
    };
    use crate::agent::runloop::unified::tool_routing::shell_approval::PersistentApprovalTarget;
    use serde_json::json;

    #[test]
    fn unified_exec_extracts_command_when_action_is_run() {
        let args = json!({
            "action": "run",
            "command": "cargo check"
        });
        let command = extract_shell_command_text("unified_exec", Some(&args));
        assert_eq!(command.as_deref(), Some("cargo check"));
    }

    #[test]
    fn unified_exec_extracts_cmd_alias_when_action_is_inferred() {
        let args = json!({
            "cmd": "cargo check"
        });
        let command = extract_shell_command_text("unified_exec", Some(&args));
        assert_eq!(command.as_deref(), Some("cargo check"));
    }

    #[test]
    fn unified_exec_extracts_indexed_command_parts_when_action_is_inferred() {
        let args = json!({
            "command.0": "cargo",
            "command.1": "check"
        });
        let command = extract_shell_command_text("unified_exec", Some(&args));
        assert_eq!(command.as_deref(), Some("cargo check"));
    }

    #[test]
    fn unified_exec_ignores_non_run_actions() {
        let args = json!({
            "action": "poll",
            "session_id": "run-123"
        });
        let command = extract_shell_command_text("unified_exec", Some(&args));
        assert_eq!(command, None);
    }

    #[test]
    fn arg_preview_truncates_unicode_safely() {
        let value = "an’t ".repeat(20);
        let truncated = truncate_arg_preview(&value);
        assert!(truncated.ends_with("..."));
        assert!(truncated.chars().count() <= 60);
    }

    #[test]
    fn shell_runs_disable_persistent_decisions() {
        let args = json!({
            "action": "run",
            "command": "echo hi",
            "sandbox_permissions": "with_additional_permissions"
        });
        assert!(!shell_allows_persistent_decisions(
            "unified_exec",
            Some(&args)
        ));
    }

    #[test]
    fn non_shell_unified_exec_actions_keep_persistent_decisions() {
        let args = json!({
            "action": "poll",
            "session_id": "run-123",
            "sandbox_permissions": "with_additional_permissions"
        });
        assert!(shell_allows_persistent_decisions(
            "unified_exec",
            Some(&args)
        ));
    }

    #[test]
    fn cache_suffix_includes_permissions_for_shell_run() {
        let plain = json!({
            "command": "echo hi"
        });
        let with_permissions = json!({
            "command": "echo hi",
            "sandbox_permissions": "with_additional_permissions",
            "additional_permissions": {
                "fs_write": ["/tmp/demo.txt"]
            }
        });

        let plain_key = shell_permission_cache_suffix("shell", Some(&plain));
        let permissioned_key = shell_permission_cache_suffix("shell", Some(&with_permissions));

        assert_ne!(plain_key, permissioned_key);
        assert!(
            permissioned_key
                .as_deref()
                .unwrap_or_default()
                .contains("with_additional_permissions")
        );
    }

    #[test]
    fn shell_approval_justification_is_extracted_for_run_actions() {
        let args = json!({
            "action": "run",
            "command": "cargo build",
            "justification": "Do you want to build the project outside the sandbox?"
        });

        let justification = extract_shell_approval_justification("unified_exec", Some(&args));
        assert_eq!(
            justification.as_deref(),
            Some("Do you want to build the project outside the sandbox?")
        );
    }

    #[test]
    fn shell_approval_justification_ignores_non_run_actions() {
        let args = json!({
            "action": "poll",
            "session_id": "run-123",
            "justification": "ignored"
        });

        let justification = extract_shell_approval_justification("unified_exec", Some(&args));
        assert_eq!(justification, None);
    }

    #[test]
    fn shell_persistent_prefix_rule_requires_matching_command_prefix() {
        let args = json!({
            "action": "run",
            "command": "cargo test -p vtcode",
            "prefix_rule": ["cargo", "build"]
        });

        let prefix_rule =
            extract_shell_persistent_approval_prefix_rule("unified_exec", Some(&args));
        assert_eq!(prefix_rule, None);
    }

    #[test]
    fn shell_persistent_prefix_rule_rejects_compound_commands() {
        let args = json!({
            "action": "run",
            "command": "cargo test && cargo fmt",
            "prefix_rule": ["cargo", "test"]
        });

        let prefix_rule =
            extract_shell_persistent_approval_prefix_rule("unified_exec", Some(&args));
        assert_eq!(prefix_rule, None);
    }

    #[test]
    fn shell_approval_prefix_text_normalizes_simple_commands() {
        let args = json!({
            "action": "run",
            "command": ["cargo", "test", "-p", "vtcode"]
        });

        let command = extract_shell_approval_command_prefix_words("unified_exec", Some(&args));
        assert_eq!(
            command,
            Some(vec![
                "cargo".to_string(),
                "test".to_string(),
                "-p".to_string(),
                "vtcode".to_string()
            ])
        );
    }

    #[test]
    fn shell_approval_scope_signature_tracks_requested_permissions() {
        let args = json!({
            "action": "run",
            "command": "cargo test",
            "sandbox_permissions": "require_escalated"
        });

        let scope = extract_shell_approval_scope_signature("unified_exec", Some(&args));
        assert_eq!(
            scope.as_deref(),
            Some("sandbox_permissions=\"require_escalated\"|additional_permissions=null")
        );
    }

    #[test]
    fn shell_persistent_approval_entry_includes_scope() {
        let args = json!({
            "action": "run",
            "command": "cargo test -p vtcode",
            "prefix_rule": ["cargo", "test"],
            "sandbox_permissions": "require_escalated"
        });

        let entry = render_shell_persistent_approval_prefix_entry(
            "unified_exec",
            Some(&args),
            &["cargo".to_string(), "test".to_string()],
        );
        assert_eq!(
            entry.as_deref(),
            Some(
                "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null"
            )
        );
    }

    #[test]
    fn canonical_mcp_tools_use_mcp_prompt_kind() {
        assert_eq!(
            tool_permission_prompt_kind("mcp::calendar::list_events"),
            ToolPermissionPromptKind::Mcp
        );
    }

    #[test]
    fn model_visible_mcp_tools_use_mcp_prompt_kind() {
        assert_eq!(
            tool_permission_prompt_kind("mcp__calendar__list_events"),
            ToolPermissionPromptKind::Mcp
        );
    }

    #[test]
    fn non_mcp_tools_keep_standard_prompt_kind() {
        assert_eq!(
            tool_permission_prompt_kind("unified_exec"),
            ToolPermissionPromptKind::Standard
        );
    }

    #[test]
    fn mcp_prompt_uses_cancel_without_persistent_deny() {
        let titles = build_tool_permission_options(
            ToolPermissionPromptKind::Mcp,
            Some(&PersistentApprovalTarget::ToolLevel),
        )
        .into_iter()
        .map(|item| item.title)
        .collect::<Vec<_>>();
        assert!(titles.iter().any(|title| title == "Cancel"));
        assert!(!titles.iter().any(|title| title == "Always Deny"));
    }

    #[test]
    fn standard_prompt_keeps_persistent_deny() {
        let titles = build_tool_permission_options(
            ToolPermissionPromptKind::Standard,
            Some(&PersistentApprovalTarget::ToolLevel),
        )
        .into_iter()
        .map(|item| item.title)
        .collect::<Vec<_>>();
        assert!(titles.iter().any(|title| title == "Deny Once"));
        assert!(titles.iter().any(|title| title == "Always Deny"));
    }

    #[test]
    fn shell_prompt_offers_policy_cache_option_for_exact_invocation() {
        let titles = build_tool_permission_options(
            ToolPermissionPromptKind::Standard,
            Some(&PersistentApprovalTarget::ExactInvocation {
                display_label: "command `cargo clippy`".to_string(),
            }),
        )
        .into_iter()
        .map(|item| item.title)
        .collect::<Vec<_>>();
        assert!(
            titles
                .iter()
                .any(|title| title == "Always approve and save to policy cache")
        );
        assert!(!titles.iter().any(|title| title == "Always Deny"));
    }

    #[test]
    fn mcp_prompt_cancellation_is_non_persistent() {
        assert_eq!(
            cancelled_prompt_decision(ToolPermissionPromptKind::Mcp),
            super::HitlDecision::DeniedOnce
        );
    }
}
