jiq 2.21.1

Interactive JSON query tool with real-time output
//! Tooltip popup rendering
//!
//! This module handles rendering of the function tooltip popup.

use ratatui::{
    Frame,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, Borders, Paragraph},
};

use crate::app::App;
use crate::tooltip::{get_operator_content, get_tooltip_content};
use crate::widgets::popup;

// Tooltip popup display constants
const TOOLTIP_MIN_WIDTH: u16 = 40;
const TOOLTIP_MAX_WIDTH: u16 = 90;
const TOOLTIP_BORDER_HEIGHT: u16 = 2;
const TOOLTIP_BORDER_WIDTH: u16 = 4; // left border + padding + right border + padding
const TOOLTIP_MIN_HEIGHT: u16 = 8;
const TOOLTIP_MAX_HEIGHT: u16 = 18; // Increased to allow for wrapped tips

/// Wrap text to fit within a given width, breaking at word boundaries (max 2 lines)
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
    if text.len() <= max_width {
        return vec![text.to_string()];
    }

    let mut lines = Vec::new();
    let mut current_line = String::new();

    for word in text.split_whitespace() {
        if current_line.is_empty() {
            current_line = word.to_string();
        } else if current_line.len() + 1 + word.len() <= max_width {
            current_line.push(' ');
            current_line.push_str(word);
        } else {
            lines.push(current_line);
            current_line = word.to_string();
        }
    }

    if !current_line.is_empty() {
        lines.push(current_line);
    }

    // Limit to 2 lines max for tips
    if lines.len() > 2 {
        lines.truncate(2);
    }

    lines
}

/// Render the tooltip popup on the right side, above the input field
pub fn render_popup(app: &App, frame: &mut Frame, input_area: Rect) {
    // Determine what to show: function takes priority over operator
    let (title_prefix, name, content) = if let Some(func) = &app.tooltip.current_function {
        if let Some(c) = get_tooltip_content(func) {
            ("fn", func.as_str(), c)
        } else {
            return;
        }
    } else if let Some(op) = &app.tooltip.current_operator {
        if let Some(c) = get_operator_content(op) {
            ("operator", op.as_str(), c)
        } else {
            return;
        }
    } else {
        return;
    };

    // Parse examples into (code, description) pairs
    let parsed_examples: Vec<(&str, &str)> = content
        .examples
        .iter()
        .map(|e| {
            if let Some(idx) = e.find('#') {
                (e[..idx].trim_end(), e[idx + 1..].trim_start())
            } else {
                (*e, "")
            }
        })
        .collect();

    // Calculate the max code width for alignment
    let max_code_width = parsed_examples
        .iter()
        .map(|(code, _)| code.len())
        .max()
        .unwrap_or(0);

    // Calculate required width based on content (code + separator + description)
    let description_width = content.description.len();
    let max_example_width = parsed_examples
        .iter()
        .map(|(code, desc)| {
            if desc.is_empty() {
                code.len() + 2 // just code + indent
            } else {
                max_code_width + 3 + desc.len() + 2 // code + " │ " + desc + indent
            }
        })
        .max()
        .unwrap_or(0);
    // Don't let tip width drive popup width - tips will wrap
    let dismiss_hint_len = 19; // "Ctrl+T to dismiss"
    // Title format: "fn: name" or "operator: op"
    let title_width = title_prefix.len() + 2 + name.len() + dismiss_hint_len + 4; // prefix + ": " + name + dismiss + spacing

    let content_width = description_width.max(max_example_width).max(title_width);

    let popup_width =
        ((content_width as u16) + TOOLTIP_BORDER_WIDTH).clamp(TOOLTIP_MIN_WIDTH, TOOLTIP_MAX_WIDTH);

    // Calculate tip wrapping - available width for tip text
    let tip_available_width = (popup_width as usize).saturating_sub(6); // borders + padding + emoji
    let wrapped_tip_lines: Vec<String> = if let Some(tip) = content.tip {
        wrap_text(tip, tip_available_width)
    } else {
        Vec::new()
    };
    let tip_line_count = wrapped_tip_lines.len() as u16;

    // Calculate content height: description (1) + blank (1) + examples + tip (blank + lines)
    let example_count = parsed_examples.len() as u16;
    let tip_height = if content.tip.is_some() {
        1 + tip_line_count // blank line + wrapped tip lines
    } else {
        0
    };
    let content_height = 1 + 1 + example_count + tip_height; // description + blank + examples + tip
    let popup_height =
        (content_height + TOOLTIP_BORDER_HEIGHT).clamp(TOOLTIP_MIN_HEIGHT, TOOLTIP_MAX_HEIGHT);

    // Position popup on the right side, above input box
    let frame_area = frame.area();
    // Allow up to 75% of screen width for tooltip
    let max_allowed_width = (frame_area.width * 3) / 4;
    let final_width = popup_width.min(max_allowed_width);

    // Position on right side with some margin
    let popup_x = frame_area.width.saturating_sub(final_width + 2);
    let popup_y = input_area.y.saturating_sub(popup_height);

    let popup_area = Rect {
        x: popup_x,
        y: popup_y,
        width: final_width,
        height: popup_height.min(input_area.y),
    };

    // Clear the background for floating effect
    popup::clear_area(frame, popup_area);

    // Build content lines
    let mut lines: Vec<Line> = Vec::new();

    // Description line
    lines.push(Line::from(vec![Span::styled(
        content.description,
        Style::default().fg(Color::White),
    )]));

    // Blank line before examples
    lines.push(Line::from(""));

    // Examples with two-column layout: code │ description
    for (code, desc) in &parsed_examples {
        if desc.is_empty() {
            // No description, just show code
            lines.push(Line::from(vec![Span::styled(
                format!("  {}", code),
                Style::default().fg(Color::Cyan),
            )]));
        } else {
            // Two-column: code (padded) │ description
            let padded_code = format!("{:width$}", code, width = max_code_width);
            lines.push(Line::from(vec![
                Span::styled(
                    format!("  {}", padded_code),
                    Style::default().fg(Color::Cyan),
                ),
                Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
                Span::styled(*desc, Style::default().fg(Color::Gray)),
            ]));
        }
    }

    // Optional tip (with wrapping)
    if content.tip.is_some() && !wrapped_tip_lines.is_empty() {
        lines.push(Line::from(""));
        // First line with emoji prefix
        lines.push(Line::from(vec![
            Span::styled("💡 ", Style::default()),
            Span::styled(
                wrapped_tip_lines[0].clone(),
                Style::default().fg(Color::Yellow),
            ),
        ]));
        // Subsequent lines with spacing to align with first line
        for line in wrapped_tip_lines.iter().skip(1) {
            lines.push(Line::from(vec![
                Span::raw("   "), // 3 spaces to align with text after emoji
                Span::styled(line.clone(), Style::default().fg(Color::Yellow)),
            ]));
        }
    }

    let text = Text::from(lines);

    // Build title with prefix and name in purple (left side)
    // Format: "fn: select" or "operator: //"
    let title = Line::from(vec![
        Span::raw(" "),
        Span::styled(
            format!("{}: {}", title_prefix, name),
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(" "),
    ]);

    // Build dismiss hint for top-right of border
    let dismiss_hint = Line::from(vec![Span::styled(
        " Ctrl+T to dismiss ",
        Style::default().fg(Color::DarkGray),
    )]);

    // Create the popup widget with purple border
    // Title on top-left, dismiss hint on top-right
    let popup_widget = Paragraph::new(text).block(
        Block::default()
            .borders(Borders::ALL)
            .title(title)
            .title_top(dismiss_hint.alignment(ratatui::layout::Alignment::Right))
            .border_style(Style::default().fg(Color::Magenta))
            .style(Style::default().bg(Color::Black)),
    );

    frame.render_widget(popup_widget, popup_area);
}

/// Generate the title text for a tooltip
///
/// # Arguments
/// * `is_function` - true for function, false for operator
/// * `name` - the function or operator name
///
/// # Returns
/// The formatted title string: "fn: <name>" or "operator: <op>"
#[cfg(test)]
pub fn format_tooltip_title(is_function: bool, name: &str) -> String {
    if is_function {
        format!("fn: {}", name)
    } else {
        format!("operator: {}", name)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::autocomplete::jq_functions::JQ_FUNCTION_METADATA;
    use crate::tooltip::operator_content::OPERATOR_CONTENT;
    use proptest::prelude::*;

    // ==================== Unit Tests ====================

    #[test]
    fn test_format_tooltip_title_function() {
        assert_eq!(format_tooltip_title(true, "select"), "fn: select");
        assert_eq!(format_tooltip_title(true, "map"), "fn: map");
        assert_eq!(format_tooltip_title(true, "sort_by"), "fn: sort_by");
    }

    #[test]
    fn test_format_tooltip_title_operator() {
        assert_eq!(format_tooltip_title(false, "//"), "operator: //");
        assert_eq!(format_tooltip_title(false, "|="), "operator: |=");
        assert_eq!(format_tooltip_title(false, "//="), "operator: //=");
        assert_eq!(format_tooltip_title(false, ".."), "operator: ..");
    }

    // ==================== Property Tests ====================

    // **Feature: operator-tooltips, Property 6: Title format correctness**
    // *For any* function name, the title generator SHALL produce `fn: <name>`.
    // *For any* operator, the title generator SHALL produce `operator: <op>`.
    // **Validates: Requirements 1.3, 2.3, 3.3, 4.3, 5.1, 5.2**
    proptest! {
        #![proptest_config(ProptestConfig::with_cases(100))]

        #[test]
        fn prop_function_title_format(func_index in 0usize..JQ_FUNCTION_METADATA.len()) {
            let func = &JQ_FUNCTION_METADATA[func_index];
            let func_name = func.name;

            let title = format_tooltip_title(true, func_name);

            // Title should start with "fn: "
            prop_assert!(
                title.starts_with("fn: "),
                "Function title '{}' should start with 'fn: '",
                title
            );

            // Title should end with the function name
            prop_assert!(
                title.ends_with(func_name),
                "Function title '{}' should end with function name '{}'",
                title,
                func_name
            );

            // Title should be exactly "fn: <name>"
            let expected = format!("fn: {}", func_name);
            prop_assert_eq!(
                title,
                expected,
                "Function title should be exactly 'fn: {}'",
                func_name
            );
        }

        #[test]
        fn prop_operator_title_format(op_index in 0usize..OPERATOR_CONTENT.len()) {
            let op = &OPERATOR_CONTENT[op_index];
            let op_name = op.function;

            let title = format_tooltip_title(false, op_name);

            // Title should start with "operator: "
            prop_assert!(
                title.starts_with("operator: "),
                "Operator title '{}' should start with 'operator: '",
                title
            );

            // Title should end with the operator
            prop_assert!(
                title.ends_with(op_name),
                "Operator title '{}' should end with operator '{}'",
                title,
                op_name
            );

            // Title should be exactly "operator: <op>"
            let expected = format!("operator: {}", op_name);
            prop_assert_eq!(
                title,
                expected,
                "Operator title should be exactly 'operator: {}'",
                op_name
            );
        }
    }
}