//! Diagnostic emitted for files that aren't part of any crate.

use std::iter;

use hir::crate_def_map;
use hir::{EditionedFileId, InFile, ModuleSource};
use ide_db::text_edit::TextEdit;
use ide_db::{FileId, FileRange, base_db::SourceDatabase, source_change::SourceChange};
use ide_db::{base_db, line_index};
use paths::Utf8Component;
use syntax::{
    AstNode, Edition, TextRange,
    ast::{self, HasModuleItem, HasName, edit::IndentLevel},
    utils::{is_identifier, is_raw_identifier},
};

use crate::{Assist, Diagnostic, DiagnosticCode, DiagnosticsContext, Severity, fix};

// Diagnostic: unlinked-file
//
// This diagnostic is shown for files that are not included in any crate, or files that are part of
// crates rust-analyzer failed to discover. The file will not have IDE features available.
pub(crate) fn unlinked_file(
    ctx: &DiagnosticsContext<'_, '_>,
    acc: &mut Vec<Diagnostic>,
    editioned_file_id: EditionedFileId,
) {
    let file_id = editioned_file_id.file_id(ctx.sema.db);
    let mut range = TextRange::up_to(line_index(ctx.sema.db, file_id).len());
    let fixes = fixes(ctx, editioned_file_id, range);
    // FIXME: This is a hack for the vscode extension to notice whether there is an autofix or not before having to resolve diagnostics.
    // This is to prevent project linking popups from appearing when there is an autofix. https://github.com/rust-lang/rust-analyzer/issues/14523
    let message = if fixes.is_none() {
        "This file is not included in any crates, so rust-analyzer can't offer IDE services."
    } else {
        "This file is not included anywhere in the module tree, so rust-analyzer can't offer IDE services."
    };

    let message = format!(
        "{message}\n\nIf you're intentionally working on unowned files, you can silence this warning by adding \"unlinked-file\" to rust-analyzer.diagnostics.disabled in your settings."
    );

    let mut unused = true;

    if fixes.is_none() {
        // If we don't have a fix, the unlinked-file diagnostic is not
        // actionable. This generally means that rust-analyzer hasn't
        // finished startup, or we couldn't find the Cargo.toml.
        //
        // Only show this diagnostic on the first three characters of
        // the file, to avoid overwhelming the user during startup.
        range = SourceDatabase::file_text(ctx.sema.db, file_id)
            .text(ctx.sema.db)
            .char_indices()
            .take(3)
            .last()
            .map(|(i, _)| i)
            .map(|i| TextRange::up_to(i.try_into().unwrap()))
            .unwrap_or(range);
        // Prefer a diagnostic underline over graying out the text,
        // since we're only highlighting a small region.
        unused = false;
    }

    acc.push(
        Diagnostic::new(
            DiagnosticCode::Ra("unlinked-file", Severity::WeakWarning),
            message,
            FileRange { file_id, range },
        )
        .with_unused(unused)
        .stable()
        .with_fixes(fixes),
    );
}

fn fixes(
    ctx: &DiagnosticsContext<'_, '_>,
    editioned_file_id: EditionedFileId,
    trigger_range: TextRange,
) -> Option<Vec<Assist>> {
    // If there's an existing module that could add `mod` or `pub mod` items to include the unlinked file,
    // suggest that as a fix.

    let db = ctx.sema.db;
    let file_id = editioned_file_id.file_id(db);
    let edition = editioned_file_id.edition(db);

    let source_root = ctx.sema.db.file_source_root(file_id).source_root_id(db);
    let source_root = ctx.sema.db.source_root(source_root).source_root(db);

    let our_path = source_root.path_for_file(&file_id)?;
    let parent = our_path.parent()?;
    let (module_name, _) = our_path.name_and_extension()?;
    let (parent, module_name) = match module_name {
        // for mod.rs we need to actually look up one higher
        // and take the parent as our to be module name
        "mod" => {
            let (name, _) = parent.name_and_extension()?;
            (parent.parent()?, name.to_owned())
        }
        _ => (parent, module_name.to_owned()),
    };

    // check crate roots, i.e. main.rs, lib.rs, ...
    let relevant_crates = base_db::relevant_crates(db, file_id);
    'crates: for &krate in relevant_crates {
        // FIXME: This shouldnt need to access the crate def map directly
        let crate_def_map = crate_def_map(ctx.sema.db, krate);

        let root_module = &crate_def_map[crate_def_map.root_module_id()];
        let Some(root_file_id) = root_module.origin.file_id() else { continue };
        let Some(crate_root_path) = source_root.path_for_file(&root_file_id.file_id(ctx.sema.db))
        else {
            continue;
        };
        let Some(rel) = parent.strip_prefix(&crate_root_path.parent()?) else { continue };

        // try resolving the relative difference of the paths as inline modules
        let mut current = root_module;
        for ele in rel.as_utf8_path().components() {
            let seg = match ele {
                Utf8Component::Normal(seg) => seg,
                Utf8Component::RootDir => continue,
                // shouldn't occur
                _ => continue 'crates,
            };
            match current.children.iter().find(|(name, _)| name.as_str() == seg) {
                Some((_, &child)) => current = &crate_def_map[child],
                None => continue 'crates,
            }
            if !current.origin.is_inline() {
                continue 'crates;
            }
        }

        let InFile { file_id: parent_file_id, value: source } =
            current.definition_source(ctx.sema.db);
        let parent_file_id = parent_file_id.file_id()?;
        return make_fixes(
            parent_file_id.file_id(ctx.sema.db),
            source,
            edition,
            &module_name,
            trigger_range,
        );
    }

    // if we aren't adding to a crate root, walk backwards such that we support `#[path = ...]` overrides if possible

    // build all parent paths of the form `../module_name/mod.rs` and `../module_name.rs`
    let paths = iter::successors(Some(parent), |prev| prev.parent()).filter_map(|path| {
        let parent = path.parent()?;
        let (name, _) = path.name_and_extension()?;
        Some(([parent.join(&format!("{name}.rs"))?, path.join("mod.rs")?], name.to_owned()))
    });
    let mut stack = vec![];
    let &parent_id =
        paths.inspect(|(_, name)| stack.push(name.clone())).find_map(|(paths, _)| {
            paths.into_iter().find_map(|path| source_root.file_for_path(&path))
        })?;
    stack.pop();
    let relevant_crates = base_db::relevant_crates(db, parent_id);
    'crates: for &krate in relevant_crates.iter() {
        let crate_def_map = crate_def_map(ctx.sema.db, krate);
        let Some((_, module)) = crate_def_map.modules().find(|(_, module)| {
            module.origin.file_id().map(|file_id| file_id.file_id(ctx.sema.db)) == Some(parent_id)
                && !module.origin.is_inline()
        }) else {
            continue;
        };

        if stack.is_empty() {
            return make_fixes(
                parent_id,
                module.definition_source(ctx.sema.db).value,
                edition,
                &module_name,
                trigger_range,
            );
        } else {
            // direct parent file is missing,
            // try finding a parent that has an inline tree from here on
            let mut current = module;
            for s in stack.iter().rev() {
                match module.children.iter().find(|(name, _)| name.as_str() == s) {
                    Some((_, child)) => {
                        current = &crate_def_map[*child];
                    }
                    None => continue 'crates,
                }
                if !current.origin.is_inline() {
                    continue 'crates;
                }
            }
            let InFile { file_id: parent_file_id, value: source } =
                current.definition_source(ctx.sema.db);
            let parent_file_id = parent_file_id.file_id()?;
            return make_fixes(
                parent_file_id.file_id(ctx.sema.db),
                source,
                edition,
                &module_name,
                trigger_range,
            );
        }
    }

    None
}

/// Convert a module name along with its visibility to code.
/// In most cases, this just adds the visibility and keyword beforehand,
/// but the exceptions are non-identifiers and keywords.
fn format_mod_name(mod_name: &str, visibility_and_keyword: &str, edition: Edition) -> String {
    if is_identifier(mod_name, edition) {
        format!("{visibility_and_keyword} {mod_name};")
    } else {
        if is_raw_identifier(mod_name, edition) {
            format!("{visibility_and_keyword} r#{mod_name};")
        } else {
            let file_name = format!("{mod_name}.rs");
            format!("#[path = {file_name:?}]\n{visibility_and_keyword} mod_name;")
        }
    }
}

fn make_fixes(
    parent_file_id: FileId,
    source: ModuleSource,
    edition: Edition,
    new_mod_name: &str,
    trigger_range: TextRange,
) -> Option<Vec<Assist>> {
    fn is_outline_mod(item: &ast::Item) -> bool {
        matches!(item, ast::Item::Module(m) if m.item_list().is_none())
    }

    let mod_decl = format_mod_name(new_mod_name, "mod", edition);
    let pub_mod_decl = format_mod_name(new_mod_name, "pub mod", edition);
    let pub_crate_mod_decl = format_mod_name(new_mod_name, "pub(crate) mod", edition);

    let mut mod_decl_builder = TextEdit::builder();
    let mut pub_mod_decl_builder = TextEdit::builder();
    let mut pub_crate_mod_decl_builder = TextEdit::builder();

    let mut items = match &source {
        ModuleSource::SourceFile(it) => it.items(),
        ModuleSource::Module(it) => it.item_list()?.items(),
        ModuleSource::BlockExpr(_) => return None,
    };

    // If there's an existing `mod m;` statement matching the new one, don't emit a fix (it's
    // probably `#[cfg]`d out).
    for item in items.clone() {
        if let ast::Item::Module(m) = item
            && let Some(name) = m.name()
            && m.item_list().is_none()
            && name.to_string() == new_mod_name
        {
            cov_mark::hit!(unlinked_file_skip_fix_when_mod_already_exists);
            return None;
        }
    }

    // If there are existing `mod m;` items, append after them (after the first group of them, rather).
    match items.clone().skip_while(|item| !is_outline_mod(item)).take_while(is_outline_mod).last() {
        Some(last) => {
            cov_mark::hit!(unlinked_file_append_to_existing_mods);
            let offset = last.syntax().text_range().end();
            let indent = IndentLevel::from_node(last.syntax());
            mod_decl_builder.insert(offset, format!("\n{indent}{mod_decl}"));
            pub_mod_decl_builder.insert(offset, format!("\n{indent}{pub_mod_decl}"));
            pub_crate_mod_decl_builder.insert(offset, format!("\n{indent}{pub_crate_mod_decl}"));
        }
        None => {
            // Prepend before the first item in the file.
            match items.next() {
                Some(first) => {
                    cov_mark::hit!(unlinked_file_prepend_before_first_item);
                    let offset = first.syntax().text_range().start();
                    let indent = IndentLevel::from_node(first.syntax());
                    mod_decl_builder.insert(offset, format!("{mod_decl}\n\n{indent}"));
                    pub_mod_decl_builder.insert(offset, format!("{pub_mod_decl}\n\n{indent}"));
                    pub_crate_mod_decl_builder
                        .insert(offset, format!("{pub_crate_mod_decl}\n\n{indent}"));
                }
                None => {
                    // No items in the file, so just append at the end.
                    cov_mark::hit!(unlinked_file_empty_file);
                    let mut indent = IndentLevel::from(0);
                    let offset = match &source {
                        ModuleSource::SourceFile(it) => it.syntax().text_range().end(),
                        ModuleSource::Module(it) => {
                            indent = IndentLevel::from_node(it.syntax()) + 1;
                            it.item_list()?.r_curly_token()?.text_range().start()
                        }
                        ModuleSource::BlockExpr(it) => {
                            it.stmt_list()?.r_curly_token()?.text_range().start()
                        }
                    };
                    mod_decl_builder.insert(offset, format!("{indent}{mod_decl}\n"));
                    pub_mod_decl_builder.insert(offset, format!("{indent}{pub_mod_decl}\n"));
                    pub_crate_mod_decl_builder
                        .insert(offset, format!("{indent}{pub_crate_mod_decl}\n"));
                }
            }
        }
    }

    Some(vec![
        fix(
            "add_mod_declaration",
            &format!("Insert `{mod_decl}`"),
            SourceChange::from_text_edit(parent_file_id, mod_decl_builder.finish()),
            trigger_range,
        ),
        fix(
            "add_pub_mod_declaration",
            &format!("Insert `{pub_mod_decl}`"),
            SourceChange::from_text_edit(parent_file_id, pub_mod_decl_builder.finish()),
            trigger_range,
        ),
        fix(
            "add_pub_crate_mod_declaration",
            &format!("Insert `{pub_crate_mod_decl}`"),
            SourceChange::from_text_edit(parent_file_id, pub_crate_mod_decl_builder.finish()),
            trigger_range,
        ),
    ])
}

#[cfg(test)]
mod tests {
    use crate::tests::{check_diagnostics, check_fix, check_fixes, check_no_fix};

    #[test]
    fn unlinked_file_prepend_first_item() {
        cov_mark::check!(unlinked_file_prepend_before_first_item);
        // Only tests the first one for `pub mod` since the rest are the same
        check_fixes(
            r#"
//- /main.rs
fn f() {}
//- /foo.rs
$0
"#,
            vec![
                r#"
mod foo;

fn f() {}
"#,
                r#"
pub mod foo;

fn f() {}
"#,
                r#"
pub(crate) mod foo;

fn f() {}
"#,
            ],
        );
    }

    #[test]
    fn unlinked_file_append_mod() {
        cov_mark::check!(unlinked_file_append_to_existing_mods);
        check_fix(
            r#"
//- /main.rs
//! Comment on top

mod preexisting;

mod preexisting2;

struct S;

mod preexisting_bottom;)
//- /foo.rs
$0
"#,
            r#"
//! Comment on top

mod preexisting;

mod preexisting2;
mod foo;

struct S;

mod preexisting_bottom;)
"#,
        );
    }

    #[test]
    fn unlinked_file_insert_in_empty_file() {
        cov_mark::check!(unlinked_file_empty_file);
        check_fix(
            r#"
//- /main.rs
//- /foo.rs
$0
"#,
            r#"
mod foo;
"#,
        );
    }

    #[test]
    fn unlinked_file_insert_in_empty_file_mod_file() {
        check_fix(
            r#"
//- /main.rs
//- /foo/mod.rs
$0
"#,
            r#"
mod foo;
"#,
        );
        check_fix(
            r#"
//- /main.rs
mod bar;
//- /bar.rs
// bar module
//- /bar/foo/mod.rs
$0
"#,
            r#"
// bar module
mod foo;
"#,
        );
    }

    #[test]
    fn unlinked_file_old_style_modrs() {
        check_fix(
            r#"
//- /main.rs
mod submod;
//- /submod/mod.rs
// in mod.rs
//- /submod/foo.rs
$0
"#,
            r#"
// in mod.rs
mod foo;
"#,
        );
    }

    #[test]
    fn unlinked_file_new_style_mod() {
        check_fix(
            r#"
//- /main.rs
mod submod;
//- /submod.rs
//- /submod/foo.rs
$0
"#,
            r#"
mod foo;
"#,
        );
    }

    #[test]
    fn unlinked_file_with_cfg_off() {
        cov_mark::check!(unlinked_file_skip_fix_when_mod_already_exists);
        check_no_fix(
            r#"
//- /main.rs
#[cfg(never)]
mod foo;

//- /foo.rs
$0
"#,
        );
    }

    #[test]
    fn unlinked_file_with_cfg_on() {
        check_diagnostics(
            r#"
//- /main.rs
#[cfg(not(never))]
mod foo;

//- /foo.rs
"#,
        );
    }

    #[test]
    fn unlinked_file_insert_into_inline_simple() {
        check_fix(
            r#"
//- /main.rs
mod bar;
//- /bar.rs
mod foo {
}
//- /bar/foo/baz.rs
$0
"#,
            r#"
mod foo {
    mod baz;
}
"#,
        );
    }

    #[test]
    fn unlinked_file_insert_into_inline_simple_modrs() {
        check_fix(
            r#"
//- /main.rs
mod bar;
//- /bar.rs
mod baz {
}
//- /bar/baz/foo/mod.rs
$0
"#,
            r#"
mod baz {
    mod foo;
}
"#,
        );
    }

    #[test]
    fn unlinked_file_insert_into_inline_simple_modrs_main() {
        check_fix(
            r#"
//- /main.rs
mod bar {
}
//- /bar/foo/mod.rs
$0
"#,
            r#"
mod bar {
    mod foo;
}
"#,
        );
    }

    #[test]
    fn include_macro_works() {
        check_diagnostics(
            r#"
//- minicore: include
//- /main.rs
include!("bar/foo/mod.rs");
//- /bar/foo/mod.rs
"#,
        );
    }

    #[test]
    fn unlinked_file_with_strict_keyword_move() {
        check_fix(
            r#"
//- /main.rs
//- /move.rs
$0
"#,
            r#"
mod r#move;
"#,
        );
    }

    #[test]
    fn unlinked_file_with_weak_keyword_safe() {
        check_fix(
            r#"
//- /main.rs
//- /safe.rs
$0
"#,
            r#"
mod safe;
"#,
        );
    }

    #[test]
    fn unlinked_file_with_reserved_keyword_abstract() {
        check_fix(
            r#"
//- /main.rs
//- /abstract.rs
$0
"#,
            r#"
mod r#abstract;
"#,
        );
    }

    #[test]
    fn unlinked_file_with_unescaped_keyword_crate() {
        check_fix(
            r#"
//- /main.rs
//- /crate.rs
$0
"#,
            r#"#[path = "crate.rs"]
mod mod_name;
"#,
        );
    }

    #[test]
    fn unlinked_invalid_symbol_in_module_name() {
        check_fix(
            r#"
//- /main.rs
//- /my-file.rs
$0
"#,
            r#"#[path = "my-file.rs"]
mod mod_name;
"#,
        );
    }

    #[test]
    fn unlinked_numeric_module_name() {
        check_fix(
            r#"
//- /main.rs
//- /0000.rs
$0
"#,
            r#"#[path = "0000.rs"]
mod mod_name;
"#,
        );
    }
}
