//! Tab completion for TUI input.
//!
//! Handles two completion modes:
//! - **Slash commands**: `/d` → `/diff`, `/diff commit`, `/diff review`
//!   (data + matching live in [`crate::composer::slash_popup`])
//! - **@file paths**: `explain @src/m` → `explain @src/main.rs`
//!
//! Both modes share the same `InputCompleter` cycling state machine
//! (matches/idx/token) so Tab cycles through candidates regardless of
//! mode.

use std::path::{Path, PathBuf};

/// Re-export so existing call sites (and external doc-tests) can keep
/// referring to `crate::completer::SLASH_COMMANDS`. The canonical
/// definition moved to [`crate::composer::slash_popup`] in #1187.
pub use crate::composer::slash_popup::SLASH_COMMANDS;

/// Unified Tab-completion for slash commands and @file paths.
pub struct InputCompleter {
    /// Current completion matches.
    matches: Vec<String>,
    /// Index into `matches` for cycling.
    idx: usize,
    /// The token being completed (to detect changes).
    token: String,
    /// Project root for @file path resolution.
    project_root: PathBuf,
    /// Cached model names for `/model` completion.
    model_names: Vec<String>,
}

impl InputCompleter {
    pub fn new(project_root: PathBuf) -> Self {
        Self {
            matches: Vec::new(),
            idx: 0,
            token: String::new(),
            project_root,
            model_names: Vec::new(),
        }
    }

    /// Update the cached model names (call after provider switch or model list fetch).
    pub fn set_model_names(&mut self, names: Vec<String>) {
        self.model_names = names;
    }

    /// Attempt to complete the current input text.
    ///
    /// Returns `Some(replacement_text)` with the full input line replaced,
    /// or `None` if no completion is available.
    /// Repeated calls cycle through matches.
    pub fn complete(&mut self, current_text: &str) -> Option<String> {
        let trimmed = current_text.trim_end();

        // Slash command completion: input starts with /
        if trimmed.starts_with('/') {
            // /model <partial> → complete model names
            if let Some(partial) = trimmed.strip_prefix("/model ") {
                return self.complete_model(partial);
            }
            return self.complete_slash(trimmed);
        }

        // @file completion: find the last @token in the input
        if let Some(at_pos) = find_last_at_token(trimmed) {
            let partial = &trimmed[at_pos + 1..]; // after @
            let prefix = &trimmed[..at_pos]; // everything before @
            return self.complete_file(prefix, partial);
        }

        self.reset();
        None
    }

    /// Reset completion state (call on non-Tab keystrokes).
    pub fn reset(&mut self) {
        self.matches.clear();
        self.idx = 0;
        self.token.clear();
    }

    // ── Slash command completion ─────────────────────────────

    fn complete_slash(&mut self, trimmed: &str) -> Option<String> {
        // Rebuild matches if the token changed. Matching itself lives in
        // `composer::slash_popup::matches_for` (#1187) so the slash table
        // is the single source of truth for both Tab-completion and the
        // auto-dropdown.
        if trimmed != self.token && !self.matches.iter().any(|m| m == trimmed) {
            self.token = trimmed.to_string();
            self.matches = crate::composer::slash_popup::matches_for(trimmed);
            self.idx = 0;
        }

        if self.matches.is_empty() {
            return None;
        }

        let result = self.matches[self.idx].clone();
        self.idx = (self.idx + 1) % self.matches.len();
        Some(result)
    }

    // ── /model name completion ──────────────────────────────

    fn complete_model(&mut self, partial: &str) -> Option<String> {
        let token_key = format!("/model {partial}");

        if token_key != self.token {
            self.token = token_key;
            // Complete against alias names + cached provider model names
            let alias_names = koda_core::model_alias::alias_names();
            self.matches = alias_names
                .iter()
                .map(|s| s.to_string())
                .chain(self.model_names.iter().cloned())
                .filter(|name| name.contains(partial) && name.as_str() != partial)
                .map(|name| format!("/model {name}"))
                .collect();
            self.idx = 0;
        }

        if self.matches.is_empty() {
            return None;
        }

        let result = self.matches[self.idx].clone();
        self.idx = (self.idx + 1) % self.matches.len();
        Some(result)
    }

    // ── @file path completion ────────────────────────────────

    fn complete_file(&mut self, prefix: &str, partial: &str) -> Option<String> {
        // Check if the partial is already one of our matches (user is cycling)
        let is_cycling = !self.matches.is_empty() && self.matches.iter().any(|m| m == partial);

        if !is_cycling {
            self.token = format!("@{partial}");
            self.matches = list_path_matches(&self.project_root, partial);
            self.idx = 0;
        }

        if self.matches.is_empty() {
            return None;
        }

        let path = &self.matches[self.idx];
        self.idx = (self.idx + 1) % self.matches.len();

        // Rebuild full input: prefix + @completed_path
        Some(format!("{prefix}@{path}"))
    }
}

// ── Helpers ─────────────────────────────────────────────────

/// Find the byte position of the last `@` that starts a file reference.
///
/// An `@` counts as a file reference if it's preceded by whitespace
/// or is at the start of the input (not an email address).
///
/// # Examples
///
/// ```ignore
/// // pub(crate): completer is not importable from a doc-test binary; illustrative only.
/// use koda_cli::completer::find_last_at_token;
///
/// // Leading @ at position 0
/// assert_eq!(find_last_at_token("@file.rs"), Some(0));
///
/// // @ after a space
/// assert_eq!(find_last_at_token("explain @src/main.rs"), Some(8));
///
/// // Email address — no space before @, so it is NOT treated as a file ref
/// assert_eq!(find_last_at_token("user@example.com"), None);
///
/// // Returns the LAST @ in multi-@ input
/// assert_eq!(find_last_at_token("@a @b"), Some(3));
///
/// // No @ at all
/// assert_eq!(find_last_at_token("just a normal prompt"), None);
/// ```
pub fn find_last_at_token(text: &str) -> Option<usize> {
    for (i, c) in text.char_indices().rev() {
        if c == '@' && (i == 0 || matches!(text.as_bytes()[i - 1], b' ' | b'\n')) {
            return Some(i);
        }
    }
    None
}

/// List filesystem paths matching a partial path relative to project_root.
/// Public wrapper for the `@` auto-dropdown in `tui_app.rs`.
pub fn list_path_matches_public(project_root: &Path, partial: &str) -> Vec<String> {
    list_path_matches(project_root, partial)
}

/// List filesystem paths matching a partial path relative to project_root.
///
/// Uses fuzzy subsequence matching: `@mrs` matches `main.rs`, `@ctml` matches `Cargo.toml`.
/// Prefix matches rank higher than fuzzy matches.
/// Directories get a trailing `/` to encourage further completion.
fn list_path_matches(project_root: &Path, partial: &str) -> Vec<String> {
    let (dir_part, file_prefix) = match partial.rfind('/') {
        Some(pos) => (&partial[..=pos], &partial[pos + 1..]),
        None => ("", partial),
    };

    let search_dir = if dir_part.is_empty() {
        project_root.to_path_buf()
    } else {
        // Security: reject paths with traversal components
        if dir_part.contains("..") {
            return Vec::new();
        }
        project_root.join(dir_part)
    };

    let entries = match std::fs::read_dir(&search_dir) {
        Ok(entries) => entries,
        Err(_) => return Vec::new(),
    };

    let lower_prefix = file_prefix.to_lowercase();

    let mut scored: Vec<(i32, String)> = entries
        .filter_map(|e| e.ok())
        .filter_map(|entry| {
            let name = entry.file_name().to_string_lossy().to_string();

            // Skip hidden files and common noise
            if name.starts_with('.') {
                return None;
            }

            let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);

            // Skip build artifacts / deps
            if is_dir
                && matches!(
                    name.as_str(),
                    "target" | "node_modules" | "__pycache__" | ".git"
                )
            {
                return None;
            }

            // query is lowered; target keeps original case for camelCase detection
            let score = fuzzy_score(&lower_prefix, &name)?;

            let path = if is_dir {
                format!("{dir_part}{name}/")
            } else {
                format!("{dir_part}{name}")
            };
            Some((score, path))
        })
        .collect();

    // Sort by score (higher = better match), then alphabetically
    scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
    scored.into_iter().map(|(_, path)| path).collect()
}

/// Fuzzy subsequence scoring.
///
/// Returns `Some(score)` if all chars of `query` appear in `target` in order.
/// Higher score = better match.
///
/// Scoring (nucleo-inspired, matching CC's `native-ts/file-index`):
/// - Base: +1 per matched char
/// - Prefix / first char at pos 0: +100
/// - Consecutive chars: +10
/// - After separator (`_`, `-`, `.`, `/`): +5
/// - camelCase transition (lower→upper): +6
/// - Gap penalty: −3 (start) + −1 per additional gap char
///
/// `query` must be lowercased. `target` is **original case** so camelCase
/// transitions can be detected; character comparison is case-insensitive.
fn fuzzy_score(query: &str, target: &str) -> Option<i32> {
    if query.is_empty() {
        return Some(0);
    }

    let query_chars: Vec<char> = query.chars().collect();
    let target_chars: Vec<char> = target.chars().collect();

    let mut qi = 0;
    let mut score: i32 = 0;
    let mut prev_match_pos: Option<usize> = None;

    for (ti, &tc) in target_chars.iter().enumerate() {
        if qi < query_chars.len() && tc.to_ascii_lowercase() == query_chars[qi] {
            score += 1;

            // Bonus: prefix match
            if qi == 0 && ti == 0 {
                score += 100;
            }

            // Bonus: consecutive match
            if ti > 0 && prev_match_pos == Some(ti - 1) {
                score += 10;
            }

            // Bonus: after separator
            if ti > 0 && matches!(target_chars[ti - 1], '_' | '-' | '.' | '/') {
                score += 5;
            }

            // Bonus: camelCase transition (previous char lowercase, current uppercase)
            if ti > 0 && target_chars[ti - 1].is_ascii_lowercase() && tc.is_ascii_uppercase() {
                score += 6;
            }

            // Penalty: gap between consecutive matches
            if let Some(prev) = prev_match_pos {
                let gap = ti - prev - 1;
                if gap > 0 {
                    score -= 3 + gap as i32; // start + extension
                }
            }

            prev_match_pos = Some(ti);
            qi += 1;
        }
    }

    if qi == query_chars.len() {
        Some(score)
    } else {
        None // Not all query chars matched
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    // ── Slash command tests ─────────────────────────────────

    #[test]
    fn test_complete_slash_d() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        let first = c.complete("/d");
        assert!(first.is_some());
        assert!(first.unwrap().starts_with("/d"));
    }

    #[test]
    fn test_complete_cycles() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        let a = c.complete("/d");
        let b = c.complete("/d");
        assert!(a.is_some());
        assert!(b.is_some());
    }

    #[test]
    fn test_no_match() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        assert!(c.complete("/zzz").is_none());
    }

    #[test]
    fn test_non_slash_no_at_returns_none() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        assert!(c.complete("hello").is_none());
    }

    #[test]
    fn test_exact_match_no_complete() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        assert!(c.complete("/exit").is_none());
    }

    // ── @file completion tests ───────────────────────────────

    #[test]
    fn test_at_file_completes() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
        fs::write(tmp.path().join("mod.rs"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        let result = c.complete("explain @m");
        assert!(result.is_some());
        let text = result.unwrap();
        assert!(text.starts_with("explain @m"), "got: {text}");
        assert!(
            text.contains("main.rs") || text.contains("mod.rs"),
            "got: {text}"
        );
    }

    #[test]
    fn test_at_file_in_subdir() {
        let tmp = tempdir().unwrap();
        fs::create_dir_all(tmp.path().join("src")).unwrap();
        fs::write(tmp.path().join("src/lib.rs"), "").unwrap();
        fs::write(tmp.path().join("src/main.rs"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        let result = c.complete("@src/l");
        assert_eq!(result, Some("@src/lib.rs".to_string()));
    }

    #[test]
    fn test_at_file_dir_gets_trailing_slash() {
        let tmp = tempdir().unwrap();
        fs::create_dir_all(tmp.path().join("src")).unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        let result = c.complete("@s");
        assert_eq!(result, Some("@src/".to_string()));
    }

    #[test]
    fn test_at_file_cycles() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join("alpha.rs"), "").unwrap();
        fs::write(tmp.path().join("beta.rs"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        // First Tab: input is "@" → returns first match
        let a = c.complete("@").unwrap();
        // Second Tab: input is now the completed text (e.g., "@alpha.rs")
        let b = c.complete(&a).unwrap();
        assert_ne!(a, b, "should cycle through different files");
        // Third Tab: should cycle back
        let c_result = c.complete(&b).unwrap();
        assert_eq!(c_result, a, "should cycle back to first");
        assert_eq!(c_result, a, "should cycle back to first");
    }

    #[test]
    fn test_at_file_skips_hidden() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join(".hidden"), "").unwrap();
        fs::write(tmp.path().join("visible.rs"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        let result = c.complete("@");
        assert_eq!(result, Some("@visible.rs".to_string()));
    }

    #[test]
    fn test_at_file_case_insensitive() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join("Makefile"), "").unwrap();
        fs::write(tmp.path().join("README.md"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        let result = c.complete("@make");
        assert_eq!(result, Some("@Makefile".to_string()));

        c.reset();
        let result = c.complete("@read");
        assert_eq!(result, Some("@README.md".to_string()));
    }

    #[test]
    fn test_at_file_preserves_prefix_text() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join("config.toml"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        let result = c.complete("review this @c");
        assert_eq!(result, Some("review this @config.toml".to_string()));
    }

    // ── /model completion tests ──────────────────────────────

    #[test]
    fn test_model_complete() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        c.set_model_names(vec![
            "gpt-4o".into(),
            "gpt-4o-mini".into(),
            "gpt-3.5-turbo".into(),
        ]);
        let result = c.complete("/model gpt-4");
        assert!(result.is_some());
        let text = result.unwrap();
        assert!(text.starts_with("/model gpt-4"), "got: {text}");
    }

    #[test]
    fn test_model_complete_cycles() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        c.set_model_names(vec!["gpt-4o".into(), "gpt-4o-mini".into()]);
        let a = c.complete("/model gpt");
        let b = c.complete("/model gpt");
        assert!(a.is_some());
        assert!(b.is_some());
        assert_ne!(a, b, "should cycle through models");
    }

    #[test]
    fn test_model_no_names_returns_none() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        // No provider model names set; "gpt" matches no aliases (we only have gemini/claude)
        assert!(c.complete("/model gpt").is_none());
    }

    #[test]
    fn test_model_no_match_returns_none() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        // "zzz" matches no aliases or model names
        assert!(c.complete("/model zzz").is_none());
    }

    #[test]
    fn test_model_substring_match() {
        let tmp = tempdir().unwrap();
        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        c.set_model_names(vec!["claude-3-sonnet".into(), "claude-3-opus".into()]);
        let result = c.complete("/model opus");
        // "opus" matches both the alias "claude-opus" and "claude-3-opus" from model_names
        assert!(result.is_some());
        let text = result.unwrap();
        assert!(text.contains("opus"), "got: {text}");
    }

    // ── Helper tests ────────────────────────────────────────

    #[test]
    fn test_find_last_at_token() {
        assert_eq!(find_last_at_token("@file"), Some(0));
        assert_eq!(find_last_at_token("explain @file"), Some(8));
        assert_eq!(find_last_at_token("email@domain"), None); // no space before @
        assert_eq!(find_last_at_token("a @b @c"), Some(5)); // last @
        assert_eq!(find_last_at_token("no at here"), None);
        // @ after newline (multi-line input via Alt+Enter)
        assert_eq!(find_last_at_token("line1\n@file"), Some(6));
        assert_eq!(find_last_at_token("a\nb\n@c"), Some(4));
    }

    #[test]
    fn test_at_file_after_newline() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join("config.toml"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        // Simulate multi-line input: first line + newline + @partial
        let result = c.complete("explain this\n@c");
        assert_eq!(result, Some("explain this\n@config.toml".to_string()));
    }

    #[test]
    fn test_at_file_traversal_blocked() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join("safe.rs"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        // Attempt path traversal — should return no matches
        let result = c.complete("@../../etc/");
        assert!(result.is_none(), "traversal should be blocked");
    }

    // ── Fuzzy matching tests ────────────────────────────────

    #[test]
    fn test_fuzzy_score_basic() {
        // Exact prefix → high score
        assert!(fuzzy_score("main", "main.rs").unwrap() > 100);
        // Subsequence match
        assert!(fuzzy_score("mrs", "main.rs").is_some());
        // No match
        assert!(fuzzy_score("xyz", "main.rs").is_none());
    }

    #[test]
    fn test_fuzzy_score_prefix_wins() {
        let prefix = fuzzy_score("ma", "main.rs").unwrap();
        let fuzzy = fuzzy_score("ma", "format.rs").unwrap();
        assert!(prefix > fuzzy, "prefix {prefix} should beat fuzzy {fuzzy}");
    }

    #[test]
    fn test_fuzzy_at_file() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join("main.rs"), "").unwrap();
        fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
        fs::write(tmp.path().join("config.rs"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        // "mrs" → should fuzzy-match main.rs (m...r.s)
        let result = c.complete("@mrs");
        assert_eq!(result, Some("@main.rs".to_string()));
    }

    #[test]
    fn test_fuzzy_cargo_toml() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
        fs::write(tmp.path().join("config.rs"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        // "ctml" → fuzzy-match Cargo.toml (c...t..m.l)
        let result = c.complete("@ctml");
        assert_eq!(result, Some("@Cargo.toml".to_string()));
    }

    #[test]
    fn test_fuzzy_prefix_ranked_first() {
        let tmp = tempdir().unwrap();
        fs::write(tmp.path().join("main.rs"), "").unwrap();
        fs::write(tmp.path().join("format.rs"), "").unwrap();

        let mut c = InputCompleter::new(tmp.path().to_path_buf());
        // "m" → main.rs should come before format.rs (prefix match wins)
        let result = c.complete("@m");
        assert_eq!(result, Some("@main.rs".to_string()));
    }

    // ── Gap penalty tests ──────────────────────────────────

    #[test]
    fn test_gap_penalty_tight_beats_scattered() {
        // "mrs": main.rs has gap=1 (m-a-i-n-.-r-s), scattered has large gaps
        let tight = fuzzy_score("mrs", "main.rs").unwrap();
        let scattered = fuzzy_score("mrs", "my_really_long_script.rs").unwrap();
        assert!(
            tight > scattered,
            "tight {tight} should beat scattered {scattered}"
        );
    }

    #[test]
    fn test_gap_penalty_consecutive_no_penalty() {
        // Consecutive chars should get bonus, not penalty
        let consec = fuzzy_score("mai", "main.rs").unwrap();
        let gapped = fuzzy_score("mai", "m_a_i.rs").unwrap();
        assert!(
            consec > gapped,
            "consecutive {consec} should beat gapped {gapped}"
        );
    }

    // ── camelCase bonus tests ──────────────────────────────

    #[test]
    fn test_camel_case_bonus() {
        // "dm" at camelCase boundary (D→M) should score higher
        let camel = fuzzy_score("dm", "DropdownMenu").unwrap();
        let flat = fuzzy_score("dm", "random_dm_file").unwrap();
        assert!(camel > flat, "camelCase {camel} should beat flat {flat}");
    }
}
