//! Authoring metadata and completion helpers.
//!
//! This module exposes context-aware completion candidates and hover docs
//! backed by the `authoring` catalog in `rules.json` (generated by agnix-rules).

use crate::{FileType, parsers::frontmatter::split_frontmatter};
use serde::Deserialize;
use std::sync::OnceLock;

/// Completion item kind for editor adapters (LSP, IDE plugins).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CompletionKind {
    Key,
    Value,
    Snippet,
}

/// Context-aware completion suggestion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionCandidate {
    pub label: String,
    pub insert_text: String,
    pub detail: Option<String>,
    pub documentation: Option<String>,
    pub kind: CompletionKind,
    pub rule_links: Vec<String>,
}

/// Hover documentation payload for a config field.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HoverDoc {
    pub markdown: String,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct AuthoringCatalog {
    #[serde(default)]
    families: Vec<AuthoringFamily>,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct AuthoringFamily {
    id: String,
    #[serde(default)]
    keys: Vec<AuthoringKey>,
    #[serde(default)]
    snippets: Vec<AuthoringSnippet>,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct AuthoringKey {
    key: String,
    #[serde(default)]
    docs: String,
    #[serde(default)]
    rules: Vec<String>,
    #[serde(default)]
    values: Vec<String>,
    #[serde(default)]
    snippet: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct AuthoringSnippet {
    label: String,
    body: String,
    #[serde(default)]
    docs: Option<String>,
    #[serde(default)]
    rules: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum CursorContext {
    Key,
    Value(String),
    Body,
}

fn catalog() -> &'static AuthoringCatalog {
    static CATALOG: OnceLock<AuthoringCatalog> = OnceLock::new();
    CATALOG.get_or_init(|| {
        serde_json::from_str::<AuthoringCatalog>(agnix_rules::authoring_catalog_json())
            .unwrap_or_default()
    })
}

fn family_id_for_file_type(file_type: FileType) -> Option<&'static str> {
    match file_type {
        FileType::Skill => Some("skill"),
        FileType::Agent | FileType::KiroAgent => Some("agent"),
        FileType::Hooks | FileType::KiroHook => Some("hooks"),
        FileType::Plugin => Some("plugin"),
        FileType::Mcp | FileType::KiroMcp => Some("mcp"),
        FileType::Copilot | FileType::CopilotScoped => Some("copilot"),
        FileType::CursorRule | FileType::CursorRulesLegacy => Some("cursor"),
        FileType::CursorHooks | FileType::CursorAgent | FileType::CursorEnvironment => None,
        FileType::ClaudeMd => Some("claude-agents"),
        FileType::ClaudeRule => None, // No authoring catalog entry yet
        FileType::GeminiMd => None,   // No authoring catalog entry yet
        _ => None,
    }
}

fn family_for_file_type(file_type: FileType) -> Option<&'static AuthoringFamily> {
    let family_id = family_id_for_file_type(file_type)?;
    catalog()
        .families
        .iter()
        .find(|family| family.id == family_id)
}

fn is_yaml_family(file_type: FileType) -> bool {
    matches!(
        file_type,
        FileType::Skill
            | FileType::Agent
            | FileType::CopilotScoped
            | FileType::CursorRule
            | FileType::CursorAgent
            | FileType::ClaudeRule
    )
}

fn is_json_family(file_type: FileType) -> bool {
    matches!(
        file_type,
        FileType::Hooks
            | FileType::KiroHook
            | FileType::Plugin
            | FileType::Mcp
            | FileType::KiroMcp
            | FileType::KiroAgent
    )
}

fn line_bounds_at(content: &str, cursor_byte: usize) -> (usize, usize) {
    let clamped = cursor_byte.min(content.len());
    let line_start = content[..clamped]
        .rfind('\n')
        .map(|idx| idx + 1)
        .unwrap_or(0);
    let line_end = content[clamped..]
        .find('\n')
        .map(|idx| clamped + idx)
        .unwrap_or(content.len());
    (line_start, line_end)
}

fn parse_context_from_line(line: &str, cursor_col: usize) -> CursorContext {
    let trimmed = line.trim_start();
    if trimmed.is_empty() || trimmed.starts_with('#') {
        return CursorContext::Key;
    }

    if let Some(colon_idx) = trimmed.find(':') {
        let key_raw = trimmed[..colon_idx].trim();
        let key = key_raw.trim_matches('"').trim_matches('\'');
        let leading_ws = line.len().saturating_sub(trimmed.len());
        let key_end_col = leading_ws + colon_idx;

        if key.is_empty() || cursor_col <= key_end_col {
            CursorContext::Key
        } else {
            CursorContext::Value(key.to_string())
        }
    } else {
        CursorContext::Key
    }
}

fn detect_cursor_context(file_type: FileType, content: &str, cursor_byte: usize) -> CursorContext {
    if content.is_empty() {
        return CursorContext::Key;
    }

    if is_yaml_family(file_type) {
        let parts = split_frontmatter(content);
        let within_frontmatter = parts.has_frontmatter
            && parts.has_closing
            && cursor_byte >= parts.frontmatter_start
            && cursor_byte < parts.body_start;
        if !within_frontmatter {
            return CursorContext::Body;
        }
    } else if matches!(file_type, FileType::ClaudeMd) {
        return CursorContext::Body;
    }

    let (line_start, line_end) = line_bounds_at(content, cursor_byte);
    let line = &content[line_start..line_end];
    let cursor_col = cursor_byte.saturating_sub(line_start);
    parse_context_from_line(line, cursor_col)
}

fn key_insert_text(file_type: FileType, key: &str) -> String {
    if is_json_family(file_type) {
        format!("\"{}\": ", key)
    } else {
        format!("{}: ", key)
    }
}

fn value_insert_text(file_type: FileType, value: &str) -> String {
    if !is_json_family(file_type) {
        return value.to_string();
    }

    let looks_structured =
        value.starts_with('{') || value.starts_with('[') || value == "true" || value == "false";
    let looks_numeric = !value.is_empty() && value.chars().all(|ch| ch.is_ascii_digit());
    if looks_structured || looks_numeric {
        value.to_string()
    } else {
        format!("\"{}\"", value)
    }
}

fn dedupe_candidates(candidates: &mut Vec<CompletionCandidate>) {
    let mut seen = std::collections::HashSet::new();
    candidates.retain(|candidate| {
        let key = (candidate.kind.clone(), candidate.label.clone());
        seen.insert(key)
    });
}

/// Return context-aware completion candidates for a file and cursor byte offset.
pub fn completion_candidates(
    file_type: FileType,
    content: &str,
    cursor_byte: usize,
) -> Vec<CompletionCandidate> {
    let Some(family) = family_for_file_type(file_type) else {
        return Vec::new();
    };
    let cursor = cursor_byte.min(content.len());
    let context = detect_cursor_context(file_type, content, cursor);
    let mut out = Vec::new();

    match context {
        CursorContext::Key => {
            for key in &family.keys {
                out.push(CompletionCandidate {
                    label: key.key.clone(),
                    insert_text: key_insert_text(file_type, &key.key),
                    detail: Some("Field".to_string()),
                    documentation: Some(key.docs.clone()),
                    kind: CompletionKind::Key,
                    rule_links: key.rules.clone(),
                });
            }

            for snippet in &family.snippets {
                out.push(CompletionCandidate {
                    label: snippet.label.clone(),
                    insert_text: snippet.body.clone(),
                    detail: Some("Snippet".to_string()),
                    documentation: snippet.docs.clone(),
                    kind: CompletionKind::Snippet,
                    rule_links: snippet.rules.clone(),
                });
            }
        }
        CursorContext::Value(key_name) => {
            if let Some(key) = family.keys.iter().find(|key| key.key == key_name) {
                for value in &key.values {
                    out.push(CompletionCandidate {
                        label: value.clone(),
                        insert_text: value_insert_text(file_type, value),
                        detail: Some("Allowed value".to_string()),
                        documentation: Some(key.docs.clone()),
                        kind: CompletionKind::Value,
                        rule_links: key.rules.clone(),
                    });
                }

                if let Some(snippet) = &key.snippet {
                    out.push(CompletionCandidate {
                        label: format!("{} example", key.key),
                        insert_text: snippet.clone(),
                        detail: Some("Snippet".to_string()),
                        documentation: Some(key.docs.clone()),
                        kind: CompletionKind::Snippet,
                        rule_links: key.rules.clone(),
                    });
                }
            }
        }
        CursorContext::Body => {
            for snippet in &family.snippets {
                out.push(CompletionCandidate {
                    label: snippet.label.clone(),
                    insert_text: snippet.body.clone(),
                    detail: Some("Snippet".to_string()),
                    documentation: snippet.docs.clone(),
                    kind: CompletionKind::Snippet,
                    rule_links: snippet.rules.clone(),
                });
            }
        }
    }

    dedupe_candidates(&mut out);
    out
}

/// Return hover documentation for a known field key in a given file type.
pub fn hover_doc(file_type: FileType, key: &str) -> Option<HoverDoc> {
    let family = family_for_file_type(file_type)?;
    let entry = family.keys.iter().find(|entry| entry.key == key)?;

    let rules = if entry.rules.is_empty() {
        String::new()
    } else {
        format!("\n\nRules: {}", entry.rules.join(", "))
    };

    Some(HoverDoc {
        markdown: format!("**{}**\n\n{}{}", entry.key, entry.docs, rules),
    })
}

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

    #[test]
    fn test_catalog_contains_core_families() {
        let family_ids: Vec<&str> = catalog().families.iter().map(|f| f.id.as_str()).collect();
        assert!(family_ids.contains(&"skill"));
        assert!(family_ids.contains(&"agent"));
        assert!(family_ids.contains(&"mcp"));
    }

    #[test]
    fn test_completion_skill_key_context() {
        let content = "---\nna\n---\n";
        let byte = content.find("na").expect("test content must contain 'na'");
        let candidates = completion_candidates(FileType::Skill, content, byte);
        assert!(candidates.iter().any(|c| c.label == "name"));
        assert!(
            candidates
                .iter()
                .any(|c| c.kind == CompletionKind::Snippet && c.label == "SKILL frontmatter")
        );
    }

    #[test]
    fn test_completion_skill_value_context() {
        let content = "---\nmodel: \n---\n";
        let byte = content
            .find("model: ")
            .expect("test content must contain 'model: '")
            + "model: ".len();
        let candidates = completion_candidates(FileType::Skill, content, byte);
        assert!(candidates.iter().any(|c| c.label == "sonnet"));
        assert!(candidates.iter().any(|c| c.label == "opus"));
    }

    #[test]
    fn test_completion_mcp_json_value_context_quotes_strings() {
        let content = "{\n  \"jsonrpc\": \n}";
        let byte = content
            .find("\"jsonrpc\": ")
            .expect("test content must contain '\"jsonrpc\": '")
            + "\"jsonrpc\": ".len();
        let candidates = completion_candidates(FileType::Mcp, content, byte);
        let version = candidates
            .iter()
            .find(|c| c.label == "2.0")
            .expect("2.0 completion should exist");
        assert_eq!(version.insert_text, "\"2.0\"");
    }

    #[test]
    fn test_completion_claude_md_body_snippets() {
        let content = "# Instructions\n";
        let candidates = completion_candidates(FileType::ClaudeMd, content, content.len());
        assert!(
            candidates
                .iter()
                .any(|c| c.label == "Project Context section")
        );
    }

    #[test]
    fn test_hover_doc_for_skill_model() {
        let hover = hover_doc(FileType::Skill, "model");
        assert!(hover.is_some());
        let markdown = hover.unwrap().markdown;
        assert!(markdown.contains("model"));
        assert!(markdown.contains("CC-SK-001"));
    }

    // ---- Value-context tests for non-skill families ----

    #[test]
    fn test_completion_agent_key_context() {
        // Agent files use YAML frontmatter like skills
        let content = "---\nmod\n---\n";
        let byte = content.find("mod").unwrap();
        let candidates = completion_candidates(FileType::Agent, content, byte);
        assert!(
            candidates.iter().any(|c| c.label == "model"),
            "Agent key completions should include 'model', got: {:?}",
            candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_completion_agent_value_context() {
        let content = "---\nmodel: \n---\n";
        let byte = content
            .find("model: ")
            .expect("test content must contain 'model: '")
            + "model: ".len();
        let candidates = completion_candidates(FileType::Agent, content, byte);
        assert!(
            candidates.iter().any(|c| c.label == "sonnet"),
            "Agent model values should include 'sonnet', got: {:?}",
            candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
        );
        assert!(
            candidates.iter().any(|c| c.label == "opus"),
            "Agent model values should include 'opus'"
        );
    }

    #[test]
    fn test_completion_kiro_agent_value_context() {
        let content = "{\n  \"model\": \n}";
        let byte = content
            .find("\"model\": ")
            .expect("test content must contain '\"model\": '")
            + "\"model\": ".len();
        let candidates = completion_candidates(FileType::KiroAgent, content, byte);
        let sonnet = candidates
            .iter()
            .find(|c| c.label == "sonnet")
            .expect("KiroAgent model values should include 'sonnet'");
        let opus = candidates
            .iter()
            .find(|c| c.label == "opus")
            .expect("KiroAgent model values should include 'opus'");

        assert_eq!(
            sonnet.insert_text, "\"sonnet\"",
            "KiroAgent value insert text should be JSON-quoted"
        );
        assert_eq!(
            opus.insert_text, "\"opus\"",
            "KiroAgent value insert text should be JSON-quoted"
        );
    }

    #[test]
    fn test_completion_kiro_agent_key_context_uses_json_key_insert() {
        let content = "{\n  \"mod\n}";
        let byte = content.find("\"mod").unwrap() + 1;
        let candidates = completion_candidates(FileType::KiroAgent, content, byte);
        let model = candidates
            .iter()
            .find(|c| c.label == "model")
            .expect("KiroAgent key completions should include 'model'");
        assert_eq!(
            model.insert_text, "\"model\": ",
            "KiroAgent key insert text should use JSON syntax"
        );
    }

    #[test]
    fn test_completion_hooks_key_context() {
        // Hooks files are JSON
        let content = "{\n  \"mat\n}";
        let byte = content.find("\"mat").unwrap() + 1; // after the opening quote
        let candidates = completion_candidates(FileType::Hooks, content, byte);
        assert!(
            candidates.iter().any(|c| c.label == "matcher"),
            "Hooks key completions should include 'matcher', got: {:?}",
            candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_completion_hooks_value_context() {
        let content = "{\n  \"type\": \n}";
        let byte = content.find("\"type\": ").unwrap() + "\"type\": ".len();
        let candidates = completion_candidates(FileType::Hooks, content, byte);
        assert!(
            candidates.iter().any(|c| c.label == "command"),
            "Hooks type values should include 'command', got: {:?}",
            candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_completion_kiro_hooks_value_context() {
        let content = "{\n  \"type\": \n}";
        let byte = content.find("\"type\": ").unwrap() + "\"type\": ".len();
        let candidates = completion_candidates(FileType::KiroHook, content, byte);
        assert!(
            candidates.iter().any(|c| c.label == "command"),
            "KiroHook type values should include 'command', got: {:?}",
            candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_completion_mcp_key_context() {
        let content = "{\n  \"json\n}";
        let byte = content.find("\"json").unwrap() + 1;
        let candidates = completion_candidates(FileType::Mcp, content, byte);
        assert!(
            candidates.iter().any(|c| c.label == "jsonrpc"),
            "MCP key completions should include 'jsonrpc', got: {:?}",
            candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_completion_kiro_mcp_key_context() {
        let content = "{\n  \"json\n}";
        let byte = content.find("\"json").unwrap() + 1;
        let candidates = completion_candidates(FileType::KiroMcp, content, byte);
        assert!(
            candidates.iter().any(|c| c.label == "jsonrpc"),
            "Kiro MCP key completions should include 'jsonrpc', got: {:?}",
            candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_completion_copilot_scoped_key_context() {
        // CopilotScoped uses YAML frontmatter
        let content = "---\napp\n---\n";
        let byte = content.find("app").unwrap();
        let candidates = completion_candidates(FileType::CopilotScoped, content, byte);
        assert!(
            candidates.iter().any(|c| c.label == "applyTo"),
            "Copilot scoped key completions should include 'applyTo', got: {:?}",
            candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_completion_cursor_rule_key_context() {
        // CursorRule uses YAML frontmatter with .mdc files
        let content = "---\ndesc\n---\n";
        let byte = content.find("desc").unwrap();
        let candidates = completion_candidates(FileType::CursorRule, content, byte);
        assert!(
            candidates.iter().any(|c| c.label == "description"),
            "Cursor rule key completions should include 'description', got: {:?}",
            candidates.iter().map(|c| &c.label).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_cursor_auxiliary_file_types_have_no_authoring_catalog_entries() {
        let content = "---\nname\n---\n";
        let cursor_byte = content.find("name").unwrap();

        assert!(
            completion_candidates(FileType::CursorHooks, content, cursor_byte).is_empty(),
            "CursorHooks should not expose catalog completions",
        );
        assert!(
            completion_candidates(FileType::CursorAgent, content, cursor_byte).is_empty(),
            "CursorAgent should not expose catalog completions",
        );
        assert!(
            completion_candidates(FileType::CursorEnvironment, content, cursor_byte).is_empty(),
            "CursorEnvironment should not expose catalog completions",
        );
        assert!(
            hover_doc(FileType::CursorHooks, "version").is_none(),
            "CursorHooks should not expose catalog hover docs",
        );
        assert!(
            hover_doc(FileType::CursorAgent, "name").is_none(),
            "CursorAgent should not expose catalog hover docs",
        );
        assert!(
            hover_doc(FileType::CursorEnvironment, "snapshot").is_none(),
            "CursorEnvironment should not expose catalog hover docs",
        );
    }

    #[test]
    fn test_hover_doc_for_agent_model() {
        let hover = hover_doc(FileType::Agent, "model");
        assert!(hover.is_some(), "Agent should have hover for 'model'");
        let markdown = hover.unwrap().markdown;
        assert!(markdown.contains("model"));
        assert!(markdown.contains("CC-AG-003"));
    }

    #[test]
    fn test_hover_doc_for_hooks_type() {
        let hover = hover_doc(FileType::Hooks, "type");
        assert!(hover.is_some(), "Hooks should have hover for 'type'");
        let markdown = hover.unwrap().markdown;
        assert!(markdown.contains("type"));
        assert!(markdown.contains("CC-HK-005"));
    }

    #[test]
    fn test_hover_doc_for_mcp_jsonrpc() {
        let hover = hover_doc(FileType::Mcp, "jsonrpc");
        assert!(hover.is_some(), "MCP should have hover for 'jsonrpc'");
        let markdown = hover.unwrap().markdown;
        assert!(markdown.contains("jsonrpc"));
        assert!(markdown.contains("MCP-001"));
    }

    #[test]
    fn test_hover_doc_for_kiro_mcp_jsonrpc() {
        let hover = hover_doc(FileType::KiroMcp, "jsonrpc");
        assert!(hover.is_some(), "KiroMcp should have hover for 'jsonrpc'");
        let markdown = hover.unwrap().markdown;
        assert!(markdown.contains("jsonrpc"));
        assert!(markdown.contains("MCP-001"));
    }

    #[test]
    fn test_hover_doc_for_copilot_applyto() {
        let hover = hover_doc(FileType::CopilotScoped, "applyTo");
        assert!(
            hover.is_some(),
            "CopilotScoped should have hover for 'applyTo'"
        );
        let markdown = hover.unwrap().markdown;
        assert!(markdown.contains("applyTo"));
    }

    #[test]
    fn test_hover_doc_for_cursor_description() {
        let hover = hover_doc(FileType::CursorRule, "description");
        assert!(
            hover.is_some(),
            "CursorRule should have hover for 'description'"
        );
        let markdown = hover.unwrap().markdown;
        assert!(markdown.contains("description"));
        assert!(markdown.contains("CUR-003"));
    }

    #[test]
    fn test_invalid_partial_content_falls_back_without_panic() {
        let content = "---\nmodel\n";
        let candidates = completion_candidates(FileType::Skill, content, content.len());
        assert!(
            !candidates.is_empty(),
            "partial/invalid content should still return fallback completions"
        );
    }
}
