//! Command interaction heuristics for non-interactive shells.
//!
//! WHY this exists: some commands are unsafe to run blindly from the agent
//! because they either require a real TTY (e.g. `vim`, `top`, interactive
//! `memo`) or may stall behind a macOS privacy / automation consent dialog.
//! The correct fix is not "wait longer" — it is to detect the unsupported
//! interaction mode, fail deterministically, and tell the user what action is
//! required.

use std::sync::OnceLock;
use std::time::Duration;

use edgecrab_types::ToolError;
use regex::Regex;

use crate::macos_permissions::{MacosConsentState, preflight_command_permissions};
use crate::shell_syntax::command_contains_heredoc;
use crate::tools::backends::{BackendKind, ExecOutput};

const DEFAULT_MACOS_DIALOG_STALL_SECS: u64 = 15;

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct CommandInteractionAssessment {
    pub tty_reason: Option<&'static str>,
    pub macos_prompt_reason: Option<&'static str>,
    pub macos_privacy_reason: Option<&'static str>,
}

struct CapabilityBlock {
    code: &'static str,
    message: String,
    suggested_action: Option<String>,
}

fn obvious_tty_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"(?i)(?:^|[;&|]\s*)(?:vim|vi|nano|less|more|man|top|htop|watch|fzf|tig|lazygit|tmux|screen)\b")
            .expect("valid tty regex")
    })
}

fn memo_interactive_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"(?i)\bmemo\s+notes\b.*\s-(?:a|e|d|m)\b").expect("valid memo regex")
    })
}

fn applescript_automation_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r#"(?is)\bosascript\b.*(?:tell\s+application(?:\s+id)?\s+["']|system\s+events|keystroke|click\s+(?:button|menu|menu item)|activate\b)"#)
            .expect("valid osascript regex")
    })
}

fn shortcuts_run_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"(?i)\bshortcuts\s+run\b").expect("valid shortcuts regex"))
}

fn memo_notes_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"(?i)\bmemo\s+notes\b").expect("valid memo notes regex"))
}

fn host_is_local_macos(backend_kind: &BackendKind) -> bool {
    cfg!(target_os = "macos") && matches!(backend_kind, BackendKind::Local)
}

fn command_preview(command: &str) -> String {
    let single_line = command.split_whitespace().collect::<Vec<_>>().join(" ");
    crate::safe_truncate(&single_line, 120).to_string()
}

fn describe_macos_preflight(command: &str) -> Option<CapabilityBlock> {
    let preflight = preflight_command_permissions(command);

    if let Some(state) = preflight.accessibility_state
        && matches!(
            state,
            MacosConsentState::Denied | MacosConsentState::WouldPrompt
        )
    {
        let (code, action) = if state == MacosConsentState::WouldPrompt {
            ("macos_accessibility_required", "would trigger")
        } else {
            ("macos_accessibility_denied", "was denied")
        };
        return Some(CapabilityBlock {
                code,
                message: format!(
                    "macOS Accessibility access for the terminal host {action}. Grant Accessibility access in System Settings -> Privacy & Security -> Accessibility."
                ),
                suggested_action: Some(
                    "Grant Accessibility access to the terminal host application, then rerun the command."
                        .into(),
                ),
            });
    }

    if let Some(state) = preflight.automation_state {
        if matches!(
            state,
            MacosConsentState::Denied | MacosConsentState::WouldPrompt
        ) {
            let target = preflight
                .automation_target
                .as_deref()
                .unwrap_or("the target app");
            let (code, action) = if state == MacosConsentState::WouldPrompt {
                (
                    "macos_automation_required",
                    "would trigger a consent prompt",
                )
            } else {
                ("macos_automation_denied", "was previously denied")
            };
            return Some(CapabilityBlock {
                code,
                message: format!(
                    "macOS Automation consent for {target} {action}. Bring the terminal host application to the front and allow it under System Settings -> Privacy & Security -> Automation."
                ),
                suggested_action: Some(format!(
                    "Bring the terminal host application to the front, allow Automation access for {target}, then rerun the command."
                )),
            });
        }

        if state == MacosConsentState::Unknown {
            let target = preflight
                .automation_target
                .as_deref()
                .unwrap_or("the target app");
            return Some(CapabilityBlock {
                code: "macos_automation_unknown",
                message: format!(
                    "macOS could not determine Automation consent for {target}. This usually means the target app is not running yet, or AppleEvents preflight cannot inspect the command shape safely. Open the target app first, run `/permissions bootstrap` or `/permissions status`, and rerun only after consent shows as granted. If macOS still never shows a prompt, use `/permissions reset` to reset AppleEvents and Accessibility for the terminal host app."
                ),
                suggested_action: Some(format!(
                    "Open {target}, run `/permissions bootstrap` or `/permissions status`, and retry only after Automation consent is granted."
                )),
            });
        }
    }

    if applescript_automation_regex().is_match(command)
        && preflight.automation_state.is_none()
        && preflight.accessibility_state.is_none()
    {
        return Some(CapabilityBlock {
            code: "macos_applescript_target_unresolved",
            message: "EdgeCrab could not statically determine the AppleScript target from this command. This commonly happens when the script is fed to `osascript` through stdin, a heredoc, or another shell layer. To avoid a hidden macOS consent dialog or a flaky stall, EdgeCrab requires an explicit permission bootstrap first. Prefer inline `osascript -e '...'`, or run `/permissions bootstrap` and retry after consent is granted.".into(),
            suggested_action: Some(
                "Prefer an explicit AppleScript target or run `/permissions bootstrap` before retrying."
                    .into(),
            ),
        });
    }

    None
}

fn memo_command_has_explicit_stdin(command: &str) -> bool {
    command.contains("<<")
        || command.contains("<<<")
        || command.contains(" < ")
        || command.contains("\n<")
        || command.contains("| memo")
        || command.contains("|memo")
}

pub(crate) fn assess_command(command: &str) -> CommandInteractionAssessment {
    let mut assessment = CommandInteractionAssessment::default();

    if obvious_tty_regex().is_match(command) {
        assessment.tty_reason = Some("full-screen terminal UI");
    } else if memo_interactive_regex().is_match(command)
        && !memo_command_has_explicit_stdin(command)
    {
        assessment.tty_reason = Some("interactive memo notes flow");
    }

    if applescript_automation_regex().is_match(command) {
        assessment.macos_prompt_reason = Some("AppleScript automation");
    } else if memo_notes_regex().is_match(command) {
        assessment.macos_prompt_reason = Some("Apple Notes automation");
    } else if shortcuts_run_regex().is_match(command) {
        assessment.macos_prompt_reason = Some("Shortcuts automation");
    }

    let normalized = command.to_ascii_lowercase();
    if normalized.contains("/library/messages")
        || normalized.contains("/library/mail")
        || normalized.contains("/library/safari")
        || normalized.contains("/library/calendars")
        || normalized.contains("/library/addressbook")
    {
        assessment.macos_privacy_reason = Some("protected macOS application data");
    }

    assessment
}

pub(crate) fn guard_terminal_command(
    command: &str,
    backend_kind: &BackendKind,
    pty: bool,
) -> Result<CommandInteractionAssessment, ToolError> {
    let assessment = assess_command(command);
    if command_contains_heredoc(command) {
        return Err(
            ToolError::capability_denied(
                "terminal",
                "shell_heredoc_unsupported",
                format!(
                    "Shell heredocs are not supported in `terminal`. A heredoc embeds multi-line input directly inside the tool-call command string, which is a second content-transport path and is unreliable for edits or large stdin payloads.\nCommand: `{}`",
                    command_preview(command)
                ),
            )
            .with_suggested_action(
                "Use `write_file`, `patch`, or `apply_patch` for file content. If a command genuinely needs stdin, write the input to a file first and redirect it with `< file`."
                    .to_string(),
            ),
        );
    }
    if let Some(reason) = assessment.tty_reason {
        let (code, message, action) = if pty {
            (
                "terminal_observation_unsupported",
                format!(
                    "Command requires an interactive terminal UI ({reason}). PTY transport is available, but EdgeCrab does not model full-screen terminal screens, cursor state, or alternate-screen redraws. Running this command would still be unobservable and unreliable.\nCommand: `{}`",
                    command_preview(command)
                ),
                "Use a line-oriented CLI mode, a non-interactive flag, or a real terminal session."
                    .to_string(),
            )
        } else {
            (
                "non_interactive_terminal_required",
                format!(
                    "Command requires an interactive terminal UI ({reason}), but the terminal tool runs non-interactively unless PTY is enabled. Use non-interactive flags, explicit stdin redirection, or a real terminal session instead.\nCommand: `{}`",
                    command_preview(command)
                ),
                "Use a non-interactive command form, explicit stdin redirection, or a real terminal session.".to_string(),
            )
        };
        return Err(
            ToolError::capability_denied("terminal", code, message).with_suggested_action(action)
        );
    }
    if !host_is_local_macos(backend_kind) {
        return Ok(CommandInteractionAssessment {
            tty_reason: assessment.tty_reason,
            macos_prompt_reason: None,
            macos_privacy_reason: None,
        });
    }
    if let Some(preflight) = describe_macos_preflight(command) {
        let mut error = ToolError::capability_denied(
            "terminal",
            preflight.code,
            format!(
                "Command requires macOS consent before it can run safely. {}\nEdgeCrab did not start the command to avoid a hidden GUI prompt or flaky timeout.\nCommand: `{}`",
                preflight.message,
                command_preview(command)
            ),
        );
        if let Some(action) = preflight.suggested_action {
            error = error.with_suggested_action(action);
        }
        return Err(error);
    }
    Ok(assessment)
}

pub(crate) fn guard_run_process_command(
    command: &str,
    backend_kind: &BackendKind,
    pty: bool,
) -> Result<(), ToolError> {
    let assessment = assess_command(command);
    if let Some(reason) = assessment.tty_reason {
        let (code, message, action) = if pty {
            (
                "background_terminal_observation_unsupported",
                format!(
                    "Command requires an interactive terminal UI ({reason}). A PTY can carry bytes, but background process observation is stream-based and cannot faithfully observe full-screen terminal state. Backgrounding this command would be unreliable.\nCommand: `{}`",
                    command_preview(command)
                ),
                "Use a line-oriented CLI mode or run the command in a real terminal session."
                    .to_string(),
            )
        } else {
            (
                "background_interactive_terminal_unsupported",
                format!(
                    "Command requires an interactive terminal UI ({reason}), which is unsafe to background without a PTY. Use a non-interactive form of the command or run it in a real terminal session.\nCommand: `{}`",
                    command_preview(command)
                ),
                "Use a non-interactive command form or run it in a real terminal session instead of `run_process`.".to_string(),
            )
        };
        return Err(ToolError::capability_denied("run_process", code, message)
            .with_suggested_action(action));
    }
    if host_is_local_macos(backend_kind)
        && let Some(reason) = assessment.macos_prompt_reason
    {
        let preflight = describe_macos_preflight(command);
        let extra = preflight
            .as_ref()
            .map(|block| format!("\nPreflight: {}", block.message))
            .unwrap_or_default();
        let mut error = ToolError::capability_denied(
            "run_process",
            "background_macos_consent_unsupported",
            format!(
                "Command may block on a macOS permission dialog ({reason}). Background execution would hide that prompt and create flaky behavior. Run it in the foreground with `terminal` after granting the required macOS permission.{extra}\nCommand: `{}`",
                command_preview(command),
            ),
        );
        error = error.with_suggested_action(
                "Grant the required macOS permission, then rerun the command in the foreground with `terminal`."
                    .to_string(),
            );
        return Err(error);
    }
    Ok(())
}

pub(crate) fn macos_prompt_stall_timeout(
    command: &str,
    backend_kind: &BackendKind,
) -> Option<Duration> {
    if !host_is_local_macos(backend_kind) {
        return None;
    }
    let assessment = assess_command(command);
    assessment.macos_prompt_reason?;
    tracing::debug!(
        command_preview = &command[..command.len().min(80)],
        reason = assessment.macos_prompt_reason,
        "command may trigger macOS permission dialog — running preflight check"
    );
    let preflight = preflight_command_permissions(command);
    if matches!(
        preflight.accessibility_state,
        Some(MacosConsentState::Granted)
    ) && preflight.automation_state.is_none()
    {
        return None;
    }
    if matches!(preflight.automation_state, Some(MacosConsentState::Granted)) {
        return None;
    }
    if matches!(
        preflight.accessibility_state,
        Some(MacosConsentState::Denied | MacosConsentState::WouldPrompt)
    ) {
        return None;
    }
    if matches!(
        preflight.automation_state,
        Some(MacosConsentState::Denied | MacosConsentState::WouldPrompt)
    ) {
        return None;
    }

    let secs = std::env::var("EDGECRAB_MACOS_DIALOG_STALL_SECS")
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
        .filter(|secs| *secs > 0)
        .unwrap_or(DEFAULT_MACOS_DIALOG_STALL_SECS);
    Some(Duration::from_secs(secs))
}

fn format_macos_prompt_timeout(command: &str, reason: &str, stall_timeout: Duration) -> ToolError {
    ToolError::capability_denied(
        "terminal",
        "macos_permission_dialog_stall",
        format!(
            "Command likely stalled waiting for a macOS permission dialog ({reason}). EdgeCrab stopped waiting after {}s instead of hanging until the full command timeout.\nWhat to do: bring the terminal host application (for example VS Code, Terminal, or iTerm) to the front, allow the macOS prompt, then rerun the command. Check System Settings -> Privacy & Security -> Automation / Accessibility / Full Disk Access if needed.\nNo automatic retry was attempted to avoid duplicating side effects.\nCommand: `{}`",
            stall_timeout.as_secs(),
            command_preview(command)
        ),
    )
    .with_suggested_action(
        "Bring the terminal host application to the front, resolve the macOS dialog, then rerun the command."
            .to_string(),
    )
}

fn format_macos_prompt_denied(command: &str, reason: &str, output: &str) -> ToolError {
    ToolError::capability_denied(
        "terminal",
        "macos_automation_denied",
        format!(
            "macOS denied the requested permission ({reason}). Grant access to the terminal host application in System Settings -> Privacy & Security, then rerun the command.\nObserved output: `{}`\nCommand: `{}`",
            crate::safe_truncate(output.trim(), 220),
            command_preview(command)
        ),
    )
    .with_suggested_action(
        "Grant the missing Automation or Accessibility permission to the terminal host application, then rerun the command."
            .to_string(),
    )
}

fn format_macos_privacy_denied(command: &str, reason: &str, output: &str) -> ToolError {
    ToolError::capability_denied(
        "terminal",
        "macos_full_disk_access_denied",
        format!(
            "macOS blocked access to protected data ({reason}). Grant Full Disk Access to the terminal host application in System Settings -> Privacy & Security -> Full Disk Access, then rerun the command.\nObserved output: `{}`\nCommand: `{}`",
            crate::safe_truncate(output.trim(), 220),
            command_preview(command)
        ),
    )
    .with_suggested_action(
        "Grant Full Disk Access to the terminal host application, then rerun the command."
            .to_string(),
    )
}

fn output_mentions_macos_automation_denial(output: &str) -> bool {
    let normalized = output.to_ascii_lowercase();
    normalized.contains("not authorized to send apple events")
        || normalized.contains("not authorised to send apple events")
        || normalized.contains("apple events to") && normalized.contains("not authorized")
        || normalized.contains("(-1743)")
        || normalized.contains("assistive access")
        || normalized.contains("accessibility access")
}

fn output_mentions_permission_denial(output: &str) -> bool {
    let normalized = output.to_ascii_lowercase();
    normalized.contains("operation not permitted")
        || normalized.contains("permission denied")
        || normalized.contains("not authorized")
}

pub(crate) fn rewrite_terminal_exec_result(
    command: &str,
    backend_kind: &BackendKind,
    requested_timeout: Duration,
    exec_output: &ExecOutput,
) -> Result<(), ToolError> {
    if !host_is_local_macos(backend_kind) {
        return Ok(());
    }

    let assessment = assess_command(command);
    let combined_output = if exec_output.stderr.trim().is_empty() {
        exec_output.stdout.trim().to_string()
    } else if exec_output.stdout.trim().is_empty() {
        exec_output.stderr.trim().to_string()
    } else {
        format!(
            "{}\n{}",
            exec_output.stdout.trim(),
            exec_output.stderr.trim()
        )
    };

    if let Some(reason) = assessment.macos_prompt_reason {
        if output_mentions_macos_automation_denial(&combined_output) {
            return Err(format_macos_prompt_denied(
                command,
                reason,
                &combined_output,
            ));
        }

        if let Some(stall_timeout) = macos_prompt_stall_timeout(command, backend_kind)
            && requested_timeout > stall_timeout
            && exec_output.exit_code == 124
            && exec_output.stdout.trim().is_empty()
            && exec_output.stderr.trim().is_empty()
        {
            return Err(format_macos_prompt_timeout(command, reason, stall_timeout));
        }
    }

    if let Some(reason) = assessment.macos_privacy_reason
        && output_mentions_permission_denial(&combined_output)
    {
        return Err(format_macos_privacy_denied(
            command,
            reason,
            &combined_output,
        ));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_obvious_tty_commands() {
        let assessment = assess_command("vim Cargo.toml");
        assert_eq!(assessment.tty_reason, Some("full-screen terminal UI"));
    }

    #[test]
    fn detects_interactive_memo_without_blocking_redirected_input() {
        let blocked = assess_command("memo notes -a");
        assert_eq!(blocked.tty_reason, Some("interactive memo notes flow"));

        let allowed = assess_command("printf '%s\\n' body | memo notes -a \"Title\"");
        assert_eq!(allowed.tty_reason, None);
        assert_eq!(allowed.macos_prompt_reason, Some("Apple Notes automation"));
    }

    #[test]
    fn detects_applescript_automation_but_not_plain_delay() {
        let automation = assess_command("osascript -e 'tell application \"Notes\" to activate'");
        assert_eq!(
            automation.macos_prompt_reason,
            Some("AppleScript automation")
        );

        let by_bundle_id =
            assess_command("osascript -e 'tell application id \"com.apple.Notes\" to activate'");
        assert_eq!(
            by_bundle_id.macos_prompt_reason,
            Some("AppleScript automation")
        );

        let inert = assess_command("osascript -e 'delay 1'");
        assert_eq!(inert.macos_prompt_reason, None);
    }

    #[test]
    fn terminal_guard_blocks_tty_commands() {
        let err =
            guard_terminal_command("top", &BackendKind::Local, false).expect_err("tty should fail");
        let ToolError::CapabilityDenied { message, code, .. } = err else {
            panic!("expected capability denied");
        };
        assert_eq!(code, "non_interactive_terminal_required");
        assert!(message.contains("interactive terminal UI"));
    }

    #[test]
    fn terminal_guard_blocks_shell_heredocs() {
        let command = "cat >> src/app.rs <<'EOF'\nfn main() {}\nEOF";
        let err = guard_terminal_command(command, &BackendKind::Local, false)
            .expect_err("heredoc should fail fast");
        let ToolError::CapabilityDenied { message, code, .. } = err else {
            panic!("expected capability denied");
        };
        assert_eq!(code, "shell_heredoc_unsupported");
        assert!(message.contains("Shell heredocs are not supported"));
        assert!(message.contains("second content-transport path"));
    }

    #[test]
    fn run_process_guard_blocks_macos_prompt_commands() {
        if !cfg!(target_os = "macos") {
            return;
        }

        let err = guard_run_process_command("memo notes -s \"Title\"", &BackendKind::Local, false)
            .expect_err("macos automation should fail");
        let ToolError::CapabilityDenied { message, code, .. } = err else {
            panic!("expected capability denied");
        };
        assert_eq!(code, "background_macos_consent_unsupported");
        assert!(message.contains("macOS permission dialog"));
    }

    #[test]
    fn terminal_guard_blocks_unresolved_applescript_automation() {
        if !cfg!(target_os = "macos") {
            return;
        }

        let command = "osascript -e 'tell application id \"com.example.DoesNotExist\" to activate'";
        let err = guard_terminal_command(command, &BackendKind::Local, false)
            .expect_err("unresolved automation consent should fail fast");
        let ToolError::CapabilityDenied { message, code, .. } = err else {
            panic!("expected capability denied");
        };
        assert_eq!(code, "macos_applescript_target_unresolved");
        assert!(message.contains("could not statically determine the AppleScript target"));
        assert!(message.contains("/permissions bootstrap"));
    }

    #[test]
    fn rewrites_explicit_macos_denials() {
        if !cfg!(target_os = "macos") {
            return;
        }

        let out = ExecOutput {
            stdout: String::new(),
            stderr: "execution error: Not authorized to send Apple events to Notes. (-1743)".into(),
            exit_code: 1,
        };
        let err = rewrite_terminal_exec_result(
            "osascript -e 'tell application \"Notes\" to activate'",
            &BackendKind::Local,
            Duration::from_secs(120),
            &out,
        )
        .expect_err("should rewrite");
        let ToolError::CapabilityDenied { message, code, .. } = err else {
            panic!("expected capability denied");
        };
        assert_eq!(code, "macos_automation_denied");
        assert!(message.contains("macOS denied"));
        assert!(message.contains("AppleScript automation"));
    }

    #[test]
    fn granted_macos_preflight_disables_stall_timeout() {
        if !cfg!(target_os = "macos") {
            return;
        }

        let timeout = macos_prompt_stall_timeout(
            "osascript -e 'tell application \"Notes\" to activate'",
            &BackendKind::Local,
        );
        let preflight =
            preflight_command_permissions("osascript -e 'tell application \"Notes\" to activate'");
        if matches!(preflight.automation_state, Some(MacosConsentState::Granted)) {
            assert!(timeout.is_none());
        }
    }

    #[test]
    fn terminal_guard_blocks_fullscreen_ui_even_with_pty() {
        let err = guard_terminal_command("vim Cargo.toml", &BackendKind::Local, true)
            .expect_err("tty ui should fail");
        let ToolError::CapabilityDenied { code, message, .. } = err else {
            panic!("expected capability denied");
        };
        assert_eq!(code, "terminal_observation_unsupported");
        assert!(message.contains("full-screen terminal screens"));
    }

    #[test]
    fn run_process_guard_blocks_fullscreen_ui_even_with_pty() {
        let err = guard_run_process_command("top", &BackendKind::Local, true)
            .expect_err("tty ui should fail");
        let ToolError::CapabilityDenied { code, message, .. } = err else {
            panic!("expected capability denied");
        };
        assert_eq!(code, "background_terminal_observation_unsupported");
        assert!(message.contains("stream-based"));
    }
}
