//! Discovers tests

use hir::{Crate, Module, ModuleDef, Semantics};
use ide_db::base_db::{self, all_crates};
use ide_db::{FileId, RootDatabase};
use syntax::TextRange;

use crate::{NavigationTarget, Runnable, TryToNav, runnables::runnable_fn};

#[derive(Debug)]
pub enum TestItemKind {
    Crate(base_db::Crate),
    Module,
    Function,
}

#[derive(Debug)]
pub struct TestItem {
    pub id: String,
    pub kind: TestItemKind,
    pub label: String,
    pub parent: Option<String>,
    pub file: Option<FileId>,
    pub text_range: Option<TextRange>,
    pub runnable: Option<Runnable>,
}

pub(crate) fn discover_test_roots(db: &RootDatabase) -> Vec<TestItem> {
    all_crates(db)
        .iter()
        .copied()
        .filter(|&id| id.data(db).origin.is_local())
        .filter_map(|id| {
            let test_id = id.extra_data(db).display_name.as_ref()?.to_string();
            Some(TestItem {
                kind: TestItemKind::Crate(id),
                label: test_id.clone(),
                id: test_id,
                parent: None,
                file: None,
                text_range: None,
                runnable: None,
            })
        })
        .collect()
}

fn find_crate_by_id(db: &RootDatabase, crate_id: &str) -> Option<base_db::Crate> {
    // here, we use display_name as the crate id. This is not super ideal, but it works since we
    // only show tests for the local crates.
    all_crates(db).iter().copied().find(|&id| {
        id.data(db).origin.is_local()
            && id.extra_data(db).display_name.as_ref().is_some_and(|x| x.to_string() == crate_id)
    })
}

fn discover_tests_in_module(
    db: &RootDatabase,
    module: Module,
    prefix_id: String,
    only_in_this_file: bool,
) -> Vec<TestItem> {
    let sema = Semantics::new(db);

    let mut r = vec![];
    for c in module.children(db) {
        let module_name = c
            .name(db)
            .as_ref()
            .map(|n| n.as_str().to_owned())
            .unwrap_or_else(|| "[mod without name]".to_owned());
        let module_id = format!("{prefix_id}::{module_name}");
        let module_children = discover_tests_in_module(db, c, module_id.clone(), only_in_this_file);
        if !module_children.is_empty() {
            let nav = NavigationTarget::from_module_to_decl(sema.db, c).call_site;
            r.push(TestItem {
                id: module_id,
                kind: TestItemKind::Module,
                label: module_name,
                parent: Some(prefix_id.clone()),
                file: Some(nav.file_id),
                text_range: Some(nav.focus_or_full_range()),
                runnable: None,
            });
            if !only_in_this_file || c.is_inline(db) {
                r.extend(module_children);
            }
        }
    }
    for def in module.declarations(db) {
        let ModuleDef::Function(f) = def else {
            continue;
        };
        if !f.is_test(db) {
            continue;
        }
        let nav = f.try_to_nav(&sema).map(|r| r.call_site);
        let fn_name = f.name(db).as_str().to_owned();
        r.push(TestItem {
            id: format!("{prefix_id}::{fn_name}"),
            kind: TestItemKind::Function,
            label: fn_name,
            parent: Some(prefix_id.clone()),
            file: nav.as_ref().map(|n| n.file_id),
            text_range: nav.as_ref().map(|n| n.focus_or_full_range()),
            runnable: runnable_fn(&sema, f),
        });
    }
    r
}

pub(crate) fn discover_tests_in_crate_by_test_id(
    db: &RootDatabase,
    crate_test_id: &str,
) -> Vec<TestItem> {
    let Some(crate_id) = find_crate_by_id(db, crate_test_id) else {
        return vec![];
    };
    discover_tests_in_crate(db, crate_id)
}

pub(crate) fn discover_tests_in_file(db: &RootDatabase, file_id: FileId) -> Vec<TestItem> {
    let sema = Semantics::new(db);

    let Some(module) = sema.file_to_module_def(file_id) else { return vec![] };
    let Some((mut tests, id)) = find_module_id_and_test_parents(&sema, module) else {
        return vec![];
    };
    tests.extend(discover_tests_in_module(db, module, id, true));
    tests
}

fn find_module_id_and_test_parents(
    sema: &Semantics<'_, RootDatabase>,
    module: Module,
) -> Option<(Vec<TestItem>, String)> {
    let Some(parent) = module.parent(sema.db) else {
        let name = module.krate(sema.db).display_name(sema.db)?.to_string();
        return Some((
            vec![TestItem {
                id: name.clone(),
                kind: TestItemKind::Crate(module.krate(sema.db).into()),
                label: name.clone(),
                parent: None,
                file: None,
                text_range: None,
                runnable: None,
            }],
            name,
        ));
    };
    let (mut r, mut id) = find_module_id_and_test_parents(sema, parent)?;
    let parent = Some(id.clone());
    id += "::";
    let module_name = &module.name(sema.db);
    let module_name = module_name.as_ref().map(|n| n.as_str()).unwrap_or("[mod without name]");
    id += module_name;
    let nav = NavigationTarget::from_module_to_decl(sema.db, module).call_site;
    r.push(TestItem {
        id: id.clone(),
        kind: TestItemKind::Module,
        label: module_name.to_owned(),
        parent,
        file: Some(nav.file_id),
        text_range: Some(nav.focus_or_full_range()),
        runnable: None,
    });
    Some((r, id))
}

pub(crate) fn discover_tests_in_crate(
    db: &RootDatabase,
    crate_id: base_db::Crate,
) -> Vec<TestItem> {
    if !crate_id.data(db).origin.is_local() {
        return vec![];
    }
    let Some(crate_test_id) = &crate_id.extra_data(db).display_name else {
        return vec![];
    };
    let kind = TestItemKind::Crate(crate_id);
    let crate_test_id = crate_test_id.to_string();
    let crate_id: Crate = crate_id.into();
    let module = crate_id.root_module(db);
    let mut r = vec![TestItem {
        id: crate_test_id.clone(),
        kind,
        label: crate_test_id.clone(),
        parent: None,
        file: None,
        text_range: None,
        runnable: None,
    }];
    r.extend(discover_tests_in_module(db, module, crate_test_id, false));
    r
}
