//! Completion: one snippet item for the whole abbreviation, plus
//! trigger-prefix discovery items for the segment being typed.

use codemmet_core::Engine;
use codemmet_core::context::{ContextProvider, DocSnapshot, ExpansionContext};
use codemmet_core::lang::registry::LanguageSnippets;
use codemmet_core::snippet::render::TabstopMode;
use lsp_types::{
    CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionTextEdit,
    Documentation, InsertTextFormat, MarkupContent, MarkupKind, Range, TextEdit,
};

use super::line_index::{LineIndex, PositionEncoding};
use super::{CompletionMode, extract};

/// Maximum number of trigger-prefix discovery items offered.
const MAX_PREFIX_ITEMS: usize = 50;

/// Compute completion items for `offset` in `text`, or `None` for a null
/// response (no candidate, gated off, unknown language, or a cursor inside
/// a string literal or comment).
///
/// Takes the caller's already-built `index` so the request indexes the
/// document only once (the caller needs it to resolve the cursor `offset`).
// The document view (index, text, encoding) plus cursor and gating naturally
// exceed the arg-count lint; bundling them would obscure more than it helps.
#[allow(clippy::too_many_arguments)]
pub fn compute(
    engine: &Engine,
    provider: &mut dyn ContextProvider,
    index: &LineIndex,
    text: &str,
    language_id: &str,
    offset: usize,
    mode: CompletionMode,
    encoding: PositionEncoding,
) -> Option<Vec<CompletionItem>> {
    if mode == CompletionMode::Off {
        return None;
    }
    let lang = engine.registry().language(language_id)?;
    let (start, candidate) = extract::candidate_at(text, offset)?;
    if mode == CompletionMode::OperatorsOnly && !extract::has_operator(candidate) {
        return None;
    }
    let doc = DocSnapshot {
        language: lang.profile.id.to_owned(),
        text: text.to_owned(),
    };
    let mut ctx = provider.context_at(&doc, offset);
    if ctx.in_string_or_comment {
        // No items at all inside strings/comments, prefix discovery included.
        return None;
    }
    // LSP snippet clients (VS Code, Neovim's `vim.snippet`) re-indent snippet
    // continuation lines from the insertion position, so a server-side base
    // indent would be applied twice. Render with an empty base indent;
    // nesting depth still uses the provider's indent unit.
    ctx.base_indent = String::new();

    let mut items = Vec::new();
    let range = Range {
        start: index.position(text, start, encoding),
        end: index.position(text, offset, encoding),
    };
    if let Some((mut item, expansion_requires)) =
        expansion_item(engine, lang, candidate, range, &ctx)
    {
        // Missing `requires` imports ride along as `additionalTextEdits`:
        // the standard auto-import mechanism, applied atomically with the
        // main edit by any LSP client. A provider without syntax awareness
        // returns no scan and the requires are skipped, never guessed.
        if !expansion_requires.is_empty()
            && let Some(scan) = provider.import_scan(&doc)
            && let Some(edit) = super::requires::import_edit(
                &doc.language,
                &expansion_requires,
                &scan,
                index,
                text,
                encoding,
                range.start,
            )
        {
            item.additional_text_edits = Some(vec![edit]);
        }
        items.push(item);
    }
    items.extend(option_items(lang, candidate));
    items.extend(prefix_items(lang, candidate));

    if items.is_empty() { None } else { Some(items) }
}

/// Named-option items when the cursor is positioned to type an **option name**
/// for a composite — i.e. the current node's trigger is a composite and the
/// argument being typed is past the declared parameters (`classp:Point,‸`).
///
/// Options that are already selected are omitted, and the list is filtered by
/// the partial name being typed. Like [`prefix_items`], these are plain word
/// items that the client completes against the typed prefix.
fn option_items(lang: &LanguageSnippets, candidate: &str) -> Vec<CompletionItem> {
    let Some(node) = extract::current_node_args(candidate) else {
        return Vec::new();
    };
    // No argument introduced yet: still typing the trigger itself.
    if node.args.is_empty() {
        return Vec::new();
    }
    let Some((snippet, remainder)) = lang.set.resolve_word(node.trigger) else {
        return Vec::new();
    };
    let Some(composite) = &snippet.composite else {
        return Vec::new();
    };
    // A glued argument (`classpWidget`) consumes one parameter without an
    // explicit piece, so it lowers how many explicit args are still params.
    let param_pieces = snippet
        .params
        .len()
        .saturating_sub(usize::from(!remainder.is_empty()));
    // The last piece is the partial being typed; earlier ones are committed.
    let partial = *node.args.last().unwrap_or(&"");
    let partial_index = node.args.len() - 1;
    if partial_index < param_pieces {
        // Still filling positional parameters, not selecting an option.
        return Vec::new();
    }
    let already: std::collections::HashSet<&str> = node.args[param_pieces..node.args.len() - 1]
        .iter()
        .copied()
        .collect();

    composite
        .options
        .iter()
        .filter(|(name, _)| name.starts_with(partial) && !already.contains(name.as_str()))
        .map(|(name, fragment)| CompletionItem {
            label: name.clone(),
            kind: Some(CompletionItemKind::ENUM_MEMBER),
            detail: Some(fragment.clone()),
            label_details: Some(CompletionItemLabelDetails {
                detail: None,
                description: Some(format!("{} option", snippet.trigger)),
            }),
            sort_text: Some(format!("0{name}")),
            filter_text: Some(name.clone()),
            ..CompletionItem::default()
        })
        .collect()
}

/// The single whole-abbreviation snippet item, when `candidate` renders,
/// paired with the expansion's collected `requires` specs (the caller
/// attaches any missing-import edit).
///
/// The engine's `expand` already parses and resolves the whole abbreviation,
/// returning an error for anything unrenderable, so the `.ok()?` gate is the
/// only check needed — a separate pre-parse would only duplicate (and risk
/// drifting from) that logic. The item renders exactly once, in [`Keep`]
/// mode; the label detail reuses the leading trigger's own description rather
/// than a second [`Strip`] render.
///
/// [`Keep`]: TabstopMode::Keep
/// [`Strip`]: TabstopMode::Strip
fn expansion_item(
    engine: &Engine,
    lang: &LanguageSnippets,
    candidate: &str,
    range: Range,
    ctx: &ExpansionContext,
) -> Option<(CompletionItem, Vec<String>)> {
    let keep = engine
        .expand(candidate, lang.profile.id, ctx, TabstopMode::Keep)
        .ok()?;
    // Describe the item with the leading trigger's human description.
    let description = lang
        .set
        .resolve_word(candidate)
        .and_then(|(snippet, _)| snippet.description.clone());

    let item = CompletionItem {
        label: candidate.to_owned(),
        label_details: Some(CompletionItemLabelDetails {
            detail: None,
            description,
        }),
        kind: Some(CompletionItemKind::SNIPPET),
        documentation: Some(Documentation::MarkupContent(MarkupContent {
            kind: MarkupKind::Markdown,
            value: format!("```{}\n{}\n```", lang.profile.id, keep.body),
        })),
        sort_text: Some(format!("0{candidate}")),
        filter_text: Some(candidate.to_owned()),
        insert_text_format: Some(InsertTextFormat::SNIPPET),
        text_edit: Some(CompletionTextEdit::Edit(TextEdit {
            range,
            new_text: keep.body,
        })),
        ..CompletionItem::default()
    };
    Some((item, keep.requires))
}

/// Plain word items for registered triggers the final segment is a prefix of.
fn prefix_items(lang: &LanguageSnippets, candidate: &str) -> Vec<CompletionItem> {
    let Some(segment) = extract::final_segment(candidate) else {
        return Vec::new();
    };
    lang.set
        .iter()
        .filter(|snippet| snippet.trigger.starts_with(segment))
        .take(MAX_PREFIX_ITEMS)
        .map(|snippet| CompletionItem {
            label: snippet.trigger.clone(),
            kind: Some(CompletionItemKind::KEYWORD),
            detail: snippet.description.clone(),
            sort_text: Some(format!("1{}", snippet.trigger)),
            ..CompletionItem::default()
        })
        .collect()
}

#[cfg(test)]
// Expected texts like `${1:i}` are LSP snippet syntax, not format strings.
#[allow(clippy::literal_string_with_formatting_args)]
mod tests {
    use codemmet_core::context::{NullContextProvider, WrapSelection};
    use codemmet_core::lang::registry::Registry;
    use lsp_types::Position;

    use super::*;

    fn engine() -> Engine {
        Engine::with_registry(Registry::with_builtins().expect("builtins should load"))
    }

    fn complete(text: &str, language: &str, mode: CompletionMode) -> Option<Vec<CompletionItem>> {
        compute(
            &engine(),
            &mut NullContextProvider,
            &LineIndex::new(text),
            text,
            language,
            text.len(),
            mode,
            PositionEncoding::Utf8,
        )
    }

    /// A provider that always reports a string/comment position.
    struct InStringProvider;

    impl ContextProvider for InStringProvider {
        fn context_at(&mut self, doc: &DocSnapshot, offset: usize) -> ExpansionContext {
            let mut ctx = NullContextProvider.context_at(doc, offset);
            ctx.in_string_or_comment = true;
            ctx
        }

        fn validate_wrap(
            &mut self,
            _doc: &DocSnapshot,
            range: std::ops::Range<usize>,
        ) -> WrapSelection {
            WrapSelection::Valid { range }
        }
    }

    #[test]
    fn full_abbreviation_yields_one_snippet_item() {
        let text = "fn main() {\n    fori10";
        let items = complete(text, "rust", CompletionMode::Always).expect("items expected");
        let snippets: Vec<_> = items
            .iter()
            .filter(|i| i.kind == Some(CompletionItemKind::SNIPPET))
            .collect();
        assert_eq!(snippets.len(), 1);
        let item = snippets[0];
        assert_eq!(item.label, "fori10");
        assert_eq!(item.sort_text.as_deref(), Some("0fori10"));
        // The label detail is the leading trigger's own description.
        assert_eq!(
            item.label_details
                .as_ref()
                .and_then(|d| d.description.as_deref()),
            Some("for loop over integer range")
        );
        let Some(CompletionTextEdit::Edit(edit)) = &item.text_edit else {
            panic!("expected a plain text edit");
        };
        assert_eq!(edit.range.start, Position::new(1, 4));
        assert_eq!(edit.range.end, Position::new(1, 10));
        // Continuation lines carry only relative depth: the snippet client
        // re-indents them from the insertion position, so no base indent.
        assert_eq!(edit.new_text, "for ${1:i} in 0..10 {\n    $0\n}");
    }

    #[test]
    fn prefix_discovery_lists_triggers() {
        let items = complete("fo", "rust", CompletionMode::Always).expect("items expected");
        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
        assert!(labels.contains(&"fori"), "labels: {labels:?}");
        assert!(labels.contains(&"fore"), "labels: {labels:?}");
        assert!(
            items
                .iter()
                .all(|i| i.kind == Some(CompletionItemKind::KEYWORD))
        );
    }

    fn option_labels(text: &str) -> Vec<String> {
        complete(text, "python", CompletionMode::Always)
            .unwrap_or_default()
            .into_iter()
            .filter(|i| i.kind == Some(CompletionItemKind::ENUM_MEMBER))
            .map(|i| i.label)
            .collect()
    }

    #[test]
    fn composite_options_completed_after_the_comma() {
        // `classp` (one `name` param) ships options eq/hash/len; after the
        // param, a comma offers them as ENUM_MEMBER items.
        let all = option_labels("classp:Point,");
        assert!(all.contains(&"eq".to_owned()), "{all:?}");
        assert!(all.contains(&"hash".to_owned()), "{all:?}");
        // Filtered by the partial name being typed.
        assert_eq!(option_labels("classp:Point,ha"), vec!["hash".to_owned()]);
        // An already-selected option is not offered again.
        let rest = option_labels("classp:Point,eq,");
        assert!(!rest.contains(&"eq".to_owned()), "{rest:?}");
        assert!(rest.contains(&"hash".to_owned()), "{rest:?}");
    }

    #[test]
    fn no_option_items_while_typing_the_parameter() {
        // The first arg fills `name`, so no options yet.
        assert!(option_labels("classp:Point").is_empty());
        assert!(option_labels("classp").is_empty());
        // A non-composite trigger never offers options.
        assert!(option_labels("def:foo,").is_empty());
    }

    #[test]
    fn unknown_language_and_empty_candidate_yield_none() {
        assert!(complete("fori10", "cobol", CompletionMode::Always).is_none());
        assert!(complete("   ", "rust", CompletionMode::Always).is_none());
        assert!(complete("zzzz", "rust", CompletionMode::Always).is_none());
    }

    #[test]
    fn operators_only_gates_bare_words() {
        assert!(complete("fori10", "rust", CompletionMode::OperatorsOnly).is_none());
        let items =
            complete("fn:main>pr", "rust", CompletionMode::OperatorsOnly).expect("items expected");
        assert!(
            items
                .iter()
                .any(|i| i.kind == Some(CompletionItemKind::SNIPPET))
        );
    }

    #[test]
    fn off_mode_yields_none() {
        assert!(complete("fori10", "rust", CompletionMode::Off).is_none());
    }

    #[test]
    fn string_or_comment_context_suppresses_all_items() {
        // `fori10` alone would yield a snippet item and prefix discovery;
        // a provider reporting a string/comment position suppresses both.
        let items = compute(
            &engine(),
            &mut InStringProvider,
            &LineIndex::new("fori10"),
            "fori10",
            "rust",
            6,
            CompletionMode::Always,
            PositionEncoding::Utf8,
        );
        assert!(items.is_none());
    }

    #[test]
    fn tab_indented_line_uses_tab_unit() {
        let text = "\tfori10>pr";
        let items = complete(text, "rust", CompletionMode::Always).expect("items expected");
        let snippet = items
            .iter()
            .find(|i| i.kind == Some(CompletionItemKind::SNIPPET))
            .expect("snippet item expected");
        let Some(CompletionTextEdit::Edit(edit)) = &snippet.text_edit else {
            panic!("expected a plain text edit");
        };
        // One tab of relative depth; the cursor line's own tab is not baked in.
        assert!(edit.new_text.contains("\n\tprintln!"), "{}", edit.new_text);
        assert!(!edit.new_text.contains("\n\t\t"), "{}", edit.new_text);
    }

    #[test]
    fn glued_arg_that_cannot_bind_offers_no_snippet_item() {
        // `pr10` resolves (`pr` + glued `10`) but has no int param to bind.
        let items = complete("pr10", "rust", CompletionMode::Always);
        assert!(
            items
                .into_iter()
                .flatten()
                .all(|i| i.kind != Some(CompletionItemKind::SNIPPET))
        );
    }
}
