//! Autocomplete providers (port of `src/ui/autocomplete.ts`).
//!
//! `CombinedAutocompleteProvider` fuses three engines: slash-command, `@file`
//! fuzzy (shells out to `fd`), and path completion, plus a completion-apply
//! strategy. Path logic is parity-critical: the `extractPathPrefix` delimiter
//! set `[" ","\t",'"',"'","="]`, the `@file` regex `/(?:^|[\s])(@[^\s]*)$/`,
//! the `fd` invocation flags, and the `scoreEntry` tiers all match the TS.

use std::cell::RefCell;
use std::collections::HashMap;
use std::fs;
use std::process::Command;
use std::sync::OnceLock;

use regex::Regex;

use crate::fuzzy::fuzzy_filter;

/// A single completion candidate (port of TS `AutocompleteItem`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutocompleteItem {
    pub value: String,
    pub label: String,
    pub description: Option<String>,
}

/// A registered slash command (port of TS `SlashCommand`).
///
/// `get_argument_completions` is the optional hook supplying completions for
/// this command's arguments; `None` means the command has nothing to suggest.
pub struct SlashCommand {
    pub name: String,
    pub description: Option<String>,
    #[allow(clippy::type_complexity)]
    pub get_argument_completions:
        Option<Box<dyn Fn(&str) -> Option<Vec<AutocompleteItem>> + Send + Sync>>,
}

/// Where the cursor sits in a multi-line buffer, used by the provider surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CursorPos {
    pub line: usize,
    pub col: usize,
}

/// The set of suggestions plus the fragment being completed
/// (port of the TS `{ items, prefix }` result shape).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SuggestionResult {
    pub items: Vec<AutocompleteItem>,
    /// The fragment being completed, e.g. `"/"` or `"src/"`.
    pub prefix: String,
}

/// The buffer + cursor after applying a completion
/// (port of the TS `{ lines, cursorLine, cursorCol }` result shape).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApplyResult {
    pub lines: Vec<String>,
    pub cursor_line: usize,
    pub cursor_col: usize,
}

/// Synchronous completion provider (port of TS `AutocompleteProvider`).
pub trait AutocompleteProvider {
    /// Compute candidates for the text up to the caret; `None` means nothing
    /// worth suggesting at this position.
    fn get_suggestions(&self, lines: &[String], cursor: CursorPos) -> Option<SuggestionResult>;

    /// Commit the chosen suggestion, yielding the updated buffer + caret.
    fn apply_completion(
        &self,
        lines: &[String],
        cursor: CursorPos,
        item: &AutocompleteItem,
        prefix: &str,
    ) -> ApplyResult;
}

// ---------------------------------------------------------------------------
// String / cursor / path helpers (UTF-16-code-unit parity with JS)
// ---------------------------------------------------------------------------

/// The buffer is indexed in JS by UTF-16 code units, and the caret column the
/// editor hands us is a UTF-16 offset. To stay byte-for-byte compatible with
/// the TS reference we operate on the line as a `Vec<u16>` whenever we need to
/// slice at a caret column, then round-trip back through `String::from_utf16`.
fn utf16_units(s: &str) -> Vec<u16> {
    s.encode_utf16().collect()
}

fn from_utf16_lossy(units: &[u16]) -> String {
    String::from_utf16_lossy(units)
}

/// `lines[i] || ""` parity (out-of-range reads yield the empty string).
fn line_at(lines: &[String], index: usize) -> String {
    lines.get(index).cloned().unwrap_or_default()
}

/// Length of a string in UTF-16 code units (JS `String.length`).
fn js_len(s: &str) -> usize {
    s.encode_utf16().count()
}

// --- Node posix path helpers ----------------------------------------------

/// `path.basename` parity (posix): the final path segment, ignoring a single
/// trailing slash.
fn node_basename(path: &str) -> String {
    let trimmed = path.strip_suffix('/').unwrap_or(path);
    match trimmed.rfind('/') {
        Some(idx) => trimmed[idx + 1..].to_string(),
        None => trimmed.to_string(),
    }
}

/// `path.dirname` parity (posix). Mirrors Node's behavior, including the
/// "no slash -> '.'", "root -> '/'", and trailing-slash handling.
fn node_dirname(path: &str) -> String {
    if path.is_empty() {
        return ".".to_string();
    }
    let bytes = path.as_bytes();
    let has_root = bytes[0] == b'/';
    // Find the last slash that is not a trailing one.
    let mut end: Option<usize> = None;
    let mut matched_slash = true;
    let mut i = path.len();
    while i >= 1 {
        i -= 1;
        if bytes[i] == b'/' {
            if !matched_slash {
                end = Some(i);
                break;
            }
        } else {
            matched_slash = false;
        }
    }

    match end {
        None => {
            if has_root {
                "/".to_string()
            } else {
                ".".to_string()
            }
        }
        Some(0) => {
            if has_root {
                "/".to_string()
            } else {
                ".".to_string()
            }
        }
        Some(idx) => {
            if has_root && idx == 1 {
                "//".to_string()
            } else {
                path[..idx].to_string()
            }
        }
    }
}

/// `path.join` parity (posix): join non-empty parts with `/`, then normalize.
/// An all-empty result yields `"."`. A trailing slash on the joined input is
/// preserved through normalization.
fn node_join(parts: &[&str]) -> String {
    let mut joined = String::new();
    for part in parts {
        if part.is_empty() {
            continue;
        }
        if joined.is_empty() {
            joined.push_str(part);
        } else {
            joined.push('/');
            joined.push_str(part);
        }
    }
    if joined.is_empty() {
        return ".".to_string();
    }
    normalize_posix(&joined)
}

/// posix `path.normalize` (the subset Node's `join` relies on): collapse
/// repeated slashes, resolve `.`/`..`, and keep a single trailing slash if the
/// input ended in one.
fn normalize_posix(path: &str) -> String {
    if path.is_empty() {
        return ".".to_string();
    }
    let is_absolute = path.starts_with('/');
    let trailing = path.ends_with('/');

    let mut segments: Vec<&str> = Vec::new();
    for segment in path.split('/') {
        match segment {
            "" | "." => {}
            ".." => {
                if let Some(last) = segments.last()
                    && *last != ".."
                {
                    segments.pop();
                    continue;
                }
                if !is_absolute {
                    segments.push("..");
                }
            }
            other => segments.push(other),
        }
    }

    let mut result = segments.join("/");
    if result.is_empty() {
        result = if is_absolute {
            "/".to_string()
        } else {
            ".".to_string()
        };
    } else if is_absolute {
        result = format!("/{result}");
    }

    if trailing && !result.ends_with('/') {
        result.push('/');
    }
    result
}

/// `os.homedir()` parity on unix (darwin/linux): `$HOME`.
fn homedir() -> String {
    std::env::var("HOME").unwrap_or_default()
}

// --- Regexes (spelled-out JS `\s` for parity) ------------------------------

// JS `\s` / `\S` character classes spelled out so the attachment/path regexes
// behave exactly like the TypeScript ones.
const JS_WS: &str = " \\t\\n\\r\\x0c\\x0b\\u{00a0}\\u{1680}\\u{2000}-\\u{200a}\\u{2028}\\u{2029}\\u{202f}\\u{205f}\\u{3000}\\u{feff}";

fn attachment_prefix_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(&format!(r"(?:^|[{JS_WS}])(@[^{JS_WS}]*)$")).unwrap())
}

fn at_path_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(&format!(r"@([^{JS_WS}]*)$")).unwrap())
}

// ---------------------------------------------------------------------------
// fd shell-out
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Eq)]
struct PathEntry {
    path: String,
    is_directory: bool,
}

/// Shell out to `fd` for a quick, gitignore-aware traversal of the tree.
fn walk_directory_with_fd(
    base_dir: &str,
    fd_path: &str,
    query: &str,
    max_results: usize,
) -> Vec<PathEntry> {
    let mut invocation: Vec<String> = vec!["--base-directory".to_string(), base_dir.to_string()];
    invocation.push("--max-results".to_string());
    invocation.push(max_results.to_string());
    invocation.push("--type".to_string());
    invocation.push("f".to_string());
    invocation.push("--type".to_string());
    invocation.push("d".to_string());
    invocation.push("--full-path".to_string());
    if !query.is_empty() {
        invocation.push(query.to_string());
    }

    let output = match Command::new(fd_path).args(&invocation).output() {
        Ok(out) => out,
        Err(_) => return Vec::new(),
    };

    let stdout = String::from_utf8_lossy(&output.stdout);
    let exited_cleanly = output.status.success() && !stdout.is_empty();
    if !exited_cleanly {
        return Vec::new();
    }

    let mut entries: Vec<PathEntry> = Vec::new();
    for line in stdout.trim().split('\n') {
        if line.is_empty() {
            continue;
        }
        entries.push(PathEntry {
            path: line.to_string(),
            is_directory: line.ends_with('/'),
        });
    }
    entries
}

// ---------------------------------------------------------------------------
// Suggestion cache (insertion-ordered, evict-oldest over 200)
// ---------------------------------------------------------------------------

struct SuggestionCache {
    keys: Vec<String>,
    store: HashMap<String, Vec<AutocompleteItem>>,
}

impl SuggestionCache {
    fn new() -> Self {
        Self {
            keys: Vec::new(),
            store: HashMap::new(),
        }
    }

    fn get_or_load(
        &mut self,
        key: &str,
        loader: impl FnOnce() -> Vec<AutocompleteItem>,
    ) -> Vec<AutocompleteItem> {
        if let Some(cached) = self.store.get(key) {
            return cached.clone();
        }
        let loaded = loader();
        self.evict_if_needed();
        self.keys.push(key.to_string());
        self.store.insert(key.to_string(), loaded.clone());
        loaded
    }

    fn clear(&mut self) {
        self.keys.clear();
        self.store.clear();
    }

    fn evict_if_needed(&mut self) {
        if self.store.len() <= 200 {
            return;
        }
        if self.keys.is_empty() {
            return;
        }
        let oldest = self.keys.remove(0);
        self.store.remove(&oldest);
    }
}

// ---------------------------------------------------------------------------
// Command list entry
// ---------------------------------------------------------------------------

/// A command list entry: either a full [`SlashCommand`] or a bare
/// [`AutocompleteItem`] (port of the TS `SlashCommand | AutocompleteItem`
/// heterogeneous list element).
pub enum CommandEntry {
    Command(SlashCommand),
    Item(AutocompleteItem),
}

impl CommandEntry {
    /// The matchable name: `name` for commands, `value` for bare items.
    fn name(&self) -> &str {
        match self {
            CommandEntry::Command(c) => &c.name,
            CommandEntry::Item(i) => &i.value,
        }
    }

    /// The display label: `name` for commands, `label` for bare items.
    fn label(&self) -> &str {
        match self {
            CommandEntry::Command(c) => &c.name,
            CommandEntry::Item(i) => &i.label,
        }
    }

    fn description(&self) -> Option<&str> {
        match self {
            CommandEntry::Command(c) => c.description.as_deref(),
            CommandEntry::Item(i) => i.description.as_deref(),
        }
    }
}

/// A flattened, searchable view of a command entry (port of the TS `searchable`
/// projection).
#[derive(Clone)]
struct SearchableEntry {
    name: String,
    label: String,
    description: Option<String>,
}

// ---------------------------------------------------------------------------
// Command engine
// ---------------------------------------------------------------------------

struct CommandAutocompleteEngine {
    commands: Vec<CommandEntry>,
}

impl CommandAutocompleteEngine {
    fn new(commands: Vec<CommandEntry>) -> Self {
        Self { commands }
    }

    fn get_suggestions(&self, text_before_cursor: &str) -> Option<SuggestionResult> {
        if !text_before_cursor.starts_with('/') {
            return None;
        }

        match text_before_cursor.find(' ') {
            None => self.complete_command_name(text_before_cursor),
            Some(space_index) => self.complete_command_argument(text_before_cursor, space_index),
        }
    }

    fn complete_command_name(&self, text_before_cursor: &str) -> Option<SuggestionResult> {
        let typed = &text_before_cursor[1..];

        // Normalise the heterogeneous command list into a flat, searchable shape.
        let searchable: Vec<SearchableEntry> = self
            .commands
            .iter()
            .map(|entry| SearchableEntry {
                name: entry.name().to_string(),
                label: entry.label().to_string(),
                description: entry.description().map(|d| d.to_string()),
            })
            .collect();

        let ranked = fuzzy_filter(searchable, typed, |entry| entry.name.clone());
        let items: Vec<AutocompleteItem> = ranked
            .into_iter()
            .map(|entry| AutocompleteItem {
                value: entry.name,
                label: entry.label,
                // Only attach a description when it is truthy (non-empty),
                // matching the TS `if (entry.description)` guard.
                description: entry.description.filter(|d| !d.is_empty()),
            })
            .collect();

        if items.is_empty() {
            return None;
        }
        Some(SuggestionResult {
            items,
            prefix: text_before_cursor.to_string(),
        })
    }

    fn complete_command_argument(
        &self,
        text_before_cursor: &str,
        space_index: usize,
    ) -> Option<SuggestionResult> {
        let command_name = &text_before_cursor[1..space_index];
        let argument_prefix = &text_before_cursor[space_index + 1..];

        let command = self
            .commands
            .iter()
            .find(|cmd| cmd.name() == command_name)?;
        let hook = match command {
            CommandEntry::Command(c) => c.get_argument_completions.as_ref()?,
            CommandEntry::Item(_) => return None,
        };

        let items = hook(argument_prefix)?;
        if items.is_empty() {
            return None;
        }
        Some(SuggestionResult {
            items,
            prefix: argument_prefix.to_string(),
        })
    }
}

// ---------------------------------------------------------------------------
// Attachment engine
// ---------------------------------------------------------------------------

struct AttachmentAutocompleteEngine;

impl AttachmentAutocompleteEngine {
    fn get_attachment_prefix(&self, text_before_cursor: &str) -> Option<String> {
        attachment_prefix_re()
            .captures(text_before_cursor)
            .and_then(|caps| caps.get(1))
            .map(|m| m.as_str().to_string())
    }

    fn to_query(&self, prefix: &str) -> String {
        prefix[1..].to_string()
    }
}

// ---------------------------------------------------------------------------
// Path engine
// ---------------------------------------------------------------------------

struct SearchPlan {
    search_dir: String,
    search_prefix: String,
    expanded_prefix: String,
    is_at_prefix: bool,
}

struct PathAutocompleteEngine {
    base_path: String,
    fd_path: Option<String>,
}

impl PathAutocompleteEngine {
    fn new(base_path: String, fd_path: Option<String>) -> Self {
        Self { base_path, fd_path }
    }

    fn extract_path_prefix(&self, text: &str, force_extract: bool) -> Option<String> {
        if let Some(caps) = at_path_re().captures(text) {
            return Some(caps.get(0).unwrap().as_str().to_string());
        }

        // Take everything after the right-most token delimiter as the fragment.
        let delimiters = [' ', '\t', '"', '\'', '='];
        let mut cut: isize = -1;
        for delimiter in delimiters {
            if let Some(at) = text.rfind(delimiter)
                && (at as isize) > cut
            {
                cut = at as isize;
            }
        }
        let fragment = if cut == -1 {
            text.to_string()
        } else {
            // `cut` indexes a single-byte ASCII delimiter, so +1 is char-safe.
            text[(cut as usize) + 1..].to_string()
        };

        if force_extract {
            return Some(fragment);
        }

        let looks_like_path =
            fragment.contains('/') || fragment.starts_with('.') || fragment.starts_with("~/");
        if looks_like_path {
            return Some(fragment);
        }

        let at_fresh_token = fragment.is_empty() && (text.is_empty() || text.ends_with(' '));
        if at_fresh_token {
            return Some(fragment);
        }

        None
    }

    fn should_trigger_for_tab(&self, text_before_cursor: &str) -> bool {
        let trimmed = text_before_cursor.trim();
        let is_bare_command_name = trimmed.starts_with('/') && !trimmed.contains(' ');
        !is_bare_command_name
    }

    fn get_file_suggestions(&self, prefix: &str) -> Vec<AutocompleteItem> {
        let plan = self.resolve_search_plan(prefix);

        let read = match fs::read_dir(&plan.search_dir) {
            Ok(rd) => rd,
            Err(_) => return Vec::new(),
        };

        let search_prefix_lower = plan.search_prefix.to_lowercase();
        let mut suggestions: Vec<AutocompleteItem> = Vec::new();

        for entry in read.flatten() {
            let name = match entry.file_name().into_string() {
                Ok(n) => n,
                Err(_) => continue,
            };
            if !name.to_lowercase().starts_with(&search_prefix_lower) {
                continue;
            }

            let file_type = entry.file_type().ok();
            let is_dir_flag = file_type.map(|t| t.is_dir()).unwrap_or(false);
            let is_symlink = file_type.map(|t| t.is_symlink()).unwrap_or(false);
            let is_directory =
                self.resolve_directory_flag(&plan.search_dir, &name, is_dir_flag, is_symlink);

            let relative_path =
                self.to_relative_path(prefix, &plan.expanded_prefix, &name, plan.is_at_prefix);
            suggestions.push(AutocompleteItem {
                value: if is_directory {
                    format!("{relative_path}/")
                } else {
                    relative_path
                },
                label: if is_directory {
                    format!("{name}/")
                } else {
                    name
                },
                description: None,
            });
        }

        // Directories first, then label order. The TS comparator used
        // `localeCompare`; a casefold-then-exact ordering is the closest stable
        // stand-in (mirrors the Python port).
        suggestions.sort_by(|a, b| {
            let a_dir = a.value.ends_with('/');
            let b_dir = b.value.ends_with('/');
            if a_dir && !b_dir {
                return std::cmp::Ordering::Less;
            }
            if !a_dir && b_dir {
                return std::cmp::Ordering::Greater;
            }
            a.label
                .to_lowercase()
                .cmp(&b.label.to_lowercase())
                .then_with(|| a.label.cmp(&b.label))
        });
        suggestions
    }

    fn get_fuzzy_file_suggestions(&self, query: &str) -> Vec<AutocompleteItem> {
        let fd_path = match &self.fd_path {
            Some(p) => p,
            None => return Vec::new(),
        };

        let found = walk_directory_with_fd(&self.base_path, fd_path, query, 100);

        // Rank by heuristic score, keep the positives, take the best 20.
        let mut with_scores: Vec<(PathEntry, i64)> = found
            .into_iter()
            .map(|entry| {
                let score = if query.is_empty() {
                    1
                } else {
                    self.score_entry(&entry.path, query, entry.is_directory)
                };
                (entry, score)
            })
            .filter(|(_, score)| *score > 0)
            .collect();

        // Stable descending sort by score (TS `b.score - a.score` over a stable
        // `Array.sort`). `sort_by_key` is stable, so equal scores keep input order.
        with_scores.sort_by_key(|x| std::cmp::Reverse(x.1));
        with_scores.truncate(20);

        with_scores
            .into_iter()
            .map(|(entry, _)| {
                let bare = if entry.is_directory {
                    entry.path[..entry.path.len() - 1].to_string()
                } else {
                    entry.path.clone()
                };
                AutocompleteItem {
                    value: format!("@{}", entry.path),
                    label: format!(
                        "{}{}",
                        node_basename(&bare),
                        if entry.is_directory { "/" } else { "" }
                    ),
                    description: Some(bare),
                }
            })
            .collect()
    }

    fn resolve_search_plan(&self, prefix: &str) -> SearchPlan {
        let is_at_prefix = prefix.starts_with('@');
        let mut expanded_prefix = if is_at_prefix {
            prefix[1..].to_string()
        } else {
            prefix.to_string()
        };
        if expanded_prefix.starts_with('~') {
            expanded_prefix = self.expand_home_path(&expanded_prefix);
        }

        let root_like = expanded_prefix.is_empty()
            || expanded_prefix == "./"
            || expanded_prefix == "../"
            || expanded_prefix == "~"
            || expanded_prefix == "~/"
            || expanded_prefix == "/"
            || prefix == "@";

        if root_like {
            let search_dir = if prefix.starts_with('~') || expanded_prefix == "/" {
                expanded_prefix.clone()
            } else {
                node_join(&[&self.base_path, &expanded_prefix])
            };
            return SearchPlan {
                search_dir,
                search_prefix: String::new(),
                expanded_prefix,
                is_at_prefix,
            };
        }

        if expanded_prefix.ends_with('/') {
            let search_dir = if prefix.starts_with('~') || expanded_prefix.starts_with('/') {
                expanded_prefix.clone()
            } else {
                node_join(&[&self.base_path, &expanded_prefix])
            };
            return SearchPlan {
                search_dir,
                search_prefix: String::new(),
                expanded_prefix,
                is_at_prefix,
            };
        }

        let dir = node_dirname(&expanded_prefix);
        let file = node_basename(&expanded_prefix);
        let search_dir = if prefix.starts_with('~') || expanded_prefix.starts_with('/') {
            dir.clone()
        } else {
            node_join(&[&self.base_path, &dir])
        };
        SearchPlan {
            search_dir,
            search_prefix: file,
            expanded_prefix,
            is_at_prefix,
        }
    }

    fn to_relative_path(
        &self,
        prefix: &str,
        expanded_prefix: &str,
        name: &str,
        is_at_prefix: bool,
    ) -> String {
        if is_at_prefix {
            return self.build_at_path(expanded_prefix, name);
        }
        if prefix.ends_with('/') {
            return format!("{prefix}{name}");
        }
        if prefix.contains('/') {
            if let Some(rest) = prefix.strip_prefix("~/") {
                let dir = node_dirname(rest);
                return if dir == "." {
                    format!("~/{name}")
                } else {
                    format!("~/{}", node_join(&[&dir, name]))
                };
            }
            if prefix.starts_with('/') {
                let dir = node_dirname(prefix);
                return if dir == "/" {
                    format!("/{name}")
                } else {
                    format!("{dir}/{name}")
                };
            }
            return node_join(&[&node_dirname(prefix), name]);
        }
        if prefix.starts_with('~') {
            format!("~/{name}")
        } else {
            name.to_string()
        }
    }

    fn build_at_path(&self, path_without_at: &str, name: &str) -> String {
        if path_without_at.ends_with('/') {
            return format!("@{path_without_at}{name}");
        }
        if path_without_at.contains('/') {
            if let Some(rest) = path_without_at.strip_prefix("~/") {
                let dir = node_dirname(rest);
                return if dir == "." {
                    format!("@~/{name}")
                } else {
                    format!("@~/{}", node_join(&[&dir, name]))
                };
            }
            return format!("@{}", node_join(&[&node_dirname(path_without_at), name]));
        }
        if path_without_at.starts_with('~') {
            format!("@~/{name}")
        } else {
            format!("@{name}")
        }
    }

    fn resolve_directory_flag(
        &self,
        search_dir: &str,
        name: &str,
        is_directory: bool,
        is_symlink: bool,
    ) -> bool {
        if is_directory || !is_symlink {
            return is_directory;
        }
        // statSync follows symlinks, exactly what the TS fallback did.
        let joined = node_join(&[search_dir, name]);
        fs::metadata(&joined).map(|m| m.is_dir()).unwrap_or(false)
    }

    fn expand_home_path(&self, path_value: &str) -> String {
        if let Some(rest) = path_value.strip_prefix("~/") {
            let expanded = node_join(&[&homedir(), rest]);
            if path_value.ends_with('/') && !expanded.ends_with('/') {
                return format!("{expanded}/");
            }
            return expanded;
        }
        if path_value == "~" {
            return homedir();
        }
        path_value.to_string()
    }

    fn score_entry(&self, file_path: &str, query: &str, is_directory: bool) -> i64 {
        let file_name = node_basename(file_path).to_lowercase();
        let query_lower = query.to_lowercase();
        let mut score: i64 = 0;
        if file_name == query_lower {
            score = 100;
        } else if file_name.starts_with(&query_lower) {
            score = 80;
        } else if file_name.contains(&query_lower) {
            score = 50;
        } else if file_path.to_lowercase().contains(&query_lower) {
            score = 30;
        }
        if is_directory && score > 0 {
            score += 10;
        }
        score
    }
}

// ---------------------------------------------------------------------------
// Completion apply strategy
// ---------------------------------------------------------------------------

struct CompletionApplyStrategy;

impl CompletionApplyStrategy {
    fn apply(
        &self,
        lines: &[String],
        cursor: CursorPos,
        item: &AutocompleteItem,
        prefix: &str,
    ) -> ApplyResult {
        let current_line = line_at(lines, cursor.line);
        let units = utf16_units(&current_line);

        let prefix_len = js_len(prefix);
        // `cursorCol - prefix.length`; JS produces a NaN-ish negative slice that
        // clamps to 0 at the start. Saturating-sub mirrors `slice` clamping.
        let head_end = cursor.col.saturating_sub(prefix_len);
        let head = from_utf16_lossy(&units[..head_end.min(units.len())]);
        let tail_start = cursor.col.min(units.len());
        let tail = from_utf16_lossy(&units[tail_start..]);

        let head_len = js_len(&head);
        let value_len = js_len(&item.value);

        // Slash command name: re-insert the leading "/" and a trailing space.
        if self.is_command_name(prefix, &head) {
            let rebuilt = format!("{head}/{} {tail}", item.value);
            let caret = head_len + value_len + 2;
            return self.write_line(lines, cursor.line, rebuilt, caret);
        }

        // "@file" attachment: append a space only when it is not a directory.
        if prefix.starts_with('@') {
            let trailing = if item.value.ends_with('/') { "" } else { " " };
            let rebuilt = format!("{head}{}{trailing}{tail}", item.value);
            let caret = head_len + value_len + js_len(trailing);
            return self.write_line(lines, cursor.line, rebuilt, caret);
        }

        // Plain path completion: drop the value in place, caret right after it.
        let rebuilt = format!("{head}{}{tail}", item.value);
        let caret = head_len + value_len;
        self.write_line(lines, cursor.line, rebuilt, caret)
    }

    fn is_command_name(&self, prefix: &str, head: &str) -> bool {
        prefix.starts_with('/') && head.trim().is_empty() && !prefix[1..].contains('/')
    }

    fn write_line(
        &self,
        lines: &[String],
        cursor_line: usize,
        line: String,
        cursor_col: usize,
    ) -> ApplyResult {
        let mut new_lines: Vec<String> = lines.to_vec();
        // The TS `newLines[cursorLine] = line` on a sparse array would create
        // holes; in Rust we grow with empty strings to the target index.
        while new_lines.len() <= cursor_line {
            new_lines.push(String::new());
        }
        new_lines[cursor_line] = line;
        ApplyResult {
            lines: new_lines,
            cursor_line,
            cursor_col,
        }
    }
}

// ---------------------------------------------------------------------------
// Combined provider
// ---------------------------------------------------------------------------

/// Combined provider for slash commands, `@file` fuzzy, and path completion
/// (port of TS `CombinedAutocompleteProvider`).
pub struct CombinedAutocompleteProvider {
    cache: RefCell<SuggestionCache>,
    command_engine: CommandAutocompleteEngine,
    attachment_engine: AttachmentAutocompleteEngine,
    path_engine: PathAutocompleteEngine,
    apply_strategy: CompletionApplyStrategy,
}

impl CombinedAutocompleteProvider {
    /// Build the provider from the known commands, a base path for path/file
    /// completion, and an optional `fd` binary path for fuzzy `@file` search.
    pub fn new(commands: Vec<CommandEntry>, base_path: String, fd_path: Option<String>) -> Self {
        let path_engine = PathAutocompleteEngine::new(base_path, fd_path);
        Self {
            cache: RefCell::new(SuggestionCache::new()),
            command_engine: CommandAutocompleteEngine::new(commands),
            attachment_engine: AttachmentAutocompleteEngine,
            path_engine,
            apply_strategy: CompletionApplyStrategy,
        }
    }

    /// Clear the internal suggestion cache.
    pub fn clear_cache(&mut self) {
        self.cache.borrow_mut().clear();
    }

    /// Force file completion (called on Tab) — always tries to return path
    /// suggestions, even when the heuristics would otherwise stay quiet.
    pub fn get_force_file_suggestions(
        &self,
        lines: &[String],
        cursor: CursorPos,
    ) -> Option<SuggestionResult> {
        let current_line = line_at(lines, cursor.line);
        let text_before_cursor = text_before_cursor(&current_line, cursor.col);
        if !self.path_engine.should_trigger_for_tab(&text_before_cursor) {
            return None;
        }

        let path_prefix = self
            .path_engine
            .extract_path_prefix(&text_before_cursor, true)?;

        let key = format!("path:{path_prefix}");
        let items = self
            .cache
            .borrow_mut()
            .get_or_load(&key, || self.path_engine.get_file_suggestions(&path_prefix));
        if items.is_empty() {
            return None;
        }
        Some(SuggestionResult {
            items,
            prefix: path_prefix,
        })
    }

    /// Whether Tab should trigger file completion at this position.
    pub fn should_trigger_file_completion(&self, lines: &[String], cursor: CursorPos) -> bool {
        let current_line = line_at(lines, cursor.line);
        let text_before_cursor = text_before_cursor(&current_line, cursor.col);
        self.path_engine.should_trigger_for_tab(&text_before_cursor)
    }
}

/// `currentLine.slice(0, cursorCol)` parity (UTF-16-code-unit slice).
fn text_before_cursor(current_line: &str, cursor_col: usize) -> String {
    let units = utf16_units(current_line);
    let end = cursor_col.min(units.len());
    from_utf16_lossy(&units[..end])
}

impl AutocompleteProvider for CombinedAutocompleteProvider {
    fn get_suggestions(&self, lines: &[String], cursor: CursorPos) -> Option<SuggestionResult> {
        let current_line = line_at(lines, cursor.line);
        let text_before_cursor = text_before_cursor(&current_line, cursor.col);

        // 1. "@" attachment fuzzy search wins when present and productive.
        if let Some(attachment_prefix) = self
            .attachment_engine
            .get_attachment_prefix(&text_before_cursor)
        {
            let query = self.attachment_engine.to_query(&attachment_prefix);
            let key = format!("fuzzy:{query}");
            let items = self
                .cache
                .borrow_mut()
                .get_or_load(&key, || self.path_engine.get_fuzzy_file_suggestions(&query));
            if !items.is_empty() {
                return Some(SuggestionResult {
                    items,
                    prefix: attachment_prefix,
                });
            }
        }

        // 2. Slash commands.
        if let Some(command_suggestions) = self.command_engine.get_suggestions(&text_before_cursor)
        {
            return Some(command_suggestions);
        }

        // 3. Fall back to plain path completion.
        let path_prefix = self
            .path_engine
            .extract_path_prefix(&text_before_cursor, false)?;
        let key = format!("path:{path_prefix}");
        let items = self
            .cache
            .borrow_mut()
            .get_or_load(&key, || self.path_engine.get_file_suggestions(&path_prefix));
        if items.is_empty() {
            return None;
        }
        Some(SuggestionResult {
            items,
            prefix: path_prefix,
        })
    }

    fn apply_completion(
        &self,
        lines: &[String],
        cursor: CursorPos,
        item: &AutocompleteItem,
        prefix: &str,
    ) -> ApplyResult {
        self.apply_strategy.apply(lines, cursor, item, prefix)
    }
}

// ---------------------------------------------------------------------------
// Async chain
// ---------------------------------------------------------------------------

/// Async completion provider (port of TS `AsyncAutocompleteProvider`).
///
/// The only async surface in the subtree. Kept as a separate trait; the M6
/// writer decides whether this is `async fn` or returns a future.
pub trait AsyncAutocompleteProvider {
    /// Async variant of `get_suggestions`.
    fn get_suggestions_async(
        &self,
        lines: &[String],
        cursor: CursorPos,
    ) -> Option<SuggestionResult>;
}

/// Chains async providers, returning the first productive result
/// (port of TS `AsyncAutocompleteChain`).
pub struct AsyncAutocompleteChain {
    providers: Vec<Box<dyn AsyncAutocompleteProvider>>,
}

impl AsyncAutocompleteChain {
    pub fn new(providers: Vec<Box<dyn AsyncAutocompleteProvider>>) -> Self {
        Self { providers }
    }
}

impl AsyncAutocompleteProvider for AsyncAutocompleteChain {
    fn get_suggestions_async(
        &self,
        lines: &[String],
        cursor: CursorPos,
    ) -> Option<SuggestionResult> {
        for provider in &self.providers {
            if let Some(result) = provider.get_suggestions_async(lines, cursor)
                && !result.items.is_empty()
            {
                return Some(result);
            }
        }
        None
    }
}

// ===========================================================================
// Tests
// ===========================================================================

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

    fn item(value: &str, label: &str) -> AutocompleteItem {
        AutocompleteItem {
            value: value.to_string(),
            label: label.to_string(),
            description: None,
        }
    }

    fn cmd(name: &str) -> CommandEntry {
        CommandEntry::Command(SlashCommand {
            name: name.to_string(),
            description: None,
            get_argument_completions: None,
        })
    }

    fn cmd_desc(name: &str, desc: &str) -> CommandEntry {
        CommandEntry::Command(SlashCommand {
            name: name.to_string(),
            description: Some(desc.to_string()),
            get_argument_completions: None,
        })
    }

    fn lines(s: &str) -> Vec<String> {
        vec![s.to_string()]
    }

    fn provider(commands: Vec<CommandEntry>) -> CombinedAutocompleteProvider {
        CombinedAutocompleteProvider::new(commands, "/tmp".to_string(), None)
    }

    // --- Node path helpers ---------------------------------------------------

    #[test]
    fn node_basename_matches_posix() {
        assert_eq!(node_basename("a/b/c.txt"), "c.txt");
        assert_eq!(node_basename("a/b/"), "b");
        assert_eq!(node_basename("file"), "file");
        assert_eq!(node_basename("/"), "");
    }

    #[test]
    fn node_dirname_matches_posix() {
        assert_eq!(node_dirname("a/b/c.txt"), "a/b");
        assert_eq!(node_dirname("file"), ".");
        assert_eq!(node_dirname("/foo"), "/");
        assert_eq!(node_dirname("/foo/bar"), "/foo");
        assert_eq!(node_dirname(""), ".");
    }

    #[test]
    fn node_join_matches_posix() {
        assert_eq!(node_join(&["a", "b", "c"]), "a/b/c");
        assert_eq!(node_join(&["a/", "b"]), "a/b");
        assert_eq!(node_join(&["", ""]), ".");
        assert_eq!(node_join(&["/tmp", "src/"]), "/tmp/src/");
        assert_eq!(node_join(&["a", "..", "b"]), "b");
    }

    // --- Command name completion --------------------------------------------

    #[test]
    fn slash_only_lists_all_commands() {
        let p = provider(vec![cmd("help"), cmd("clear"), cmd("model")]);
        let result = p.get_suggestions(&lines("/"), CursorPos { line: 0, col: 1 });
        let result = result.expect("should suggest");
        assert_eq!(result.prefix, "/");
        assert_eq!(result.items.len(), 3);
        // Empty query => fuzzy_filter returns items unchanged (all kept).
        let values: Vec<&str> = result.items.iter().map(|i| i.value.as_str()).collect();
        assert!(values.contains(&"help"));
        assert!(values.contains(&"clear"));
        assert!(values.contains(&"model"));
    }

    #[test]
    fn slash_prefix_filters_commands() {
        let p = provider(vec![cmd("help"), cmd("clear"), cmd("model")]);
        let result = p
            .get_suggestions(&lines("/he"), CursorPos { line: 0, col: 3 })
            .expect("should suggest");
        assert_eq!(result.prefix, "/he");
        let values: Vec<&str> = result.items.iter().map(|i| i.value.as_str()).collect();
        assert!(values.contains(&"help"));
        assert!(!values.contains(&"model"));
    }

    #[test]
    fn slash_command_carries_description() {
        let p = provider(vec![cmd_desc("help", "show help")]);
        let result = p
            .get_suggestions(&lines("/help"), CursorPos { line: 0, col: 5 })
            .expect("should suggest");
        assert_eq!(result.items[0].value, "help");
        assert_eq!(result.items[0].description.as_deref(), Some("show help"));
    }

    #[test]
    fn non_slash_is_not_a_command() {
        let p = provider(vec![cmd("help")]);
        // "hello" doesn't start with "/", isn't a path => no suggestions.
        let result = p.get_suggestions(&lines("hello"), CursorPos { line: 0, col: 5 });
        assert!(result.is_none());
    }

    #[test]
    fn unknown_slash_prefix_yields_none() {
        let p = provider(vec![cmd("help")]);
        let result = p.get_suggestions(&lines("/zzz"), CursorPos { line: 0, col: 4 });
        assert!(result.is_none());
    }

    #[test]
    fn bare_item_entries_complete_too() {
        let p = provider(vec![CommandEntry::Item(item("init", "init"))]);
        let result = p
            .get_suggestions(&lines("/in"), CursorPos { line: 0, col: 3 })
            .expect("should suggest");
        assert_eq!(result.items[0].value, "init");
    }

    // --- Command argument completion ----------------------------------------

    #[test]
    fn command_argument_completion_invokes_hook() {
        let commands = vec![CommandEntry::Command(SlashCommand {
            name: "theme".to_string(),
            description: None,
            get_argument_completions: Some(Box::new(|arg: &str| {
                if "dark".starts_with(arg) {
                    Some(vec![AutocompleteItem {
                        value: "dark".to_string(),
                        label: "dark".to_string(),
                        description: None,
                    }])
                } else {
                    None
                }
            })),
        })];
        let p = provider(commands);
        let result = p
            .get_suggestions(&lines("/theme da"), CursorPos { line: 0, col: 9 })
            .expect("should suggest");
        assert_eq!(result.prefix, "da");
        assert_eq!(result.items[0].value, "dark");
    }

    #[test]
    fn command_argument_hook_none_yields_none() {
        // Command exists but has no argument hook.
        let p = provider(vec![cmd("help")]);
        let result = p.get_suggestions(&lines("/help foo"), CursorPos { line: 0, col: 9 });
        assert!(result.is_none());
    }

    // --- Attachment prefix detection ----------------------------------------

    #[test]
    fn attachment_prefix_detected_at_start() {
        let eng = AttachmentAutocompleteEngine;
        assert_eq!(eng.get_attachment_prefix("@src"), Some("@src".to_string()));
        assert_eq!(eng.to_query("@src"), "src");
    }

    #[test]
    fn attachment_prefix_detected_after_space() {
        let eng = AttachmentAutocompleteEngine;
        assert_eq!(
            eng.get_attachment_prefix("look at @foo/bar"),
            Some("@foo/bar".to_string())
        );
    }

    #[test]
    fn attachment_prefix_requires_boundary() {
        let eng = AttachmentAutocompleteEngine;
        // "a@b" — the @ is not at a boundary, so no attachment match.
        assert_eq!(eng.get_attachment_prefix("a@b"), None);
    }

    #[test]
    fn bare_at_is_an_attachment_prefix() {
        let eng = AttachmentAutocompleteEngine;
        assert_eq!(eng.get_attachment_prefix("@"), Some("@".to_string()));
    }

    // --- extract_path_prefix -------------------------------------------------

    fn path_engine() -> PathAutocompleteEngine {
        PathAutocompleteEngine::new("/base".to_string(), None)
    }

    #[test]
    fn extract_path_prefix_at_takes_priority() {
        let e = path_engine();
        assert_eq!(
            e.extract_path_prefix("see @lib/x", false),
            Some("@lib/x".to_string())
        );
    }

    #[test]
    fn extract_path_prefix_recognizes_slash_fragment() {
        let e = path_engine();
        assert_eq!(
            e.extract_path_prefix("edit src/main", false),
            Some("src/main".to_string())
        );
    }

    #[test]
    fn extract_path_prefix_recognizes_dot_and_tilde() {
        let e = path_engine();
        assert_eq!(
            e.extract_path_prefix("./foo", false),
            Some("./foo".to_string())
        );
        assert_eq!(
            e.extract_path_prefix("~/foo", false),
            Some("~/foo".to_string())
        );
    }

    #[test]
    fn extract_path_prefix_plain_word_is_none() {
        let e = path_engine();
        // A bare word that doesn't look like a path => None (without force).
        assert_eq!(e.extract_path_prefix("hello", false), None);
    }

    #[test]
    fn extract_path_prefix_fresh_token_after_space() {
        let e = path_engine();
        // Empty fragment after a trailing space => "" prefix.
        assert_eq!(e.extract_path_prefix("run ", false), Some(String::new()));
    }

    #[test]
    fn extract_path_prefix_force_returns_fragment() {
        let e = path_engine();
        // force_extract always returns the last fragment, even a plain word.
        assert_eq!(
            e.extract_path_prefix("hello", true),
            Some("hello".to_string())
        );
    }

    #[test]
    fn extract_path_prefix_delimiters() {
        let e = path_engine();
        // Quotes and "=" act as token delimiters.
        assert_eq!(
            e.extract_path_prefix("KEY=./val", false),
            Some("./val".to_string())
        );
        assert_eq!(
            e.extract_path_prefix("x=\"./val", false),
            Some("./val".to_string())
        );
    }

    // --- should_trigger_for_tab ----------------------------------------------

    #[test]
    fn tab_does_not_trigger_on_bare_command() {
        let e = path_engine();
        assert!(!e.should_trigger_for_tab("/help"));
    }

    #[test]
    fn tab_triggers_on_command_with_arg() {
        let e = path_engine();
        assert!(e.should_trigger_for_tab("/open src"));
    }

    #[test]
    fn tab_triggers_on_plain_text() {
        let e = path_engine();
        assert!(e.should_trigger_for_tab("edit foo"));
    }

    // --- score_entry tiers ---------------------------------------------------

    #[test]
    fn score_entry_exact_match() {
        let e = path_engine();
        assert_eq!(e.score_entry("a/b/main.rs", "main.rs", false), 100);
    }

    #[test]
    fn score_entry_prefix_match() {
        let e = path_engine();
        assert_eq!(e.score_entry("a/b/main.rs", "main", false), 80);
    }

    #[test]
    fn score_entry_substring_in_name() {
        let e = path_engine();
        assert_eq!(e.score_entry("a/b/main.rs", "ain", false), 50);
    }

    #[test]
    fn score_entry_substring_in_path_only() {
        let e = path_engine();
        // "src" appears in the path but not in the basename.
        assert_eq!(e.score_entry("src/main.rs", "src", false), 30);
    }

    #[test]
    fn score_entry_no_match() {
        let e = path_engine();
        assert_eq!(e.score_entry("a/b/main.rs", "zzz", false), 0);
    }

    #[test]
    fn score_entry_directory_bonus() {
        let e = path_engine();
        // Directory bonus applies only when the base score is positive.
        assert_eq!(e.score_entry("a/b/main.rs", "main", true), 90);
        assert_eq!(e.score_entry("a/b/main.rs", "zzz", true), 0);
    }

    // --- resolve_search_plan -------------------------------------------------

    #[test]
    fn plan_empty_prefix_is_base_path() {
        let e = path_engine();
        let plan = e.resolve_search_plan("");
        assert_eq!(plan.search_dir, "/base");
        assert_eq!(plan.search_prefix, "");
        assert!(!plan.is_at_prefix);
    }

    #[test]
    fn plan_dir_with_trailing_slash() {
        let e = path_engine();
        let plan = e.resolve_search_plan("src/");
        // Node's path.join("/base", "src/") preserves the trailing slash
        // ("/base/src/"), so searchDir keeps it too — matching the TS ground truth.
        assert_eq!(plan.search_dir, "/base/src/");
        assert_eq!(plan.search_prefix, "");
    }

    #[test]
    fn plan_file_fragment_splits_dir_and_prefix() {
        let e = path_engine();
        let plan = e.resolve_search_plan("src/ma");
        assert_eq!(plan.search_dir, "/base/src");
        assert_eq!(plan.search_prefix, "ma");
    }

    #[test]
    fn plan_absolute_path_keeps_root() {
        let e = path_engine();
        let plan = e.resolve_search_plan("/etc/ho");
        assert_eq!(plan.search_dir, "/etc");
        assert_eq!(plan.search_prefix, "ho");
    }

    #[test]
    fn plan_at_prefix_flagged() {
        let e = path_engine();
        let plan = e.resolve_search_plan("@src/ma");
        assert!(plan.is_at_prefix);
        assert_eq!(plan.search_dir, "/base/src");
        assert_eq!(plan.search_prefix, "ma");
    }

    // --- to_relative_path / build_at_path ------------------------------------

    #[test]
    fn relative_path_trailing_slash_dir() {
        let e = path_engine();
        assert_eq!(
            e.to_relative_path("src/", "src/", "main.rs", false),
            "src/main.rs"
        );
    }

    #[test]
    fn relative_path_with_dir_fragment() {
        let e = path_engine();
        assert_eq!(
            e.to_relative_path("src/ma", "src/ma", "main.rs", false),
            "src/main.rs"
        );
    }

    #[test]
    fn relative_path_plain_name() {
        let e = path_engine();
        assert_eq!(e.to_relative_path("ma", "ma", "main.rs", false), "main.rs");
    }

    #[test]
    fn relative_path_absolute() {
        let e = path_engine();
        assert_eq!(
            e.to_relative_path("/etc/ho", "/etc/ho", "hosts", false),
            "/etc/hosts"
        );
    }

    #[test]
    fn at_path_building() {
        let e = path_engine();
        // is_at_prefix true => build_at_path is used. expanded_prefix has @ stripped.
        assert_eq!(
            e.to_relative_path("@src/ma", "src/ma", "main.rs", true),
            "@src/main.rs"
        );
        assert_eq!(e.to_relative_path("@", "", "file", true), "@file");
    }

    // --- apply_completion ----------------------------------------------------

    #[test]
    fn apply_command_name_inserts_slash_and_space() {
        let p = provider(vec![]);
        let result = p.apply_completion(
            &lines("/he"),
            CursorPos { line: 0, col: 3 },
            &item("help", "help"),
            "/he",
        );
        assert_eq!(result.lines[0], "/help ");
        assert_eq!(result.cursor_col, 6); // 0 + 4 + 2
        assert_eq!(result.cursor_line, 0);
    }

    #[test]
    fn apply_command_preserves_tail() {
        let p = provider(vec![]);
        let result = p.apply_completion(
            &lines("/hetail"),
            CursorPos { line: 0, col: 3 },
            &item("help", "help"),
            "/he",
        );
        // head="", value="help", tail="tail" => "/help tail"
        assert_eq!(result.lines[0], "/help tail");
        assert_eq!(result.cursor_col, 6);
    }

    #[test]
    fn apply_at_file_adds_trailing_space_for_file() {
        let p = provider(vec![]);
        let result = p.apply_completion(
            &lines("@sr"),
            CursorPos { line: 0, col: 3 },
            &item("@src/main.rs", "main.rs"),
            "@sr",
        );
        assert_eq!(result.lines[0], "@src/main.rs ");
        assert_eq!(result.cursor_col, 13); // 0 + 12 + 1
    }

    #[test]
    fn apply_at_file_no_space_for_directory() {
        let p = provider(vec![]);
        let result = p.apply_completion(
            &lines("@sr"),
            CursorPos { line: 0, col: 3 },
            &item("@src/", "src/"),
            "@sr",
        );
        assert_eq!(result.lines[0], "@src/");
        assert_eq!(result.cursor_col, 5); // 0 + 5 + 0
    }

    #[test]
    fn apply_plain_path_drops_value_in_place() {
        let p = provider(vec![]);
        let result = p.apply_completion(
            &lines("edit src/ma"),
            CursorPos { line: 0, col: 11 },
            &item("src/main.rs", "main.rs"),
            "src/ma",
        );
        // head="edit ", value="src/main.rs", tail="" => "edit src/main.rs"
        assert_eq!(result.lines[0], "edit src/main.rs");
        assert_eq!(result.cursor_col, 16); // head_len 5 + value_len 11
    }

    #[test]
    fn apply_preserves_other_lines() {
        let p = provider(vec![]);
        let buf = vec!["first".to_string(), "/he".to_string(), "third".to_string()];
        let result = p.apply_completion(
            &buf,
            CursorPos { line: 1, col: 3 },
            &item("help", "help"),
            "/he",
        );
        assert_eq!(result.lines[0], "first");
        assert_eq!(result.lines[1], "/help ");
        assert_eq!(result.lines[2], "third");
        assert_eq!(result.cursor_line, 1);
    }

    // --- suggestion cache ----------------------------------------------------

    #[test]
    fn cache_loads_once_then_serves_cached() {
        let mut cache = SuggestionCache::new();
        let mut calls = 0;
        let first = cache.get_or_load("k", || {
            calls += 1;
            vec![item("a", "a")]
        });
        assert_eq!(first.len(), 1);
        let second = cache.get_or_load("k", || {
            calls += 1;
            vec![item("b", "b")]
        });
        // Second call served from cache: loader not re-invoked.
        assert_eq!(second[0].value, "a");
        assert_eq!(calls, 1);
    }

    #[test]
    fn cache_clear_drops_entries() {
        let mut cache = SuggestionCache::new();
        cache.get_or_load("k", || vec![item("a", "a")]);
        cache.clear();
        let reload = cache.get_or_load("k", || vec![item("b", "b")]);
        assert_eq!(reload[0].value, "b");
    }

    #[test]
    fn cache_evicts_oldest_over_200() {
        let mut cache = SuggestionCache::new();
        // Match the TS Map semantics: evictIfNeeded runs BEFORE the insert and is a
        // no-op while size <= 200. After 201 inserts the map holds 201 entries and
        // "k0" is still present; the first eviction (dropping "k0") fires on the
        // 202nd insert, when the pre-insert size is 201 > 200.
        for i in 0..201 {
            cache.get_or_load(&format!("k{i}"), || vec![item("x", "x")]);
        }
        assert!(cache.store.contains_key("k0"));
        assert_eq!(cache.store.len(), 201);

        cache.get_or_load("k201", || vec![item("x", "x")]);
        assert!(!cache.store.contains_key("k0"));
        assert!(cache.store.contains_key("k200"));
        assert!(cache.store.contains_key("k201"));
        assert_eq!(cache.store.len(), 201);
    }

    // --- async chain ---------------------------------------------------------

    struct StubAsync(Option<SuggestionResult>);
    impl AsyncAutocompleteProvider for StubAsync {
        fn get_suggestions_async(
            &self,
            _lines: &[String],
            _cursor: CursorPos,
        ) -> Option<SuggestionResult> {
            self.0.clone()
        }
    }

    #[test]
    fn async_chain_returns_first_productive() {
        let empty = StubAsync(None);
        let empty_items = StubAsync(Some(SuggestionResult {
            items: vec![],
            prefix: "x".to_string(),
        }));
        let productive = StubAsync(Some(SuggestionResult {
            items: vec![item("hit", "hit")],
            prefix: "h".to_string(),
        }));
        let chain = AsyncAutocompleteChain::new(vec![
            Box::new(empty),
            Box::new(empty_items),
            Box::new(productive),
        ]);
        let result = chain
            .get_suggestions_async(&lines(""), CursorPos { line: 0, col: 0 })
            .expect("should find productive provider");
        assert_eq!(result.items[0].value, "hit");
    }

    #[test]
    fn async_chain_none_when_all_empty() {
        let chain = AsyncAutocompleteChain::new(vec![
            Box::new(StubAsync(None)),
            Box::new(StubAsync(Some(SuggestionResult {
                items: vec![],
                prefix: "x".to_string(),
            }))),
        ]);
        assert!(
            chain
                .get_suggestions_async(&lines(""), CursorPos { line: 0, col: 0 })
                .is_none()
        );
    }

    // --- combined provider routing -------------------------------------------

    #[test]
    fn combined_clear_cache_is_callable() {
        let mut p = provider(vec![cmd("help")]);
        // Populate then clear; must not panic.
        let _ = p.get_suggestions(&lines("/h"), CursorPos { line: 0, col: 2 });
        p.clear_cache();
    }

    #[test]
    fn should_trigger_file_completion_routing() {
        let p = provider(vec![cmd("help")]);
        assert!(!p.should_trigger_file_completion(&lines("/help"), CursorPos { line: 0, col: 5 }));
        assert!(p.should_trigger_file_completion(&lines("/open x"), CursorPos { line: 0, col: 7 }));
    }

    #[test]
    fn text_before_cursor_slices_utf16() {
        // "ab" with caret at col 1 yields "a".
        assert_eq!(text_before_cursor("ab", 1), "a");
        // Caret beyond end clamps.
        assert_eq!(text_before_cursor("ab", 99), "ab");
    }
}
