//! Output formatting for MCP grep/search results.

use fff::GrepMatch;
use fff::file_picker::FilePicker;
use fff::git::format_git_status_opt;
use fff::grep::is_import_line;
use fff::types::FileItem;

use crate::cursor::CursorStore;

fn frecency_word(score: i32) -> Option<&'static str> {
    if score >= 100 {
        Some("hot")
    } else if score >= 50 {
        Some("warm")
    } else if score >= 10 {
        Some("frequent")
    } else {
        None
    }
}

pub fn file_suffix(git_status: Option<git2::Status>, frecency_score: i32) -> String {
    match (
        frecency_word(frecency_score),
        format_git_status_opt(git_status),
    ) {
        (Some(f), Some(g)) => format!(" - {f} git:{g}"),
        (Some(f), None) => format!(" - {f}"),
        (None, Some(g)) => format!(" git:{g}"),
        (None, None) => String::new(),
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
    Content,
    FilesWithMatches,
    Count,
    Usage,
}

impl OutputMode {
    pub fn new(s: Option<&str>) -> Self {
        match s {
            Some("files_with_matches") => Self::FilesWithMatches,
            Some("count") => Self::Count,
            Some("usage") => Self::Usage,
            _ => Self::Content,
        }
    }
}

const LARGE_FILE_BYTES: u64 = 20_000;

fn size_tag(bytes: u64) -> String {
    if bytes < LARGE_FILE_BYTES {
        String::new()
    } else {
        let kb = (bytes + 512) / 1024; // round
        format!(" ({}KB - use offset to read relevant section)", kb)
    }
}

const MAX_PREVIEW: usize = 120;
const MAX_LINE_LEN: usize = 180;
const MAX_DEF_EXPAND_FIRST: usize = 8;
const MAX_DEF_EXPAND: usize = 5;
const MAX_FIRST_MATCH_EXPAND: usize = 8;

fn trauncate_line_for_ai(
    line: &str,
    match_ranges: Option<&[(u32, u32)]>,
    max_len: usize,
) -> String {
    // Leading whitespace is already stripped by core (trim_whitespace option).
    // Only strip trailing whitespace here.
    let trimmed = line.trim_end();
    if trimmed.is_empty() {
        return String::new();
    }

    if trimmed.len() <= max_len {
        return trimmed.to_string();
    }

    // Use first match range to center the window
    if let Some(ranges) = match_ranges
        && let Some(&(match_start, match_end)) = ranges.first()
    {
        let match_start = match_start as usize;
        let match_end = match_end as usize;
        let match_len = match_end.saturating_sub(match_start);

        let budget = max_len.saturating_sub(match_len);
        let before = budget / 3;
        let after = budget - before;

        let win_start = match_start.saturating_sub(before);
        let win_end = (match_end + after).min(trimmed.len());

        // Clamp to char boundaries
        let win_start = floor_char_boundary(trimmed, win_start);
        let win_end = ceil_char_boundary(trimmed, win_end);

        let mut result = trimmed[win_start..win_end].to_string();
        if win_start > 0 {
            result.insert(0, '…');
        }
        if win_end < trimmed.len() {
            result.push('…');
        }
        return result;
    }

    // No match ranges — truncate from start
    let end = ceil_char_boundary(trimmed, max_len);
    format!("{}…", &trimmed[..end])
}

fn floor_char_boundary(s: &str, index: usize) -> usize {
    if index >= s.len() {
        return s.len();
    }
    let mut i = index;
    while i > 0 && !s.is_char_boundary(i) {
        i -= 1;
    }
    i
}

fn ceil_char_boundary(s: &str, index: usize) -> usize {
    if index >= s.len() {
        return s.len();
    }
    let mut i = index;
    while i < s.len() && !s.is_char_boundary(i) {
        i += 1;
    }
    i
}

struct FileMeta<'a> {
    file: &'a FileItem,
    line_number: u64,
    line_content: String,
    is_definition: bool,
    match_ranges: Vec<(u32, u32)>,
    context_after: Vec<String>,
}

pub struct GrepFormatter<'a> {
    pub matches: &'a [GrepMatch],
    pub files: &'a [&'a FileItem],
    pub total_matched: usize,
    pub next_file_offset: usize,
    pub output_mode: OutputMode,
    pub max_results: usize,
    pub show_context: bool,
    pub auto_expand_defs: bool,
    pub picker: &'a FilePicker,
}

impl GrepFormatter<'_> {
    pub fn format(&self, cursor_store: &mut CursorStore) -> String {
        let GrepFormatter {
            matches,
            files,
            total_matched,
            next_file_offset,
            output_mode,
            max_results,
            show_context,
            auto_expand_defs,
            picker,
        } = *self;

        let items = if matches.len() > max_results {
            &matches[..max_results]
        } else {
            matches
        };

        if output_mode == OutputMode::FilesWithMatches {
            return format_files_with_matches(
                items,
                files,
                next_file_offset,
                auto_expand_defs,
                cursor_store,
                picker,
            );
        }

        if output_mode == OutputMode::Count {
            return format_count(items, files, next_file_offset, cursor_store, picker);
        }

        // output_mode == usage
        let mut lines: Vec<String> = Vec::new();
        let unique_files = {
            let mut seen = std::collections::HashSet::new();
            for m in items {
                seen.insert(m.file_index);
            }
            seen.len()
        };

        let max_output_chars: usize = if output_mode == OutputMode::Usage || unique_files <= 3 {
            5000
        } else if unique_files <= 8 {
            3500
        } else {
            2500
        };

        // File overview: collect first match per file
        let file_preview = collect_file_preview(items, files, picker);
        let mut content_def_file = String::new();
        let mut content_first_file = String::new();
        for fm in &file_preview {
            if content_first_file.is_empty() {
                content_first_file = fm.file.relative_path(picker);
            }
            if content_def_file.is_empty() && fm.is_definition {
                content_def_file = fm.file.relative_path(picker);
            }
        }

        let content_suggest = if !content_def_file.is_empty() {
            &content_def_file
        } else {
            &content_first_file
        };
        if !content_suggest.is_empty() {
            let file_count = file_preview.len();
            if file_count == 1 {
                lines.push(format!("→ Read {} (only match)", content_suggest));
            } else if !content_def_file.is_empty() {
                lines.push(format!("→ Read {} [def]", content_suggest));
            } else if file_count <= 3 {
                lines.push(format!("→ Read {} (best match)", content_suggest));
            }
        }

        if total_matched > items.len() {
            lines.push(format!("{}/{} matches shown", items.len(), total_matched));
        }

        // Track which files already had a definition expanded
        let mut def_expanded_files = std::collections::HashSet::new();

        // Detailed content (subject to budget)
        let mut char_count = 0usize;
        let mut shown_count = 0usize;
        let mut current_file = String::new();

        // Reorder: definitions first, then usages, then imports (when auto-expanding)
        let sorted_items: Vec<usize> = if auto_expand_defs {
            let mut indices: Vec<usize> = (0..items.len()).collect();
            indices.sort_unstable_by_key(|&i| {
                if items[i].is_definition {
                    0
                } else if is_import_line(&items[i].line_content) {
                    2
                } else {
                    1
                }
            });

            indices
        } else {
            (0..items.len()).collect()
        };

        for &idx in &sorted_items {
            let m = &items[idx];
            let file = files[m.file_index];
            let mut match_lines: Vec<String> = Vec::new();

            let file_rel_path = file.relative_path(picker);
            if file_rel_path != current_file {
                current_file = file_rel_path;
                match_lines.push(current_file.to_string());
            }

            // Skip import-only lines when we already have definitions
            if auto_expand_defs && is_import_line(&m.line_content) && !def_expanded_files.is_empty()
            {
                continue;
            }

            // Context before (only when explicitly requested)
            if show_context && !m.context_before.is_empty() {
                let start_line = m.line_number.saturating_sub(m.context_before.len() as u64);
                for (i, ctx) in m.context_before.iter().enumerate() {
                    match_lines.push(format!(
                        " {}-{}",
                        start_line + i as u64,
                        trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
                    ));
                }
            }

            // Match line
            match_lines.push(format!(
                " {}: {}",
                m.line_number,
                trauncate_line_for_ai(
                    &m.line_content,
                    Some(m.match_byte_offsets.as_ref()),
                    MAX_LINE_LEN
                )
            ));

            // Context after (only when explicitly requested via context parameter)
            if show_context && !m.context_after.is_empty() {
                let start_line = m.line_number + 1;
                for (i, ctx) in m.context_after.iter().enumerate() {
                    match_lines.push(format!(
                        " {}-{}",
                        start_line + i as u64,
                        trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
                    ));
                }
                match_lines.push("--".to_string());
            }

            // Auto-expand definitions with body context
            let file_rel_for_expand = file.relative_path(picker);
            if auto_expand_defs
                && !show_context
                && m.is_definition
                && !m.context_after.is_empty()
                && !def_expanded_files.contains(&file_rel_for_expand)
            {
                let expand_limit = if def_expanded_files.is_empty() {
                    MAX_DEF_EXPAND_FIRST
                } else {
                    MAX_DEF_EXPAND
                };
                def_expanded_files.insert(file_rel_for_expand);
                let start_line = m.line_number + 1;
                for (i, ctx) in m.context_after.iter().take(expand_limit).enumerate() {
                    if ctx.trim().is_empty() {
                        break;
                    }
                    match_lines.push(format!(
                        "  {}| {}",
                        start_line + i as u64,
                        trauncate_line_for_ai(ctx, None, MAX_LINE_LEN)
                    ));
                }
            }

            let chunk = match_lines.join("\n");
            if char_count + chunk.len() > max_output_chars && shown_count > 0 {
                break;
            }

            char_count += chunk.len();
            lines.push(chunk);
            shown_count += 1;
        }

        if next_file_offset > 0 {
            let cursor_id = cursor_store.store(next_file_offset);
            lines.push(format!("\ncursor: {}", cursor_id));
        }

        lines.join("\n")
    }
}

fn format_files_with_matches(
    items: &[GrepMatch],
    files: &[&FileItem],
    next_file_offset: usize,
    auto_expand_defs: bool,
    cursor_store: &mut CursorStore,
    picker: &FilePicker,
) -> String {
    let file_map = collect_file_preview(items, files, picker);

    let mut lines: Vec<String> = Vec::new();
    let file_count = file_map.len();

    // Find best Read target
    let mut first_def_file = String::new();
    let mut first_file = String::new();
    for fm in &file_map {
        if first_file.is_empty() {
            first_file = fm.file.relative_path(picker);
        }
        if first_def_file.is_empty() && fm.is_definition {
            first_def_file = fm.file.relative_path(picker);
        }
    }
    let suggest_path = if !first_def_file.is_empty() {
        &first_def_file
    } else {
        &first_file
    };

    if !suggest_path.is_empty() {
        if file_count == 1 {
            lines.push(format!(
                "→ Read {} (only match — no need to search further)",
                suggest_path
            ));
        } else if !first_def_file.is_empty() && file_count <= 5 {
            lines.push(format!("→ Read {} (definition found)", suggest_path));
        } else if !first_def_file.is_empty() {
            lines.push(format!("→ Read {} (definition)", suggest_path));
        } else if file_count <= 3 {
            lines.push(format!("→ Read {} (best match)", suggest_path));
        } else {
            lines.push(format!("→ Read {}", suggest_path));
        }
    }

    let is_small_set = file_count <= 5;
    let mut def_expanded_count = 0usize;

    for (file_idx, fm) in file_map.iter().enumerate() {
        let is_def = fm.is_definition;
        let def_tag = if is_def { " [def]" } else { "" };
        lines.push(format!(
            "{}{}{}",
            fm.file.relative_path(picker),
            def_tag,
            size_tag(fm.file.size)
        ));

        // Show preview
        if !fm.line_content.is_empty() && (is_def || file_idx == 0 || is_small_set) {
            let ranges_ref: Option<&[(u32, u32)]> = if fm.match_ranges.is_empty() {
                None
            } else {
                Some(&fm.match_ranges)
            };
            lines.push(format!(
                "  {}: {}",
                fm.line_number,
                trauncate_line_for_ai(&fm.line_content, ranges_ref, MAX_PREVIEW)
            ));

            // Auto-expand body context
            if auto_expand_defs && !fm.context_after.is_empty() {
                let expand_limit = if is_def {
                    let limit = if def_expanded_count == 0 {
                        MAX_DEF_EXPAND_FIRST
                    } else {
                        MAX_DEF_EXPAND
                    };
                    def_expanded_count += 1;
                    limit
                } else if is_small_set && file_idx == 0 {
                    MAX_FIRST_MATCH_EXPAND
                } else if is_small_set {
                    MAX_DEF_EXPAND
                } else {
                    0
                };

                if expand_limit > 0 {
                    let start_line = fm.line_number + 1;
                    for (i, ctx) in fm.context_after.iter().take(expand_limit).enumerate() {
                        if ctx.trim().is_empty() {
                            break;
                        }
                        lines.push(format!(
                            "  {}| {}",
                            start_line + i as u64,
                            trauncate_line_for_ai(ctx, None, MAX_PREVIEW)
                        ));
                    }
                }
            }
        }
    }

    if next_file_offset > 0 {
        let cursor_id = cursor_store.store(next_file_offset);
        lines.push(format!("\ncursor: {}", cursor_id));
    }

    lines.join("\n")
}

fn format_count(
    items: &[GrepMatch],
    files: &[&FileItem],
    next_file_offset: usize,
    cursor_store: &mut CursorStore,
    picker: &FilePicker,
) -> String {
    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    let mut order: Vec<String> = Vec::new();
    for m in items {
        let file = files[m.file_index];
        let path = file.relative_path(picker);
        let count = counts.entry(path.to_string()).or_insert_with(|| {
            order.push(path.to_string());
            0
        });
        *count += 1;
    }

    let mut lines: Vec<String> = Vec::new();
    for path in &order {
        lines.push(format!("{}: {}", path, counts[path.as_str()]));
    }
    if next_file_offset > 0 {
        let cursor_id = cursor_store.store(next_file_offset);
        lines.push(format!("\ncursor: {}", cursor_id));
    }
    lines.join("\n")
}

fn collect_file_preview<'a>(
    items: &[GrepMatch],
    files: &[&'a FileItem],
    picker: &FilePicker,
) -> Vec<FileMeta<'a>> {
    let mut file_preview: Vec<FileMeta<'a>> = Vec::new();
    let mut seen = std::collections::HashSet::new();
    for m in items {
        let file = files[m.file_index];
        if seen.insert(file.relative_path(picker)) {
            file_preview.push(FileMeta {
                file,
                line_number: m.line_number,
                line_content: m.line_content.clone(),
                is_definition: m.is_definition,
                match_ranges: m.match_byte_offsets.iter().copied().collect(),
                context_after: m.context_after.clone(),
            });
        }
    }
    file_preview
}

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

    #[test]
    fn trunc_strips_trailing_whitespace() {
        // Leading whitespace is now stripped by core's trim_whitespace option.
        // This function only strips trailing whitespace.
        assert_eq!(trauncate_line_for_ai("foo()", None, 180), "foo()");
        assert_eq!(trauncate_line_for_ai("bar  ", None, 180), "bar");
        assert_eq!(trauncate_line_for_ai("   ", None, 180), "");
    }

    #[test]
    fn trunc_preserves_pre_trimmed_match_ranges() {
        // Core already stripped leading whitespace and adjusted offsets,
        // so "hello" arrives with match at bytes 0..5.
        let line = "hello";
        let ranges = [(0, 5)];
        let result = trauncate_line_for_ai(line, Some(&ranges), 180);
        assert_eq!(result, "hello");
    }

    #[test]
    fn trunc_long_line_centered() {
        // Core already stripped leading whitespace; offsets are pre-adjusted.
        let line = format!("match_here{}", "x".repeat(200));
        let ranges = [(0u32, 10u32)];
        let result = trauncate_line_for_ai(&line, Some(&ranges), 50);
        assert!(result.contains("match_here"));
        assert!(result.len() <= 55); // budget + ellipsis chars
    }
}
