use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use crate::metadata::project::{BookdownFiles, read_bookdown_files};
use regex::Regex;
use rowan::TextRange;

use crate::config::Config;
use crate::linter::diagnostics::{Diagnostic, Location};
use crate::syntax::{
    AstNode, AttributeNode, CodeBlock, FootnoteDefinition, ReferenceDefinition, Shortcode,
    SyntaxKind, SyntaxNode,
};
use crate::utils::normalize_label;

#[derive(Debug, Clone)]
pub struct IncludeOccurrence {
    pub path: PathBuf,
    pub range: TextRange,
}

#[derive(Debug, Default)]
pub struct IncludeResolution {
    pub includes: Vec<IncludeOccurrence>,
    pub diagnostics: Vec<Diagnostic>,
}

#[derive(Debug, Clone)]
pub struct DefinitionLocation {
    pub path: PathBuf,
    pub range: TextRange,
    pub line: usize,
}

#[derive(Debug, Clone, Default)]
pub struct DefinitionIndex {
    references: HashMap<String, DefinitionLocation>,
    footnotes: HashMap<String, DefinitionLocation>,
    crossrefs: HashMap<String, DefinitionLocation>,
}

impl DefinitionIndex {
    pub fn is_empty(&self) -> bool {
        self.references.is_empty() && self.footnotes.is_empty() && self.crossrefs.is_empty()
    }
}

impl DefinitionLocation {
    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn range(&self) -> TextRange {
        self.range
    }
}

pub fn collect_includes(
    tree: &SyntaxNode,
    input: &str,
    base_dir: &Path,
    project_root: Option<&Path>,
    config: &Config,
) -> IncludeResolution {
    if !config.extensions.quarto_shortcodes {
        return IncludeResolution::default();
    }

    let mut resolution = IncludeResolution::default();

    for shortcode in tree.descendants().filter_map(Shortcode::cast) {
        if shortcode.is_escaped() {
            continue;
        }

        if shortcode.name().as_deref() != Some("include") {
            continue;
        }
        let args = shortcode.args();
        let Some(raw_path) = args.get(1) else {
            continue;
        };

        let resolved = resolve_include_path(raw_path, base_dir, project_root);
        if !resolved.exists() || !resolved.is_file() {
            resolution.diagnostics.push(include_not_found_diagnostic(
                input,
                shortcode.syntax().text_range(),
                &resolved,
            ));
            continue;
        }

        resolution.includes.push(IncludeOccurrence {
            path: resolved,
            range: shortcode.syntax().text_range(),
        });
    }

    resolution
}

pub fn collect_cross_doc_duplicates(
    index: &mut DefinitionIndex,
    tree: &SyntaxNode,
    input: &str,
    doc_path: &Path,
    config: &Config,
) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();
    for def in tree.descendants().filter_map(ReferenceDefinition::cast) {
        let label = def.label();
        if label.is_empty() {
            continue;
        }
        let location = DefinitionLocation::new(doc_path, def.syntax().text_range(), input);
        if let Some(first) = index.insert_reference(&label, location.clone())
            && first.path != doc_path
        {
            diagnostics.push(Diagnostic::warning(
                Location::from_range(location.range, input),
                "duplicate-reference-labels",
                format!(
                    "Duplicate reference label '[{}]' (first defined at {}:{})",
                    label,
                    first.path.display(),
                    first.line
                ),
            ));
        }
    }

    for def in tree.descendants().filter_map(FootnoteDefinition::cast) {
        let id = def.id();
        if id.is_empty() {
            continue;
        }
        let location = DefinitionLocation::new(doc_path, def.syntax().text_range(), input);
        if let Some(first) = index.insert_footnote(&id, location.clone())
            && first.path != doc_path
        {
            diagnostics.push(Diagnostic::warning(
                Location::from_range(location.range, input),
                "duplicate-reference-labels",
                format!(
                    "Duplicate footnote ID '[^{}]' (first defined at {}:{})",
                    id,
                    first.path.display(),
                    first.line
                ),
            ));
        }
    }

    for attribute in tree.descendants().filter_map(AttributeNode::cast) {
        if let Some(id) = attribute.id() {
            let location =
                DefinitionLocation::new(doc_path, attribute.syntax().text_range(), input);
            index.insert_crossref(&id, location);
        }
    }

    for block in tree.descendants().filter_map(CodeBlock::cast) {
        for label in block.chunk_label_entries() {
            if label.value().is_empty() {
                continue;
            }
            let location = DefinitionLocation::new(doc_path, label.declaration_range(), input);
            index.insert_crossref(label.value(), location);
        }
    }

    if config.extensions.bookdown_references {
        collect_bookdown_definitions(
            index,
            tree,
            input,
            doc_path,
            config.extensions.bookdown_equation_references,
        );
    }

    diagnostics
}

fn collect_bookdown_definitions(
    index: &mut DefinitionIndex,
    tree: &SyntaxNode,
    input: &str,
    doc_path: &Path,
    collect_equation_definitions: bool,
) {
    use crate::parser::inlines::bookdown::{
        try_parse_bookdown_definition, try_parse_bookdown_equation_definition,
        try_parse_bookdown_text_reference,
    };

    for element in tree.descendants_with_tokens() {
        let Some(token) = element.into_token() else {
            continue;
        };
        if token.kind() != SyntaxKind::TEXT {
            continue;
        }
        let text = token.text();
        let mut offset = 0usize;
        let bytes = text.as_bytes();
        while offset < bytes.len() {
            if bytes[offset] != b'(' {
                offset += 1;
                continue;
            }
            let slice = &text[offset..];
            if collect_equation_definitions
                && let Some((len, label)) = try_parse_bookdown_equation_definition(slice)
            {
                let start: usize = token.text_range().start().into();
                let range = rowan::TextRange::new(
                    rowan::TextSize::from((start + offset) as u32),
                    rowan::TextSize::from((start + offset + len) as u32),
                );
                let location = DefinitionLocation::new(doc_path, range, input);
                index.insert_crossref(label, location);
                offset += len;
                continue;
            }
            if let Some((len, label)) = try_parse_bookdown_definition(slice) {
                if label.starts_with("eq:") && !collect_equation_definitions {
                    offset += len;
                    continue;
                }
                let start: usize = token.text_range().start().into();
                let range = rowan::TextRange::new(
                    rowan::TextSize::from((start + offset) as u32),
                    rowan::TextSize::from((start + offset + len) as u32),
                );
                let location = DefinitionLocation::new(doc_path, range, input);
                index.insert_crossref(label, location);
                offset += len;
                continue;
            }
            if let Some((len, label)) = try_parse_bookdown_text_reference(slice) {
                let start: usize = token.text_range().start().into();
                let range = rowan::TextRange::new(
                    rowan::TextSize::from((start + offset) as u32),
                    rowan::TextSize::from((start + offset + len) as u32),
                );
                let location = DefinitionLocation::new(doc_path, range, input);
                index.insert_crossref(label, location);
                offset += len;
                continue;
            }
            offset += 1;
        }
    }
}

pub fn include_cycle_diagnostic(input: &str, range: TextRange, path: &Path) -> Diagnostic {
    Diagnostic::error(
        Location::from_range(range, input),
        "include-cycle",
        format!("Include cycle detected: {}", path.display()),
    )
}

pub fn include_read_error_diagnostic(
    input: &str,
    range: TextRange,
    path: &Path,
    error: &str,
) -> Diagnostic {
    Diagnostic::error(
        Location::from_range(range, input),
        "include-read-error",
        format!("Failed to read included file {}: {}", path.display(), error),
    )
}

pub fn find_quarto_root(doc_path: &Path) -> Option<PathBuf> {
    find_project_roots(doc_path).quarto
}

pub fn find_bookdown_root(doc_path: &Path) -> Option<PathBuf> {
    find_project_roots(doc_path).bookdown
}

/// Combined project-root lookup. Walks ancestors **once** searching for both
/// `_quarto.yml` and `_bookdown.yml` markers, then caches the result by parent
/// directory in a thread-local memo. Multiple files in the same directory only
/// pay the filesystem cost once per worker thread.
///
/// The memo lives for the lifetime of the thread; it does not invalidate on
/// filesystem changes. Long-running hosts (LSP) call [`clear_project_roots_cache`]
/// at session boundaries when project membership may have changed.
pub fn find_project_roots(doc_path: &Path) -> ProjectRoots {
    let key: PathBuf = doc_path.parent().unwrap_or(doc_path).to_path_buf();
    PROJECT_ROOTS_CACHE.with(|cache| {
        if let Some(roots) = cache.borrow().get(&key) {
            return roots.clone();
        }
        let roots = find_project_roots_uncached(doc_path);
        cache.borrow_mut().insert(key, roots.clone());
        roots
    })
}

/// Drops any cached `ProjectRoots` entries on the current thread. Long-running
/// hosts should call this when project membership might have changed
/// (e.g., between LSP requests where workspace files may have been
/// added/removed/edited).
pub fn clear_project_roots_cache() {
    PROJECT_ROOTS_CACHE.with(|cache| cache.borrow_mut().clear());
}

#[derive(Debug, Clone, Default)]
pub struct ProjectRoots {
    pub quarto: Option<PathBuf>,
    pub bookdown: Option<PathBuf>,
}

impl ProjectRoots {
    /// Quarto wins over bookdown when both are present, matching the order
    /// used by `project_graph` and `visit_document`. Use [`Self::bookdown_first`]
    /// for call sites that historically prefer bookdown (e.g.
    /// `undefined_references` rule).
    pub fn quarto_first(&self) -> Option<PathBuf> {
        self.quarto.clone().or_else(|| self.bookdown.clone())
    }

    pub fn bookdown_first(&self) -> Option<PathBuf> {
        self.bookdown.clone().or_else(|| self.quarto.clone())
    }
}

fn find_project_roots_uncached(doc_path: &Path) -> ProjectRoots {
    let mut roots = ProjectRoots::default();
    let Some(mut current) = doc_path.parent() else {
        return roots;
    };
    loop {
        if roots.quarto.is_none() && current.join("_quarto.yml").exists() {
            roots.quarto = Some(current.to_path_buf());
        }
        if roots.bookdown.is_none() && current.join("_bookdown.yml").exists() {
            roots.bookdown = Some(current.to_path_buf());
        }
        if roots.quarto.is_some() && roots.bookdown.is_some() {
            break;
        }
        let Some(parent) = current.parent() else {
            break;
        };
        current = parent;
    }
    roots
}

thread_local! {
    static PROJECT_ROOTS_CACHE: std::cell::RefCell<HashMap<PathBuf, ProjectRoots>> =
        std::cell::RefCell::new(HashMap::new());
}

pub fn find_project_documents(
    project_root: &Path,
    config: &Config,
    is_bookdown: bool,
) -> Vec<PathBuf> {
    let mut docs = Vec::new();
    let mut seen = HashSet::new();
    let bookdown_files = if is_bookdown {
        read_bookdown_files(project_root)
    } else {
        None
    };
    let quarto_render = if is_bookdown {
        None
    } else {
        read_quarto_render(project_root)
    };
    #[cfg(feature = "cli")]
    let paths = ignore::WalkBuilder::new(project_root)
        .build()
        .flatten()
        .map(ignore::DirEntry::into_path)
        .collect::<Vec<_>>();
    #[cfg(not(feature = "cli"))]
    let paths = walk_project_files(project_root);

    for path in &paths {
        if !path.is_file() {
            continue;
        }
        if let Some(files) = &bookdown_files {
            let contains = match files {
                BookdownFiles::List(files) => files.contains(&path.to_path_buf()),
                BookdownFiles::ByFormat(formats) => {
                    formats.values().flatten().any(|value| value == path)
                }
            };
            if !contains {
                continue;
            }
        }
        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
            continue;
        };
        if !crate::all_document_extensions().contains(&ext) {
            continue;
        }
        if ext == "md" && !config.extensions.quarto_shortcodes {
            continue;
        }
        if !is_bookdown && !is_quarto_render_target(path, project_root, quarto_render.as_deref()) {
            continue;
        }
        if seen.insert(path.to_path_buf()) {
            docs.push(path.to_path_buf());
        }
    }

    docs
}

#[cfg(not(feature = "cli"))]
fn walk_project_files(project_root: &Path) -> Vec<PathBuf> {
    fn visit(path: &Path, out: &mut Vec<PathBuf>) {
        let Ok(entries) = std::fs::read_dir(path) else {
            return;
        };

        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                visit(&path, out);
            } else {
                out.push(path);
            }
        }
    }

    let mut files = Vec::new();
    visit(project_root, &mut files);
    files
}

fn read_quarto_render(project_root: &Path) -> Option<Vec<String>> {
    let quarto_config = project_root.join("_quarto.yml");
    if !quarto_config.exists() {
        return None;
    }
    let yaml = std::fs::read_to_string(quarto_config).ok()?;
    let root = yaml_parser::ast::Root::cast(yaml_parser::parse(&yaml).ok()?)?;
    let document = root.documents().next()?;
    let top_level = document.block()?.block_map()?;
    let project_entry = top_level
        .entries()
        .find(|entry| block_map_entry_key(entry).as_deref() == Some("project"))?;
    let project_map = project_entry.value()?.block()?.block_map()?;
    let render_entry = project_map
        .entries()
        .find(|entry| block_map_entry_key(entry).as_deref() == Some("render"))?;
    block_map_value_to_string_vec(render_entry.value()?)
}

fn block_map_entry_key(entry: &yaml_parser::ast::BlockMapEntry) -> Option<String> {
    let key = entry.key()?;
    if let Some(flow) = key.flow() {
        return flow_scalar_text(&flow);
    }
    let block = key.block()?;
    let flow = block_to_flow_scalar(&block)?;
    flow_scalar_text(&flow)
}

fn block_map_value_to_string_vec(value: yaml_parser::ast::BlockMapValue) -> Option<Vec<String>> {
    if let Some(flow) = value.flow()
        && let Some(single) = flow_scalar_text(&flow)
    {
        return Some(vec![single]);
    }
    let block = value.block()?;
    if let Some(sequence) = block.block_seq() {
        let mut out = Vec::new();
        for entry in sequence.entries() {
            let item = if let Some(flow) = entry.flow() {
                flow_scalar_text(&flow)
            } else if let Some(block) = entry.block() {
                block_to_flow_scalar(&block).and_then(|flow| flow_scalar_text(&flow))
            } else {
                None
            }?;
            out.push(item);
        }
        return Some(out);
    }
    None
}

fn block_to_flow_scalar(block: &yaml_parser::ast::Block) -> Option<yaml_parser::ast::Flow> {
    block
        .syntax()
        .children()
        .find_map(yaml_parser::ast::Flow::cast)
}

fn flow_scalar_text(flow: &yaml_parser::ast::Flow) -> Option<String> {
    if let Some(token) = flow.plain_scalar() {
        return Some(token.text().to_string());
    }
    if let Some(token) = flow.single_quoted_scalar() {
        return Some(token.text().trim_matches('\'').to_string());
    }
    if let Some(token) = flow.double_qouted_scalar() {
        return Some(token.text().trim_matches('"').to_string());
    }
    None
}

fn is_quarto_render_target(path: &Path, root: &Path, render: Option<&[String]>) -> bool {
    let Some(rel_path) = relative_path_string(path, root) else {
        return false;
    };
    if let Some(patterns) = render {
        return matches_render_patterns(&rel_path, patterns);
    }
    is_default_quarto_target(path, &rel_path)
}

fn is_default_quarto_target(path: &Path, rel_path: &str) -> bool {
    if matches!(
        path.file_name().and_then(|name| name.to_str()),
        Some("README.md") | Some("README.qmd")
    ) {
        return false;
    }
    !rel_path
        .split('/')
        .any(|component| component.starts_with('.') || component.starts_with('_'))
}

fn relative_path_string(path: &Path, root: &Path) -> Option<String> {
    let relative = path.strip_prefix(root).ok()?;
    let components: Vec<String> = relative
        .iter()
        .map(|component| component.to_string_lossy().to_string())
        .collect();
    if components.is_empty() {
        None
    } else {
        Some(components.join("/"))
    }
}

fn matches_render_patterns(rel_path: &str, patterns: &[String]) -> bool {
    let mut included = false;
    for raw_pattern in patterns {
        let trimmed = raw_pattern.trim();
        if trimmed.is_empty() {
            continue;
        }
        let (is_exclusion, pattern) = if let Some(pattern) = trimmed.strip_prefix('!') {
            (true, pattern.trim())
        } else {
            (false, trimmed)
        };
        if pattern.is_empty() {
            continue;
        }
        if quarto_render_pattern_matches(rel_path, pattern) {
            included = !is_exclusion;
        }
    }
    included
}

fn quarto_render_pattern_matches(rel_path: &str, pattern: &str) -> bool {
    let normalized = pattern.trim_start_matches("./");
    if let Some(dir) = normalized.strip_suffix('/') {
        if dir.is_empty() {
            return false;
        }
        return rel_path == dir || rel_path.starts_with(&format!("{dir}/"));
    }
    wildcard_match(rel_path, normalized)
}

fn wildcard_match(path: &str, pattern: &str) -> bool {
    let mut regex = String::from("^");
    let mut chars = pattern.chars().peekable();
    while let Some(ch) = chars.next() {
        match ch {
            '*' => {
                if chars.peek() == Some(&'*') {
                    chars.next();
                    regex.push_str(".*");
                } else {
                    regex.push_str("[^/]*");
                }
            }
            '?' => regex.push_str("[^/]"),
            '.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '[' | ']' | '\\' => {
                regex.push('\\');
                regex.push(ch);
            }
            _ => regex.push(ch),
        }
    }
    regex.push('$');
    Regex::new(&regex)
        .map(|compiled| compiled.is_match(path))
        .unwrap_or(false)
}

fn include_not_found_diagnostic(input: &str, range: TextRange, path: &Path) -> Diagnostic {
    Diagnostic::error(
        Location::from_range(range, input),
        "include-not-found",
        format!("Included file not found: {}", path.display()),
    )
}

pub fn resolve_include_path(raw: &str, base_dir: &Path, project_root: Option<&Path>) -> PathBuf {
    let trimmed = raw.trim();
    if let Some(path) = trimmed.strip_prefix('/')
        && let Some(root) = project_root
    {
        return root.join(path);
    }
    base_dir.join(trimmed)
}

impl DefinitionLocation {
    fn new(path: &Path, range: TextRange, input: &str) -> Self {
        let location = Location::from_range(range, input);
        Self {
            path: path.to_path_buf(),
            range,
            line: location.line,
        }
    }
}

impl DefinitionIndex {
    pub fn find_reference(&self, label: &str) -> Option<&DefinitionLocation> {
        let key = normalize_label(label);
        self.references.get(&key)
    }

    pub fn find_footnote(&self, id: &str) -> Option<&DefinitionLocation> {
        let key = normalize_label(id);
        self.footnotes.get(&key)
    }

    pub fn find_crossref(&self, id: &str) -> Option<&DefinitionLocation> {
        let key = normalize_label(id);
        self.crossrefs.get(&key)
    }

    fn insert_reference(
        &mut self,
        label: &str,
        location: DefinitionLocation,
    ) -> Option<DefinitionLocation> {
        let key = normalize_label(label);
        if let Some(existing) = self.references.get(&key) {
            return Some(existing.clone());
        }
        self.references.insert(key, location);
        None
    }

    fn insert_footnote(
        &mut self,
        id: &str,
        location: DefinitionLocation,
    ) -> Option<DefinitionLocation> {
        let key = normalize_label(id);
        if let Some(existing) = self.footnotes.get(&key) {
            return Some(existing.clone());
        }
        self.footnotes.insert(key, location);
        None
    }

    fn insert_crossref(&mut self, id: &str, location: DefinitionLocation) {
        let key = normalize_label(id);
        self.crossrefs.entry(key).or_insert(location);
    }
}

pub fn is_escaped_shortcode(node: &SyntaxNode) -> bool {
    Shortcode::cast(node.clone()).is_some_and(|shortcode| shortcode.is_escaped())
}

pub fn extract_shortcode_content(node: &SyntaxNode) -> Option<String> {
    Shortcode::cast(node.clone()).and_then(|shortcode| shortcode.content())
}

pub fn split_shortcode_args(content: &str) -> Vec<String> {
    crate::syntax::split_shortcode_args(content)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn project_graph_tracks_metadata_and_bibliography_edges() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        let doc_path = root.join("doc.qmd");

        fs::write(
            root.join("_quarto.yml"),
            "metadata-files:\n  - _site.yml\nbibliography: proj.bib\n",
        )
        .unwrap();
        fs::write(root.join("_site.yml"), "title: Site\n").unwrap();
        fs::write(root.join("proj.bib"), "@book{proj,}\n").unwrap();
        fs::write(&doc_path, "---\n---\n\nText").unwrap();

        let input = fs::read_to_string(&doc_path).unwrap();
        let config = Config::default();
        let graph = {
            let db = crate::salsa::SalsaDb::default();
            let file = crate::salsa::FileText::new(&db, input.clone());
            let config_input = crate::salsa::FileConfig::new(&db, config.clone());
            crate::salsa::project_graph(&db, file, config_input, doc_path.clone()).clone()
        };

        let metadata_deps =
            graph.dependencies(&doc_path, Some(crate::salsa::EdgeKind::MetadataFile));
        assert!(metadata_deps.contains(&root.join("_site.yml")));

        let bib_deps = graph.dependencies(&doc_path, Some(crate::salsa::EdgeKind::Bibliography));
        assert!(bib_deps.contains(&root.join("proj.bib")));
    }

    #[test]
    fn project_graph_builds_from_project_root() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        let doc_path = root.join("doc.qmd");
        let other_path = root.join("other.qmd");

        fs::write(root.join("_quarto.yml"), "project: default\n").unwrap();
        fs::write(&doc_path, "See [link][ref].\n").unwrap();
        fs::write(&other_path, "[ref]: https://example.com\n").unwrap();

        let input = fs::read_to_string(&doc_path).unwrap();
        let config = Config::default();
        let graph = {
            let db = crate::salsa::SalsaDb::default();
            let file = crate::salsa::FileText::new(&db, input.clone());
            let config_input = crate::salsa::FileConfig::new(&db, config.clone());
            crate::salsa::project_graph(&db, file, config_input, doc_path.clone()).clone()
        };

        assert!(graph.documents().contains(&doc_path));
        assert!(graph.documents().contains(&other_path));
        let mut definitions = DefinitionIndex::default();
        for path in graph.documents() {
            let Ok(text) = fs::read_to_string(path) else {
                continue;
            };
            let tree = crate::parse(&text, Some(config.clone()));
            crate::includes::collect_cross_doc_duplicates(
                &mut definitions,
                &tree,
                &text,
                path,
                &config,
            );
        }
        assert!(definitions.find_reference("ref").is_some());
    }

    #[test]
    fn find_project_documents_applies_quarto_default_render_rules() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        fs::write(root.join("_quarto.yml"), "project: default\n").unwrap();
        fs::create_dir_all(root.join("_partials")).unwrap();
        fs::create_dir_all(root.join(".hidden")).unwrap();
        fs::write(root.join("doc.qmd"), "# Doc\n").unwrap();
        fs::write(root.join("README.qmd"), "# Readme\n").unwrap();
        fs::write(root.join("_partials").join("part.qmd"), "# Part\n").unwrap();
        fs::write(root.join(".hidden").join("hidden.qmd"), "# Hidden\n").unwrap();

        let docs = find_project_documents(root, &Config::default(), false);
        assert!(docs.contains(&root.join("doc.qmd")));
        assert!(!docs.contains(&root.join("README.qmd")));
        assert!(!docs.contains(&root.join("_partials").join("part.qmd")));
        assert!(!docs.contains(&root.join(".hidden").join("hidden.qmd")));
    }

    #[test]
    fn find_project_documents_applies_quarto_render_patterns() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        fs::write(
            root.join("_quarto.yml"),
            "project:\n  render:\n    - \"*.qmd\"\n    - \"!ignored.qmd\"\n    - \"!ignored-dir/\"\n    - \"nested/kept.qmd\"\n",
        )
        .unwrap();
        fs::create_dir_all(root.join("nested")).unwrap();
        fs::create_dir_all(root.join("ignored-dir")).unwrap();
        fs::write(root.join("doc.qmd"), "# Doc\n").unwrap();
        fs::write(root.join("ignored.qmd"), "# Ignored\n").unwrap();
        fs::write(root.join("ignored-dir").join("child.qmd"), "# Child\n").unwrap();
        fs::write(root.join("nested").join("kept.qmd"), "# Kept\n").unwrap();

        let docs = find_project_documents(root, &Config::default(), false);
        assert!(docs.contains(&root.join("doc.qmd")));
        assert!(docs.contains(&root.join("nested").join("kept.qmd")));
        assert!(!docs.contains(&root.join("ignored.qmd")));
        assert!(!docs.contains(&root.join("ignored-dir").join("child.qmd")));
    }

    #[test]
    fn project_graph_excludes_non_render_targets_from_project_documents() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        let doc_path = root.join("doc.qmd");
        let non_rendered = root.join("non-rendered.qmd");

        fs::write(
            root.join("_quarto.yml"),
            "project:\n  render:\n    - doc.qmd\n",
        )
        .unwrap();
        fs::write(&doc_path, "See [link][ref].\n").unwrap();
        fs::write(&non_rendered, "[ref]: https://example.com\n").unwrap();

        let input = fs::read_to_string(&doc_path).unwrap();
        let config = Config::default();
        let graph = {
            let db = crate::salsa::SalsaDb::default();
            let file = crate::salsa::FileText::new(&db, input.clone());
            let config_input = crate::salsa::FileConfig::new(&db, config.clone());
            crate::salsa::project_graph(&db, file, config_input, doc_path.clone()).clone()
        };

        assert!(graph.documents().contains(&doc_path));
        assert!(!graph.documents().contains(&non_rendered));
    }

    #[test]
    fn project_graph_uses_bookdown_file_list() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        let doc_path = root.join("index.Rmd");
        let other_path = root.join("chapter.Rmd");
        let ignored_path = root.join("ignored.Rmd");

        fs::write(
            root.join("_bookdown.yml"),
            "rmd_files: [\"index.Rmd\", \"chapter.Rmd\"]\n",
        )
        .unwrap();
        fs::write(&doc_path, "[ref]: https://example.com\n").unwrap();
        fs::write(&other_path, "See [link][ref].\n").unwrap();
        fs::write(&ignored_path, "[ignored]: https://example.org\n").unwrap();

        let input = fs::read_to_string(&other_path).unwrap();
        let config = Config::default();
        let graph = {
            let db = crate::salsa::SalsaDb::default();
            let file = crate::salsa::FileText::new(&db, input.clone());
            let config_input = crate::salsa::FileConfig::new(&db, config.clone());
            crate::salsa::project_graph(&db, file, config_input, other_path.clone()).clone()
        };

        assert!(graph.documents().contains(&doc_path));
        assert!(graph.documents().contains(&other_path));
        assert!(!graph.documents().contains(&ignored_path));
    }

    #[test]
    fn project_graph_collects_bookdown_definitions() {
        let temp_dir = TempDir::new().unwrap();
        let root = temp_dir.path();
        let doc_path = root.join("doc.Rmd");

        fs::write(
            &doc_path,
            "See \\@ref(fig:plot).\n\n(\\#fig:plot)\n\n(ref:caption)\n",
        )
        .unwrap();

        let input = fs::read_to_string(&doc_path).unwrap();
        let mut config = Config::default();
        config.extensions.bookdown_references = true;
        let _graph = {
            let db = crate::salsa::SalsaDb::default();
            let file = crate::salsa::FileText::new(&db, input.clone());
            let config_input = crate::salsa::FileConfig::new(&db, config.clone());
            crate::salsa::project_graph(&db, file, config_input, doc_path.clone()).clone()
        };

        let mut definitions = DefinitionIndex::default();
        let tree = crate::parse(&input, Some(config.clone()));
        crate::includes::collect_cross_doc_duplicates(
            &mut definitions,
            &tree,
            &input,
            &doc_path,
            &config,
        );
        assert!(definitions.find_crossref("fig:plot").is_some());
        assert!(definitions.find_crossref("caption").is_some());
    }
}
