//! Bash-mode shell completion: the always-on Tab surface (deterministic
//! fetch arming, terminal Tab semantics execution) and the dropdown accept
//! path shared by Tab/Enter/mouse.

#[cfg(test)]
use super::test_fixtures;
use super::{AgentView, PromptInputMode};
use crate::views::suggestion_controller::TabAction;

impl AgentView {
    /// Accept the selected completion-dropdown item into the prompt (the
    /// what-to-write policy lives in `CompletionSplice`). Returns whether
    /// the key was consumed; `false` only for the empty-items race (callers
    /// keep their close-and-fall-through arm).
    pub(in crate::app) fn accept_completion_dropdown_item(&mut self) -> bool {
        let had_items = !self.prompt.suggestions.dropdown.items.is_empty();
        // The SELECTED splice would clip an atomic element (paste chip):
        // committing would consume the candidates and then be declined by
        // the write path — honest no-op instead (nothing safe to write;
        // the dropdown stays up so another selection can still accept).
        if self.prompt.completion_accept_would_clip_element() {
            return true;
        }
        let Some(splice) = self.prompt.completion_dropdown_accept() else {
            // Stale-generation refusal closed the dropdown; swallow the key
            // (the refreshed fetch is in flight) instead of falling through
            // to focus-cycling or send.
            return had_items;
        };
        if self.prompt.apply_completion_splice(splice) {
            self.prompt_input_mode = PromptInputMode::Bash;
            // Re-fetch for the accepted text so accepting a directory
            // (trailing `/`) lets the NEXT Tab complete inside it.
            self.kick_shell_suggest_refetch();
        }
        true
    }

    /// Terminal-like Tab over a closed dropdown's completion items: decide
    /// via `SuggestionController::tab_decision`, then execute. Used by the
    /// pending-Tab landing (where `Nothing` — stale/empty items — must do
    /// nothing rather than fetch again).
    pub(in crate::app) fn shell_completion_tab(&mut self) {
        let action = self
            .prompt
            .suggestions
            .tab_decision(self.prompt.text(), self.prompt.cursor());
        self.execute_tab_action(action);
    }

    /// View-side executor for a [`TabAction`] (the policy lives in the
    /// controller's `tab_decision`).
    pub(super) fn execute_tab_action(&mut self, action: TabAction) {
        match action {
            TabAction::InstaAccept => {
                // A splice clipping an atomic element (paste chip) would be
                // declined AFTER the accept consumed the sole candidate —
                // every Tab would then refetch the same set. Show it instead.
                if self.prompt.completion_accept_would_clip_element() {
                    self.prompt.completion_dropdown_open_if_available();
                } else {
                    self.accept_completion_dropdown_item();
                }
            }
            TabAction::Fill(range, fill) => {
                if self.prompt.apply_completion_fill(range, &fill) {
                    // A fill is typing: refresh the candidate set for the longer
                    // token (the next Tab opens the dropdown on the refreshed set).
                    self.kick_shell_suggest_refetch();
                } else {
                    // Declined (range clips an atomic element): show the
                    // candidates instead of respinning fill+refetch every Tab.
                    self.prompt.completion_dropdown_open_if_available();
                }
            }
            TabAction::Open => {
                self.prompt.completion_dropdown_open_if_available();
            }
            TabAction::Nothing => {}
        }
    }

    /// Fire a deterministic (`includeAi: false`) completion fetch for the
    /// current draft, bypassing the env-gated as-you-type debounce — the
    /// always-on Tab path. `run_tab_on_load` makes the landing response run
    /// the terminal Tab semantics once (a Tab that found no usable items
    /// still completes when its candidates arrive).
    pub(super) fn request_shell_tab_completion(&mut self, run_tab_on_load: bool) {
        // Repeat Tab while the armed fetch is still in flight: keep the
        // marker (its landing runs the Tab semantics) — no second RPC.
        if run_tab_on_load && self.prompt.suggestions.tab_fetch_pending() {
            return;
        }
        let generation = self
            .prompt
            .suggestions
            .begin_tab_completion(run_tab_on_load);
        self.pending_effects
            .push(super::actions::Effect::FetchShellSuggestions {
                agent_id: self.session.id,
                text: self.prompt.text().to_owned(),
                cursor: self.prompt.cursor(),
                cwd: self.session.cwd.to_string_lossy().into_owned(),
                generation,
                limit: crate::views::suggestion_controller::SHELL_SUGGEST_WIRE_LIMIT,
                include_ai: false,
                ai_model: None,
                session_id: self.session.session_id.as_ref().map(|s| s.0.to_string()),
                // Deterministic Tab surface: token providers only (a
                // history row would make the set mixed and kill
                // insta-accept/LCP).
                token_only: true,
            });
    }

    /// Refresh the candidate set after an accept or a prefix fill changed
    /// the draft: through the debounced as-you-type pipeline when enabled,
    /// else a direct deterministic fetch. Either way the refreshed items
    /// land silently and the NEXT Tab consumes them.
    fn kick_shell_suggest_refetch(&mut self) {
        if self.prompt.suggestions.enabled {
            if let Some(eff) = self.notify_suggestion_text_changed() {
                self.pending_effects.push(eff);
            }
        } else {
            self.request_shell_tab_completion(false);
        }
    }
}

#[cfg(test)]
mod shell_suggestion_key_tests {
    use super::*;
    use crate::app::actions::{Action, Effect};
    use crate::app::app_view::InputOutcome;
    use crate::views::suggestion_controller::{CompletionItemParsed, SuggestionSource};
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    /// Wire-shaped token item: `insert_text` is the compat whole line,
    /// `token_text` the span replacement (what a new shell sends).
    fn token_item(line: &str, token: &str, range: std::ops::Range<usize>) -> CompletionItemParsed {
        CompletionItemParsed {
            display: token.to_owned(),
            description: String::new(),
            insert_text: line.to_owned(),
            source: SuggestionSource::PathExecutable,
            priority: 0,
            replace_range: Some(range),
            token_text: Some(token.to_owned()),
            truncated: false,
        }
    }

    fn item(insert: &str, range: Option<std::ops::Range<usize>>) -> CompletionItemParsed {
        CompletionItemParsed {
            display: insert.to_owned(),
            description: String::new(),
            insert_text: insert.to_owned(),
            source: SuggestionSource::PathExecutable,
            priority: 0,
            replace_range: range,
            token_text: None,
            truncated: false,
        }
    }

    /// Wire-shaped FILE token item (what the file provider sends).
    fn file_item(line: &str, token: &str, range: std::ops::Range<usize>) -> CompletionItemParsed {
        CompletionItemParsed {
            display: token.to_owned(),
            description: String::new(),
            insert_text: line.to_owned(),
            source: SuggestionSource::FilePath,
            priority: 0,
            replace_range: Some(range),
            token_text: Some(token.to_owned()),
            truncated: false,
        }
    }

    /// Whole-line history item (insert_text doubles as the span replacement).
    fn history_item(line: &str, range: std::ops::Range<usize>) -> CompletionItemParsed {
        CompletionItemParsed {
            display: line.to_owned(),
            description: String::new(),
            insert_text: line.to_owned(),
            source: SuggestionSource::History,
            priority: 10,
            replace_range: Some(range),
            token_text: None,
            truncated: false,
        }
    }

    /// Bash-mode agent with the env-gated as-you-type pipeline ON and
    /// `text` typed (the dropdown's request-text anchor pinned to it — the
    /// state right after a suggest response landed for the draft).
    fn bash_agent(text: &str) -> AgentView {
        let mut agent = bash_agent_always_on(text);
        agent.prompt.suggestions.enabled = true;
        agent
    }

    /// Same, with the pipeline OFF (`KIGI_SUGGESTIONS` unset) — the
    /// always-on Tab surface under test.
    fn bash_agent_always_on(text: &str) -> AgentView {
        let mut agent = super::test_fixtures::make_agent();
        agent.prompt_input_mode = PromptInputMode::Bash;
        agent.prompt.suggestions.enabled = false;
        agent.prompt.textarea.insert_str(text);
        agent.prompt.suggestions.dropdown.request_text = text.to_owned();
        agent.prompt.suggestions.dropdown.request_cursor = text.len();
        agent
    }

    /// THE acceptance regression: accepting a $PATH item after `ls | gr`
    /// edits the token in place — never replaces the whole line with `grep`.
    #[test]
    fn dropdown_tab_accept_replaces_token_in_place() {
        let mut agent = bash_agent("ls | gr");
        agent.prompt.suggestions.dropdown.open = true;
        agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "ls | grep");
        assert_eq!(agent.prompt.cursor(), "ls | grep".len());
        assert_eq!(agent.prompt_input_mode, PromptInputMode::Bash);
        assert!(!agent.prompt.completion_dropdown_open());
    }

    /// Enter accepts the same way (both arms share the accept helper).
    #[test]
    fn dropdown_enter_accept_replaces_token_in_place() {
        let mut agent = bash_agent("ls | gr");
        agent.prompt.suggestions.dropdown.open = true;
        agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "ls | grep");
        assert_eq!(agent.prompt_input_mode, PromptInputMode::Bash);
    }

    /// The accept works identically with the as-you-type pipeline OFF —
    /// in-place acceptance is not env-gated.
    #[test]
    fn dropdown_accept_works_without_env_flag() {
        let mut agent = bash_agent_always_on("ls | gr");
        agent.prompt.suggestions.dropdown.open = true;
        agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "ls | grep");
    }

    /// A ranged item whose range no longer fits the draft is a NO-OP accept
    /// — the draft survives untouched, the dropdown closes, the key is
    /// consumed (never a whole-line clobber, never a send).
    #[test]
    fn dropdown_accept_stale_range_is_a_draft_preserving_noop() {
        let mut agent = bash_agent("ls | gr");
        agent.prompt.set_text("totally different");
        agent.prompt.suggestions.dropdown.open = true;
        agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
        // Pass the generation gate (`set_text` bumped it) so this pins the
        // range-validation no-op, not the staleness gate. The "ls | gr"
        // anchor from `bash_agent` survives the swap (close() keeps it).
        agent.prompt.suggestions.dropdown.generation = agent.prompt.suggestions.generation();

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "totally different");
        assert!(!agent.prompt.completion_dropdown_open());
    }

    /// Items populated for a superseded generation refuse the accept
    /// wholesale: dropdown closes, draft untouched, Enter does not fall
    /// through to send.
    #[test]
    fn dropdown_accept_stale_generation_is_a_noop() {
        let mut agent = bash_agent("ls | gr");
        agent.prompt.suggestions.dropdown.open = true;
        agent.prompt.suggestions.dropdown.items = vec![token_item("ls | grep", "grep", 5..7)];
        // A newer edit bumped the controller past the items' generation.
        agent.prompt.suggestions.dropdown.generation = 3;

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
        assert!(
            matches!(outcome, InputOutcome::Changed),
            "stale accept must consume the key, got {outcome:?}"
        );
        assert_eq!(agent.prompt.text(), "ls | gr");
        assert!(!agent.prompt.completion_dropdown_open());
    }

    /// Rangeless items (older shells) keep the whole-line behavior.
    #[test]
    fn dropdown_accept_without_range_sets_whole_line() {
        let mut agent = bash_agent("git st");
        agent.prompt.suggestions.dropdown.open = true;
        agent.prompt.suggestions.dropdown.items = vec![item("git status --porcelain", None)];

        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert_eq!(agent.prompt.text(), "git status --porcelain");
        assert_eq!(agent.prompt.cursor(), agent.prompt.text().len());
    }

    /// Tab opens the dropdown whenever items exist — a ghost is NOT required
    /// (pure path/file completions never carry one). Two candidates with no
    /// shared prefix beyond the typed token = the plain-open path (a single
    /// candidate insta-accepts instead — see the terminal-Tab tests below).
    #[test]
    fn tab_opens_dropdown_without_ghost() {
        let mut agent = bash_agent("ls | gr");
        agent.prompt.suggestions.dropdown.items =
            vec![item("grep", Some(5..7)), item("grip", Some(5..7))];
        assert!(!agent.prompt.has_ghost_text());
        assert!(!agent.prompt.completion_dropdown_open());

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.completion_dropdown_open());
        assert_eq!(
            agent.prompt.text(),
            "ls | gr",
            "no fill without a longer LCP"
        );
    }

    // -- always-on Tab fetch (no KIGI_SUGGESTIONS) --------------------------

    /// Tab in bash mode with no fetched candidates fires a deterministic
    /// fetch — no env flag, no AI, dropdown-scale limit.
    #[test]
    fn tab_without_items_fires_deterministic_fetch() {
        let mut agent = bash_agent_always_on("cat no");
        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));

        let fetch = agent.pending_effects.iter().find_map(|e| match e {
            Effect::FetchShellSuggestions {
                include_ai,
                generation,
                limit,
                text,
                token_only,
                ..
            } => Some((*include_ai, *generation, *limit, text.clone(), *token_only)),
            _ => None,
        });
        let (include_ai, generation, limit, text, token_only) =
            fetch.expect("Tab must fire a fetch");
        assert!(!include_ai, "Tab completion is deterministic (no AI)");
        assert!(token_only, "Tab fetches run only the token providers");
        assert_eq!(limit, 50);
        assert_eq!(text, "cat no");
        assert_eq!(generation, agent.prompt.suggestions.generation());
    }

    /// Repeat Tab while the armed fetch is still in flight is a no-op: one
    /// RPC, one landing that runs the Tab semantics once.
    #[test]
    fn repeat_tab_fires_single_fetch_while_pending() {
        let mut agent = bash_agent_always_on("cat no");
        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));

        let fetches = agent
            .pending_effects
            .iter()
            .filter(|e| matches!(e, Effect::FetchShellSuggestions { .. }))
            .count();
        assert_eq!(fetches, 1, "the second Tab must not fire a second RPC");
        assert!(
            agent.prompt.suggestions.tab_fetch_pending(),
            "the pending-Tab marker survives the repeat press"
        );
    }

    /// Items outdated by an edit (stale generation) refetch instead of
    /// completing over the old candidate set.
    #[test]
    fn tab_with_stale_items_refetches() {
        let mut agent = bash_agent_always_on("cat no");
        agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
        agent.prompt.suggestions.dropdown.generation = 7;

        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert_eq!(agent.prompt.text(), "cat no", "no accept from stale items");
        assert!(
            agent
                .pending_effects
                .iter()
                .any(|e| matches!(e, Effect::FetchShellSuggestions { .. })),
            "stale items must refetch"
        );
    }

    /// An empty bash draft has no token to complete: Tab keeps its
    /// focus-cycling fallthrough.
    #[test]
    fn tab_on_empty_bash_draft_falls_through_to_focus_scrollback() {
        let mut agent = bash_agent_always_on("");
        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(
            outcome,
            InputOutcome::Action(Action::FocusScrollback)
        ));
        assert!(agent.pending_effects.is_empty());
    }

    /// The normal (chat) prompt keeps its Tab behavior: no fetch, no
    /// completion — the surface is bash-mode-only.
    #[test]
    fn tab_in_normal_mode_does_not_fetch() {
        let mut agent = super::test_fixtures::make_agent();
        agent.prompt.textarea.insert_str("cat no");

        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(
            !agent
                .pending_effects
                .iter()
                .any(|e| matches!(e, Effect::FetchShellSuggestions { .. })),
            "normal-mode Tab must not fetch completions"
        );
    }

    // -- terminal-like Tab (single-candidate accept / common-prefix fill) --

    /// Exactly one token candidate: Tab accepts it immediately — no
    /// dropdown flash — and the accept re-fetch keeps the pipeline alive.
    #[test]
    fn tab_single_token_candidate_accepts_without_dropdown_flash() {
        let mut agent = bash_agent("cat no");
        agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "cat notes.md");
        assert_eq!(agent.prompt.cursor(), "cat notes.md".len());
        assert!(!agent.prompt.completion_dropdown_open());
    }

    /// The same insta-accept with the pipeline OFF: the refetch kick is a
    /// direct deterministic fetch instead of a debounce.
    #[test]
    fn tab_single_candidate_accepts_and_kicks_fetch_always_on() {
        let mut agent = bash_agent_always_on("cat no");
        agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "cat notes.md");
        assert!(
            agent.pending_effects.iter().any(|e| matches!(
                e,
                Effect::FetchShellSuggestions {
                    include_ai: false,
                    ..
                }
            )),
            "accept must kick a deterministic refetch"
        );
    }

    /// A single HISTORY item keeps the plain dropdown-open behavior:
    /// terminal Tab semantics apply to token completions only.
    #[test]
    fn tab_single_history_item_opens_dropdown() {
        let mut agent = bash_agent("git st");
        agent.prompt.suggestions.dropdown.items =
            vec![history_item("git status --porcelain", 0..6)];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.completion_dropdown_open());
        assert_eq!(agent.prompt.text(), "git st");
    }

    /// THE legacy-shell compatibility case: a rangeless `path` row (old
    /// shells send `insertText: "grep"`, no range) must never insta-accept
    /// — its whole-line fallback would replace `ls | gr` with `grep`. Tab
    /// plain-opens instead, sole match or not.
    #[test]
    fn tab_sole_rangeless_path_row_opens_dropdown_never_accepts() {
        let mut agent = bash_agent("ls | gr");
        agent.prompt.suggestions.dropdown.items = vec![item("grep", None)];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "ls | gr", "draft must survive");
        assert!(agent.prompt.completion_dropdown_open());
    }

    /// Any rangeless row in a MIXED set (legacy PATH row next to a ranged
    /// file row) forces plain-open too — no insta-accept, no fill.
    #[test]
    fn tab_mixed_rangeless_and_ranged_rows_open_dropdown() {
        let mut agent = bash_agent("ls | gr");
        agent.prompt.suggestions.dropdown.items = vec![
            item("grep", None),
            file_item("ls | kigifile", "kigifile", 5..7),
        ];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.completion_dropdown_open());
        assert_eq!(agent.prompt.text(), "ls | gr", "no accept, no fill");
    }

    /// A MIXED set (any non-token item alongside file/path rows) disables
    /// terminal-Tab semantics wholesale: no insta-accept, no fill — Tab
    /// plain-opens so the user sees every candidate, history included.
    #[test]
    fn tab_mixed_file_and_history_items_opens_dropdown() {
        let mut agent = bash_agent("cat no");
        agent.prompt.suggestions.dropdown.items = vec![
            history_item("cat notes.md --verbose", 0..6),
            file_item("cat notes.md", "notes.md", 4..6),
        ];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.completion_dropdown_open());
        assert_eq!(agent.prompt.text(), "cat no", "no accept, no fill");
    }

    /// Whole-line history sets never prefix-fill (half a history line is
    /// not a command) — Tab plain-opens.
    #[test]
    fn tab_whole_line_history_items_open_dropdown_not_fill() {
        let mut agent = bash_agent("git st");
        agent.prompt.suggestions.dropdown.items = vec![
            history_item("git status --porcelain-A", 0..6),
            history_item("git status --porcelain-B", 0..6),
        ];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.completion_dropdown_open());
        assert_eq!(agent.prompt.text(), "git st");
    }

    /// Multiple candidates sharing a prefix longer than the typed token:
    /// the first Tab fills the common prefix in place (no dropdown) and
    /// re-fetches; when the refreshed items land, the second Tab opens the
    /// dropdown.
    #[test]
    fn tab_fills_common_prefix_then_opens_dropdown_on_refresh() {
        let mut agent = bash_agent("cat al");
        agent.prompt.suggestions.dropdown.items = vec![
            file_item("cat alpha_one.txt", "alpha_one.txt", 4..6),
            file_item("cat alpha_two.txt", "alpha_two.txt", 4..6),
        ];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "cat alpha_");
        assert_eq!(agent.prompt.cursor(), "cat alpha_".len());
        assert!(
            !agent.prompt.completion_dropdown_open(),
            "first Tab fills; the dropdown waits for the second"
        );
        assert!(
            agent
                .pending_effects
                .iter()
                .any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
            "the fill re-fetches candidates for the longer prefix"
        );

        // The refreshed response lands for the filled text…
        let generation = agent.prompt.suggestions.generation();
        agent.prompt.suggestions.on_suggestions_loaded(
            crate::views::suggestion_controller::SuggestResponseParsed {
                ghost: None,
                completions: vec![
                    file_item("cat alpha_one.txt", "alpha_one.txt", 4..10),
                    file_item("cat alpha_two.txt", "alpha_two.txt", 4..10),
                ],
                generation,
            },
            "cat alpha_",
            "cat alpha_".len(),
        );

        // …and the second Tab opens the dropdown (LCP no longer extends).
        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.completion_dropdown_open());
        assert_eq!(agent.prompt.text(), "cat alpha_");
    }

    /// The fill's refetch with the pipeline OFF is a direct deterministic
    /// fetch (no debounce to ride on).
    #[test]
    fn tab_fill_kicks_deterministic_fetch_always_on() {
        let mut agent = bash_agent_always_on("cat al");
        agent.prompt.suggestions.dropdown.items = vec![
            file_item("cat alpha_one.txt", "alpha_one.txt", 4..6),
            file_item("cat alpha_two.txt", "alpha_two.txt", 4..6),
        ];

        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert_eq!(agent.prompt.text(), "cat alpha_");
        assert!(
            agent.pending_effects.iter().any(|e| matches!(
                e,
                Effect::FetchShellSuggestions {
                    include_ai: false,
                    ..
                }
            )),
            "fill must kick a deterministic refetch"
        );
    }

    /// Bash-mode agent whose draft is a paste CHIP (atomic element), with
    /// the dropdown anchor pinned to it — the state a landing would leave
    /// when the shell's token range points into the chip's raw text.
    fn chip_agent(items: Vec<CompletionItemParsed>) -> (AgentView, String) {
        let mut agent = super::test_fixtures::make_agent();
        agent.prompt_input_mode = PromptInputMode::Bash;
        agent.prompt.suggestions.enabled = false;
        agent
            .prompt
            .handle_paste("line one\nline two\nline three\nline four");
        let text = agent.prompt.text().to_owned();
        agent.prompt.suggestions.dropdown.request_text = text.clone();
        agent.prompt.suggestions.dropdown.request_cursor = agent.prompt.cursor();
        agent.prompt.suggestions.dropdown.items = items;
        (agent, text)
    }

    fn suggest_fetch_count(agent: &AgentView) -> usize {
        agent
            .pending_effects
            .iter()
            .filter(|e| {
                matches!(
                    e,
                    Effect::FetchShellSuggestions { .. } | Effect::DebounceSuggestions { .. }
                )
            })
            .count()
    }

    /// BugBot: a Fill whose range clips a paste chip used to no-op the
    /// write and STILL kick a refetch — every Tab spun fill+refetch with no
    /// draft change. The declined fill now degrades to opening the
    /// dropdown: candidates visible, nothing fetched, chip intact, and the
    /// second Tab rides the normal open-dropdown handling.
    #[test]
    fn tab_fill_clipping_paste_chip_opens_dropdown_without_refetch() {
        // Two candidates whose shared range (chip bytes 0..2, "li") fills
        // to "lima_" — a valid Fill decision over an unwritable span.
        let (mut agent, text) = chip_agent(vec![
            file_item("lima_one.txt", "lima_one.txt", 0..2),
            file_item("lima_two.txt", "lima_two.txt", 0..2),
        ]);
        let gen_before = agent.prompt.suggestions.generation();

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), text, "chip must survive the fill");
        assert!(agent.prompt.completion_dropdown_open());
        assert_eq!(
            agent.prompt.suggestions.generation(),
            gen_before,
            "a declined fill must not invalidate anything"
        );
        assert_eq!(suggest_fetch_count(&agent), 0, "no refetch kick");

        // Second Tab goes through the open dropdown (accept path), never
        // the fetch arm — no spin.
        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert_eq!(agent.prompt.text(), text);
        assert_eq!(suggest_fetch_count(&agent), 0);
    }

    /// Same hole on the insta-accept arm: committing would consume the
    /// sole candidate and THEN decline the splice, leaving every Tab to
    /// refetch the same set. The probe degrades to showing the candidate.
    #[test]
    fn tab_insta_accept_clipping_paste_chip_opens_dropdown_without_refetch() {
        let (mut agent, text) = chip_agent(vec![file_item("lima_one.txt", "lima_one.txt", 0..2)]);

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), text, "chip must survive");
        assert!(agent.prompt.completion_dropdown_open());
        assert_eq!(
            agent.prompt.suggestions.dropdown.items.len(),
            1,
            "the candidate must not be consumed"
        );
        assert_eq!(suggest_fetch_count(&agent), 0, "no refetch kick");
    }

    /// BugBot sibling hole: the OPEN-dropdown accept (Tab/Enter/mouse all
    /// share the helper) used to consume the candidates and close before
    /// the write path declined the chip-clipping splice — leaving nothing.
    /// The probe now makes it an honest no-op: nothing consumed, dropdown
    /// up, chip/draft/generation untouched, no kick — and Enter must not
    /// fall through to send.
    #[test]
    fn dropdown_accept_clipping_paste_chip_keeps_candidates() {
        let (mut agent, text) = chip_agent(vec![
            file_item("lima_one.txt", "lima_one.txt", 0..2),
            file_item("lima_two.txt", "lima_two.txt", 0..2),
        ]);
        agent.prompt.suggestions.dropdown.open = true;
        let gen_before = agent.prompt.suggestions.generation();

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), text, "chip must survive");
        assert!(
            agent.prompt.completion_dropdown_open(),
            "candidates stay up"
        );
        assert_eq!(
            agent.prompt.suggestions.dropdown.items.len(),
            2,
            "nothing consumed"
        );
        assert_eq!(agent.prompt.suggestions.generation(), gen_before);
        assert_eq!(suggest_fetch_count(&agent), 0, "no refetch kick");

        // Tab rides the same helper.
        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert_eq!(agent.prompt.suggestions.dropdown.items.len(), 2);
        assert_eq!(agent.prompt.text(), text);
        assert_eq!(suggest_fetch_count(&agent), 0);
    }

    /// The probe peeks the SELECTED item: with a chip-clipping row next to
    /// a plain-text row, acceptance follows the selection — no-op on the
    /// clipping one, normal accept after Down moves to the safe one.
    #[test]
    fn dropdown_accept_respects_selection_over_mixed_clip_ranges() {
        let (mut agent, _) = chip_agent(vec![]);
        agent.prompt.textarea.insert_str(" li");
        let text = agent.prompt.text().to_owned();
        agent.prompt.suggestions.dropdown.request_text = text.clone();
        agent.prompt.suggestions.dropdown.request_cursor = agent.prompt.cursor();
        let tok = text.len() - 2;
        agent.prompt.suggestions.dropdown.items = vec![
            file_item("lima_one.txt", "lima_one.txt", 0..2),
            file_item("lima_two.txt", "lima_two.txt", tok..text.len()),
        ];
        agent.prompt.suggestions.dropdown.open = true;

        // Selected = the chip-clipping row: honest no-op.
        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Enter));
        assert_eq!(agent.prompt.suggestions.dropdown.items.len(), 2);
        assert_eq!(agent.prompt.text(), text);

        // Down selects the plain-text row: accepts normally.
        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Down));
        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(
            agent.prompt.text().ends_with(" lima_two.txt"),
            "safe selection must splice: {}",
            agent.prompt.text()
        );
        assert!(!agent.prompt.completion_dropdown_open());
    }

    /// Accepting a directory completion (trailing `/`) must re-fetch so the
    /// NEXT Tab completes inside it — drill-down chaining.
    #[test]
    fn dir_accept_kicks_refetch_for_drill_down() {
        let mut agent = bash_agent("cat no");
        agent.prompt.suggestions.dropdown.open = true;
        agent.prompt.suggestions.dropdown.items =
            vec![file_item("cat Notes\\ Archive/", "Notes\\ Archive/", 4..6)];

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "cat Notes\\ Archive/");
        assert!(
            agent
                .pending_effects
                .iter()
                .any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
            "dir accept must kick a fresh fetch for the drill-down"
        );
    }

    // -- Bash-mode gating of the as-you-type pipeline ------------------------

    /// Typing in the normal (chat) prompt never fires the suggest pipeline;
    /// the same keystroke in bash mode debounces a request.
    #[test]
    fn pipeline_fires_only_in_bash_mode() {
        let mut agent = super::test_fixtures::make_agent();
        agent.prompt.suggestions.enabled = true;

        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Char('g')));
        assert!(
            !agent
                .pending_effects
                .iter()
                .any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
            "normal-mode typing must not reach the suggest pipeline"
        );

        let mut agent = super::test_fixtures::make_agent();
        agent.prompt.suggestions.enabled = true;
        agent.prompt_input_mode = PromptInputMode::Bash;

        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Char('g')));
        assert!(
            agent
                .pending_effects
                .iter()
                .any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
            "bash-mode typing debounces a suggest request"
        );
    }

    /// Esc closes a dropdown the Tab-armed landing opened (the always-on
    /// dismissal path), and the draft survives.
    #[test]
    fn esc_closes_tab_fetched_dropdown() {
        let mut agent = bash_agent_always_on("git st");
        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        let generation = agent.prompt.suggestions.generation();
        agent.prompt.suggestions.on_suggestions_loaded(
            crate::views::suggestion_controller::SuggestResponseParsed {
                ghost: None,
                completions: vec![
                    history_item("git status --porcelain-A", 0..6),
                    history_item("git status --porcelain-B", 0..6),
                ],
                generation,
            },
            "git st",
            "git st".len(),
        );
        assert!(agent.prompt.suggestions.take_pending_tab(generation));
        agent.shell_completion_tab();
        assert!(agent.prompt.completion_dropdown_open());

        let outcome = agent.handle_prompt_key_for_test(&key(KeyCode::Esc));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(!agent.prompt.completion_dropdown_open());
        assert_eq!(agent.prompt.text(), "git st");
        assert_eq!(agent.prompt_input_mode, PromptInputMode::Bash);
    }

    /// With the pipeline OFF, typing invalidates Tab-fetched state instead:
    /// the landing response for the pre-edit text is stale.
    #[test]
    fn typing_invalidates_tab_state_always_on() {
        let mut agent = bash_agent_always_on("cat no");
        agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
        let gen_before = agent.prompt.suggestions.generation();

        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Char('x')));
        assert!(
            agent.prompt.suggestions.generation() > gen_before,
            "the edit must invalidate Tab-fetched state"
        );
        assert!(agent.prompt.suggestions.dropdown.items.is_empty());
        assert!(
            !agent
                .pending_effects
                .iter()
                .any(|e| matches!(e, Effect::DebounceSuggestions { .. })),
            "no as-you-type fetch without the env flag"
        );
    }

    /// THE stale-anchor regression: a mouse click repositions the cursor
    /// with no text change, so it must invalidate cached completion items
    /// exactly like a typed edit — the next Tab fetches for the token under
    /// the clicked cursor instead of completing the old one.
    #[test]
    fn prompt_click_invalidates_cached_items_before_tab() {
        use crate::app::agent_view::AgentPane;
        use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
        let mut agent = bash_agent_always_on("cat no");
        agent.prompt.suggestions.dropdown.items = vec![file_item("cat notes.md", "notes.md", 4..6)];
        agent.pane_areas.prompt = ratatui::layout::Rect::new(0, 40, 80, 5);
        // Already focused: an unfocused-collapse click only refocuses and
        // never reaches the textarea (the exact bug needs a focused click).
        agent.active_pane = AgentPane::Prompt;
        let gen_before = agent.prompt.suggestions.generation();

        let click = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            column: 2,
            row: 41,
            modifiers: KeyModifiers::NONE,
        };
        let _ = agent.handle_mouse(&click);
        assert!(
            agent.prompt.suggestions.generation() > gen_before,
            "a prompt click must invalidate cached completion state"
        );
        assert!(agent.prompt.suggestions.dropdown.items.is_empty());

        let _ = agent.handle_prompt_key_for_test(&key(KeyCode::Tab));
        assert_eq!(agent.prompt.text(), "cat no", "old token must not complete");
        assert!(
            agent
                .pending_effects
                .iter()
                .any(|e| matches!(e, Effect::FetchShellSuggestions { .. })),
            "Tab must refetch for the clicked position"
        );
    }
}
