use crate::config;

/// Shell-safe single-quoting: wraps `value` in single quotes,
/// escaping any embedded single quotes as `'\''`.
fn shell_quote_single(value: &str) -> String {
    let escaped = value.replace('\'', "'\\''");
    format!("'{escaped}'")
}

/// Generate executor-detection shell function, shared between zsh and bash hooks.
/// Custom agents from config are injected between CI detection and the built-in
/// agent/IDE detection so that user-defined rules take priority.
fn executor_detection_script(config: &config::Config) -> String {
    use std::fmt::Write;

    let mut script = String::from(
        r#"# Detect executor type and name
__suvadu_detect_executor() {
    local executor_type="unknown"
    local executor="unknown"

    # CI/CD Detection
    if [[ -n "$CI" ]]; then
        executor_type="ci"
        if [[ -n "$GITHUB_ACTIONS" ]]; then
            executor="github-actions"
        elif [[ -n "$GITLAB_CI" ]]; then
            executor="gitlab"
        elif [[ -n "$CIRCLECI" ]]; then
            executor="circleci"
        else
            executor="ci-unknown"
        fi
"#,
    );

    // Inject custom agents from [agents] config, checked before built-in agents.
    // Each custom agent is an elif block that checks a single env var.
    let mut agents: Vec<_> = config.agents.iter().collect();
    agents.sort_by_key(|(name, _)| (*name).clone());
    for (name, agent) in &agents {
        // Sanitize: only allow alphanumeric, hyphens, underscores in names and env vars
        if !is_safe_shell_identifier(name) || !is_safe_shell_identifier(&agent.env_var) {
            continue;
        }
        let exec_type = if is_safe_shell_identifier(&agent.executor_type) {
            &agent.executor_type
        } else {
            "agent"
        };
        let _ = write!(
            script,
            "    elif [[ -n \"${0}\" ]]; then\n        executor_type=\"{exec_type}\"\n        executor=\"{name}\"\n",
            agent.env_var
        );
    }

    script.push_str(
        r#"    # AI Agent Detection
    elif [[ -n "$CLAUDE_CODE" ]] || [[ "$TERM_PROGRAM" == "claude" ]]; then
        executor_type="agent"
        executor="claude-code"
    elif [[ -n "$CODEX_CLI" ]]; then
        executor_type="agent"
        executor="openai-codex"
    elif [[ -n "$AIDER" ]] || [[ -n "$AIDER_SESSION" ]]; then
        executor_type="agent"
        executor="aider"
    elif [[ -n "$CONTINUE_SESSION" ]]; then
        executor_type="agent"
        executor="continue-dev"
    elif [[ -n "$COPILOT_WORKSPACE" ]]; then
        executor_type="agent"
        executor="copilot"
    elif [[ "$CURSOR_AGENT" == "1" ]]; then
        executor_type="agent"
        executor="cursor"
    # IDE Detection
    elif [[ -n "$WINDSURF" ]] || [[ -n "$CODEIUM" ]]; then
        executor_type="ide"
        executor="windsurf"
    elif [[ -n "$CURSOR_INJECTION" ]] || [[ -n "$CURSOR_TRACE_ID" ]]; then
        executor_type="ide"
        executor="cursor"
    elif [[ -n "$ANTIGRAVITY_AGENT" ]]; then
        executor_type="agent"
        executor="antigravity"
    elif [[ "$TERM_PROGRAM" == "vscode" ]] || [[ -n "$VSCODE_INJECTION" ]]; then
        executor_type="ide"
        executor="vscode"
    elif [[ -n "$INTELLIJ_ENVIRONMENT_READER" ]]; then
        executor_type="ide"
        executor="intellij"
    elif [[ -n "$PYCHARM_HOSTED" ]]; then
        executor_type="ide"
        executor="pycharm"
    # Human Detection
    elif [[ -t 0 ]]; then
        executor_type="human"
        executor="terminal"
    else
        # Non-interactive shell — likely agent or script
        executor_type="programmatic"
        executor="subprocess"
    fi

    echo "$executor_type:$executor"
}
"#,
    );

    script
}

/// Returns true if the string contains only safe characters for use in shell
/// scripts (alphanumeric, hyphens, underscores).
fn is_safe_shell_identifier(s: &str) -> bool {
    !s.is_empty()
        && s.len() <= 256
        && s.bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}

/// Zsh-specific preamble, preexec/precmd hooks, and hook registration.
///
/// `bin_path` is interpolated into the script via `format!()`.
fn zsh_preexec_script(bin_path: &str) -> String {
    let escaped = shell_quote_single(bin_path);
    format!(
        r#"# Suvadu - Shell History Integration
# Add this to your ~/.zshrc:
# eval "$(suv init zsh)"

# Require Zsh 5.1+ for EPOCHREALTIME support
autoload -Uz is-at-least
if ! is-at-least 5.1 "$ZSH_VERSION"; then
    echo "[suvadu] Warning: Zsh 5.1+ required (you have $ZSH_VERSION). Shell hooks disabled." >&2
    return 0 2>/dev/null || true
fi

# Load zsh/datetime for $EPOCHREALTIME (not auto-loaded on all systems)
zmodload zsh/datetime

# Use add-zsh-hook for proper hook registration (cooperates with oh-my-zsh, plugins)
autoload -Uz add-zsh-hook

# Legacy alias support
alias suvadu="suv"

export SUVADU_SESSION_ID="${{SUVADU_SESSION_ID:-$(uuidgen)}}"
_SUVADU_START_TIME=0
_SUVADU_OFFSET=-1
_SUVADU_BIN={escaped}

# Shell function wrapper: intercepts `suv search` and `suv bookmark pick` so the
# selected command is placed into the editing buffer (via print -z) instead of
# being printed as dead text.  All other subcommands pass straight through.
suv() {{
    if [[ "${{1:-}}" == "search" || ("${{1:-}}" == "bookmark" && "${{2:-}}" == "pick") ]]; then
        local selected
        selected="$("$_SUVADU_BIN" "$@")"
        if [[ -n "$selected" ]]; then
            print -z -- "$selected"
        fi
    else
        "$_SUVADU_BIN" "$@"
    fi
}}

"#
    )
}

/// Zsh preexec/precmd function bodies and hook registration.
const fn zsh_hook_functions() -> &'static str {
    r#"# Capture command start time (registered via add-zsh-hook)
_suvadu_preexec() {
    _SUVADU_CMD="$1"
    _SUVADU_START_TIME=$(( ${EPOCHREALTIME%.*} * 1000 + ${${EPOCHREALTIME#*.}:0:3} ))
}

# Capture command completion and save to DB (registered via add-zsh-hook)
_suvadu_precmd() {
    local exit_code=$?
    local end_time=$(( ${EPOCHREALTIME%.*} * 1000 + ${${EPOCHREALTIME#*.}:0:3} ))

    # Reset history offset for new prompt
    _SUVADU_OFFSET=-1

    # Skip if no command captured (e.g. empty enter)
    if [ -z "$_SUVADU_CMD" ]; then
        return
    fi

    # Detect executor
    local executor_info=$(__suvadu_detect_executor)
    local executor_type="${executor_info%%:*}"
    local executor="${executor_info##*:}"

    # Synchronous add to avoid race conditions with immediate Up arrow
    $_SUVADU_BIN add \
        --session-id "$SUVADU_SESSION_ID" \
        --command "$_SUVADU_CMD" \
        --cwd "$PWD" \
        --exit-code "$exit_code" \
        --started-at "$_SUVADU_START_TIME" \
        --ended-at "$end_time" \
        --executor-type "$executor_type" \
        --executor "$executor" >/dev/null 2>&1

    # Clear captured command so empty enters don't duplicated
    _SUVADU_CMD=""
}

# Register hooks properly (cooperates with oh-my-zsh, zsh-autosuggestions, etc.)
add-zsh-hook preexec _suvadu_preexec
add-zsh-hook precmd _suvadu_precmd

"#
}

/// Zsh interactive search widget (Ctrl+R binding).
#[allow(clippy::literal_string_with_formatting_args)]
const fn zsh_search_widget_script() -> &'static str {
    r#"# Interactive Search Widget
_suvadu_search_widget() {
    # Normalize zsh options to defaults for this widget scope. This prevents
    # interference from user/plugin options (SH_WORD_SPLIT, GLOB_SUBST, etc.)
    # that can cause BUFFER assignment to silently fail. Standard practice
    # used by Atuin, fzf, and other ZLE-based tools.
    emulate -L zsh

    local selected tty_dev

    # Prefer $TTY (zsh sets this to the actual device, e.g. /dev/ttys003).
    # Fall back to /dev/tty for environments where $TTY is not readable (SSM sessions).
    if [[ -r "$TTY" ]]; then
        tty_dev="$TTY"
    elif [[ -r /dev/tty ]]; then
        tty_dev=/dev/tty
    else
        # Terminal not readable — fall back to default search
        zle .history-incremental-search-backward
        return
    fi

    # Invalidate ZLE display before handing control to the TUI.
    # Required for compatibility with Powerlevel10k instant prompt and other
    # prompt frameworks that cache terminal state.
    zle -I

    # Save terminal state so we can restore it after the TUI exits.
    # Some terminal/prompt combinations (e.g. iTerm2 + p10k) leave stty in
    # a bad state after a full-screen TUI, causing the buffer to appear as
    # dead text rather than being placed in the readline buffer.
    local stty_state
    stty_state=$(stty -g 2>/dev/null)

    # If suvadu is disabled (exit code 10), fallback to default search
    selected="$($_SUVADU_BIN search --query "$BUFFER" < "$tty_dev")"
    local ret=$?

    # Restore terminal state
    [[ -n "$stty_state" ]] && stty "$stty_state" 2>/dev/null

    if [[ $ret -eq 10 ]]; then
        zle .history-incremental-search-backward
        return
    fi

    if [[ -n "$selected" ]]; then
        LBUFFER="$selected"
        RBUFFER=""
    fi

    zle reset-prompt
    zle -R
}

# Register widget and bind to Ctrl+R
zle -N suvadu-search _suvadu_search_widget
bindkey '^R' suvadu-search

"#
}

/// Zsh arrow-key cycling widgets (Up/Down) and their registration.
#[allow(clippy::literal_string_with_formatting_args)]
const fn zsh_arrow_widgets_script() -> &'static str {
    r#"# Up Arrow Widget (Native Cycling)
_suvadu_up_arrow_widget() {
    emulate -L zsh

    # If starting fresh, reset state
    if [[ "$LASTWIDGET" != suvadu-* && "$LASTWIDGET" != *autosuggest* && "$LASTWIDGET" != zle-line-* && "$LASTWIDGET" != *highlight* ]]; then
        _SUVADU_OFFSET=0
        _SUVADU_QUERY="$BUFFER"
    else
        # If we were at the prompt (-1), start at 0. Otherwise increment.
        if [[ $_SUVADU_OFFSET -eq -1 ]]; then
            _SUVADU_OFFSET=0
        else
            ((_SUVADU_OFFSET++))
        fi
    fi

    local result _suvadu_agents_flag=""
    # Up-arrow shows only commands you typed by default; Alt+A reveals AI-agent
    # commands by setting _SUVADU_SHOW_AGENTS for this shell.
    [[ -n "$_SUVADU_SHOW_AGENTS" ]] && _suvadu_agents_flag="--include-agents"
    result="$($_SUVADU_BIN get --query "$_SUVADU_QUERY" --offset $_SUVADU_OFFSET --prefix --cwd "$PWD" $_suvadu_agents_flag 2>/dev/null)"

    if [[ -n "$result" ]]; then
        LBUFFER="$result"
        RBUFFER=""
    else
        # No more results, stay at the last found or current
        [[ $_SUVADU_OFFSET -gt 0 ]] && ((_SUVADU_OFFSET--))
    fi
    zle -R
}

# Down Arrow Widget (Native Cycling)
_suvadu_down_arrow_widget() {
    emulate -L zsh

    # If not already in history mode, use standard down-line-or-history
    if [[ "$LASTWIDGET" != suvadu-* && "$LASTWIDGET" != *autosuggest* && "$LASTWIDGET" != zle-line-* && "$LASTWIDGET" != *highlight* ]]; then
        zle down-line-or-history
        return
    fi

    if [[ $_SUVADU_OFFSET -gt 0 ]]; then
        ((_SUVADU_OFFSET--))
        local result _suvadu_agents_flag=""
        [[ -n "$_SUVADU_SHOW_AGENTS" ]] && _suvadu_agents_flag="--include-agents"
        result="$($_SUVADU_BIN get --query "$_SUVADU_QUERY" --offset $_SUVADU_OFFSET --prefix --cwd "$PWD" $_suvadu_agents_flag 2>/dev/null)"
        if [[ -n "$result" ]]; then
            LBUFFER="$result"
            RBUFFER=""
        fi
    elif [[ $_SUVADU_OFFSET -eq 0 ]]; then
        # Restore original input and set state to prompt (-1)
        LBUFFER="$_SUVADU_QUERY"
        RBUFFER=""
        _SUVADU_OFFSET=-1
    else
        # We are at prompt (-1), pass through
        zle down-line-or-history
    fi
    zle -R
}

# Toggle AI-agent commands in Up/Down arrow recall (per shell session)
_suvadu_toggle_agents_widget() {
    emulate -L zsh
    if [[ -n "$_SUVADU_SHOW_AGENTS" ]]; then
        unset _SUVADU_SHOW_AGENTS
        zle -M "suvadu: AI-agent commands hidden from history recall"
    else
        export _SUVADU_SHOW_AGENTS=1
        zle -M "suvadu: AI-agent commands shown in history recall"
    fi
}

# Register Up/Down
zle -N suvadu-up-arrow _suvadu_up_arrow_widget
zle -N suvadu-down-arrow _suvadu_down_arrow_widget
zle -N suvadu-toggle-agents _suvadu_toggle_agents_widget

"#
}

/// Bash-specific preamble, time helper, preexec/precmd hooks, and hook
/// installation (DEBUG trap + `PROMPT_COMMAND`).
///
/// `bin_path` is interpolated into the script via `format!()`.
fn bash_preexec_script(bin_path: &str) -> String {
    let escaped = shell_quote_single(bin_path);
    format!(
        r#"# Suvadu - Bash Shell History Integration
# Add this to your ~/.bashrc:
# eval "$(suv init bash)"

# Require Bash 4.0+ for associative arrays and EPOCHREALTIME
if [[ "${{BASH_VERSINFO[0]}}" -lt 4 ]]; then
    echo "[suvadu] Warning: Bash 4.0+ required (you have $BASH_VERSION). Shell hooks disabled." >&2
    return 0 2>/dev/null || true
fi

# Legacy alias support
alias suvadu="suv"

export SUVADU_SESSION_ID="${{SUVADU_SESSION_ID:-$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || python3 -c 'import uuid; print(uuid.uuid4())' 2>/dev/null || head -c16 /dev/urandom 2>/dev/null | od -A n -t x1 | tr -d ' \n' || echo "bash-$$-$RANDOM-$RANDOM-$RANDOM")}}"
_SUVADU_START_TIME=0
_SUVADU_CMD=""
_SUVADU_BIN={escaped}

# Shell function wrapper: intercepts `suv search` and `suv bookmark pick` so the
# selected command is placed into readline history (press Up to recall) instead
# of being printed as dead text.  All other subcommands pass straight through.
suv() {{
    if [[ "${{1:-}}" == "search" || ("${{1:-}}" == "bookmark" && "${{2:-}}" == "pick") ]]; then
        local selected
        selected="$("$_SUVADU_BIN" "$@")"
        if [[ -n "$selected" ]]; then
            history -s -- "$selected"
            echo "$selected"
            echo "[suv] Command added to history — press Up to recall."
        fi
    else
        "$_SUVADU_BIN" "$@"
    fi
}}

"#
    )
}

/// Bash time-helper, preexec via DEBUG trap, precmd via `PROMPT_COMMAND`, and
/// hook installation.
const fn bash_hook_functions() -> &'static str {
    r#"# Get current time in milliseconds (Bash 5+ has EPOCHREALTIME, fallback to date)
__suvadu_time_ms() {
    if [[ -n "$EPOCHREALTIME" ]]; then
        local secs="${EPOCHREALTIME%%.*}"
        local frac="${EPOCHREALTIME##*.}"
        echo "$(( secs * 1000 + 10#${frac:0:3} ))"
    else
        # Fallback: date +%s gives seconds, multiply by 1000
        echo "$(( $(date +%s) * 1000 ))"
    fi
}

# Capture command via DEBUG trap (preexec equivalent)
__suvadu_preexec() {
    # Don't capture PROMPT_COMMAND itself or empty commands
    if [[ "$BASH_COMMAND" == "$PROMPT_COMMAND" ]] || [[ -z "$BASH_COMMAND" ]]; then
        return
    fi
    # Don't capture our own functions
    if [[ "$BASH_COMMAND" == __suvadu_* ]]; then
        return
    fi
    # Don't capture during tab completion (COMP_LINE is set by readline)
    if [[ -n "${COMP_LINE+x}" ]]; then
        return
    fi

    _SUVADU_CMD="$BASH_COMMAND"
    _SUVADU_START_TIME=$(__suvadu_time_ms)
}

# Capture command completion (precmd equivalent via PROMPT_COMMAND)
__suvadu_precmd() {
    local exit_code=$?
    local end_time=$(__suvadu_time_ms)

    # Skip if no command was captured
    if [[ -z "$_SUVADU_CMD" ]]; then
        return
    fi

    # Detect executor
    local executor_info=$(__suvadu_detect_executor)
    local executor_type="${executor_info%%:*}"
    local executor="${executor_info##*:}"

    "$_SUVADU_BIN" add \
        --session-id "$SUVADU_SESSION_ID" \
        --command "$_SUVADU_CMD" \
        --cwd "$PWD" \
        --exit-code "$exit_code" \
        --started-at "$_SUVADU_START_TIME" \
        --ended-at "$end_time" \
        --executor-type "$executor_type" \
        --executor "$executor" >/dev/null 2>&1

    _SUVADU_CMD=""
}

# Install hooks (preserve any existing DEBUG trap)
_suvadu_old_debug_trap=$(trap -p DEBUG | sed "s/^trap -- '\\(.*\\)' DEBUG$/\\1/")
if [[ -n "$_suvadu_old_debug_trap" ]]; then
    eval "trap '$_suvadu_old_debug_trap; __suvadu_preexec' DEBUG"
else
    trap '__suvadu_preexec' DEBUG
fi
unset _suvadu_old_debug_trap

# Append to PROMPT_COMMAND (don't overwrite existing)
if [[ -z "$PROMPT_COMMAND" ]]; then
    PROMPT_COMMAND="__suvadu_precmd"
elif [[ "$PROMPT_COMMAND" != *"__suvadu_precmd"* ]]; then
    PROMPT_COMMAND="__suvadu_precmd;$PROMPT_COMMAND"
fi

"#
}

/// Bash interactive search widget and Ctrl+R binding.
const fn bash_search_widget() -> &'static str {
    r#"# Interactive Search Widget (Ctrl+R replacement)
__suvadu_search_widget() {
    local selected tty_dev

    # Find a readable TTY for the TUI.
    # Bash has no $TTY built-in, so check /dev/tty first, then $(tty).
    if [[ -r /dev/tty ]]; then
        tty_dev=/dev/tty
    else
        tty_dev=$(tty 2>/dev/null)
        if [[ ! -r "$tty_dev" ]]; then
            # No readable terminal — skip (regular readline search still works)
            return
        fi
    fi

    selected=$("$_SUVADU_BIN" search --query "$READLINE_LINE" < "$tty_dev" 2>"$tty_dev")
    local ret=$?

    if [[ $ret -eq 10 ]]; then
        # Disabled: fall back to default reverse search
        return
    fi

    if [[ -n "$selected" ]]; then
        READLINE_LINE="$selected"
        READLINE_POINT=${#READLINE_LINE}
    fi
}

# Bind Ctrl+R to suvadu search
bind -x '"\C-r": __suvadu_search_widget'

"#
}

/// Auto-source section for managed aliases (shared between zsh and bash).
///
/// Returns `Some(snippet)` when the project directory can be resolved, `None`
/// otherwise.
fn aliases_source_script() -> Option<String> {
    let dirs = crate::util::project_dirs()?;
    let aliases_path = dirs.data_dir().join("aliases.sh");
    Some(format!(
        "\n# Suvadu managed aliases\n[ -f \"{}\" ] && source \"{}\"\n",
        aliases_path.display(),
        aliases_path.display()
    ))
}

pub fn get_zsh_hook(config: &config::Config) -> Result<String, Box<dyn std::error::Error>> {
    let current_exe = std::env::current_exe()?;
    let bin_path = current_exe.to_string_lossy();

    let mut script = zsh_preexec_script(&bin_path);
    script.push_str(&executor_detection_script(config));
    script.push_str(zsh_hook_functions());
    script.push_str(zsh_search_widget_script());
    script.push_str(zsh_arrow_widgets_script());

    if config.shell.enable_arrow_navigation {
        script.push_str(
            r"
bindkey '^[[A' suvadu-up-arrow
bindkey '^[OA' suvadu-up-arrow
bindkey '^[[B' suvadu-down-arrow
bindkey '^[OB' suvadu-down-arrow
# Alt+A toggles AI-agent commands in Up/Down recall for this shell
bindkey '^[a' suvadu-toggle-agents
",
        );
    }

    if let Some(aliases) = aliases_source_script() {
        script.push_str(&aliases);
    }

    Ok(script)
}

pub fn get_bash_hook(config: &config::Config) -> Result<String, Box<dyn std::error::Error>> {
    let current_exe = std::env::current_exe()?;
    let bin_path = current_exe.to_string_lossy();

    let mut script = bash_preexec_script(&bin_path);
    script.push_str(&executor_detection_script(config));
    script.push_str(bash_hook_functions());
    script.push_str(bash_search_widget());

    // Note: Bash doesn't have zsh's zle widgets for arrow key override,
    // so arrow-based history navigation is not supported in Bash.

    if let Some(aliases) = aliases_source_script() {
        script.push_str(&aliases);
    }

    Ok(script)
}

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

    #[test]
    fn test_shell_quote_simple_path() {
        assert_eq!(
            shell_quote_single("/usr/local/bin/suv"),
            "'/usr/local/bin/suv'"
        );
    }

    #[test]
    fn test_shell_quote_path_with_spaces() {
        assert_eq!(shell_quote_single("/my path/to/suv"), "'/my path/to/suv'");
    }

    #[test]
    fn test_shell_quote_path_with_single_quote() {
        assert_eq!(shell_quote_single("/it's/a/path"), "'/it'\\''s/a/path'");
    }

    #[test]
    fn test_shell_quote_path_with_dollar() {
        // Single quotes prevent shell expansion of $
        assert_eq!(shell_quote_single("/path/$HOME/suv"), "'/path/$HOME/suv'");
    }

    #[test]
    fn test_zsh_hook_generation() {
        let config = config::Config::default();
        let hook = get_zsh_hook(&config).expect("Failed to generate zsh hook");

        // Verify essential components are present
        assert!(hook.contains("# Suvadu - Shell History Integration"));
        assert!(hook.contains("alias suvadu=\"suv\""));
        assert!(hook.contains("export SUVADU_SESSION_ID"));
        assert!(hook.contains("__suvadu_detect_executor"));
        assert!(hook.contains("_suvadu_preexec"));
        assert!(hook.contains("_suvadu_precmd"));
        assert!(hook.contains("add-zsh-hook preexec"));
        assert!(hook.contains("add-zsh-hook precmd"));
        assert!(hook.contains("_suvadu_search_widget"));
        assert!(hook.contains("_suvadu_up_arrow_widget"));
        assert!(hook.contains("_suvadu_down_arrow_widget"));
        assert!(hook.contains("zle -N suvadu-search"));
        assert!(hook.contains("bindkey '^R' suvadu-search"));

        // Verify ZLE widget hardening (issue #6 — dead text bug)
        // All widgets must use emulate -L zsh to prevent interference from
        // user/plugin options (p10k, oh-my-zsh, etc.)
        assert!(
            hook.contains("emulate -L zsh"),
            "search widget missing emulate -L zsh"
        );
        assert!(
            hook.matches("emulate -L zsh").count() == 4,
            "expected emulate -L zsh in all 4 widgets (search, up, down, toggle-agents)"
        );
        // Widgets must use LBUFFER/RBUFFER (not BUFFER/CURSOR) for buffer placement
        assert!(hook.contains("LBUFFER=\"$selected\""));
        assert!(hook.contains("LBUFFER=\"$result\""));
        // Search widget must always call zle reset-prompt + zle -R
        assert!(hook.contains("zle reset-prompt"));
        assert!(hook.contains("zle -R"));

        // Verify suv() shell function wrapper for `suv search` (issue #6)
        // Must use print -z to inject into the editing buffer
        assert!(
            hook.contains("suv()"),
            "missing suv() shell function wrapper"
        );
        assert!(
            hook.contains("print -z -- \"$selected\""),
            "suv() wrapper must use print -z to inject into buffer"
        );

        // Verify executor detection logic
        assert!(hook.contains("ANTIGRAVITY_AGENT"));
        assert!(hook.contains("GITHUB_ACTIONS"));
        assert!(hook.contains("executor_type=\"ci\""));
        assert!(hook.contains("executor_type=\"ide\""));
        assert!(hook.contains("executor_type=\"agent\""));

        // Verify arrow navigation binding when enabled
        if config.shell.enable_arrow_navigation {
            assert!(hook.contains("bindkey '^[[A' suvadu-up-arrow"));
            assert!(hook.contains("bindkey '^[[B' suvadu-down-arrow"));
        }
    }

    #[test]
    fn test_bash_hook_generation() {
        let config = config::Config::default();
        let hook = get_bash_hook(&config).expect("Failed to generate bash hook");

        // Verify essential Bash-specific components
        assert!(hook.contains("# Suvadu - Bash Shell History Integration"));
        assert!(hook.contains("alias suvadu=\"suv\""));
        assert!(hook.contains("export SUVADU_SESSION_ID"));
        assert!(hook.contains("__suvadu_detect_executor"));
        assert!(hook.contains("__suvadu_preexec"));
        assert!(hook.contains("__suvadu_precmd"));
        assert!(hook.contains("__suvadu_search_widget"));
        assert!(hook.contains("trap '__suvadu_preexec' DEBUG"));
        assert!(hook.contains("PROMPT_COMMAND"));
        assert!(hook.contains("bind -x"));
        assert!(hook.contains("BASH_VERSINFO"));

        // Verify suv() shell function wrapper for `suv search` (issue #6)
        assert!(
            hook.contains("suv()"),
            "missing suv() shell function wrapper in bash"
        );
        assert!(
            hook.contains("history -s -- \"$selected\""),
            "bash suv() wrapper must use history -s to recall via Up arrow"
        );
    }

    #[test]
    fn test_zsh_suv_wrapper_structure() {
        let config = config::Config::default();
        let hook = get_zsh_hook(&config).expect("Failed to generate zsh hook");

        // The suv() function must be defined exactly once
        assert_eq!(
            hook.matches("suv() {").count(),
            1,
            "suv() wrapper must be defined exactly once"
        );

        // Must check for "search" subcommand specifically
        assert!(
            hook.contains(r#"== "search""#),
            "wrapper must check for 'search' subcommand"
        );

        // Must delegate to $_SUVADU_BIN for the actual binary call
        assert!(
            hook.contains(r#""$_SUVADU_BIN" "$@""#),
            "wrapper must delegate to $_SUVADU_BIN with all args"
        );

        // Non-search subcommands must pass through (the else branch)
        // Count: one in the search branch, one in the else branch
        let bin_calls = hook.matches(r#""$_SUVADU_BIN" "$@""#).count();
        assert_eq!(
            bin_calls, 2,
            "expected 2 $_SUVADU_BIN calls (search + passthrough), found {bin_calls}"
        );

        // Must NOT use BUFFER/CURSOR in the wrapper (those are ZLE-only)
        // Extract just the suv() function body
        let wrapper_start = hook.find("suv() {").unwrap();
        let wrapper_end = hook[wrapper_start..].find("\n}\n").unwrap() + wrapper_start;
        let wrapper = &hook[wrapper_start..wrapper_end];
        assert!(
            !wrapper.contains("LBUFFER"),
            "suv() wrapper must not use LBUFFER (ZLE-only)"
        );
        assert!(
            !wrapper.contains("RBUFFER"),
            "suv() wrapper must not use RBUFFER (ZLE-only)"
        );
    }

    #[test]
    fn test_bash_suv_wrapper_structure() {
        let config = config::Config::default();
        let hook = get_bash_hook(&config).expect("Failed to generate bash hook");

        // The suv() function must be defined exactly once
        assert_eq!(
            hook.matches("suv() {").count(),
            1,
            "suv() wrapper must be defined exactly once in bash"
        );

        // Must check for "search" subcommand
        assert!(
            hook.contains(r#"== "search""#),
            "bash wrapper must check for 'search' subcommand"
        );

        // Must delegate to $_SUVADU_BIN
        let bin_calls = hook.matches(r#""$_SUVADU_BIN" "$@""#).count();
        assert_eq!(
            bin_calls, 2,
            "expected 2 $_SUVADU_BIN calls in bash (search + passthrough), found {bin_calls}"
        );

        // Must NOT use zsh-specific constructs
        let wrapper_start = hook.find("suv() {").unwrap();
        let wrapper_end = hook[wrapper_start..].find("\n}\n").unwrap() + wrapper_start;
        let wrapper = &hook[wrapper_start..wrapper_end];
        assert!(
            !wrapper.contains("print -z"),
            "bash wrapper must not use print -z (zsh-only)"
        );
        assert!(
            !wrapper.contains("LBUFFER"),
            "bash wrapper must not use LBUFFER (ZLE-only)"
        );
    }

    #[test]
    fn test_zsh_hook_binary_path() {
        let config = config::Config::default();
        let hook = get_zsh_hook(&config).expect("Failed to generate zsh hook");

        // Verify the binary path is single-quoted for shell safety
        assert!(hook.contains("_SUVADU_BIN='"));
    }

    #[test]
    fn test_zsh_hook_contains_session_id() {
        let config = config::Config::default();
        let hook = get_zsh_hook(&config).expect("Failed to generate zsh hook");
        // Verify SUVADU_SESSION_ID export is present
        assert!(
            hook.contains("export SUVADU_SESSION_ID"),
            "Zsh hook must export SUVADU_SESSION_ID"
        );
    }

    #[test]
    fn test_bash_hook_contains_executor_detection() {
        let config = config::Config::default();
        let hook = get_bash_hook(&config).expect("Failed to generate bash hook");
        // Verify executor detection function is present
        assert!(
            hook.contains("__suvadu_detect_executor"),
            "Bash hook must contain executor detection function"
        );
        // Verify CI detection
        assert!(hook.contains("GITHUB_ACTIONS"));
        // Verify agent detection
        assert!(hook.contains("CLAUDE_CODE"));
        // Verify IDE detection
        assert!(hook.contains("CURSOR_INJECTION"));
    }

    #[test]
    fn test_zsh_hook_arrow_nav_disabled() {
        let mut config = config::Config::default();
        config.shell.enable_arrow_navigation = false;

        let hook = get_zsh_hook(&config).expect("Failed to generate zsh hook");

        // Arrow key bindings should NOT be present when disabled
        assert!(
            !hook.contains("bindkey '^[[A' suvadu-up-arrow"),
            "Arrow up binding should not be present when arrow nav is disabled"
        );
        assert!(
            !hook.contains("bindkey '^[[B' suvadu-down-arrow"),
            "Arrow down binding should not be present when arrow nav is disabled"
        );

        // But Ctrl+R should still be bound
        assert!(hook.contains("bindkey '^R' suvadu-search"));
    }

    /// Regression: const fn hook strings were using `{{`/`}}` (format! escapes)
    /// but were emitted via push_str, producing literal doubled braces in the
    /// shell output and causing `bad substitution` errors.
    #[test]
    fn test_no_doubled_braces_in_output() {
        let config = config::Config::default();

        let zsh = get_zsh_hook(&config).unwrap();
        assert!(
            !zsh.contains("{{"),
            "Zsh hook must not contain '{{{{' (doubled braces)"
        );
        assert!(
            !zsh.contains("}}"),
            "Zsh hook must not contain '}}}}' (doubled braces)"
        );

        let bash = get_bash_hook(&config).unwrap();
        assert!(
            !bash.contains("{{"),
            "Bash hook must not contain '{{{{' (doubled braces)"
        );
        assert!(
            !bash.contains("}}"),
            "Bash hook must not contain '}}}}' (doubled braces)"
        );
    }

    #[test]
    fn test_zsh_hook_arrow_nav_enabled() {
        let mut config = config::Config::default();
        config.shell.enable_arrow_navigation = true;

        let hook = get_zsh_hook(&config).expect("Failed to generate zsh hook");

        // Arrow key bindings SHOULD be present when enabled
        assert!(
            hook.contains("bindkey '^[[A' suvadu-up-arrow"),
            "Arrow up binding should be present when arrow nav is enabled"
        );
        assert!(
            hook.contains("bindkey '^[OA' suvadu-up-arrow"),
            "Alternate arrow up binding should be present"
        );
        assert!(
            hook.contains("bindkey '^[[B' suvadu-down-arrow"),
            "Arrow down binding should be present when arrow nav is enabled"
        );
        assert!(
            hook.contains("bindkey '^[OB' suvadu-down-arrow"),
            "Alternate arrow down binding should be present"
        );
    }

    #[test]
    fn test_custom_agents_in_zsh_hook() {
        let mut config = config::Config::default();
        config.agents.insert(
            "opencode".to_string(),
            config::CustomAgent {
                env_var: "OPENCODE".to_string(),
                executor_type: "agent".to_string(),
            },
        );
        config.agents.insert(
            "my-tool".to_string(),
            config::CustomAgent {
                env_var: "MY_TOOL_SESSION".to_string(),
                executor_type: "ide".to_string(),
            },
        );

        let hook = get_zsh_hook(&config).expect("Failed to generate zsh hook");

        // Custom agents should appear in the detection script
        assert!(hook.contains("OPENCODE"));
        assert!(hook.contains("executor=\"opencode\""));
        assert!(hook.contains("MY_TOOL_SESSION"));
        assert!(hook.contains("executor=\"my-tool\""));
        assert!(hook.contains("executor_type=\"ide\""));

        // Built-in agents should still be present
        assert!(hook.contains("CLAUDE_CODE"));
        assert!(hook.contains("CURSOR_INJECTION"));
    }

    #[test]
    fn test_custom_agents_in_bash_hook() {
        let mut config = config::Config::default();
        config.agents.insert(
            "opencode".to_string(),
            config::CustomAgent {
                env_var: "OPENCODE".to_string(),
                executor_type: "agent".to_string(),
            },
        );

        let hook = get_bash_hook(&config).expect("Failed to generate bash hook");
        assert!(hook.contains("OPENCODE"));
        assert!(hook.contains("executor=\"opencode\""));
    }

    #[test]
    fn test_custom_agents_checked_before_builtins() {
        let mut config = config::Config::default();
        config.agents.insert(
            "opencode".to_string(),
            config::CustomAgent {
                env_var: "OPENCODE".to_string(),
                executor_type: "agent".to_string(),
            },
        );

        let hook = get_zsh_hook(&config).expect("Failed to generate zsh hook");

        // Custom agents should appear before built-in agent detection
        let custom_pos = hook.find("OPENCODE").unwrap();
        let builtin_pos = hook.find("CLAUDE_CODE").unwrap();
        assert!(
            custom_pos < builtin_pos,
            "Custom agents must be checked before built-in agents"
        );
    }

    #[test]
    fn test_custom_agent_unsafe_name_skipped() {
        let mut config = config::Config::default();
        config.agents.insert(
            "bad;name".to_string(),
            config::CustomAgent {
                env_var: "SAFE_VAR".to_string(),
                executor_type: "agent".to_string(),
            },
        );
        config.agents.insert(
            "good-name".to_string(),
            config::CustomAgent {
                env_var: "GOOD_VAR".to_string(),
                executor_type: "agent".to_string(),
            },
        );

        let hook = get_zsh_hook(&config).expect("Failed to generate zsh hook");
        assert!(
            !hook.contains("bad;name"),
            "Unsafe agent name must be skipped"
        );
        assert!(hook.contains("GOOD_VAR"), "Safe agent should be present");
    }

    #[test]
    fn test_no_custom_agents_default_config() {
        let config = config::Config::default();
        let script = executor_detection_script(&config);

        // Should still have built-in agents
        assert!(script.contains("CLAUDE_CODE"));
        assert!(script.contains("CURSOR_INJECTION"));
        // No custom elif blocks between CI and built-in agents
        let ci_end = script.find("ci-unknown").unwrap();
        let agent_start = script.find("CLAUDE_CODE").unwrap();
        let between = &script[ci_end..agent_start];
        assert!(
            !between.contains("executor=\""),
            "No custom agents should appear with default config"
        );
    }
}
