//
// comment.rs
//
// Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved.
//
//

use std::sync::LazyLock;

#[cfg(test)]
use oak_db::OakDatabase;
use oak_db::RootKind;
use regex::Regex;
use tower_lsp_server::ls_types::CompletionItem;
use tower_lsp_server::ls_types::Documentation;
use tower_lsp_server::ls_types::InsertTextFormat;
use tower_lsp_server::ls_types::MarkupContent;
use tower_lsp_server::ls_types::MarkupKind;
use yaml_rust2::YamlLoader;

use crate::lsp::completions::completion_context::CompletionContext;
use crate::lsp::completions::completion_item::completion_item;
use crate::lsp::completions::sources::CompletionSource;
use crate::lsp::completions::types::CompletionData;
use crate::lsp::db::ArkDb;
use crate::lsp::document_context::DocumentContext;
#[cfg(test)]
use crate::lsp::document_context::TestDocument;
use crate::lsp::traits::node::NodeExt;
use crate::treesitter::NodeTypeExt;

pub(super) struct CommentSource;

impl CompletionSource for CommentSource {
    fn name(&self) -> &'static str {
        "comment"
    }

    fn provide_completions(
        &self,
        completion_context: &CompletionContext,
    ) -> anyhow::Result<Option<Vec<CompletionItem>>> {
        completions_from_comment(
            completion_context.document_context,
            completion_context.state.db(),
        )
    }
}

// Strip everything up to and including the last whitespace
static RE_UP_TO_LAST_WHITESPACE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^.*\s").unwrap());

fn completions_from_comment(
    context: &DocumentContext,
    db: &dyn ArkDb,
) -> anyhow::Result<Option<Vec<CompletionItem>>> {
    let node = context.node;

    if !node.is_comment() {
        return Ok(None);
    }

    let contents = node.node_as_str(context.contents)?;
    let token = RE_UP_TO_LAST_WHITESPACE.replace(contents, "");

    let mut completions: Vec<CompletionItem> = vec![];

    if !token.starts_with('@') {
        // We are done, there are no completions, but we are in a comment so
        // no one else should get a chance to register anything
        return Ok(Some(completions));
    }

    let Some(roxygen2) = db.package_by_name("roxygen2") else {
        return Ok(Some(completions));
    };

    let Some(root) = db.root_by_package(roxygen2) else {
        return Ok(Some(completions));
    };

    let Some(dir) = roxygen2
        .description_path(db)
        .as_path()
        .and_then(|path| path.parent())
    else {
        return Ok(Some(completions));
    };

    // When you're actively working on roxygen2 itself, pull the tags from `inst/`
    let tags = match root.kind(db) {
        RootKind::Workspace => dir.join("inst").join("roxygen2-tags.yml"),
        RootKind::Library => dir.join("roxygen2-tags.yml"),
    };

    if !tags.exists() {
        return Ok(Some(completions));
    }

    // TODO: Cache these?
    let contents = std::fs::read_to_string(tags).unwrap();
    let docs = YamlLoader::load_from_str(contents.as_str()).unwrap();
    let doc = &docs[0];

    let items = doc.as_vec().unwrap();
    for entry in items.iter() {
        let Some(name) = entry["name"].as_str() else {
            continue;
        };

        let template = entry["template"].as_str();
        let template = template.map(inject_roxygen_comment_after_newline);
        let template = template.as_deref();

        let description = entry["description"].as_str();

        let item = completion_item_from_roxygen(name, template, description)?;

        completions.push(item);
    }

    Ok(Some(completions))
}

fn completion_item_from_roxygen(
    name: &str,
    template: Option<&str>,
    description: Option<&str>,
) -> anyhow::Result<CompletionItem> {
    let label = name.to_string();

    let mut item = completion_item(label.clone(), CompletionData::RoxygenTag {
        tag: label.clone(),
    })?;

    // TODO: What is the appropriate icon for us to use here?
    if let Some(template) = template {
        item.insert_text_format = Some(InsertTextFormat::SNIPPET);
        item.insert_text = Some(format!("{name}{template}"));
    } else {
        item.insert_text = Some(label.to_string());
    }

    item.detail = Some(format!("roxygen @{} (R)", name));
    if let Some(description) = description {
        let markup = MarkupContent {
            kind: MarkupKind::Markdown,
            value: description.to_string(),
        };
        item.documentation = Some(Documentation::MarkupContent(markup));
    }

    Ok(item)
}

fn inject_roxygen_comment_after_newline(x: &str) -> String {
    x.replace("\n", "\n#' ")
}

#[test]
fn test_comment() {
    use tree_sitter::Point;

    let db = OakDatabase::new();

    // If not in a comment, return `None`
    let point = Point { row: 0, column: 1 };
    let doc = TestDocument::new("mean()");
    let context = doc.context(point);
    let completions = completions_from_comment(&context, &db).unwrap();
    assert!(completions.is_none());

    // If in a comment, return empty vector
    let point = Point { row: 0, column: 1 };
    let doc = TestDocument::new("# mean");
    let context = doc.context(point);
    let completions = completions_from_comment(&context, &db).unwrap().unwrap();
    assert!(completions.is_empty());
}

#[test]
fn test_roxygen_comment() {
    use oak_scan::DbScan;
    use tempfile::TempDir;
    use tree_sitter::Point;

    // Straight from https://github.com/r-lib/roxygen2/blob/main/inst/roxygen2-tags.yml
    let content = r#"
- name: aliases
  description: >
    Add additional aliases to the topic.

    Use `NULL` to suppress the default alias automatically generated by roxygen2.
  template: ' ${1:alias}'
  vignette: index-crossref
  recommend: true

- name: description
  description: >
    A short description of the purpose of the function. Usually around
    a paragraph, but can be longer if needed.
  template: "\n${1:A short description...}\n"
  vignette: rd-functions
  recommend: true
"#;

    // Lay out an installed `roxygen2` package in a library directory, with the
    // tags file beside its `DESCRIPTION` for the completion source to find.
    let library = TempDir::new().unwrap();
    let package_dir = library.path().join("roxygen2");
    std::fs::create_dir(&package_dir).unwrap();
    std::fs::write(package_dir.join("DESCRIPTION"), "Package: roxygen2\n").unwrap();
    std::fs::write(package_dir.join("roxygen2-tags.yml"), content).unwrap();

    let mut db = OakDatabase::new();
    db.set_library_paths(&[library.path().to_path_buf()]);

    let point = Point { row: 0, column: 4 };
    let doc = TestDocument::new("#' @");
    let context = doc.context(point);
    let completions = completions_from_comment(&context, &db).unwrap().unwrap();

    // Make sure we find it
    let aliases: Vec<&CompletionItem> = completions
        .iter()
        .filter(|item| item.label == "aliases")
        .collect();
    assert_eq!(aliases.len(), 1);

    // Replace `\n` with `\n#' ` since we are directly injecting into the
    // document with no allowance for context specific rules to kick in
    // and automatically add the leading comment for us.
    let description: Vec<&CompletionItem> = completions
        .iter()
        .filter(|item| item.label == "description")
        .collect();
    let description = description.first().unwrap();
    assert_eq!(
        description.insert_text,
        Some(String::from(
            "description\n#' ${1:A short description...}\n#' "
        ))
    );
}

#[test]
fn test_roxygen_comment_while_working_on_roxygen2() {
    use std::collections::HashSet;

    use oak_scan::ScanScheduler;
    use tempfile::TempDir;
    use tree_sitter::Point;

    let content = r#"
- name: aliases
  description: Add additional aliases to the topic.
"#;

    // Lay out `roxygen2` as a package in the workspace. When you're actively
    // working on roxygen2 itself, the tags file lives under `inst/`, only
    // landing beside `DESCRIPTION` once the package is installed.
    let workspace = TempDir::new().unwrap();
    std::fs::write(
        workspace.path().join("DESCRIPTION"),
        "Package: roxygen2\nVersion: 1.0.0\n",
    )
    .unwrap();
    std::fs::create_dir(workspace.path().join("inst")).unwrap();
    std::fs::write(
        workspace.path().join("inst").join("roxygen2-tags.yml"),
        content,
    )
    .unwrap();

    let mut db = OakDatabase::new();

    // Drive the workspace scanner to quiescence so `roxygen2` registers as a
    // workspace package
    let mut scheduler = ScanScheduler::new();
    let editor_owned = HashSet::new();
    let mut requests =
        scheduler.set_workspace_paths(&mut db, &[workspace.path().to_path_buf()], &editor_owned);
    while let Some(request) = requests.pop() {
        let completed = request.run();
        requests.extend(scheduler.apply_scan_completed(&mut db, completed, &editor_owned));
    }

    let point = Point { row: 0, column: 4 };
    let doc = TestDocument::new("#' @");
    let context = doc.context(point);
    let completions = completions_from_comment(&context, &db).unwrap().unwrap();

    // The tags came from `inst/roxygen2-tags.yml`
    let aliases: Vec<&CompletionItem> = completions
        .iter()
        .filter(|item| item.label == "aliases")
        .collect();
    assert_eq!(aliases.len(), 1);
}

#[test]
fn test_roxygen_completion_item() {
    let name = "aliases";
    let template = " ${1:alias}";
    let description = "Add additional aliases to the topic.";

    // With all optional details
    let item = completion_item_from_roxygen(name, Some(template), Some(description)).unwrap();
    assert_eq!(item.label, name);
    assert_eq!(item.detail, Some("roxygen @aliases (R)".to_string()));
    assert_eq!(item.insert_text, Some("aliases ${1:alias}".to_string()));

    let markup = Documentation::MarkupContent(MarkupContent {
        kind: MarkupKind::Markdown,
        value: description.to_string(),
    });
    assert_eq!(item.documentation, Some(markup));

    // Without optional details
    let name = "export";
    let item = completion_item_from_roxygen(name, None, None).unwrap();
    assert_eq!(item.label, name);
    assert_eq!(item.insert_text, Some("export".to_string()));
    assert_eq!(item.documentation, None);
}
