// SPDX-License-Identifier: MIT

use saphyr::{ScalarOwned, YamlOwned};
use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};

use crate::schema::JsonSchema;

// ──────────────────────────────────────────────────────────────────────────────
// Constants
// ──────────────────────────────────────────────────────────────────────────────

/// Maximum number of examples to display in hover output.
const MAX_EXAMPLES: usize = 3;

/// Maximum characters for a schema description before truncation.
const MAX_DESCRIPTION_LEN: usize = 200;

/// Maximum characters for an example value before truncation.
const MAX_EXAMPLE_LEN: usize = 100;

// ──────────────────────────────────────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────────────────────────────────────

/// Compute hover information for the given YAML text and cursor position.
///
/// Returns `None` if the position is on whitespace, a comment, a document
/// separator, outside the document, or when no AST is available.
#[must_use]
pub fn hover_at(
    text: &str,
    documents: Option<&Vec<YamlOwned>>,
    position: Position,
    schema: Option<&JsonSchema>,
) -> Option<Hover> {
    let documents = documents?;
    if documents.is_empty() {
        return None;
    }

    let lines: Vec<&str> = text.lines().collect();
    let line_idx = position.line as usize;
    let col_idx = position.character as usize;

    // Bounds check
    let line = lines.get(line_idx)?;

    // Check if position is beyond line length
    if col_idx > line.len() {
        return None;
    }

    let trimmed = line.trim();

    // Empty line or comment
    if trimmed.is_empty() || trimmed.starts_with('#') {
        return None;
    }

    // Document separator
    if trimmed == "---" || trimmed == "..." {
        return None;
    }

    // Determine which document this line belongs to
    let doc_index = document_index_for_line(&lines, line_idx);
    let doc = documents.get(doc_index)?;

    // Parse the line to find what token the cursor is on
    let token = token_at_cursor(line, col_idx)?;

    // Walk the AST to find the node and build the path
    let result = find_node_info(doc, &token, line, &lines, line_idx)?;

    let mut markdown =
        format_hover_markdown(&result.path, &result.yaml_type, result.value.as_deref());

    // Append schema info if a schema is available
    if let Some(s) = schema {
        let key_path = build_schema_key_path(&result.path);
        if let Some(prop_schema) = resolve_schema_path(s, &key_path) {
            let schema_section = format_schema_section(prop_schema);
            if !schema_section.is_empty() {
                markdown.push('\n');
                markdown.push_str(&schema_section);
            }
        }
    }

    Some(Hover {
        contents: HoverContents::Markup(MarkupContent {
            kind: MarkupKind::Markdown,
            value: markdown,
        }),
        range: None,
    })
}

/// Determine which YAML document (by `---` separator) a line belongs to.
fn document_index_for_line(lines: &[&str], target_line: usize) -> usize {
    lines
        .iter()
        .enumerate()
        .take(target_line)
        .filter(|(i, line)| line.trim() == "---" && *i > 0)
        .count()
}

/// Information about a YAML token at the cursor position.
enum CursorToken {
    Key(String),
    Value(String),
    SequenceValue,
}

/// Extract the token at the cursor position from a YAML line.
fn token_at_cursor(line: &str, col: usize) -> Option<CursorToken> {
    let trimmed = line.trim();

    // Check for sequence item: "  - value"
    if let Some(after_dash) = trimmed.strip_prefix("- ") {
        let dash_col = line.find("- ").map_or(0, |i| i + 2);
        if col >= dash_col {
            let value = after_dash.trim();
            // Could be a key-value inside a sequence item: "- key: value"
            if let Some(colon_pos) = find_mapping_colon(after_dash) {
                let key = after_dash[..colon_pos].trim();
                let value_part = after_dash[colon_pos + 1..].trim();
                let abs_colon = dash_col + colon_pos;
                if col < abs_colon {
                    return Some(CursorToken::Key(key.to_string()));
                }
                if !value_part.is_empty() {
                    return Some(CursorToken::Value(value_part.to_string()));
                }
                return Some(CursorToken::Key(key.to_string()));
            }
            if !value.is_empty() {
                return Some(CursorToken::SequenceValue);
            }
            return None;
        }
        // On the dash itself — treat as sequence value
        let value = after_dash.trim();
        if !value.is_empty() {
            return Some(CursorToken::SequenceValue);
        }
        return None;
    }

    // Check for bare sequence item: "  - " followed by nothing meaningful on this line
    if trimmed == "-" {
        return None;
    }

    // Regular mapping line: "key: value"
    if let Some(colon_pos) = find_mapping_colon(line) {
        let key = line[..colon_pos].trim();
        let value_part = line[colon_pos + 1..].trim();

        if col <= colon_pos || value_part.is_empty() {
            // Cursor is on the key side or there is no value
            if !key.is_empty() {
                return Some(CursorToken::Key(key.to_string()));
            }
        } else {
            // Cursor is on the value side
            return Some(CursorToken::Value(value_part.to_string()));
        }
    } else if !trimmed.is_empty() {
        // Plain scalar line (no colon) — treat as a value
        return Some(CursorToken::Value(trimmed.to_string()));
    }

    None
}

/// Find the position of the mapping colon in a YAML line.
/// Skips colons inside quoted strings.
fn find_mapping_colon(line: &str) -> Option<usize> {
    let mut in_single_quote = false;
    let mut in_double_quote = false;

    for (i, ch) in line.char_indices() {
        match ch {
            '\'' if !in_double_quote => in_single_quote = !in_single_quote,
            '"' if !in_single_quote => in_double_quote = !in_double_quote,
            ':' if !in_single_quote && !in_double_quote => {
                // Must be followed by space, end of line, or be at end
                let rest = &line[i + 1..];
                if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
                    return Some(i);
                }
            }
            _ => {}
        }
    }
    None
}

struct NodeInfo {
    path: String,
    yaml_type: String,
    value: Option<String>,
}

/// Find the node info (path, type, value) for the token at the cursor.
fn find_node_info(
    doc: &YamlOwned,
    token: &CursorToken,
    line: &str,
    lines: &[&str],
    line_idx: usize,
) -> Option<NodeInfo> {
    // Build the key path by analyzing indentation
    let path = build_key_path(token, line, lines, line_idx);

    // Look up the value in the AST using the path
    let node = resolve_path(doc, &path)?;

    let yaml_type = yaml_type_name(node);
    let value = scalar_value(node);

    Some(NodeInfo {
        path: format_path(&path),
        yaml_type,
        value,
    })
}

/// A segment in a YAML key path.
#[derive(Debug)]
enum PathSegment {
    Key(String),
    Index(usize),
}

/// Build the key path from indentation analysis.
fn build_key_path(
    token: &CursorToken,
    line: &str,
    lines: &[&str],
    line_idx: usize,
) -> Vec<PathSegment> {
    let mut path = Vec::new();

    // Collect parent keys by walking up the indentation
    let current_indent = indentation_level(line);

    // Find parent keys
    let mut parents = Vec::new();
    let mut target_indent = current_indent;

    if target_indent > 0 {
        let mut i = line_idx;
        while i > 0 {
            i -= 1;
            let Some(prev_line) = lines.get(i).copied() else {
                continue;
            };
            let prev_trimmed = prev_line.trim();

            // Skip empty lines, comments, separators
            if prev_trimmed.is_empty()
                || prev_trimmed.starts_with('#')
                || prev_trimmed == "---"
                || prev_trimmed == "..."
            {
                continue;
            }

            let prev_indent = indentation_level(prev_line);
            if prev_indent < target_indent {
                // This is a parent
                if let Some(key) = extract_key(prev_trimmed) {
                    parents.push(key);
                    target_indent = prev_indent;
                    if target_indent == 0 {
                        break;
                    }
                }
            }
        }
    }

    // Reverse parents (we collected bottom-up)
    parents.reverse();
    path.extend(parents.into_iter().map(PathSegment::Key));

    // Add current token to path
    match token {
        CursorToken::Key(key) | CursorToken::Value(key) => {
            // For a value, we need the key from the same line
            if matches!(token, CursorToken::Value(_)) {
                if let Some(key) = extract_key(line.trim()) {
                    path.push(PathSegment::Key(key));
                }
            } else {
                path.push(PathSegment::Key(key.clone()));
            }
        }
        CursorToken::SequenceValue => {
            // Find the index within the sequence
            let idx = sequence_index(lines, line_idx);
            path.push(PathSegment::Index(idx));
        }
    }

    path
}

/// Get the indentation level (number of leading spaces) of a line.
fn indentation_level(line: &str) -> usize {
    line.len() - line.trim_start().len()
}

/// Extract the key from a YAML line like "key: value" or "key:".
fn extract_key(trimmed_line: &str) -> Option<String> {
    // Handle sequence item with key: "- key: value"
    let line = trimmed_line.strip_prefix("- ").unwrap_or(trimmed_line);

    if let Some(colon_pos) = find_mapping_colon(line) {
        let key = line[..colon_pos].trim();
        if !key.is_empty() {
            return Some(key.to_string());
        }
    }
    None
}

/// Find the index of a sequence item by counting preceding siblings.
fn sequence_index(lines: &[&str], line_idx: usize) -> usize {
    let current_indent = lines.get(line_idx).map_or(0, |l| indentation_level(l));
    let mut idx = 0;

    for i in (0..line_idx).rev() {
        let Some(prev_line) = lines.get(i).copied() else {
            continue;
        };
        let prev_trimmed = prev_line.trim();
        let prev_indent = indentation_level(prev_line);

        if prev_trimmed.is_empty() || prev_trimmed.starts_with('#') {
            continue;
        }

        if prev_indent < current_indent {
            break; // Reached parent
        }

        if prev_indent == current_indent && prev_trimmed.starts_with("- ") {
            idx += 1;
        }
    }

    idx
}

/// Resolve a path through the YAML AST to find the target node.
fn resolve_path<'a>(doc: &'a YamlOwned, path: &[PathSegment]) -> Option<&'a YamlOwned> {
    let mut current = doc;

    for segment in path {
        match segment {
            PathSegment::Key(key) => {
                let yaml_key = YamlOwned::Value(ScalarOwned::String(key.clone()));
                match current {
                    YamlOwned::Mapping(map) => {
                        current = map.get(&yaml_key)?;
                    }
                    YamlOwned::Value(_)
                    | YamlOwned::Sequence(_)
                    | YamlOwned::Alias(_)
                    | YamlOwned::BadValue
                    | YamlOwned::Tagged(_, _)
                    | YamlOwned::Representation(_, _, _) => return None,
                }
            }
            PathSegment::Index(idx) => match current {
                YamlOwned::Sequence(arr) => {
                    current = arr.get(*idx)?;
                }
                YamlOwned::Value(_)
                | YamlOwned::Mapping(_)
                | YamlOwned::Alias(_)
                | YamlOwned::BadValue
                | YamlOwned::Tagged(_, _)
                | YamlOwned::Representation(_, _, _) => return None,
            },
        }
    }

    Some(current)
}

/// Get the type name for a YAML node.
fn yaml_type_name(yaml: &YamlOwned) -> String {
    match yaml {
        YamlOwned::Mapping(_) => "mapping".to_string(),
        YamlOwned::Sequence(_) => "sequence".to_string(),
        YamlOwned::Value(_) => "scalar".to_string(),
        YamlOwned::Alias(_) => "alias".to_string(),
        YamlOwned::Tagged(_, _) => "tagged".to_string(),
        YamlOwned::BadValue | YamlOwned::Representation(_, _, _) => "bad value".to_string(),
    }
}

/// Get the scalar value representation for display.
fn scalar_value(yaml: &YamlOwned) -> Option<String> {
    match yaml {
        YamlOwned::Value(ScalarOwned::String(s)) => Some(s.clone()),
        YamlOwned::Value(ScalarOwned::Integer(i)) => Some(i.to_string()),
        YamlOwned::Value(ScalarOwned::FloatingPoint(f)) => Some(f.to_string()),
        YamlOwned::Value(ScalarOwned::Boolean(b)) => Some(b.to_string()),
        YamlOwned::Value(ScalarOwned::Null) => Some("null".to_string()),
        YamlOwned::Mapping(_)
        | YamlOwned::Sequence(_)
        | YamlOwned::Alias(_)
        | YamlOwned::BadValue
        | YamlOwned::Tagged(_, _)
        | YamlOwned::Representation(_, _, _) => None,
    }
}

/// Format the path segments into a dotted path string.
fn format_path(path: &[PathSegment]) -> String {
    let mut result = String::new();
    for (i, segment) in path.iter().enumerate() {
        match segment {
            PathSegment::Key(key) => {
                if i > 0 {
                    result.push('.');
                }
                result.push_str(key);
            }
            PathSegment::Index(idx) => {
                result.push('[');
                result.push_str(&idx.to_string());
                result.push(']');
            }
        }
    }
    result
}

/// Escape backtick characters in a string for safe embedding in a markdown code span.
fn escape_for_code_span(s: &str) -> String {
    s.replace('`', "\\`")
}

/// Truncate a string to at most `max_chars` characters.
/// If truncated, appends the Unicode ellipsis character (U+2026).
fn truncate_to(s: &str, max_chars: usize) -> String {
    if s.chars().count() <= max_chars {
        return s.to_string();
    }
    let keep = max_chars - 1;
    let truncated: String = s
        .char_indices()
        .nth(keep)
        .map_or_else(|| s.to_string(), |(byte_idx, _)| s[..byte_idx].to_string());
    format!("{truncated}\u{2026}")
}

/// Format the hover content as Markdown.
fn format_hover_markdown(path: &str, yaml_type: &str, value: Option<&str>) -> String {
    use std::fmt::Write;
    let mut md = String::new();
    let escaped_path = escape_for_code_span(path);
    let _ = write!(md, "**Path:** `{escaped_path}`\n\n");
    let _ = writeln!(md, "**Type:** {yaml_type}");
    if let Some(val) = value {
        let escaped_val = escape_for_code_span(val);
        let _ = write!(md, "\n**Value:** `{escaped_val}`\n");
    }
    md
}

/// Build a schema key path (list of string keys) from a dotted path string like "a.b.c".
/// Array index segments like "[0]" are represented as "[]" to match schema `items` lookups.
fn build_schema_key_path(dotted_path: &str) -> Vec<String> {
    dotted_path
        .split('.')
        .flat_map(|segment| {
            // Handle "foo[0]" → ["foo", "[]"]
            segment.find('[').map_or_else(
                || vec![segment.to_string()],
                |bracket_pos| {
                    let key = &segment[..bracket_pos];
                    if key.is_empty() {
                        vec!["[]".to_string()]
                    } else {
                        vec![key.to_string(), "[]".to_string()]
                    }
                },
            )
        })
        .filter(|s| !s.is_empty())
        .collect()
}

/// Resolve a dotted key path through a `JsonSchema`, returning the matching sub-schema
/// for the final key. Returns `None` if the path cannot be resolved.
fn resolve_schema_path<'a>(schema: &'a JsonSchema, path: &[String]) -> Option<&'a JsonSchema> {
    let [key, rest @ ..] = path else {
        return None;
    };

    // Look in direct properties
    let found = schema
        .properties
        .as_ref()
        .and_then(|props| props.get(key.as_str()));

    // If not in direct properties, check composition branches
    let found = found.or_else(|| find_in_branches(schema, key));

    let child = found?;

    if rest.is_empty() {
        Some(child)
    } else {
        resolve_schema_path(child, rest)
    }
}

/// Search composition branches (allOf / anyOf / oneOf) for a property key.
fn find_in_branches<'a>(schema: &'a JsonSchema, key: &str) -> Option<&'a JsonSchema> {
    schema
        .all_of
        .iter()
        .flatten()
        .chain(schema.any_of.iter().flatten())
        .chain(schema.one_of.iter().flatten())
        .find_map(|branch| branch.properties.as_ref()?.get(key))
}

/// Format the schema information section appended below the structural hover.
fn format_schema_section(schema: &JsonSchema) -> String {
    use std::fmt::Write;
    let mut md = String::new();

    // Description takes priority over title; skip empty strings
    let text = schema
        .description
        .as_deref()
        .filter(|d| !d.is_empty())
        .or_else(|| schema.title.as_deref().filter(|t| !t.is_empty()));

    if let Some(desc) = text {
        let truncated = truncate_to(desc, MAX_DESCRIPTION_LEN);
        let _ = writeln!(md, "\n**Description:** {truncated}");
    }

    // Schema type
    if let Some(schema_type) = &schema.schema_type {
        let type_str = match schema_type {
            crate::schema::SchemaType::Single(t) => t.clone(),
            crate::schema::SchemaType::Multiple(ts) => ts.join(" | "),
        };
        let _ = writeln!(md, "\n**Schema type:** {type_str}");
    }

    // Default value
    if let Some(default) = &schema.default {
        let _ = writeln!(md, "\n**Default:** {default}");
    }

    // Examples — at most MAX_EXAMPLES, with "and N more" note
    if let Some(examples) = &schema.examples
        && !examples.is_empty()
    {
        let shown = examples.len().min(MAX_EXAMPLES);
        let _ = write!(md, "\n**Examples:**");
        examples.iter().take(shown).for_each(|ex| {
            match ex {
                serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
                    // Pretty-print objects/arrays in a fenced code block.
                    // Fall back to compact inline if pretty form exceeds the limit
                    // (a truncated code block renders incorrectly).
                    if let Ok(pretty) = serde_json::to_string_pretty(ex) {
                        if pretty.chars().count() <= MAX_EXAMPLE_LEN {
                            let _ = write!(md, "\n```json\n{pretty}\n```");
                        } else {
                            let compact = json_value_to_display_string(ex);
                            let truncated = truncate_to(&compact, MAX_EXAMPLE_LEN);
                            let _ = write!(md, "\n- {truncated}");
                        }
                    } else {
                        let truncated =
                            truncate_to(&json_value_to_display_string(ex), MAX_EXAMPLE_LEN);
                        let _ = write!(md, "\n- {truncated}");
                    }
                }
                serde_json::Value::Null
                | serde_json::Value::Bool(_)
                | serde_json::Value::Number(_)
                | serde_json::Value::String(_) => {
                    let truncated = truncate_to(&json_value_to_display_string(ex), MAX_EXAMPLE_LEN);
                    let _ = write!(md, "\n- {truncated}");
                }
            }
        });
        let remaining = examples.len().saturating_sub(shown);
        if remaining > 0 {
            let _ = write!(md, "\n- *and {remaining} more*");
        }
        md.push('\n');
    }

    md
}

/// Convert a `serde_json::Value` to a display string for hover output.
/// Uses `Value::to_string()` which produces the JSON representation.
fn json_value_to_display_string(value: &serde_json::Value) -> String {
    match value {
        serde_json::Value::String(s) => s.clone(),
        serde_json::Value::Null
        | serde_json::Value::Bool(_)
        | serde_json::Value::Number(_)
        | serde_json::Value::Array(_)
        | serde_json::Value::Object(_) => value.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use serde_json::Value as JsonValue;

    use super::*;
    use crate::schema::{JsonSchema, SchemaType};

    fn pos(line: u32, character: u32) -> Position {
        Position::new(line, character)
    }

    fn hover_content(hover: &Hover) -> &str {
        match &hover.contents {
            HoverContents::Markup(m) => &m.value,
            _ => panic!("expected MarkupContent"),
        }
    }

    fn parse_docs(text: &str) -> Option<Vec<YamlOwned>> {
        use saphyr::LoadableYamlNode;
        YamlOwned::load_from_str(text).ok()
    }

    fn schema_with_description(description: &str) -> JsonSchema {
        JsonSchema {
            description: Some(description.to_string()),
            ..Default::default()
        }
    }

    // Test 1
    #[test]
    fn should_return_hover_for_simple_key() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(content.contains("name"), "should contain key path 'name'");
        assert!(
            content.to_lowercase().contains("scalar"),
            "should mention scalar type"
        );
    }

    // Test 2
    #[test]
    fn should_return_hover_for_simple_value() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 6), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(content.contains("name"), "should contain key path 'name'");
        assert!(
            content.to_lowercase().contains("scalar"),
            "should mention scalar type"
        );
        assert!(content.contains("Alice"), "should contain value 'Alice'");
    }

    // Test 3
    #[test]
    fn should_return_none_for_whitespace() {
        let text = "key: value\n\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(1, 0), None);

        assert!(result.is_none());
    }

    // Test 4
    #[test]
    fn should_return_none_for_comment() {
        let text = "# comment\nkey: value\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 2), None);

        assert!(result.is_none());
    }

    // Test 5
    #[test]
    fn should_return_hover_for_nested_key() {
        let text = "server:\n  port: 8080\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(1, 2), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("server.port"),
            "should contain key path 'server.port'"
        );
        assert!(
            content.to_lowercase().contains("scalar"),
            "should mention scalar type"
        );
    }

    // Test 6
    #[test]
    fn should_return_hover_for_deeply_nested_key() {
        let text = "a:\n  b:\n    c: deep\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(2, 4), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(content.contains("a.b.c"), "should contain key path 'a.b.c'");
        assert!(
            content.to_lowercase().contains("scalar"),
            "should mention scalar type"
        );
    }

    // Test 7
    #[test]
    fn should_return_hover_for_sequence_item() {
        let text = "items:\n  - first\n  - second\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(1, 4), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("items[0]") || content.contains("items.0"),
            "should contain path like 'items[0]' or 'items.0'"
        );
        assert!(content.contains("first"), "should contain value 'first'");
    }

    // Test 8
    #[test]
    fn should_return_hover_for_mapping_value_type() {
        let text = "server:\n  port: 8080\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("server"),
            "should contain key path 'server'"
        );
        assert!(
            content.to_lowercase().contains("mapping"),
            "should mention mapping type"
        );
    }

    // Test 9
    #[test]
    fn should_return_hover_for_sequence_value_type() {
        let text = "items:\n  - one\n  - two\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(content.contains("items"), "should contain key path 'items'");
        assert!(
            content.to_lowercase().contains("sequence"),
            "should mention sequence type"
        );
    }

    // Test 10
    #[test]
    fn should_return_hover_with_scalar_value() {
        let text = "port: 8080\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 6), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(content.contains("8080"), "should contain value '8080'");
    }

    // Test 11
    #[test]
    fn should_format_hover_as_markdown() {
        let text = "key: value\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), None);

        let hover = result.expect("should return hover");
        match &hover.contents {
            HoverContents::Markup(m) => {
                assert_eq!(m.kind, MarkupKind::Markdown);
            }
            _ => panic!("expected MarkupContent"),
        }
    }

    // Test 12
    #[test]
    fn should_return_none_for_empty_document() {
        let text = "";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), None);

        assert!(result.is_none());
    }

    // Test 13
    #[test]
    fn should_return_none_when_document_failed_to_parse() {
        let text = "key: [bad";
        let result = hover_at(text, None, pos(0, 0), None);

        assert!(result.is_none());
    }

    // Test 14
    #[test]
    fn should_return_hover_in_multi_document_yaml() {
        let text = "doc1key: value1\n---\ndoc2key: value2\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(2, 0), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("doc2key"),
            "should contain key path 'doc2key'"
        );
    }

    // Test 15
    #[test]
    fn should_return_none_for_position_beyond_document() {
        let text = "key: value\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(5, 0), None);

        assert!(result.is_none());
    }

    // Test 16
    #[test]
    fn should_return_none_for_document_separator_line() {
        let text = "key1: value1\n---\nkey2: value2\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(1, 0), None);

        assert!(result.is_none());
    }

    // Test 17
    #[test]
    fn should_return_hover_for_boolean_value() {
        let text = "enabled: true\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 9), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.to_lowercase().contains("scalar") || content.to_lowercase().contains("boolean"),
            "should mention scalar or boolean type"
        );
        assert!(content.contains("true"), "should contain value 'true'");
    }

    // Test 18
    #[test]
    fn should_return_hover_for_null_value() {
        let text = "empty: ~\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 7), None);

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.to_lowercase().contains("scalar") || content.to_lowercase().contains("null"),
            "should mention scalar or null type"
        );
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Group B — Schema info at key position (Tests 19–24)
    // ──────────────────────────────────────────────────────────────────────────

    // Test 19 — schema with description appended below structural hover
    #[test]
    fn schema_description_appended_for_key_at_root() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "name".to_string(),
            schema_with_description("The user's display name"),
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(content.contains("name"), "should contain key path 'name'");
        assert!(
            content.contains("The user's display name"),
            "should contain schema description"
        );
    }

    // Test 20 — schema type shown in hover
    #[test]
    fn schema_type_shown_for_key() {
        let text = "port: 8080\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "port".to_string(),
            JsonSchema {
                schema_type: Some(SchemaType::Single("integer".to_string())),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("integer"),
            "should contain schema type 'integer'"
        );
    }

    // Test 21 — schema default shown in hover
    #[test]
    fn schema_default_shown_for_key() {
        let text = "timeout: 30\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "timeout".to_string(),
            JsonSchema {
                default: Some(JsonValue::Number(30.into())),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(content.contains("30"), "should contain default value '30'");
        assert!(
            content.to_lowercase().contains("default"),
            "should mention 'default'"
        );
    }

    // Test 22 — schema examples shown in hover (up to 3)
    #[test]
    fn schema_examples_shown_for_key() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "name".to_string(),
            JsonSchema {
                examples: Some(vec![
                    JsonValue::String("Alice".to_string()),
                    JsonValue::String("Bob".to_string()),
                ]),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.to_lowercase().contains("example"),
            "should mention examples"
        );
        assert!(content.contains("Alice"), "should show first example");
        assert!(content.contains("Bob"), "should show second example");
    }

    // Test 23 — no schema info when key not in schema properties
    #[test]
    fn no_schema_info_for_unknown_key() {
        let text = "unknown: value\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "known".to_string(),
            schema_with_description("A known property"),
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        // Structural hover still works
        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("unknown"),
            "should contain key path 'unknown'"
        );
        // Schema description not shown for unknown key
        assert!(
            !content.contains("A known property"),
            "should not show description for unknown key"
        );
    }

    // Test 24 — schema info for nested key resolves through properties
    #[test]
    fn schema_description_for_nested_key() {
        let text = "server:\n  port: 8080\n";
        let docs = parse_docs(text);
        let mut port_props = HashMap::new();
        port_props.insert(
            "port".to_string(),
            JsonSchema {
                description: Some("HTTP port number".to_string()),
                schema_type: Some(SchemaType::Single("integer".to_string())),
                ..Default::default()
            },
        );
        let mut root_props = HashMap::new();
        root_props.insert(
            "server".to_string(),
            JsonSchema {
                properties: Some(port_props),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(root_props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(1, 2), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("server.port"),
            "should contain nested path"
        );
        assert!(
            content.contains("HTTP port number"),
            "should contain nested schema description"
        );
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Group C — Schema info at value position (Tests 25–26)
    // ──────────────────────────────────────────────────────────────────────────

    // Test 25 — hovering on value shows schema description for the parent key
    #[test]
    fn schema_description_shown_for_value_position() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "name".to_string(),
            schema_with_description("The user's display name"),
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 6), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("The user's display name"),
            "should show schema description when hovering on value"
        );
    }

    // Test 26 — hovering on value shows schema type for the parent key
    #[test]
    fn schema_type_shown_for_value_position() {
        let text = "port: 8080\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "port".to_string(),
            JsonSchema {
                schema_type: Some(SchemaType::Single("integer".to_string())),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 6), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("integer"),
            "should show schema type when hovering on value"
        );
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Group D — Formatting and truncation (Tests 27–30)
    // ──────────────────────────────────────────────────────────────────────────

    // Test 27 — existing structural hover section present before schema section
    #[test]
    fn schema_info_appended_below_structural_hover() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "name".to_string(),
            schema_with_description("The user's display name"),
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        let path_pos = content.find("Path").expect("should contain 'Path'");
        let schema_pos = content
            .find("The user's display name")
            .expect("should contain description");
        assert!(
            path_pos < schema_pos,
            "structural hover (Path) should appear before schema info"
        );
    }

    // Test 28 — long description is truncated to ≤200 chars + ellipsis
    #[test]
    fn long_description_truncated() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let long_desc = "A".repeat(500);
        let mut props = HashMap::new();
        props.insert("name".to_string(), schema_with_description(&long_desc));
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        // The full 500-char description must not appear verbatim
        assert!(
            !content.contains(&long_desc),
            "full 500-char description must not appear"
        );
        assert!(
            content.contains('\u{2026}'),
            "truncated description must end with ellipsis"
        );
        // Description body must be ≤199 chars (plus ellipsis = 200)
        let a_run: String = content
            .chars()
            .skip_while(|&c| c != 'A')
            .take_while(|&c| c == 'A')
            .collect();
        assert!(
            a_run.chars().count() <= 199,
            "truncated description body must be ≤199 chars (plus ellipsis = 200), got {}",
            a_run.chars().count()
        );
    }

    // Test 29 — long example value is truncated to ≤100 chars + ellipsis
    #[test]
    fn long_example_value_truncated() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let long_example = "B".repeat(200);
        let mut props = HashMap::new();
        props.insert(
            "name".to_string(),
            JsonSchema {
                examples: Some(vec![JsonValue::String(long_example.clone())]),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            !content.contains(&long_example),
            "full 200-char example should not appear"
        );
        assert!(
            content.contains('\u{2026}'),
            "truncated example should end with ellipsis"
        );
        let b_run: String = content
            .chars()
            .skip_while(|&c| c != 'B')
            .take_while(|&c| c == 'B')
            .collect();
        assert!(
            b_run.chars().count() <= 99,
            "truncated example body should be ≤99 chars (plus ellipsis = 100)"
        );
    }

    // Test 30 — long example value is truncated to ≤100 Unicode chars
    #[test]
    fn long_example_value_truncated_at_100_chars() {
        let text = "key: v\n";
        let docs = parse_docs(text);
        let long_example = "a".repeat(200);
        let mut props = HashMap::new();
        props.insert(
            "key".to_string(),
            JsonSchema {
                examples: Some(vec![JsonValue::String(long_example.clone())]),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        // The full 200-char example must not appear verbatim
        assert!(
            !content.contains(&long_example),
            "full 200-char example must not appear verbatim"
        );
        // Find the run of 'a' characters in hover content
        let a_run: String = content
            .chars()
            .skip_while(|&c| c != 'a')
            .take_while(|&c| c == 'a')
            .collect();
        assert!(
            a_run.chars().count() <= 100,
            "displayed example must be at most 100 chars (got {})",
            a_run.chars().count()
        );
    }

    // Test 31 — at most 3 examples shown; "and N more" note for overflow
    #[test]
    fn should_show_at_most_3_examples_with_overflow_note() {
        let text = "key: v\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "key".to_string(),
            JsonSchema {
                examples: Some(vec![
                    JsonValue::String("a".to_string()),
                    JsonValue::String("b".to_string()),
                    JsonValue::String("c".to_string()),
                    JsonValue::String("d".to_string()),
                    JsonValue::String("e".to_string()),
                ]),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        // Cap is 3, not 5
        assert!(
            content.contains("and 2 more") || content.contains("2 more"),
            "should show 'and 2 more' note for 5 examples capped at 3, got: {content}"
        );
        // items 4–5 must be absent as standalone example values
        // (they appear as single-char strings "d" and "e" — check by looking for
        // them as list items, not just any occurrence)
        let lines_with_d = content
            .lines()
            .filter(|l| l.trim() == "- d" || l.trim() == "d")
            .count();
        let lines_with_e = content
            .lines()
            .filter(|l| l.trim() == "- e" || l.trim() == "e")
            .count();
        assert_eq!(
            lines_with_d, 0,
            "example 'd' (4th) must not appear as a list item"
        );
        assert_eq!(
            lines_with_e, 0,
            "example 'e' (5th) must not appear as a list item"
        );
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Group E — Nested paths through schema (Tests 32–33)
    // ──────────────────────────────────────────────────────────────────────────

    // Test 32 — schema info for two-level nested key
    #[test]
    fn schema_info_for_two_level_nested_key() {
        let text = "database:\n  host: localhost\n";
        let docs = parse_docs(text);
        let mut db_props = HashMap::new();
        db_props.insert(
            "host".to_string(),
            schema_with_description("Database host address"),
        );
        let mut root_props = HashMap::new();
        root_props.insert(
            "database".to_string(),
            JsonSchema {
                properties: Some(db_props),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(root_props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(1, 2), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("database.host"),
            "should contain nested path"
        );
        assert!(
            content.contains("Database host address"),
            "should contain nested schema description"
        );
    }

    // Test 33 — schema info not shown for key two levels deeper than schema has
    #[test]
    fn no_schema_info_for_deeper_than_schema_provides() {
        let text = "a:\n  b:\n    c: deep\n";
        let docs = parse_docs(text);
        // Schema only describes 'a' with no nested properties
        let mut root_props = HashMap::new();
        root_props.insert("a".to_string(), schema_with_description("Top level A"));
        let schema = JsonSchema {
            properties: Some(root_props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(2, 4), Some(&schema));

        // Structural hover for a.b.c should still work
        let hover = result.expect("structural hover should work");
        let content = hover_content(&hover);
        assert!(content.contains("a.b.c"), "should contain path a.b.c");
        // Schema description for 'a' should NOT appear when on 'c'
        assert!(
            !content.contains("Top level A"),
            "should not show parent description when on nested key"
        );
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Group F — Composition schemas (allOf / anyOf) (Tests 34–35)
    // ──────────────────────────────────────────────────────────────────────────

    // Test 34 — allOf branch: property description found in first matching branch
    #[test]
    fn schema_info_from_all_of_branch() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut branch_props = HashMap::new();
        branch_props.insert(
            "name".to_string(),
            schema_with_description("Name from allOf branch"),
        );
        let schema = JsonSchema {
            all_of: Some(vec![JsonSchema {
                properties: Some(branch_props),
                ..Default::default()
            }]),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("Name from allOf branch"),
            "should find property description from allOf branch"
        );
    }

    // Test 35 — anyOf branch: property description found in first matching branch
    #[test]
    fn schema_info_from_any_of_branch() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut branch_props = HashMap::new();
        branch_props.insert(
            "name".to_string(),
            schema_with_description("Name from anyOf branch"),
        );
        let schema = JsonSchema {
            any_of: Some(vec![JsonSchema {
                properties: Some(branch_props),
                ..Default::default()
            }]),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("Name from anyOf branch"),
            "should find property description from anyOf branch"
        );
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Group G — Fallback behaviour (Tests 36–38)
    // ──────────────────────────────────────────────────────────────────────────

    // Test 36 — None schema: structural hover works, no schema section appended
    #[test]
    fn no_schema_hover_unchanged() {
        let text = "port: 8080\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), None);

        let hover = result.expect("should return hover with None schema");
        let content = hover_content(&hover);
        assert!(
            content.contains("port"),
            "structural hover path must be present"
        );
        assert!(
            !content.contains("---"),
            "no schema section must be appended when schema is None"
        );
    }

    // Test 37 — Schema with no properties for cursor key: structural hover only
    #[test]
    fn schema_without_matching_property_shows_structural_only() {
        let text = "port: 8080\n";
        let docs = parse_docs(text);
        // Schema has description at root but no properties
        let schema = schema_with_description("Root schema description");
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(content.contains("port"), "structural hover path present");
        assert!(
            !content.contains("Root schema description"),
            "root description should not appear for a specific key"
        );
    }

    // Test 38 — Schema present but document fails to parse: returns None
    #[test]
    fn schema_present_but_parse_fails_returns_none() {
        let text = "key: [bad";
        let schema = schema_with_description("some desc");
        let result = hover_at(text, None, pos(0, 0), Some(&schema));

        assert!(result.is_none(), "should return None when no parsed docs");
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Group H — Edge cases (Tests 39–42)
    // ──────────────────────────────────────────────────────────────────────────

    // Test 39 — empty description string: schema section not shown
    #[test]
    fn empty_description_not_shown() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "name".to_string(),
            JsonSchema {
                description: Some(String::new()),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        // Should still contain structural hover
        assert!(content.contains("name"), "structural hover present");
        // Schema section should not be added for empty description
        // (no "Schema" header or empty description block)
        assert!(
            !content.contains("**Description:**"),
            "should not show description section for empty description"
        );
    }

    // Test 40 — title shown when description absent
    #[test]
    fn title_shown_when_description_absent() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "name".to_string(),
            JsonSchema {
                title: Some("User Name".to_string()),
                description: None,
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("User Name"),
            "should show title when description is absent"
        );
    }

    // Test 41 — null default value shown
    #[test]
    fn null_default_shown() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "name".to_string(),
            JsonSchema {
                default: Some(JsonValue::Null),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.to_lowercase().contains("null"),
            "should show null default"
        );
        assert!(
            content.to_lowercase().contains("default"),
            "should label the default"
        );
    }

    // Test 42 — title NOT shown when description present (description takes priority)
    #[test]
    fn description_takes_priority_over_title() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "name".to_string(),
            JsonSchema {
                title: Some("User Name".to_string()),
                description: Some("The full display name of the user".to_string()),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("The full display name of the user"),
            "should show description"
        );
        // Title should not appear when description is present
        assert!(
            !content.contains("User Name"),
            "should not show title when description is present"
        );
    }

    // Test 42 — 10 examples: show first 3, note "and 7 more"
    #[test]
    fn should_show_only_first_3_examples_when_10_provided() {
        let text = "key: v\n";
        let docs = parse_docs(text);
        let examples: Vec<JsonValue> = (0..10)
            .map(|i| JsonValue::String(format!("ex{i}")))
            .collect();
        let mut props = HashMap::new();
        props.insert(
            "key".to_string(),
            JsonSchema {
                examples: Some(examples),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        // First 3 shown; ex3 through ex9 must be absent as list items
        assert!(content.contains("ex0"), "ex0 must appear");
        for i in 3..10 {
            let item = format!("ex{i}");
            let lines_with_item = content
                .lines()
                .filter(|l| l.trim() == format!("- {item}") || l.trim() == item)
                .count();
            assert_eq!(
                lines_with_item, 0,
                "example '{item}' must not appear as a list item"
            );
        }
        // "and 7 more" note expected
        assert!(
            content.contains("7 more"),
            "should contain 'and 7 more' note, got: {content}"
        );
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Security tests (Tests 43–50)
    // ──────────────────────────────────────────────────────────────────────────

    // Test 43 — description truncation is char-based (not byte-based), cap is 200 chars
    #[test]
    fn should_truncate_long_description_in_hover_at_200_chars() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        // 300 × 'é' = 600 bytes but 300 chars; truncated at 200 chars (199 body + ellipsis)
        let long_desc: String = std::iter::repeat('é').take(300).collect();
        let mut props = HashMap::new();
        props.insert("name".to_string(), schema_with_description(&long_desc));
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        // Extract the é-run from the content
        let e_run: String = content
            .chars()
            .skip_while(|&c| c != 'é')
            .take_while(|&c| c == 'é')
            .collect();
        assert!(
            e_run.chars().count() <= 199,
            "truncation must use chars not bytes; body must be ≤199 chars (got {})",
            e_run.chars().count()
        );
        assert!(
            content.contains('\u{2026}'),
            "truncated description must end with ellipsis"
        );
    }

    // Test 44 — backtick in schema default value rendered safely
    #[test]
    fn should_escape_backtick_in_schema_default_value() {
        let text = "key: value\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "key".to_string(),
            JsonSchema {
                default: Some(JsonValue::String("foo`bar".to_string())),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        // Must NOT contain a broken code span pattern "`foo`bar`"
        assert!(
            !content.contains("`foo`bar`"),
            "must not contain broken code span '`foo`bar`', got: {content}"
        );
        // Must still render something that includes "foo" (the default appears)
        assert!(
            content.contains("foo"),
            "default value 'foo' must appear somewhere"
        );
    }

    // Test 45 — at most 3 examples shown with "and N more" overflow note
    #[test]
    fn should_show_at_most_3_examples_with_overflow_note_sec() {
        let text = "key: v\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "key".to_string(),
            JsonSchema {
                examples: Some(vec![
                    JsonValue::String("a".to_string()),
                    JsonValue::String("b".to_string()),
                    JsonValue::String("c".to_string()),
                    JsonValue::String("d".to_string()),
                    JsonValue::String("e".to_string()),
                ]),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        assert!(result.is_some(), "should return hover");
        let hover = result.unwrap();
        let content = hover_content(&hover);
        // Items 4–5 ("d", "e") must not appear as standalone example list items
        let d_count = content
            .lines()
            .filter(|l| l.trim() == "- d" || l.trim() == "d")
            .count();
        let e_count = content
            .lines()
            .filter(|l| l.trim() == "- e" || l.trim() == "e")
            .count();
        assert_eq!(d_count, 0, "'d' must not appear as example item");
        assert_eq!(e_count, 0, "'e' must not appear as example item");
        // "and N more" note present
        assert!(
            content.contains("more"),
            "should contain overflow note indicating more examples exist, got: {content}"
        );
    }

    // Test 46 — long example value truncated to ≤100 chars (char-based)
    #[test]
    fn should_truncate_long_example_value_at_100_chars() {
        let text = "key: v\n";
        let docs = parse_docs(text);
        let long_example = "a".repeat(200);
        let mut props = HashMap::new();
        props.insert(
            "key".to_string(),
            JsonSchema {
                examples: Some(vec![JsonValue::String(long_example.clone())]),
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        assert!(result.is_some(), "should return hover");
        let hover = result.unwrap();
        let content = hover_content(&hover);
        // Find the example run in the hover content
        let a_run: String = content
            .chars()
            .skip_while(|&c| c != 'a')
            .take_while(|&c| c == 'a')
            .collect();
        assert!(
            a_run.chars().count() <= 100,
            "displayed example must be ≤ 100 chars (got {})",
            a_run.chars().count()
        );
    }

    // Test 47 — backtick in YAML value escaped in **Value:** code span
    #[test]
    fn should_escape_backtick_in_yaml_value_display() {
        // YAML value contains a backtick — must be escaped so the code span doesn't break
        let text = "foo: bar`baz\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 5), None);

        assert!(result.is_some(), "should return hover");
        let hover = result.unwrap();
        let content = hover_content(&hover);
        // The raw markdown must not contain the broken pattern "`bar`baz`"
        assert!(
            !content.contains("`bar`baz`"),
            "must not contain broken code span '`bar`baz`', got: {content}"
        );
        // "bar" must still appear in some form
        assert!(content.contains("bar"), "value 'bar' must appear in hover");
    }

    // Test 48 — no schema section when schema has no info for the hovered key
    #[test]
    fn should_show_structural_only_when_schema_has_no_info_for_hovered_key() {
        let text = "name: Alice\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert("other".to_string(), schema_with_description("Other"));
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        assert!(result.is_some(), "should return hover");
        let hover = result.unwrap();
        let content = hover_content(&hover);
        // No schema section separator or "Other" description
        assert!(
            !content.contains("---"),
            "should not contain schema section separator"
        );
        assert!(
            !content.contains("Other"),
            "should not show 'Other' description"
        );
    }

    // Test 49 — title shown as fallback when no description
    #[test]
    fn should_show_title_as_fallback_when_no_description() {
        let text = "key: value\n";
        let docs = parse_docs(text);
        let mut props = HashMap::new();
        props.insert(
            "key".to_string(),
            JsonSchema {
                title: Some("My Key Title".to_string()),
                description: None,
                ..Default::default()
            },
        );
        let schema = JsonSchema {
            properties: Some(props),
            ..Default::default()
        };
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        assert!(result.is_some(), "should return hover");
        let hover = result.unwrap();
        let content = hover_content(&hover);
        assert!(
            content.contains("My Key Title"),
            "should show title as fallback when description absent"
        );
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Group I — Previously uncovered paths (Tests 56–65)
    // ──────────────────────────────────────────────────────────────────────────

    // Test 56 — col_idx beyond line length returns None (line 50 branch)
    #[test]
    fn hover_returns_none_when_col_beyond_line_length() {
        let text = "key: value\n";
        let docs = parse_docs(text);
        // Line 0 is "key: value" (10 chars), col 11 is beyond the end
        let result = hover_at(text, docs.as_ref(), pos(0, 11), None);

        assert!(result.is_none());
    }

    // Test 57 — cursor on dash of sequence item (col < dash_col, lines 143-148)
    #[test]
    fn hover_returns_sequence_value_when_cursor_on_dash() {
        let text = "items:\n  - first\n";
        let docs = parse_docs(text);
        // Line 1: "  - first". The '-' is at col 2. Cursor at col 2 (the dash itself).
        let result = hover_at(text, docs.as_ref(), pos(1, 2), None);

        let hover = result.expect("should return hover for sequence item when cursor on dash");
        let content = hover_content(&hover);
        assert!(
            content.contains("items"),
            "should contain parent key 'items'"
        );
    }

    // Test 58 — cursor before content in sequence item returns SequenceValue (col < dash_col
    // but the sequence item has a plain value, lines 143-148)
    #[test]
    fn hover_on_dash_of_sequence_with_empty_value_returns_none() {
        // Bare dash line "  -" (no value after dash+space)
        let text = "items:\n  -\n";
        let docs = parse_docs(text);
        // Line 1: "  -" — trimmed is "-", no value after dash
        let result = hover_at(text, docs.as_ref(), pos(1, 2), None);

        assert!(result.is_none());
    }

    // Test 59 — plain scalar line (no colon) triggers Value path (lines 170-172)
    #[test]
    fn hover_on_plain_scalar_line_returns_value_token() {
        let text = "- plainvalue\n";
        let docs = parse_docs(text);
        // After stripping "- ", the line is "plainvalue" with no colon.
        // Cursor at col 4 (within "plainvalue" after dash).
        let result = hover_at(text, docs.as_ref(), pos(0, 4), None);

        let hover = result.expect("should return hover for plain scalar in sequence");
        let content = hover_content(&hover);
        assert!(
            content.contains("0") || content.contains("plainvalue"),
            "should contain sequence index or value"
        );
    }

    // Test 60 — sequence item with key-only (no value after colon, line 136 path)
    #[test]
    fn hover_on_sequence_item_key_with_no_value_returns_key_token() {
        let text = "items:\n  - name:\n";
        let docs = parse_docs(text);
        // Line 1: "  - name:" — colon present but no value_part; cursor beyond colon → Key token
        let result = hover_at(text, docs.as_ref(), pos(1, 8), None);

        // May return None if the node can't be resolved (empty value), but should not panic
        // If it does return Some, it should mention the path
        if let Some(hover) = result {
            let content = hover_content(&hover);
            assert!(
                content.contains("name") || content.contains("items"),
                "path should reference the key"
            );
        }
    }

    // Test 61 — mapping value token when cursor is on value side (line 167-168)
    #[test]
    fn hover_on_value_side_of_mapping_returns_value_token() {
        let text = "status: active\n";
        let docs = parse_docs(text);
        // "status: active" — colon at index 6; cursor at 8 (within "active")
        let result = hover_at(text, docs.as_ref(), pos(0, 8), None);

        let hover = result.expect("should return hover for value side of mapping");
        let content = hover_content(&hover);
        assert!(
            content.contains("status"),
            "path should contain key 'status'"
        );
        assert!(content.contains("active"), "should contain value 'active'");
    }

    // Test 62 — key with empty name after colon strip returns None (line 163-164 fallthrough)
    #[test]
    fn hover_returns_none_for_line_starting_with_colon() {
        // A line starting with ": value" has an empty key — falls through to None
        let text = ": orphan\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), None);

        // Either None (no token found) or a hover — the key point is no panic
        let _ = result; // must not panic
    }

    // Test 63 — sequence item key before colon exercises the key-token path (lines 130-131)
    // The token is constructed but AST path resolution returns None because the path traversal
    // goes through a sequence index not a key. The important thing is no panic.
    #[test]
    fn hover_on_sequence_item_key_before_colon_does_not_panic() {
        let text = "people:\n  - name: Alice\n";
        let docs = parse_docs(text);
        // Line 1: "  - name: Alice"; cursor at col 5 (within "name", before colon).
        // token_at_cursor exercises the col < abs_colon branch (lines 130-131).
        let _result = hover_at(text, docs.as_ref(), pos(1, 5), None);
        // No assertion on value — AST path resolution may or may not find the node.
        // The key invariant is that the function completes without panicking.
    }

    // Test 64 — sequence item value after colon exercises the value-token path (lines 133-134)
    #[test]
    fn hover_on_sequence_item_value_after_colon_does_not_panic() {
        let text = "people:\n  - name: Alice\n";
        let docs = parse_docs(text);
        // Line 1: "  - name: Alice"; cursor at col 12 (within "Alice", after colon).
        // token_at_cursor exercises the !value_part.is_empty() branch (lines 133-134).
        let _result = hover_at(text, docs.as_ref(), pos(1, 12), None);
        // No assertion on value — same reasoning as Test 63.
    }

    // Test 65 — document with only empty lines and a valid key: no None from is_empty check
    #[test]
    fn hover_returns_none_for_ellipsis_document_terminator() {
        let text = "key: value\n...\n";
        let docs = parse_docs(text);
        // "..." is a document terminator — should return None
        let result = hover_at(text, docs.as_ref(), pos(1, 0), None);

        assert!(result.is_none());
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Group J — Hover examples formatting (Tests 51–55)
    // ──────────────────────────────────────────────────────────────────────────

    fn schema_with_examples(examples: Vec<JsonValue>) -> JsonSchema {
        let mut props = HashMap::new();
        props.insert(
            "key".to_string(),
            JsonSchema {
                examples: Some(examples),
                ..Default::default()
            },
        );
        JsonSchema {
            properties: Some(props),
            ..Default::default()
        }
    }

    // Test 51 — object example renders as fenced ```json code block
    #[test]
    fn object_example_renders_as_fenced_code_block() {
        use serde_json::json;
        let schema = schema_with_examples(vec![json!({"name": "Alice", "age": 30})]);
        let text = "key: v\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("```json"),
            "object example should be in a fenced json code block, got: {content}"
        );
        // Pretty-printed: each key on its own line
        assert!(
            content.contains("\"name\": \"Alice\"") || content.contains("\"age\": 30"),
            "object example should be pretty-printed, got: {content}"
        );
    }

    // Test 52 — array example renders as fenced ```json code block
    #[test]
    fn array_example_renders_as_fenced_code_block() {
        use serde_json::json;
        let schema = schema_with_examples(vec![json!(["a", "b", "c"])]);
        let text = "key: v\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("```json"),
            "array example should be in a fenced json code block, got: {content}"
        );
    }

    // Test 53 — simple value (string, number, bool) uses list-item format, no code block
    #[test]
    fn simple_value_examples_use_list_item_format() {
        use serde_json::json;
        let schema = schema_with_examples(vec![json!("hello"), json!(42), json!(true)]);
        let text = "key: v\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            !content.contains("```json"),
            "simple examples must not use code blocks, got: {content}"
        );
        assert!(
            content.contains("- hello"),
            "string example should appear as list item '- hello', got: {content}"
        );
        assert!(
            content.contains("- 42"),
            "number example should appear as list item '- 42', got: {content}"
        );
        assert!(
            content.contains("- true"),
            "bool example should appear as list item '- true', got: {content}"
        );
    }

    // Test 54 — mixed examples: object gets code block, simple gets list item
    #[test]
    fn mixed_examples_dispatch_correctly_per_type() {
        use serde_json::json;
        let schema = schema_with_examples(vec![json!({"host": "localhost"}), json!("simple")]);
        let text = "key: v\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        assert!(
            content.contains("```json"),
            "object example should use code block, got: {content}"
        );
        assert!(
            content.contains("- simple"),
            "string example should use list item format, got: {content}"
        );
    }

    // Test 55 — long object exceeding MAX_EXAMPLE_LEN falls back to compact inline
    #[test]
    fn long_object_example_falls_back_to_compact_inline() {
        use serde_json::json;
        // Build an object whose pretty-printed form exceeds 100 chars
        let big_value: String = "x".repeat(50);
        let schema = schema_with_examples(vec![json!({
            "field_a": big_value,
            "field_b": "another long value that pushes past the limit"
        })]);
        let text = "key: v\n";
        let docs = parse_docs(text);
        let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));

        let hover = result.expect("should return hover");
        let content = hover_content(&hover);
        // The pretty form exceeds 100 chars, so no code block should appear
        assert!(
            !content.contains("```json"),
            "long object should fall back to compact inline (no code block), got: {content}"
        );
    }
}
