//! @import and markdown link reference validation
//!
//! This module validates:
//! - CC-MEM-001: @import references point to existing files (Claude Code specific)
//! - CC-MEM-002: Circular @import detection
//! - CC-MEM-003: @import depth exceeded
//! - REF-001: @import file not found (universal)
//! - REF-002: Broken markdown links (universal)

use crate::{
    config::LintConfig,
    diagnostics::Diagnostic,
    fs::FileSystem,
    parsers::markdown::{extract_imports, extract_markdown_links},
    parsers::{Import, ImportCache},
    rules::Validator,
};
use rust_i18n::t;
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};

pub struct ImportsValidator;

const MAX_IMPORT_DEPTH: usize = 5;
type DiagnosticKey = (PathBuf, usize, usize, String, String);

fn push_unique_diagnostic(
    diagnostics: &mut Vec<Diagnostic>,
    seen_diagnostics: &mut HashSet<DiagnosticKey>,
    diagnostic: Diagnostic,
) {
    let key = (
        diagnostic.file.clone(),
        diagnostic.line,
        diagnostic.column,
        diagnostic.rule.clone(),
        diagnostic.message.clone(),
    );
    if seen_diagnostics.insert(key) {
        diagnostics.push(diagnostic);
    }
}

/// Check if a URL is a local file link (not external or anchor-only)
fn is_local_file_link(url: &str) -> bool {
    const EXTERNAL_PREFIXES: &[&str] = &[
        "http://", "https://", "mailto:", "tel:", "data:", "ftp://", "file://", "//",
    ];

    if EXTERNAL_PREFIXES.iter().any(|p| url.starts_with(p)) {
        return false;
    }

    !url.is_empty() && !url.starts_with('#')
}

/// Strip URL fragment (e.g., "file.md#section" -> "file.md")
fn strip_fragment(url: &str) -> &str {
    match url.find('#') {
        Some(idx) => &url[..idx],
        None => url,
    }
}

impl Validator for ImportsValidator {
    fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic> {
        let mut diagnostics = Vec::new();

        // Check both new category flag and legacy flag for backward compatibility
        if !config.rules.imports || !config.rules.import_references {
            return diagnostics;
        }

        // Detect root file type for cycle/depth rules
        let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
        let is_claude_md = matches!(filename, "CLAUDE.md" | "CLAUDE.local.md");

        let fs = config.fs();
        let project_root = resolve_project_root(path, config, fs.as_ref());
        let root_path = normalize_existing_path(path, fs.as_ref());

        // Use shared cache if available (project-level validation),
        // otherwise create a local cache (single-file validation)
        let shared_cache = config.get_import_cache();
        let mut local_cache: HashMap<PathBuf, Vec<Import>> = HashMap::new();
        let mut visited_depth: HashMap<PathBuf, usize> = HashMap::new();
        let mut stack = Vec::new();
        let mut seen_diagnostics: HashSet<DiagnosticKey> = HashSet::new();

        // Insert the root file's imports into the appropriate cache (if not already present)
        let root_imports = extract_imports(content);
        if let Some(cache) = shared_cache {
            // Write to shared cache only if not already present
            let mut guard = match cache.write() {
                Ok(guard) => guard,
                Err(poisoned) => poisoned.into_inner(),
            };
            guard.entry(root_path.clone()).or_insert(root_imports);
        } else {
            // Write to local cache
            local_cache.entry(root_path.clone()).or_insert(root_imports);
        }

        visit_imports(
            &root_path,
            None,
            shared_cache,
            &mut local_cache,
            &mut visited_depth,
            &mut stack,
            &mut diagnostics,
            &mut seen_diagnostics,
            config,
            is_claude_md,
            &project_root,
            fs.as_ref(),
        );

        // Validate markdown links (REF-002)
        // Only check agent config files, not generic markdown. Generic markdown
        // files (plans, research notes, etc.) commonly have broken relative links
        // that are project documentation issues, not agent configuration problems.
        let is_agent_config = matches!(
            filename,
            "CLAUDE.md"
                | "CLAUDE.local.md"
                | "AGENTS.md"
                | "AGENTS.local.md"
                | "AGENTS.override.md"
                | "SKILL.md"
        ) || filename.ends_with(".instructions.md")
            || filename == "copilot-instructions.md";
        if is_agent_config {
            validate_markdown_links(path, content, config, &mut diagnostics, fs.as_ref());
        }

        diagnostics
    }
}

#[allow(clippy::too_many_arguments)]
fn visit_imports(
    file_path: &PathBuf,
    content_override: Option<&str>,
    shared_cache: Option<&ImportCache>,
    local_cache: &mut HashMap<PathBuf, Vec<Import>>,
    visited_depth: &mut HashMap<PathBuf, usize>,
    stack: &mut Vec<PathBuf>,
    diagnostics: &mut Vec<Diagnostic>,
    seen_diagnostics: &mut HashSet<DiagnosticKey>,
    config: &LintConfig,
    root_is_claude_md: bool,
    project_root: &Path,
    fs: &dyn FileSystem,
) {
    let depth = stack.len();
    if let Some(prev_depth) = visited_depth.get(file_path) {
        // Skip only when we have already visited this file at an equal or
        // shallower depth. If we discover a shallower path later, revisit it
        // so traversal can continue with the tighter depth budget.
        if *prev_depth <= depth {
            return;
        }
    }
    visited_depth.insert(file_path.clone(), depth);

    let imports = get_imports_for_file(file_path, content_override, shared_cache, local_cache, fs);
    let Some(imports) = imports else { return };

    let base_dir = file_path.parent().unwrap_or(Path::new("."));
    let normalized_base = normalize_existing_path(base_dir, fs);
    let normalized_root = project_root;

    // Determine file type for current file to route its own diagnostics
    let filename = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
    let is_claude_md = matches!(filename, "CLAUDE.md" | "CLAUDE.local.md");

    // Check rules based on CURRENT file type for missing imports
    // Check rules based on ROOT file type for cycles/depth (applies to entire chain)
    let check_not_found = (is_claude_md && config.is_rule_enabled("CC-MEM-001"))
        || (!is_claude_md && config.is_rule_enabled("REF-001"));
    let check_cycle = root_is_claude_md && config.is_rule_enabled("CC-MEM-002");
    let check_depth = root_is_claude_md && config.is_rule_enabled("CC-MEM-003");

    if !(check_not_found || check_cycle || check_depth) {
        return;
    }

    let rule_not_found = if is_claude_md {
        "CC-MEM-001"
    } else {
        "REF-001"
    };
    let rule_cycle = "CC-MEM-002";
    let rule_depth = "CC-MEM-003";

    stack.push(file_path.clone());

    for import in imports {
        let resolved = resolve_import_path(&import.path, base_dir);

        // Validate path to prevent traversal attacks
        // Reject absolute paths and paths that escape the project root
        let raw_path = Path::new(&import.path);
        if raw_path.is_absolute()
            || import.path.starts_with('/')
            || import.path.starts_with('\\')
            || import.path.starts_with('~')
        {
            if check_not_found {
                push_unique_diagnostic(
                    diagnostics,
                    seen_diagnostics,
                    Diagnostic::error(
                        file_path.clone(),
                        import.line,
                        import.column,
                        rule_not_found,
                        t!("rules.cc_mem_001.absolute", path = import.path.as_str()),
                    )
                    .with_suggestion(t!("rules.cc_mem_001.absolute_suggestion")),
                );
            }
            continue;
        }

        let normalized_resolved = normalize_join(&normalized_base, &import.path);
        if !normalized_resolved.starts_with(normalized_root) {
            if check_not_found {
                push_unique_diagnostic(
                    diagnostics,
                    seen_diagnostics,
                    Diagnostic::error(
                        file_path.clone(),
                        import.line,
                        import.column,
                        rule_not_found,
                        t!("rules.cc_mem_001.escapes", path = import.path.as_str()),
                    )
                    .with_suggestion(t!("rules.cc_mem_001.escapes_suggestion")),
                );
            }
            continue;
        }

        let normalized = if fs.exists(&resolved) {
            let canonical_resolved = normalize_existing_path(&resolved, fs);
            if !canonical_resolved.starts_with(normalized_root) {
                if check_not_found {
                    push_unique_diagnostic(
                        diagnostics,
                        seen_diagnostics,
                        Diagnostic::error(
                            file_path.clone(),
                            import.line,
                            import.column,
                            rule_not_found,
                            t!("rules.cc_mem_001.escapes", path = import.path.as_str()),
                        )
                        .with_suggestion(t!("rules.cc_mem_001.escapes_suggestion")),
                    );
                }
                continue;
            }
            canonical_resolved
        } else {
            resolved
        };

        // Try file-relative resolution first, then project-root resolution.
        // Claude Code resolves @imports relative to the project root, not
        // the importing file's directory.
        let normalized = if fs.exists(&normalized) {
            normalized
        } else {
            // Fallback: try resolving relative to project root
            let root_resolved = project_root.join(&import.path);
            if fs.exists(&root_resolved) {
                root_resolved
            } else {
                normalized
            }
        };

        let import_exists = fs.exists(&normalized);

        if !import_exists {
            if check_not_found {
                push_unique_diagnostic(
                    diagnostics,
                    seen_diagnostics,
                    Diagnostic::error(
                        file_path.clone(),
                        import.line,
                        import.column,
                        rule_not_found,
                        t!("rules.cc_mem_001.not_found", path = import.path.as_str()),
                    )
                    .with_suggestion(format!(
                        "Check that the file exists: {}",
                        normalized.display()
                    )),
                );
            }
            continue;
        }

        // Always check for cycles/depth to prevent infinite recursion
        let has_cycle = stack.contains(&normalized);
        let exceeds_depth = depth + 1 > MAX_IMPORT_DEPTH;

        // Emit diagnostics if rules are enabled for this file type
        if check_cycle && has_cycle {
            let cycle = format_cycle(stack, &normalized);
            push_unique_diagnostic(
                diagnostics,
                seen_diagnostics,
                Diagnostic::error(
                    file_path.clone(),
                    import.line,
                    import.column,
                    rule_cycle,
                    t!("rules.cc_mem_002.message", chain = cycle),
                )
                .with_suggestion(t!("rules.cc_mem_002.suggestion")),
            );
            continue;
        }

        if check_depth && exceeds_depth {
            push_unique_diagnostic(
                diagnostics,
                seen_diagnostics,
                Diagnostic::error(
                    file_path.clone(),
                    import.line,
                    import.column,
                    rule_depth,
                    t!(
                        "rules.cc_mem_003.message",
                        depth = depth + 1,
                        max = MAX_IMPORT_DEPTH
                    ),
                )
                .with_suggestion(t!("rules.cc_mem_003.suggestion")),
            );
            continue;
        }

        // Only recurse if no cycle/depth issues
        if !has_cycle && !exceeds_depth {
            visit_imports(
                &normalized,
                None,
                shared_cache,
                local_cache,
                visited_depth,
                stack,
                diagnostics,
                seen_diagnostics,
                config,
                root_is_claude_md,
                project_root,
                fs,
            );
        }
    }

    stack.pop();
}

/// Get imports for a file, using shared cache if available, otherwise local cache.
///
/// This function uses a read-then-write lock pattern for the shared cache:
/// 1. Try to read from cache (read lock)
/// 2. If miss, drop read lock, parse file, then write (write lock)
///
/// This avoids holding locks during file I/O and parsing.
///
/// Note: There's a small window for duplicate work where two threads could both
/// miss the cache and parse the same file. This is acceptable because:
/// - The extra work is bounded (only one extra parse per file per thread)
/// - Using entry() API prevents duplicate insertions
/// - Lock-free parsing enables better parallelism than holding locks during I/O
fn get_imports_for_file(
    file_path: &Path,
    content_override: Option<&str>,
    shared_cache: Option<&ImportCache>,
    local_cache: &mut HashMap<PathBuf, Vec<Import>>,
    fs: &dyn FileSystem,
) -> Option<Vec<Import>> {
    // Try shared cache first if available
    if let Some(cache) = shared_cache {
        // Read lock - check if already cached
        {
            let guard = match cache.read() {
                Ok(guard) => guard,
                Err(poisoned) => poisoned.into_inner(),
            };
            if let Some(imports) = guard.get(file_path) {
                return Some(imports.clone());
            }
        }
        // Cache miss - read lock dropped here before I/O

        // Parse the file outside of any lock
        let content = match content_override {
            Some(content) => content.to_string(),
            // Silently skip files that can't be read (symlinks, too large, missing).
            // This is intentional: import chains often reference optional/external files,
            // and failing noisily on each would overwhelm the user.
            None => fs.read_to_string(file_path).ok()?,
        };
        let imports = extract_imports(&content);

        // Write lock - use entry() to handle race condition where another thread
        // may have already inserted while we were parsing
        let mut guard = match cache.write() {
            Ok(guard) => guard,
            Err(poisoned) => poisoned.into_inner(),
        };
        guard
            .entry(file_path.to_path_buf())
            .or_insert_with(|| imports.clone());
        return Some(imports);
    }

    // Fallback to local cache (single-file validation)
    if !local_cache.contains_key(file_path) {
        let content = match content_override {
            Some(content) => content.to_string(),
            None => fs.read_to_string(file_path).ok()?,
        };
        let imports = extract_imports(&content);
        local_cache.insert(file_path.to_path_buf(), imports);
    }
    local_cache.get(file_path).cloned()
}

fn resolve_import_path(import_path: &str, base_dir: &Path) -> PathBuf {
    if import_path.starts_with("~/") || import_path.starts_with("~\\") {
        if let Some(home) = dirs::home_dir() {
            return home.join(&import_path[2..]);
        }
    }

    let raw = PathBuf::from(import_path);
    if raw.is_absolute() {
        raw
    } else {
        base_dir.join(raw)
    }
}

fn normalize_join(base_dir: &Path, import_path: &str) -> PathBuf {
    let mut result = PathBuf::from(base_dir);
    for component in Path::new(import_path).components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                result.pop();
            }
            Component::Normal(segment) => {
                result.push(segment);
            }
            Component::RootDir | Component::Prefix(_) => {
                result = PathBuf::from(component.as_os_str());
            }
        }
    }
    result
}

fn normalize_existing_path(path: &Path, fs: &dyn FileSystem) -> PathBuf {
    fs.canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}

fn resolve_project_root(path: &Path, config: &LintConfig, fs: &dyn FileSystem) -> PathBuf {
    if let Some(root) = config.get_root_dir() {
        return normalize_existing_path(root, fs);
    }

    find_repo_root(path, fs).unwrap_or_else(|| {
        let fallback = path.parent().unwrap_or(Path::new("."));
        normalize_existing_path(fallback, fs)
    })
}

fn find_repo_root(path: &Path, fs: &dyn FileSystem) -> Option<PathBuf> {
    for ancestor in path.ancestors() {
        if ancestor.as_os_str().is_empty() {
            continue;
        }
        let git_marker = ancestor.join(".git");
        if fs.is_dir(&git_marker) || fs.is_file(&git_marker) {
            return Some(ancestor.to_path_buf());
        }
    }
    None
}

fn format_cycle(stack: &[PathBuf], target: &Path) -> String {
    let mut cycle = Vec::new();
    let mut in_cycle = false;
    for path in stack {
        if path == target {
            in_cycle = true;
        }
        if in_cycle {
            cycle.push(path.display().to_string());
        }
    }
    cycle.push(target.display().to_string());
    cycle.join(" -> ")
}

/// Validate markdown links in content (REF-002)
fn validate_markdown_links(
    path: &Path,
    content: &str,
    config: &LintConfig,
    diagnostics: &mut Vec<Diagnostic>,
    fs: &dyn FileSystem,
) {
    if !config.is_rule_enabled("REF-002") {
        return;
    }

    let links = extract_markdown_links(content);
    let base_dir = path.parent().unwrap_or(Path::new("."));

    for link in links {
        // Skip non-local links (external URLs, anchors, etc.)
        if !is_local_file_link(&link.url) {
            continue;
        }

        // Skip template placeholders like {url}, {repoUrl}, {brackets}
        if link.url.starts_with('{') && link.url.ends_with('}') {
            continue;
        }

        // Skip single-word "links" that don't look like file paths
        // (no extension, no directory separator) - likely wiki-style links or examples
        if !link.url.contains('/')
            && !link.url.contains('\\')
            && !link.url.contains('.')
            && !link.url.contains('#')
        {
            continue;
        }

        // Strip fragment to get the file path
        let file_path = strip_fragment(&link.url);

        // Resolve the path relative to the file's directory
        let resolved = resolve_import_path(file_path, base_dir);

        // Security: Verify resolved path stays within project root
        // Normalize the resolved path to detect path traversal attempts
        if let Ok(canonical_resolved) = fs.canonicalize(&resolved) {
            if let Ok(canonical_base) = fs.canonicalize(base_dir) {
                if !canonical_resolved.starts_with(&canonical_base) {
                    // Path traversal attempt detected - skip this link
                    continue;
                }
            }
        }

        // Check if file exists
        if !fs.exists(&resolved) {
            diagnostics.push(
                Diagnostic::error(
                    path.to_path_buf(),
                    link.line,
                    link.column,
                    "REF-002",
                    t!(
                        "rules.ref_002.message",
                        url = link.url.as_str(),
                        resolved = resolved.display().to_string()
                    ),
                )
                .with_suggestion(t!("rules.ref_002.suggestion")),
            );
        }
    }
}

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

    #[test]
    fn test_config_disabled_imports_category() {
        let mut config = LintConfig::default();
        config.rules.imports = false;

        let content = "@nonexistent-file.md";
        let validator = ImportsValidator;
        let diagnostics = validator.validate(Path::new("test.md"), content, &config);

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn test_legacy_import_references_flag() {
        let mut config = LintConfig::default();
        config.rules.import_references = false;

        let content = "@nonexistent-file.md";
        let validator = ImportsValidator;
        let diagnostics = validator.validate(Path::new("test.md"), content, &config);

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn test_missing_import_in_claude_md() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("CLAUDE.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &LintConfig::default());

        assert!(diagnostics.iter().any(|d| d.rule == "CC-MEM-001"));
    }

    #[test]
    fn test_cycle_detection_in_claude_md() {
        let temp = TempDir::new().unwrap();
        let a = temp.path().join("CLAUDE.md");
        let b = temp.path().join("b.md");
        fs::write(&a, "See @b.md").unwrap();
        fs::write(&b, "See @CLAUDE.md").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&a, "See @b.md", &LintConfig::default());

        assert!(diagnostics.iter().any(|d| d.rule == "CC-MEM-002"));
    }

    #[test]
    fn test_depth_exceeded_in_claude_md() {
        let temp = TempDir::new().unwrap();
        let claude_md = temp.path().join("CLAUDE.md");
        let paths: Vec<PathBuf> = (1..7)
            .map(|i| temp.path().join(format!("{}.md", i)))
            .collect();

        fs::write(&claude_md, "See @1.md").unwrap();
        for (i, path) in paths.iter().enumerate().take(5) {
            let content = format!("See @{}.md", i + 2);
            fs::write(path, content).unwrap();
        }
        fs::write(&paths[5], "End").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&claude_md, "See @1.md", &LintConfig::default());

        assert!(diagnostics.iter().any(|d| d.rule == "CC-MEM-003"));
    }

    #[test]
    fn test_missing_import_in_skill_md() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("SKILL.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &LintConfig::default());

        assert!(diagnostics.iter().any(|d| d.rule == "REF-001"));
        assert!(!diagnostics.iter().any(|d| d.rule == "CC-MEM-001"));
    }

    #[test]
    fn test_missing_import_in_agents_md() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("AGENTS.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &LintConfig::default());

        assert!(diagnostics.iter().any(|d| d.rule == "REF-001"));
        assert!(!diagnostics.iter().any(|d| d.rule == "CC-MEM-001"));
    }

    #[test]
    fn test_missing_import_in_generic_md() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("README.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &LintConfig::default());

        assert!(diagnostics.iter().any(|d| d.rule == "REF-001"));
        assert!(!diagnostics.iter().any(|d| d.rule == "CC-MEM-001"));
    }

    #[test]
    fn test_cycle_in_skill_md() {
        let temp = TempDir::new().unwrap();
        let a = temp.path().join("SKILL.md");
        let b = temp.path().join("b.md");
        fs::write(&a, "See @b.md").unwrap();
        fs::write(&b, "See @SKILL.md").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&a, "See @b.md", &LintConfig::default());

        // Non-CLAUDE files don't check cycles, so no diagnostics expected
        assert!(diagnostics.is_empty());
    }

    #[test]
    fn test_depth_exceeded_in_skill_md() {
        let temp = TempDir::new().unwrap();
        let skill_md = temp.path().join("SKILL.md");
        let paths: Vec<PathBuf> = (1..7)
            .map(|i| temp.path().join(format!("{}.md", i)))
            .collect();

        fs::write(&skill_md, "See @1.md").unwrap();
        for (i, path) in paths.iter().enumerate().take(5) {
            let content = format!("See @{}.md", i + 2);
            fs::write(path, content).unwrap();
        }
        fs::write(&paths[5], "End").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&skill_md, "See @1.md", &LintConfig::default());

        // Non-CLAUDE files don't check depth, so no diagnostics expected
        assert!(diagnostics.is_empty());
    }

    #[test]
    fn test_ref_001_disabled_suppresses_skill_md_errors() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("SKILL.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let mut config = LintConfig::default();
        config.rules.disabled_rules.push("REF-001".to_string());

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &config);

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn test_cc_mem_disabled_still_allows_ref() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("SKILL.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let mut config = LintConfig::default();
        config.rules.disabled_rules.push("CC-MEM-001".to_string());
        config.rules.disabled_rules.push("CC-MEM-002".to_string());
        config.rules.disabled_rules.push("CC-MEM-003".to_string());

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &config);

        assert!(diagnostics.iter().any(|d| d.rule == "REF-001"));
    }

    #[test]
    fn test_ref_disabled_still_allows_cc_mem() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("CLAUDE.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let mut config = LintConfig::default();
        config.rules.disabled_rules.push("REF-001".to_string());

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &config);

        // CLAUDE.md should still emit CC-MEM-001 even when REF-001 is disabled
        assert!(diagnostics.iter().any(|d| d.rule == "CC-MEM-001"));
    }

    #[test]
    fn test_nested_file_type_detection() {
        // Test for critical fix: file type should be determined per-file in recursion
        let temp = TempDir::new().unwrap();
        let skill_md = temp.path().join("SKILL.md");
        let claude_md = temp.path().join("CLAUDE.md");

        // SKILL.md imports CLAUDE.md which has a missing import
        fs::write(&skill_md, "See @CLAUDE.md").unwrap();
        fs::write(&claude_md, "See @missing.md").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&skill_md, "See @CLAUDE.md", &LintConfig::default());

        // CLAUDE.md's missing import should emit CC-MEM-001, not REF-001
        assert!(
            diagnostics
                .iter()
                .any(|d| d.rule == "CC-MEM-001" && d.file.ends_with("CLAUDE.md"))
        );
        assert!(
            !diagnostics
                .iter()
                .any(|d| d.rule == "REF-001" && d.file.ends_with("CLAUDE.md"))
        );
    }

    #[test]
    fn test_absolute_path_rejection() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("CLAUDE.md");
        fs::write(&file_path, "See @/etc/passwd").unwrap();

        let validator = ImportsValidator;
        let diagnostics =
            validator.validate(&file_path, "See @/etc/passwd", &LintConfig::default());

        // Absolute paths should be rejected
        assert!(
            diagnostics
                .iter()
                .any(|d| d.message.contains("Absolute import paths not allowed"))
        );
    }

    #[test]
    fn test_path_escape_rejection() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("root");
        let docs = root.join("docs");
        fs::create_dir_all(&docs).unwrap();
        fs::write(temp.path().join("outside.md"), "Outside content").unwrap();

        let file_path = docs.join("CLAUDE.md");
        fs::write(&file_path, "See @../../outside.md").unwrap();

        let mut config = LintConfig::default();
        config.set_root_dir(root);

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @../../outside.md", &config);

        assert!(
            diagnostics
                .iter()
                .any(|d| { d.rule == "CC-MEM-001" && d.message.contains("escapes project root") })
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_symlink_escape_rejection() {
        use std::os::unix::fs::symlink;

        let temp = TempDir::new().unwrap();
        let root = temp.path().join("root");
        let docs = root.join("docs");
        let outside = temp.path().join("outside");

        fs::create_dir_all(&docs).unwrap();
        fs::create_dir_all(&outside).unwrap();
        fs::write(outside.join("secret.md"), "Secret content").unwrap();

        let link_path = root.join("link");
        symlink(&outside, &link_path).unwrap();

        let file_path = docs.join("CLAUDE.md");
        fs::write(&file_path, "See @../link/secret.md").unwrap();

        let mut config = LintConfig::default();
        config.set_root_dir(root);

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @../link/secret.md", &config);

        assert!(
            diagnostics
                .iter()
                .any(|d| { d.rule == "CC-MEM-001" && d.message.contains("escapes project root") })
        );
    }

    // ===== Helper Function Tests =====

    #[test]
    fn test_is_local_file_link_true() {
        assert!(is_local_file_link("file.md"));
        assert!(is_local_file_link("docs/guide.md"));
        assert!(is_local_file_link("./relative.md"));
        assert!(is_local_file_link("../parent.md"));
        assert!(is_local_file_link("file.md#section"));
    }

    #[test]
    fn test_is_local_file_link_false() {
        assert!(!is_local_file_link("https://example.com"));
        assert!(!is_local_file_link("http://example.com"));
        assert!(!is_local_file_link("mailto:test@example.com"));
        assert!(!is_local_file_link("tel:+1234567890"));
        assert!(!is_local_file_link("data:text/plain,hello"));
        assert!(!is_local_file_link("ftp://files.example.com"));
        assert!(!is_local_file_link("//cdn.example.com/file.js"));
        assert!(!is_local_file_link("#section"));
        assert!(!is_local_file_link(""));
    }

    #[test]
    fn test_strip_fragment() {
        assert_eq!(strip_fragment("file.md#section"), "file.md");
        assert_eq!(strip_fragment("file.md"), "file.md");
        assert_eq!(strip_fragment("#section"), "");
        assert_eq!(strip_fragment("docs/guide.md#heading"), "docs/guide.md");
    }

    // ===== REF-001 Tests =====

    #[test]
    fn test_ref_001_missing_import() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("test.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &LintConfig::default());

        // Non-CLAUDE.md files emit REF-001 only (not CC-MEM-001)
        assert!(diagnostics.iter().any(|d| d.rule == "REF-001"));
        assert!(!diagnostics.iter().any(|d| d.rule == "CC-MEM-001"));
    }

    #[test]
    fn test_ref_001_existing_import() {
        let temp = TempDir::new().unwrap();
        let target = temp.path().join("exists.md");
        let file_path = temp.path().join("test.md");
        fs::write(&target, "Target content").unwrap();
        fs::write(&file_path, "See @exists.md").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @exists.md", &LintConfig::default());

        // Should not emit any not-found errors
        assert!(!diagnostics.iter().any(|d| d.rule == "REF-001"));
        assert!(!diagnostics.iter().any(|d| d.rule == "CC-MEM-001"));
    }

    #[test]
    fn test_ref_001_disabled() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("test.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let mut config = LintConfig::default();
        config.rules.disabled_rules = vec!["REF-001".to_string()];

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &config);

        // Non-CLAUDE.md with REF-001 disabled emits nothing
        assert!(diagnostics.is_empty());
    }

    // ===== REF-002 Tests =====

    #[test]
    fn test_ref_002_broken_link() {
        let temp = TempDir::new().unwrap();
        // REF-002 only fires on agent config files, not generic markdown
        let file_path = temp.path().join("CLAUDE.md");
        fs::write(&file_path, "See [guide](missing.md) for more.").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(
            &file_path,
            "See [guide](missing.md) for more.",
            &LintConfig::default(),
        );

        assert!(diagnostics.iter().any(|d| d.rule == "REF-002"));
        let ref_002 = diagnostics.iter().find(|d| d.rule == "REF-002").unwrap();
        assert!(ref_002.message.contains("Broken markdown link"));
    }

    #[test]
    fn test_ref_002_valid_link() {
        let temp = TempDir::new().unwrap();
        let target = temp.path().join("exists.md");
        let file_path = temp.path().join("test.md");
        fs::write(&target, "Target content").unwrap();
        fs::write(&file_path, "See [guide](exists.md) for more.").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(
            &file_path,
            "See [guide](exists.md) for more.",
            &LintConfig::default(),
        );

        assert!(!diagnostics.iter().any(|d| d.rule == "REF-002"));
    }

    #[test]
    fn test_ref_002_skips_external_links() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("test.md");
        let content = "See [GitHub](https://github.com) and [mail](mailto:test@example.com).";
        fs::write(&file_path, content).unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, content, &LintConfig::default());

        // External links should not trigger REF-002
        assert!(!diagnostics.iter().any(|d| d.rule == "REF-002"));
    }

    #[test]
    fn test_ref_002_skips_anchor_links() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("test.md");
        let content = "See [section](#section-name) for more.";
        fs::write(&file_path, content).unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, content, &LintConfig::default());

        // Pure anchor links should not trigger REF-002
        assert!(!diagnostics.iter().any(|d| d.rule == "REF-002"));
    }

    #[test]
    fn test_ref_002_link_with_fragment() {
        let temp = TempDir::new().unwrap();
        let target = temp.path().join("exists.md");
        let file_path = temp.path().join("test.md");
        fs::write(&target, "# Section\nContent").unwrap();
        fs::write(&file_path, "See [section](exists.md#section) for more.").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(
            &file_path,
            "See [section](exists.md#section) for more.",
            &LintConfig::default(),
        );

        // File exists, fragment validation is not implemented - no error
        assert!(!diagnostics.iter().any(|d| d.rule == "REF-002"));
    }

    #[test]
    fn test_ref_002_missing_file_with_fragment() {
        let temp = TempDir::new().unwrap();
        // REF-002 only fires on agent config files, not generic markdown
        let file_path = temp.path().join("CLAUDE.md");
        fs::write(&file_path, "See [section](missing.md#section) for more.").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(
            &file_path,
            "See [section](missing.md#section) for more.",
            &LintConfig::default(),
        );

        // File doesn't exist, should error
        assert!(diagnostics.iter().any(|d| d.rule == "REF-002"));
    }

    #[test]
    fn test_ref_002_broken_image() {
        let temp = TempDir::new().unwrap();
        // REF-002 only fires on agent config files, not generic markdown
        let file_path = temp.path().join("CLAUDE.md");
        fs::write(&file_path, "![logo](images/logo.png)").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(
            &file_path,
            "![logo](images/logo.png)",
            &LintConfig::default(),
        );

        assert!(diagnostics.iter().any(|d| d.rule == "REF-002"));
        let ref_002 = diagnostics.iter().find(|d| d.rule == "REF-002").unwrap();
        assert!(ref_002.message.contains("Broken markdown link"));
    }

    #[test]
    fn test_ref_002_disabled() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("test.md");
        fs::write(&file_path, "See [guide](missing.md) for more.").unwrap();

        let mut config = LintConfig::default();
        config.rules.disabled_rules = vec!["REF-002".to_string()];

        let validator = ImportsValidator;
        let diagnostics =
            validator.validate(&file_path, "See [guide](missing.md) for more.", &config);

        assert!(!diagnostics.iter().any(|d| d.rule == "REF-002"));
    }

    #[test]
    fn test_ref_002_imports_category_disabled() {
        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("test.md");
        fs::write(&file_path, "See [guide](missing.md) for more.").unwrap();

        let mut config = LintConfig::default();
        config.rules.imports = false;

        let validator = ImportsValidator;
        let diagnostics =
            validator.validate(&file_path, "See [guide](missing.md) for more.", &config);

        // When imports category is disabled, no validation happens
        assert!(diagnostics.is_empty());
    }

    #[test]
    fn test_ref_002_relative_path() {
        let temp = TempDir::new().unwrap();
        let subdir = temp.path().join("docs");
        fs::create_dir(&subdir).unwrap();
        let target = subdir.join("guide.md");
        let file_path = temp.path().join("test.md");
        fs::write(&target, "Guide content").unwrap();
        fs::write(&file_path, "See [guide](docs/guide.md) for more.").unwrap();

        let validator = ImportsValidator;
        let diagnostics = validator.validate(
            &file_path,
            "See [guide](docs/guide.md) for more.",
            &LintConfig::default(),
        );

        // Relative path should resolve correctly
        assert!(!diagnostics.iter().any(|d| d.rule == "REF-002"));
    }

    // ===== Shared Import Cache Tests =====

    #[test]
    fn test_single_file_validation_works_without_shared_cache() {
        // Single-file validation should work without a shared cache
        let temp = TempDir::new().unwrap();
        let target = temp.path().join("target.md");
        let file_path = temp.path().join("test.md");
        fs::write(&target, "Target content").unwrap();
        fs::write(&file_path, "See @target.md").unwrap();

        let config = LintConfig::default();
        // No shared cache set - should use local cache
        assert!(config.get_import_cache().is_none());

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @target.md", &config);

        // Should succeed with no errors
        assert!(diagnostics.is_empty());
    }

    #[test]
    fn test_shared_cache_is_populated_after_first_parse() {
        use std::collections::HashMap;
        use std::sync::{Arc, RwLock};

        let temp = TempDir::new().unwrap();
        let target = temp.path().join("target.md");
        let file_path = temp.path().join("test.md");
        fs::write(&target, "Target has @nested.md import").unwrap();
        fs::write(temp.path().join("nested.md"), "Nested content").unwrap();
        fs::write(&file_path, "See @target.md").unwrap();

        // Create shared cache
        let cache: crate::parsers::ImportCache = Arc::new(RwLock::new(HashMap::new()));

        let mut config = LintConfig::default();
        config.set_import_cache(cache.clone());

        // Verify cache is empty before validation
        {
            let guard = cache.read().unwrap();
            assert!(guard.is_empty());
        }

        let validator = ImportsValidator;
        let _ = validator.validate(&file_path, "See @target.md", &config);

        // Verify cache is populated after validation
        {
            let guard = cache.read().unwrap();
            // Should have at least the root file and target file
            assert!(
                guard.len() >= 2,
                "Expected at least 2 entries, got {}",
                guard.len()
            );
        }
    }

    #[test]
    fn test_shared_cache_second_access_uses_cached_result() {
        use std::collections::HashMap;
        use std::sync::{Arc, RwLock};

        let temp = TempDir::new().unwrap();
        let target = temp.path().join("target.md");
        let file_path = temp.path().join("test.md");
        fs::write(&target, "Target content").unwrap();
        fs::write(&file_path, "See @target.md").unwrap();

        // Create shared cache
        let cache: crate::parsers::ImportCache = Arc::new(RwLock::new(HashMap::new()));

        let mut config = LintConfig::default();
        config.set_import_cache(cache.clone());

        let validator = ImportsValidator;

        // First validation - populates cache
        let _ = validator.validate(&file_path, "See @target.md", &config);
        let cache_size_after_first;
        {
            let guard = cache.read().unwrap();
            cache_size_after_first = guard.len();
        }

        // Second validation - should use cached results
        let _ = validator.validate(&file_path, "See @target.md", &config);
        let cache_size_after_second;
        {
            let guard = cache.read().unwrap();
            cache_size_after_second = guard.len();
        }

        // Cache size should be the same (entries reused, not duplicated)
        assert_eq!(cache_size_after_first, cache_size_after_second);
    }

    #[test]
    fn test_shared_cache_concurrent_access() {
        use std::collections::HashMap;
        use std::sync::{Arc, RwLock};
        use std::thread;

        let temp = TempDir::new().unwrap();

        // Create multiple files that reference each other
        for i in 0..5 {
            let content = if i < 4 {
                format!("Content with @file{}.md import", i + 1)
            } else {
                "End of chain".to_string()
            };
            fs::write(temp.path().join(format!("file{}.md", i)), content).unwrap();
        }

        // Create shared cache
        let cache: crate::parsers::ImportCache = Arc::new(RwLock::new(HashMap::new()));

        // Spawn multiple threads that validate different files with the same cache
        let handles: Vec<_> = (0..5)
            .map(|i| {
                let cache = cache.clone();
                let temp_path = temp.path().to_path_buf();
                thread::spawn(move || {
                    let mut config = LintConfig::default();
                    config.set_import_cache(cache);

                    let file_path = temp_path.join(format!("file{}.md", i));
                    let content = fs::read_to_string(&file_path).unwrap();

                    let validator = ImportsValidator;
                    validator.validate(&file_path, &content, &config)
                })
            })
            .collect();

        // All threads should complete without panic (no deadlock)
        for handle in handles {
            let result = handle.join();
            assert!(result.is_ok(), "Thread should complete without panic");
        }

        // Cache should have entries
        {
            let guard = cache.read().unwrap();
            assert!(
                !guard.is_empty(),
                "Cache should have entries after concurrent access"
            );
        }
    }

    #[test]
    fn test_shared_cache_poisoned_lock_does_not_panic() {
        use std::collections::HashMap;
        use std::sync::{Arc, RwLock};
        use std::thread;

        let temp = TempDir::new().unwrap();
        let target = temp.path().join("target.md");
        let file_path = temp.path().join("test.md");
        fs::write(&target, "Target content").unwrap();
        fs::write(&file_path, "See @target.md").unwrap();

        let cache: crate::parsers::ImportCache = Arc::new(RwLock::new(HashMap::new()));

        let cache_for_poison = cache.clone();
        let _ = thread::spawn(move || {
            let _guard = cache_for_poison.write().unwrap();
            panic!("poison import cache lock");
        })
        .join();
        assert!(cache.read().is_err(), "Cache lock should be poisoned");

        let mut config = LintConfig::default();
        config.set_import_cache(cache);

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @target.md", &config);
        assert!(
            diagnostics.is_empty(),
            "Validation should continue with a poisoned shared cache lock"
        );
    }

    #[test]
    fn test_shared_cache_poisoned_lock_still_reports_missing_import() {
        use std::collections::HashMap;
        use std::sync::{Arc, RwLock};
        use std::thread;

        let temp = TempDir::new().unwrap();
        let file_path = temp.path().join("test.md");
        fs::write(&file_path, "See @missing.md").unwrap();

        let cache: crate::parsers::ImportCache = Arc::new(RwLock::new(HashMap::new()));

        let cache_for_poison = cache.clone();
        let _ = thread::spawn(move || {
            let _guard = cache_for_poison.write().unwrap();
            panic!("poison import cache lock");
        })
        .join();
        assert!(cache.read().is_err(), "Cache lock should be poisoned");

        let mut config = LintConfig::default();
        config.set_import_cache(cache);

        let validator = ImportsValidator;
        let diagnostics = validator.validate(&file_path, "See @missing.md", &config);
        assert!(
            diagnostics
                .iter()
                .any(|d| d.rule == "REF-001" && d.message.contains("@missing.md")),
            "Validation should still report missing imports with a poisoned shared cache lock"
        );
    }

    #[test]
    fn test_revisits_file_when_later_seen_at_shallower_depth() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("CLAUDE.md");
        let a = temp.path().join("a.md");
        let b = temp.path().join("b.md");
        let c = temp.path().join("c.md");
        let d = temp.path().join("d.md");
        let shared = temp.path().join("shared.md");
        let leaf = temp.path().join("leaf.md");

        fs::write(&root, "@a.md\n@shared.md").unwrap();
        fs::write(&a, "@b.md").unwrap();
        fs::write(&b, "@c.md").unwrap();
        fs::write(&c, "@d.md").unwrap();
        fs::write(&d, "@shared.md").unwrap();
        fs::write(&shared, "@leaf.md").unwrap();
        fs::write(&leaf, "@missing.md").unwrap();

        let mut config = LintConfig::default();
        config.set_root_dir(temp.path().to_path_buf());

        let validator = ImportsValidator;
        let content = fs::read_to_string(&root).unwrap();
        let diagnostics = validator.validate(&root, &content, &config);

        assert!(
            diagnostics
                .iter()
                .any(|d| d.rule == "REF-001" && d.message.contains("@missing.md")),
            "Traversal should revisit shared.md at shallower depth and report downstream missing imports"
        );
    }

    #[test]
    fn test_shallower_revisit_does_not_duplicate_missing_import_diagnostics() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().join("CLAUDE.md");
        let a = temp.path().join("a.md");
        let b = temp.path().join("b.md");
        let shared = temp.path().join("shared.md");

        fs::write(&root, "@a.md\n@shared.md").unwrap();
        fs::write(&a, "@b.md").unwrap();
        fs::write(&b, "@shared.md").unwrap();
        fs::write(&shared, "@missing.md").unwrap();

        let mut config = LintConfig::default();
        config.set_root_dir(temp.path().to_path_buf());

        let validator = ImportsValidator;
        let content = fs::read_to_string(&root).unwrap();
        let diagnostics = validator.validate(&root, &content, &config);

        let missing: Vec<_> = diagnostics
            .iter()
            .filter(|d| d.rule == "REF-001" && d.message.contains("@missing.md"))
            .collect();
        assert_eq!(
            missing.len(),
            1,
            "Expected a single REF-001 diagnostic for @missing.md, got {}",
            missing.len()
        );
    }
}
