use serde::{Deserialize, Serialize};
use std::collections::HashSet;

const BUILTIN_HELP_JSON: &str = include_str!("../assets/help.json");

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum HelpTopicKind {
    Branch,
    #[default]
    Command,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HelpExample {
    pub command: String,
    pub description: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HelpEntry {
    pub id: String,
    pub command: String,
    pub title: String,
    pub summary: String,
    #[serde(default = "default_plugin_category")]
    pub category: String,
    #[serde(default)]
    pub topic: HelpTopicKind,
    #[serde(default)]
    pub protected: bool,
    #[serde(default)]
    pub common: bool,
    #[serde(default)]
    pub aliases: Vec<String>,
    #[serde(default)]
    pub keywords: Vec<String>,
    #[serde(default)]
    pub lines: Vec<String>,
    #[serde(default)]
    pub usage: Option<String>,
    #[serde(default)]
    pub examples: Vec<HelpExample>,
    #[serde(default)]
    pub related: Vec<String>,
    #[serde(default)]
    pub source: Option<String>,
}

#[derive(Debug, Clone)]
pub struct HelpRegistry {
    entries: Vec<HelpEntry>,
}

#[derive(Debug, Clone)]
pub struct HelpFindState {
    entries: Vec<HelpEntry>,
    filter: String,
    cursor: usize,
    scroll: usize,
    visible_height: usize,
    detail_idx: Option<usize>,
    recently_opened: Vec<String>,
    help_commands_only: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HelpFindRow<'a> {
    Category(&'a str),
    Entry(&'a HelpEntry),
}

impl<'a> HelpFindRow<'a> {
    pub fn category(&self) -> Option<&'a str> {
        match self {
            Self::Category(category) => Some(category),
            Self::Entry(_) => None,
        }
    }

    pub fn entry(&self) -> Option<&'a HelpEntry> {
        match self {
            Self::Category(_) => None,
            Self::Entry(entry) => Some(entry),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HighlightSegment {
    pub text: String,
    pub matched: bool,
}

impl HelpFindState {
    pub fn new(entries: Vec<HelpEntry>, query: &str) -> Self {
        Self::new_with_scope(entries, query, false)
    }

    pub fn new_help_commands(entries: Vec<HelpEntry>, query: &str) -> Self {
        Self::new_with_scope(entries, query, true)
    }

    fn new_with_scope(entries: Vec<HelpEntry>, query: &str, help_commands_only: bool) -> Self {
        let mut state = Self {
            entries,
            filter: query.trim().to_string(),
            cursor: 0,
            scroll: 0,
            visible_height: 10,
            detail_idx: None,
            recently_opened: Vec::new(),
            help_commands_only,
        };
        state.reset_position();
        state
    }

    pub fn filter(&self) -> &str {
        &self.filter
    }

    pub fn cursor(&self) -> usize {
        self.cursor
    }

    pub fn result_cursor(&self) -> usize {
        self.filtered_rows()
            .iter()
            .take(self.cursor + 1)
            .filter(|row| row.entry().is_some())
            .count()
            .saturating_sub(1)
    }

    pub fn scroll(&self) -> usize {
        self.scroll
    }

    pub fn set_visible_height(&mut self, height: usize) {
        self.visible_height = height.max(1);
        self.scroll_to_cursor();
    }

    pub fn filtered_entries(&self) -> Vec<&HelpEntry> {
        ranked_entries_with_mru(&self.entries, &self.filter, &self.recently_opened)
            .into_iter()
            .filter(|entry| !self.help_commands_only || is_help_command(entry))
            .collect()
    }

    pub fn filtered_rows(&self) -> Vec<HelpFindRow<'_>> {
        let entries = self.filtered_entries();
        if !self.filter.trim().is_empty() {
            return entries.into_iter().map(HelpFindRow::Entry).collect();
        }

        let mut category_names: Vec<&str> = entries.iter().map(|entry| display_category(entry)).collect();
        category_names.sort_by(|a, b| {
            category_sort_key(a)
                .cmp(&category_sort_key(b))
                .then_with(|| {
                    category_best_score(&entries, b)
                        .cmp(&category_best_score(&entries, a))
                })
                .then_with(|| a.cmp(b))
        });
        category_names.dedup();

        let mut rows = Vec::new();
        for category in category_names {
            rows.push(HelpFindRow::Category(category));
            let mut category_entries = entries
                .iter()
                .copied()
                .filter(|entry| display_category(entry) == category)
                .collect::<Vec<_>>();
            category_entries.sort_by(|a, b| {
                help_parent_sort_key(a)
                    .cmp(&help_parent_sort_key(b))
                    .then_with(|| a.command.cmp(&b.command))
            });
            rows.extend(category_entries.into_iter().map(HelpFindRow::Entry));
        }
        rows
    }

    pub fn no_results_message(&self) -> String {
        let query = self.filter.trim();
        if query.is_empty() {
            "No help topics available. Try: model, settings, plugins, sessions, doctor".to_string()
        } else {
            format!(
                "No help matches for '{}'. Try: model, settings, plugins, sessions, doctor",
                query
            )
        }
    }

    pub fn selected(&self) -> Option<&HelpEntry> {
        self.filtered_rows().get(self.cursor).and_then(HelpFindRow::entry)
    }

    pub fn open_selected(&mut self) {
        let selected_command = self.selected().map(|entry| entry.command.clone());
        if let Some(command) = selected_command.as_ref() {
            self.remember_opened(command);
        }
        self.detail_idx = selected_command
            .and_then(|command| self.entries.iter().position(|entry| entry.command == command));
    }

    pub fn close_detail(&mut self) {
        self.detail_idx = None;
    }

    pub fn detail_entry(&self) -> Option<&HelpEntry> {
        self.detail_idx.and_then(|idx| self.entries.get(idx))
    }

    pub fn move_down(&mut self) {
        let rows = self.filtered_rows();
        if rows.is_empty() {
            self.cursor = 0;
            self.scroll = 0;
            return;
        }
        let mut next = (self.cursor + 1).min(rows.len() - 1);
        while next < rows.len() && rows[next].entry().is_none() {
            if next == rows.len() - 1 {
                break;
            }
            next += 1;
        }
        if rows[next].entry().is_some() {
            self.cursor = next;
        }
        self.scroll_to_cursor();
    }

    pub fn move_up(&mut self) {
        let rows = self.filtered_rows();
        if rows.is_empty() {
            self.cursor = 0;
            self.scroll = 0;
            return;
        }
        let mut next = self.cursor.saturating_sub(1);
        while next > 0 && rows[next].entry().is_none() {
            next = next.saturating_sub(1);
        }
        if rows[next].entry().is_some() {
            self.cursor = next;
        }
        self.scroll_to_cursor();
    }

    pub fn push_char(&mut self, ch: char) {
        self.filter.push(ch);
        self.reset_position();
    }

    pub fn backspace(&mut self) {
        self.filter.pop();
        self.reset_position();
    }

    pub fn clear_filter(&mut self) {
        self.filter.clear();
        self.reset_position();
    }

    fn reset_position(&mut self) {
        self.cursor = self.first_entry_row_index();
        self.scroll = 0;
    }

    fn first_entry_row_index(&self) -> usize {
        self.filtered_rows()
            .iter()
            .position(|row| row.entry().is_some())
            .unwrap_or(0)
    }

    fn remember_opened(&mut self, command: &str) {
        self.recently_opened.retain(|existing| existing != command);
        self.recently_opened.insert(0, command.to_string());
        self.recently_opened.truncate(10);
    }

    fn scroll_to_cursor(&mut self) {
        if self.cursor < self.scroll {
            self.scroll = self.cursor;
        }
        let bottom = self.scroll + self.visible_height;
        if self.cursor >= bottom {
            self.scroll = self.cursor + 1 - self.visible_height;
        }
        let len = self.filtered_rows().len();
        if self.cursor >= len {
            self.cursor = self.first_entry_row_index();
            self.scroll = 0;
        }
    }
}

impl HelpRegistry {
    pub fn new(core_entries: Vec<HelpEntry>, plugin_entries: Vec<HelpEntry>) -> Self {
        let protected = protected_commands(&core_entries);
        let mut seen = HashSet::new();
        let mut entries = Vec::new();

        for mut entry in core_entries {
            normalize_command(&mut entry);
            if seen.insert(entry.command.clone()) {
                entries.push(entry);
            }
        }

        for mut entry in plugin_entries {
            normalize_command(&mut entry);
            if protected.contains(&entry.command)
                || protected.contains(&entry.id)
                || entry.aliases.iter().any(|alias| protected.contains(alias))
            {
                tracing::warn!(
                    command = %entry.command,
                    id = %entry.id,
                    "plugin help entry conflicts with protected namespace; ignoring"
                );
                continue;
            }
            if entry.protected {
                entry.protected = false;
            }
            entry.source = Some(plugin_source_label(entry.source.as_deref()));
            if entry.category.trim().is_empty() || entry.category.eq_ignore_ascii_case("plugin") {
                entry.category = extension_help_category(entry.source.as_deref());
            }
            if seen.insert(entry.command.clone()) {
                entries.push(entry);
            }
        }

        entries.sort_by(|a, b| a.command.cmp(&b.command));
        Self { entries }
    }

    pub fn entries(&self) -> &[HelpEntry] {
        &self.entries
    }

    pub fn entry_by_command(&self, command: &str) -> Option<&HelpEntry> {
        let needle = normalize_query_command(command);
        self.entries.iter().find(|entry| {
            entry.command == needle || entry.aliases.iter().any(|alias| normalize_query_command(alias) == needle)
        })
    }

    pub fn branch(&self, topic: &str) -> Option<&HelpEntry> {
        let normalized = topic.trim().trim_start_matches("/help").trim().trim_start_matches('/');
        self.entries.iter().find(|entry| {
            entry.topic == HelpTopicKind::Branch
                && (entry.id == normalized
                    || entry.command == format!("/help {}", normalized)
                    || entry.aliases.iter().any(|alias| alias.trim_start_matches("/help ") == normalized))
        })
    }

    pub fn search(&self, query: &str) -> Vec<&HelpEntry> {
        ranked_entries(&self.entries, query)
    }

    pub fn entry_for_help_topic(&self, topic: &str) -> Option<&HelpEntry> {
        let normalized = normalize_help_topic(topic);
        if normalized.is_empty() {
            return None;
        }

        self.entry_by_command_exact(&format!("/help {}", normalized))
            .or_else(|| self.entry_by_command_exact(&normalized))
            .or_else(|| self.entry_by_command_exact(&format!("/{}", normalized)))
            .or_else(|| self.entry_by_command(&format!("/{}", normalized)))
            .or_else(|| self.branch(&normalized))
    }

    pub fn command_prefix_match_count(&self, partial: &str) -> usize {
        let needle = partial.trim().trim_start_matches('/').to_ascii_lowercase();
        if needle.is_empty() {
            return 0;
        }
        self.entries
            .iter()
            .filter(|entry| {
                entry.command.trim_start_matches('/').to_ascii_lowercase().starts_with(&needle)
                    || entry.aliases.iter().any(|alias| {
                        alias.trim_start_matches('/').to_ascii_lowercase().starts_with(&needle)
                    })
            })
            .count()
    }

    fn entry_by_command_exact(&self, command: &str) -> Option<&HelpEntry> {
        let needle = normalize_query_command(command);
        self.entries.iter().find(|entry| entry.command == needle)
    }
}

pub fn builtin_entries() -> Vec<HelpEntry> {
    serde_json::from_str(BUILTIN_HELP_JSON).expect("assets/help.json must be valid help JSON")
}

fn default_plugin_category() -> String {
    "Plugin".to_string()
}

fn category_sort_key(category: &str) -> u8 {
    if category == "Help commands" {
        1
    } else if is_extension_help_category(category) {
        2
    } else {
        0
    }
}

fn help_parent_sort_key(entry: &HelpEntry) -> usize {
    if is_help_command(entry) {
        entry.command.matches(' ').count()
    } else {
        0
    }
}

fn display_category(entry: &HelpEntry) -> &str {
    if is_help_command(entry) {
        "Help commands"
    } else {
        entry.category.as_str()
    }
}

fn category_best_score(entries: &[&HelpEntry], category: &str) -> i32 {
    entries
        .iter()
        .filter(|entry| display_category(entry) == category)
        .map(|entry| empty_query_score(entry))
        .max()
        .unwrap_or(0)
}

fn is_help_command(entry: &HelpEntry) -> bool {
    entry.command == "/help" || entry.command.starts_with("/help ")
}

pub fn prefilter_query_for_slash_command(input: &str) -> Option<String> {
    let trimmed = input.trim();
    let partial = trimmed.strip_prefix('/')?.trim();
    if partial.is_empty() {
        return None;
    }
    Some(partial.to_string())
}

pub fn render_help(registry: &HelpRegistry, branch: Option<&str>) -> Option<String> {
    match branch.map(str::trim).filter(|s| !s.is_empty()) {
        None => registry.entry_by_command("/help").map(render_entry),
        Some("find") => registry.entry_by_command("/help find").map(render_entry),
        Some("topics") => Some(render_topics(registry)),
        Some("reference") => Some(render_reference(registry)),
        Some(topic) => registry
            .entry_for_help_topic(topic)
            .map(render_entry)
            .or_else(|| Some(render_unknown_topic(registry, topic))),
    }
}

pub fn render_entry(entry: &HelpEntry) -> String {
    let mut lines = vec![entry.title.clone(), String::new(), entry.summary.clone()];

    if !entry.lines.is_empty() {
        lines.push(String::new());
        lines.extend(body_lines_without_structured_related(entry));
    }

    append_usage_examples_related(&mut lines, entry);
    lines.join("\n")
}

fn body_lines_without_structured_related(entry: &HelpEntry) -> Vec<String> {
    if entry.related.is_empty() {
        return entry.lines.clone();
    }
    let mut lines = entry.lines.clone();
    while lines.last().is_some_and(|line| line.trim().is_empty()) {
        lines.pop();
    }
    if lines
        .last()
        .is_some_and(|line| line.trim_start().starts_with("Related:"))
    {
        lines.pop();
        while lines.last().is_some_and(|line| line.trim().is_empty()) {
            lines.pop();
        }
    }
    lines
}

fn render_topics(registry: &HelpRegistry) -> String {
    let mut entries: Vec<&HelpEntry> = registry
        .entries()
        .iter()
        .filter(|entry| entry.topic == HelpTopicKind::Branch)
        .collect();
    entries.sort_by(|a, b| a.category.cmp(&b.category).then_with(|| a.command.cmp(&b.command)));

    let mut lines = vec![
        "Help topics".to_string(),
        String::new(),
        "Conceptual guides and discovery paths. Use /help <topic> for details.".to_string(),
        String::new(),
    ];

    for entry in entries {
        lines.push(format!("  {} — {}", entry.command, entry.summary));
    }

    lines.join("\n")
}

fn render_reference(registry: &HelpRegistry) -> String {
    let mut entries: Vec<&HelpEntry> = registry.entries().iter().collect();
    entries.sort_by(|a, b| a.category.cmp(&b.category).then_with(|| a.command.cmp(&b.command)));

    let mut lines = vec![
        "Help reference".to_string(),
        String::new(),
        "All help entries grouped by category.".to_string(),
    ];
    let mut current_category: Option<&str> = None;

    for entry in entries {
        if current_category != Some(entry.category.as_str()) {
            current_category = Some(entry.category.as_str());
            lines.push(String::new());
            lines.push(entry.category.clone());
        }
        lines.push(format!("  {} — {}", entry.command, entry.summary));
    }

    lines.join("\n")
}

pub fn source_display(entry: &HelpEntry) -> Option<String> {
    match entry.source.as_deref() {
        Some(source) if !source.trim().is_empty() => Some(plugin_source_label(Some(source))),
        _ => None,
    }
}

fn append_usage_examples_related(lines: &mut Vec<String>, entry: &HelpEntry) {
    if let Some(usage) = entry.usage.as_ref().filter(|usage| !usage.trim().is_empty()) {
        lines.push(String::new());
        lines.push("Usage".to_string());
        lines.push(format!("  {}", usage));
    }

    if !entry.examples.is_empty() {
        lines.push(String::new());
        lines.push("Examples".to_string());
        for example in &entry.examples {
            if example.description.trim().is_empty() {
                lines.push(format!("  {}", example.command));
            } else {
                lines.push(format!("  {:<16} {}", example.command, example.description));
            }
        }
    }

    if !entry.related.is_empty() {
        lines.push(String::new());
        lines.push(format!("Related: {}", entry.related.join(", ")));
    }
}

fn normalize_command(entry: &mut HelpEntry) {
    entry.command = normalize_query_command(&entry.command);
    entry.aliases = entry.aliases.iter().map(|alias| normalize_query_command(alias)).collect();
}

fn normalize_query_command(command: &str) -> String {
    let trimmed = command.trim();
    if trimmed.starts_with('/') {
        trimmed.to_string()
    } else {
        format!("/{}", trimmed)
    }
}

fn normalize_help_topic(topic: &str) -> String {
    let mut normalized = topic.trim();
    if let Some(rest) = normalized.strip_prefix("/help") {
        normalized = rest.trim();
    }
    normalized.trim_start_matches('/').trim().to_ascii_lowercase()
}

fn render_unknown_topic(registry: &HelpRegistry, topic: &str) -> String {
    let mut lines = vec![
        format!("No help topic for '{}'.", topic),
        String::new(),
        "Try /help find to search every topic.".to_string(),
    ];
    let suggestions = closest_help_matches(registry, topic, 3);
    if !suggestions.is_empty() {
        lines.push(String::new());
        lines.push(format!("Closest matches: {}", suggestions.join(", ")));
    }
    lines.join("\n")
}

fn closest_help_matches(registry: &HelpRegistry, topic: &str, limit: usize) -> Vec<String> {
    let needle = normalize_help_topic(topic);
    if needle.is_empty() {
        return Vec::new();
    }

    let mut scored: Vec<(&HelpEntry, usize)> = registry
        .entries()
        .iter()
        .filter_map(|entry| {
            suggestion_distance(entry, &needle)
                .filter(|distance| *distance <= 3)
                .map(|distance| (entry, distance))
        })
        .collect();

    scored.sort_by(|(entry_a, distance_a), (entry_b, distance_b)| {
        distance_a
            .cmp(distance_b)
            .then_with(|| entry_b.common.cmp(&entry_a.common))
            .then_with(|| entry_a.command.len().cmp(&entry_b.command.len()))
            .then_with(|| entry_a.command.cmp(&entry_b.command))
    });
    scored
        .into_iter()
        .map(|(entry, _)| entry.command.clone())
        .take(limit)
        .collect()
}

fn suggestion_distance(entry: &HelpEntry, needle: &str) -> Option<usize> {
    entry
        .aliases
        .iter()
        .map(|alias| normalize_help_topic(alias))
        .chain(std::iter::once(normalize_help_topic(&entry.command)))
        .chain(std::iter::once(entry.id.to_ascii_lowercase()))
        .chain(std::iter::once(entry.title.to_ascii_lowercase()))
        .map(|candidate| levenshtein(needle, candidate.trim_start_matches("help ")))
        .min()
}

fn levenshtein(a: &str, b: &str) -> usize {
    let b_chars: Vec<char> = b.chars().collect();
    let mut costs: Vec<usize> = (0..=b_chars.len()).collect();

    for (i, ca) in a.chars().enumerate() {
        let mut previous = costs[0];
        costs[0] = i + 1;
        for (j, cb) in b_chars.iter().enumerate() {
            let current = costs[j + 1];
            costs[j + 1] = if ca == *cb {
                previous
            } else {
                1 + previous.min(current).min(costs[j])
            };
            previous = current;
        }
    }

    costs[b_chars.len()]
}

fn plugin_source_label(source: Option<&str>) -> String {
    match source.map(str::trim).filter(|source| !source.is_empty()) {
        None => "plugin".to_string(),
        Some(source) if source.eq_ignore_ascii_case("plugin") => "plugin".to_string(),
        Some(source) if source.to_ascii_lowercase().starts_with("plugin ") => source.to_string(),
        Some(source) => format!("plugin {}", source),
    }
}

fn extension_help_category(source: Option<&str>) -> String {
    let Some(source) = source.map(str::trim).filter(|source| !source.is_empty()) else {
        return "Extensions".to_string();
    };
    let plugin_name = source
        .strip_prefix("plugin ")
        .unwrap_or(source)
        .trim();
    if plugin_name.is_empty() || plugin_name.eq_ignore_ascii_case("plugin") {
        return "Extensions".to_string();
    }
    plugin_name
        .split(['-', '_', ' '])
        .filter(|word| !word.is_empty())
        .map(title_case_word)
        .collect::<Vec<_>>()
        .join(" ")
}

fn title_case_word(word: &str) -> String {
    let mut chars = word.chars();
    match chars.next() {
        None => String::new(),
        Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_ascii_lowercase(),
    }
}

fn is_extension_help_category(category: &str) -> bool {
    !category.is_empty()
        && category != "Help commands"
        && !matches!(
            category,
            "Advanced"
                | "Core"
                | "Diagnostics"
                | "Extensions"
                | "Models"
                | "Plugins"
                | "Sessions"
                | "Settings"
                | "Tools"
        )
}

fn protected_commands(entries: &[HelpEntry]) -> HashSet<String> {
    let mut protected = HashSet::new();
    for entry in entries.iter().filter(|entry| entry.protected) {
        protected.insert(normalize_query_command(&entry.command));
        protected.insert(entry.id.clone());
        for alias in &entry.aliases {
            protected.insert(normalize_query_command(alias));
        }
    }
    for builtin in crate::skills::BUILTIN_COMMANDS {
        protected.insert(format!("/{}", builtin));
        protected.insert((*builtin).to_string());
    }
    protected
}

fn ranked_entries<'a>(entries: &'a [HelpEntry], query: &str) -> Vec<&'a HelpEntry> {
    ranked_entries_with_mru(entries, query, &[])
}

fn ranked_entries_with_mru<'a>(entries: &'a [HelpEntry], query: &str, recently_opened: &[String]) -> Vec<&'a HelpEntry> {
    let needle = query.trim().to_ascii_lowercase();
    let mut scored: Vec<(&HelpEntry, i32)> = entries
        .iter()
        .filter_map(|entry| {
            if needle.is_empty() {
                Some((entry, empty_query_score(entry)))
            } else {
                match_score(entry, &needle).map(|score| (entry, score + mru_bonus(entry, recently_opened)))
            }
        })
        .collect();

    scored.sort_by(|(a, score_a), (b, score_b)| {
        score_b
            .cmp(score_a)
            .then_with(|| a.category.cmp(&b.category))
            .then_with(|| a.command.cmp(&b.command))
    });
    scored.into_iter().map(|(entry, _)| entry).collect()
}

fn empty_query_score(entry: &HelpEntry) -> i32 {
    let mut score = 0;
    if entry.common {
        score += 2_000;
    }
    if entry.category.eq_ignore_ascii_case("core") {
        score += 1_000;
    }
    score
}

fn match_score(entry: &HelpEntry, needle: &str) -> Option<i32> {
    let command = entry.command.to_ascii_lowercase();
    let title = entry.title.to_ascii_lowercase();
    if command == needle || command.trim_start_matches('/') == needle {
        return Some(11_000 + common_bonus(entry));
    }
    if title == needle {
        return Some(10_000 + common_bonus(entry));
    }
    if command.starts_with(needle)
        || command.trim_start_matches('/').starts_with(needle.trim_start_matches('/'))
    {
        return Some(8_500 + common_bonus(entry));
    }
    if title.starts_with(needle) {
        return Some(8_000 + common_bonus(entry));
    }
    if entry
        .aliases
        .iter()
        .any(|alias| field_matches(alias, needle))
    {
        return Some(6_000 + common_bonus(entry));
    }
    if entry
        .keywords
        .iter()
        .any(|keyword| field_matches(keyword, needle))
    {
        return Some(5_000 + common_bonus(entry));
    }
    if field_matches(&entry.summary, needle) {
        return Some(4_000 + common_bonus(entry));
    }
    if entry.lines.iter().any(|line| field_matches(line, needle))
        || entry.usage.as_ref().is_some_and(|usage| field_matches(usage, needle))
        || entry.examples.iter().any(|example| {
            field_matches(&example.command, needle) || field_matches(&example.description, needle)
        })
    {
        return Some(3_000 + common_bonus(entry));
    }
    None
}

fn field_matches(value: &str, needle: &str) -> bool {
    value.to_ascii_lowercase().contains(needle)
}

fn common_bonus(entry: &HelpEntry) -> i32 {
    if entry.common { 100 } else { 0 }
}

fn mru_bonus(entry: &HelpEntry, recently_opened: &[String]) -> i32 {
    recently_opened
        .iter()
        .position(|command| command == &entry.command)
        .map(|idx| 50 - (idx as i32).min(49))
        .unwrap_or(0)
}

pub fn highlight_segments(text: &str, query: &str) -> Vec<HighlightSegment> {
    let needle = query.trim().to_ascii_lowercase();
    if needle.is_empty() {
        return vec![HighlightSegment { text: text.to_string(), matched: false }];
    }

    let lower = text.to_ascii_lowercase();
    let mut segments = Vec::new();
    let mut start = 0;
    while let Some(relative) = lower[start..].find(&needle) {
        let match_start = start + relative;
        let match_end = match_start + needle.len();
        if match_start > start {
            segments.push(HighlightSegment { text: text[start..match_start].to_string(), matched: false });
        }
        segments.push(HighlightSegment { text: text[match_start..match_end].to_string(), matched: true });
        start = match_end;
    }
    if start < text.len() {
        segments.push(HighlightSegment { text: text[start..].to_string(), matched: false });
    }
    if segments.is_empty() {
        segments.push(HighlightSegment { text: text.to_string(), matched: false });
    }
    segments
}

pub fn wrap_help_text(text: &str, width: usize) -> Vec<String> {
    if width == 0 || text.len() <= width {
        return vec![text.to_string()];
    }

    let indent: String = text.chars().take_while(|ch| ch.is_whitespace()).collect();
    let content = text.trim_start();
    if content.is_empty() {
        return vec![text.to_string()];
    }

    let mut lines = Vec::new();
    let mut current = indent.clone();
    for word in content.split_whitespace() {
        let separator = if current.trim().is_empty() { "" } else { " " };
        let candidate_len = current.len() + separator.len() + word.len();
        if candidate_len > width && !current.trim().is_empty() {
            lines.push(current);
            current = format!("{}{}", indent, word);
        } else {
            if !separator.is_empty() {
                current.push(' ');
            }
            current.push_str(word);
        }
    }
    if !current.trim().is_empty() {
        lines.push(current);
    }
    if lines.is_empty() {
        vec![text.to_string()]
    } else {
        lines
    }
}

pub fn visible_help_find_window(row_heights: &[usize], cursor: usize, scroll: usize, visible_height: usize) -> usize {
    if row_heights.is_empty() || visible_height == 0 {
        return 0;
    }
    let cursor = cursor.min(row_heights.len() - 1);
    let mut start = scroll.min(cursor);
    while start < cursor && visual_height_between(row_heights, start, cursor) > visible_height {
        start += 1;
    }
    start
}

pub fn wrap_help_find_entry_lines(command: &str, summary: &str, selected: bool, width: usize) -> Vec<(String, String)> {
    let marker = if selected { "›" } else { " " };
    let first_prefix = format!("{} ", marker);
    let continuation_prefix = "    ";
    let first_command_width = width.saturating_sub(first_prefix.chars().count()).clamp(8, 22);
    let command_lines = wrap_help_find_token(command, first_command_width);
    let mut lines = Vec::new();

    for (idx, command_part) in command_lines.iter().enumerate() {
        if idx == 0 {
            lines.push((format!("{}{}", first_prefix, command_part), String::new()));
        } else {
            lines.push((format!("{}{}", continuation_prefix, command_part), String::new()));
        }
    }

    let summary_indent = "    ";
    let summary_width = width.saturating_sub(summary_indent.len()).max(8);
    let summary_lines = wrap_help_text(summary, summary_width);
    for line in summary_lines {
        lines.push((summary_indent.to_string(), line.trim_start().to_string()));
    }

    if lines.is_empty() {
        vec![(first_prefix, String::new())]
    } else {
        lines
    }
}

fn wrap_help_find_token(text: &str, width: usize) -> Vec<String> {
    if width == 0 || text.chars().count() <= width {
        return vec![text.to_string()];
    }
    let chars = text.chars().collect::<Vec<_>>();
    let mut lines = Vec::new();
    let mut start = 0;
    while start < chars.len() {
        let mut end = (start + width).min(chars.len());
        if end < chars.len() {
            if let Some(split) = chars[start..end]
                .iter()
                .rposition(|ch| matches!(ch, ':' | '-' | '_' | '/'))
                .filter(|split| *split > 0)
            {
                end = start + split + 1;
            }
        }
        lines.push(chars[start..end].iter().collect::<String>());
        start = end;
    }
    lines
}

fn visual_height_between(row_heights: &[usize], start: usize, end_inclusive: usize) -> usize {
    row_heights[start..=end_inclusive]
        .iter()
        .map(|height| (*height).max(1))
        .sum()
}

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

    #[test]
    fn builtin_help_json_loads() {
        let entries = builtin_entries();
        assert!(entries.iter().any(|entry| entry.command == "/help"));
        assert!(entries.iter().any(|entry| entry.command == "/help find"));
    }

    #[test]
    fn wrap_help_text_wraps_words_to_width_and_preserves_indent() {
        let lines = wrap_help_text("  summary text should wrap neatly", 18);

        assert_eq!(lines, vec!["  summary text", "  should wrap", "  neatly"]);
        assert!(lines.iter().all(|line| line.len() <= 18));
    }

    #[test]
    fn wrap_help_text_returns_original_line_when_width_is_too_small() {
        let lines = wrap_help_text("abc def", 0);

        assert_eq!(lines, vec!["abc def"]);
    }

    #[test]
    fn visible_help_find_window_scrolls_by_visual_row_height_to_keep_cursor_visible() {
        let heights = vec![1, 1, 3, 1, 1];

        assert_eq!(visible_help_find_window(&heights, 2, 0, 3), 2);
        assert_eq!(visible_help_find_window(&heights, 4, 2, 4), 3);
    }

    #[test]
    fn wrap_help_find_entry_lines_wraps_commands_and_indents_wrapped_descriptions_lightly() {
        let lines = wrap_help_find_entry_lines(
            "/extension-showcase:very-long-demo-command",
            "description wraps onto a second visual line",
            false,
            34,
        );

        assert!(lines.len() > 1);
        assert_eq!(lines[0].0, "  /extension-showcase:");
        assert!(lines[1].0.starts_with("    very-long-demo"), "wrapped command should indent slightly: {:?}", lines);
        assert_eq!(lines[2].0, "    ", "description should start on its own lightly indented line: {:?}", lines);
        assert_eq!(lines[2].1, "description wraps onto a");
        assert_eq!(lines[3].0, "    ", "wrapped description should keep small indent: {:?}", lines);
        assert_eq!(lines[3].1, "second visual line");
    }
}
