//!
//! Rule MD057: Existing relative links
//!
//! See [docs/md057.md](../../docs/md057.md) for full documentation, configuration, and examples.

use crate::rule::{
    CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
};
use crate::utils::frontmatter_values;
use crate::utils::range_utils::byte_to_char_count;
use crate::workspace_index::{FileIndex, extract_cross_file_links, normalize_relative_path};
use pulldown_cmark::LinkType;
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::env;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::sync::{Arc, Mutex};

mod md057_config;
use crate::utils::mkdocs_config::resolve_docs_dir;
use crate::utils::obsidian_config::resolve_attachment_folder;
use crate::utils::project_root::discover_project_root_from;
pub use md057_config::{AbsoluteLinksOption, MD057Config};

// Thread-safe cache for file existence checks to avoid redundant filesystem operations
static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));

// Reset the file existence cache (typically between rule runs)
fn reset_file_existence_cache() {
    if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
        cache.clear();
    }
}

// Check if a file exists with caching
fn file_exists_with_cache(path: &Path) -> bool {
    match FILE_EXISTENCE_CACHE.lock() {
        Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
        Err(_) => path.exists(), // Fallback to uncached check on mutex poison
    }
}

/// Check if a file exists, also trying markdown extensions for extensionless links.
/// This supports wiki-style links like `[Link](page)` that resolve to `page.md`.
fn file_exists_or_markdown_extension(path: &Path) -> bool {
    resolve_existing_target(path).is_some()
}

/// The file a link path resolves to, or `None` when nothing is there.
///
/// An extensionless link is tried against the markdown extensions in turn, so
/// `[Link](page)` resolves to `page.md`. Callers that only need existence go
/// through `file_exists_or_markdown_extension`; the resolved path itself
/// matters when the answer has to be compared against another file.
fn resolve_existing_target(path: &Path) -> Option<PathBuf> {
    // First, check exact path
    if file_exists_with_cache(path) {
        return Some(path.to_path_buf());
    }

    // If the path has no extension, try adding markdown extensions
    if path.extension().is_none() {
        for ext in MARKDOWN_EXTENSIONS {
            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
            let path_with_ext = path.with_extension(&ext[1..]);
            if file_exists_with_cache(&path_with_ext) {
                return Some(path_with_ext);
            }
        }
    }

    None
}

// Regex to match the start of a link - simplified for performance
static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());

/// Regex to extract the URL from an angle-bracketed markdown link
/// Format: `](<URL>)` or `](<URL> "title")`
/// This handles URLs with parentheses like `](<path/(with)/parens.md>)`
static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());

/// Regex to extract the URL from a normal markdown link (without angle brackets)
/// Format: `](URL)` or `](URL "title")`
static URL_EXTRACT_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());

/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
/// Matches: scheme:// or scheme: (per RFC 3986)
/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());

// Current working directory
static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

/// Project root discovered once at process start by walking up from CWD looking
/// for `.git`, `.rumdl.toml`, `pyproject.toml`, or `.markdownlint.json`. Used as
/// the anchor for resolving non-absolute paths in `roots` and `search-paths`,
/// and as the implicit fallback root for absolute-link validation. Tests that
/// pass a path via `with_path()` bypass this discovery.
static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));

/// Convert a hex digit (0-9, a-f, A-F) to its numeric value.
/// Returns None for non-hex characters.
#[inline]
fn hex_digit_to_value(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

/// Supported markdown file extensions
const MARKDOWN_EXTENSIONS: &[&str] = &[
    ".md",
    ".markdown",
    ".mdx",
    ".mkd",
    ".mkdn",
    ".mdown",
    ".mdwn",
    ".qmd",
    ".rmd",
];

/// A relative link that resolves to the file it is written in.
#[derive(Debug, PartialEq, Eq)]
enum SelfReferentialLink {
    /// The link addresses the whole file, so there is no shorter way to write
    /// the same destination.
    WholeFile,
    /// The link carries a fragment, which on its own reaches the same heading
    /// without leaving the page.
    Fragment(String),
}

/// Rule MD057: Existing relative links should point to valid files or directories.
#[derive(Debug, Clone)]
pub struct MD057ExistingRelativeLinks {
    /// Base directory for resolving relative links. Behind an `Arc<Mutex<..>>`
    /// so it is shared across clones (the rule is cloned per config group and
    /// for inline-config overrides); the base is resolved from the file under
    /// check and consulted while validating that file's links.
    base_path: Arc<Mutex<Option<PathBuf>>>,
    /// Configuration for the rule
    config: MD057Config,
}

impl Default for MD057ExistingRelativeLinks {
    fn default() -> Self {
        Self {
            base_path: Arc::new(Mutex::new(None)),
            config: MD057Config::default(),
        }
    }
}

impl MD057ExistingRelativeLinks {
    /// Create a new instance with default settings
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the base path for resolving relative links
    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
        let path = path.as_ref();
        let dir_path = if path.is_file() {
            path.parent().map(std::path::Path::to_path_buf)
        } else {
            Some(path.to_path_buf())
        };

        if let Ok(mut guard) = self.base_path.lock() {
            *guard = dir_path;
        }
        self
    }

    pub fn from_config_struct(config: MD057Config) -> Self {
        Self {
            base_path: Arc::new(Mutex::new(None)),
            config,
        }
    }

    /// Resolve a config-supplied path string (from `roots` or `search-paths`)
    /// against the project root: absolute strings are taken verbatim, relative
    /// strings are joined onto `project_root`.
    fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
        if Path::new(path_str).is_absolute() {
            PathBuf::from(path_str)
        } else {
            project_root.join(path_str)
        }
    }

    /// Check if a URL is external or should be skipped for validation.
    ///
    /// Returns `true` (skip validation) for:
    /// - URLs with protocols: `https://`, `http://`, `ftp://`, `mailto:`, etc.
    /// - Bare domains: `www.example.com`, `example.com`
    /// - Email addresses: `user@example.com` (without `mailto:`)
    /// - Template variables: `{{URL}}`, `{{% include %}}`
    /// - Absolute web URL paths: `/api/docs`, `/blog/post.html`
    ///
    /// Returns `false` (validate) for:
    /// - Relative filesystem paths: `./file.md`, `../parent/file.md`, `file.md`
    #[inline]
    fn is_external_url(&self, url: &str) -> bool {
        if url.is_empty() {
            return false;
        }

        // Quick checks for common external URL patterns
        if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
            return true;
        }

        // Skip template variables (Handlebars/Mustache/Jinja2 syntax)
        // Examples: {{URL}}, {{#URL}}, {{> partial}}, {{% include %}}, {{ variable }}
        if url.starts_with("{{") || url.starts_with("{%") {
            return true;
        }

        // Simple check: if URL contains @, it's almost certainly an email address
        // File paths with @ are extremely rare, so this is a safe heuristic
        if url.contains('@') {
            return true; // It's an email address, skip it
        }

        // Bare domain check (e.g., "example.com")
        // Note: We intentionally DON'T skip all TLDs like .org, .net, etc.
        // Links like [text](nodejs.org/path) without a protocol are broken -
        // they'll be treated as relative paths by markdown renderers.
        // Flagging them helps users find missing protocols.
        // We only skip .com as a minimal safety net for the most common case.
        // Require the absence of a path separator so a relative file reference
        // that merely ends in ".com" (e.g. "../../vendor.com") is still
        // validated rather than assumed to be a bare domain.
        if !url.contains('/') && url.ends_with(".com") {
            return true;
        }

        // Framework path aliases (resolved by build tools like Vite, webpack, etc.)
        // These are not filesystem paths but module/asset aliases
        // Examples: ~/assets/image.png, @images/photo.jpg, @/components/Button.vue
        if url.starts_with('~') || url.starts_with('@') {
            return true;
        }

        // All other cases (relative paths, etc.) are not external
        false
    }

    /// Check if the URL is a fragment-only link (internal document link)
    #[inline]
    fn is_fragment_only_link(&self, url: &str) -> bool {
        url.starts_with('#')
    }

    /// Check if the URL is an absolute path (starts with /)
    /// These are typically routes for published documentation sites.
    #[inline]
    fn is_absolute_path(url: &str) -> bool {
        url.starts_with('/')
    }

    /// Decode URL percent-encoded sequences in a path.
    /// Converts `%20` to space, `%2F` to `/`, etc.
    /// Returns the original string if decoding fails or produces invalid UTF-8.
    fn url_decode(path: &str) -> String {
        // Quick check: if no percent sign, return as-is
        if !path.contains('%') {
            return path.to_string();
        }

        let bytes = path.as_bytes();
        let mut result = Vec::with_capacity(bytes.len());
        let mut i = 0;

        while i < bytes.len() {
            if bytes[i] == b'%' && i + 2 < bytes.len() {
                // Try to parse the two hex digits following %
                let hex1 = bytes[i + 1];
                let hex2 = bytes[i + 2];
                if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
                    result.push(d1 * 16 + d2);
                    i += 3;
                    continue;
                }
            }
            result.push(bytes[i]);
            i += 1;
        }

        // Convert to UTF-8, falling back to original if invalid
        String::from_utf8(result).unwrap_or_else(|_| path.to_string())
    }

    /// Strip query parameters and fragments from a URL for file existence checking.
    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
    /// for `path/to/image.png` or `file.md` respectively.
    ///
    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
    /// so we check for `?` first. If a URL has both, only the query is stripped here
    /// (fragments are handled separately by the regex in `contribute_to_index`).
    fn strip_query_and_fragment(url: &str) -> &str {
        // Find the first occurrence of '?' or '#', whichever comes first
        // This handles both standard URLs (? before #) and edge cases (# before ?)
        let query_pos = url.find('?');
        let fragment_pos = url.find('#');

        match (query_pos, fragment_pos) {
            (Some(q), Some(f)) => {
                // Both exist - strip at whichever comes first
                &url[..q.min(f)]
            }
            (Some(q), None) => &url[..q],
            (None, Some(f)) => &url[..f],
            (None, None) => url,
        }
    }

    /// Resolve a relative link against a provided base path
    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
        base_path.join(link)
    }

    /// Compute additional search paths for fallback link resolution.
    ///
    /// Combines Obsidian attachment folder auto-detection (when flavor is Obsidian)
    /// with explicitly configured `search-paths`.
    fn compute_search_paths(
        &self,
        flavor: crate::config::MarkdownFlavor,
        source_file: Option<&Path>,
        base_path: &Path,
        project_root: &Path,
    ) -> Vec<PathBuf> {
        let mut paths = Vec::new();

        // Auto-detect Obsidian attachment folder
        if flavor == crate::config::MarkdownFlavor::Obsidian
            && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
            && attachment_dir != *base_path
        {
            paths.push(attachment_dir);
        }

        // Add explicitly configured search paths. Resolved relative to the
        // discovered project root so paths are stable regardless of which
        // subdirectory rumdl is invoked from.
        for search_path in &self.config.search_paths {
            let resolved = Self::resolve_against_project_root(search_path, project_root);
            if resolved != *base_path && !paths.contains(&resolved) {
                paths.push(resolved);
            }
        }

        paths
    }

    /// Check if a link target exists in any of the additional search paths.
    fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
        search_paths.iter().any(|dir| {
            let candidate = dir.join(decoded_path);
            file_exists_or_markdown_extension(&candidate)
        })
    }

    /// Check if a relative link can be compacted and return the simplified form.
    ///
    /// Returns `None` if compact-paths is disabled, the link has no traversal,
    /// or the link is already the shortest form.
    /// Returns `Some(suggestion)` with the full compacted URL (including fragment/query suffix).
    fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
        if !self.config.compact_paths {
            return None;
        }

        // Split URL into path and suffix (fragment/query)
        let path_end = url
            .find('?')
            .unwrap_or(url.len())
            .min(url.find('#').unwrap_or(url.len()));
        let path_part = &url[..path_end];
        let suffix = &url[path_end..];

        // URL-decode the path portion for filesystem resolution
        let decoded_path = Self::url_decode(path_part);

        compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
    }

    /// Classify a relative link that points back at the file it is written in.
    ///
    /// Returns `None` when the option is off, when the file under check is
    /// unknown, or when the link addresses anything else. Fragment-only links
    /// never reach here: they are already the form this reports towards.
    ///
    /// The link is resolved against the file's own directory first and then
    /// against each search path, matching how the existence check resolves it:
    /// a link that MD057 accepts through a search path is recognized here as
    /// well, and one that already resolves next to the document is judged
    /// against that target alone.
    fn self_referential_link(
        &self,
        url: &str,
        base_path: &Path,
        search_paths: &[PathBuf],
        source_file: Option<&Path>,
    ) -> Option<SelfReferentialLink> {
        if !self.config.self_referential_links {
            return None;
        }
        let source_file = source_file?;

        let path_part = Self::strip_query_and_fragment(url);
        if path_part.is_empty() {
            return None;
        }
        let suffix = &url[path_part.len()..];

        let decoded_path = Self::url_decode(path_part);
        // First hit wins, as it does for the existence check: a target next to
        // the document is the one the link addresses, and a search path only
        // answers for a link that resolves nowhere else.
        let resolved = std::iter::once(base_path)
            .chain(search_paths.iter().map(PathBuf::as_path))
            .find_map(|dir| resolve_existing_target(&Self::resolve_link_path_with_base(&decoded_path, dir)))?;
        if !Self::is_same_file(&resolved, source_file) {
            return None;
        }

        // A bare fragment reaches the same place. A query string does not
        // survive being detached from its path, and neither does an empty
        // fragment, so those are reported without a suggestion.
        match suffix.strip_prefix('#') {
            Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
            _ => Some(SelfReferentialLink::WholeFile),
        }
    }

    /// Byte range of a reference definition's destination in the document.
    ///
    /// Both the label and the title can repeat the destination text, so the
    /// search is bounded to what sits between the label's closing bracket and
    /// the title. Anchoring anywhere else risks a fix rewriting the label,
    /// which would leave every usage of the reference dangling.
    fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
        let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
        let label_end = Self::label_end(def)?;
        let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
            title.saturating_sub(ref_def.byte_offset).min(def.len())
        });
        let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
        let start = ref_def.byte_offset + offset;
        Some(start..start + ref_def.url.len())
    }

    /// Offset just past the `]:` that closes a reference definition's label.
    ///
    /// A label may itself contain a bracket when the bracket is escaped, so
    /// escapes are skipped rather than matched.
    fn label_end(def: &str) -> Option<usize> {
        let bytes = def.as_bytes();
        let mut i = 0;
        while i < bytes.len() {
            match bytes[i] {
                b'\\' => i += 2,
                b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
                _ => i += 1,
            }
        }
        None
    }

    /// Whether this document has frontmatter the rule is configured to check.
    /// Gates the body-link early exits, which would otherwise skip a document
    /// whose only destinations sit in its frontmatter.
    fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
        self.config.check_frontmatter && ctx.front_matter_end_line() > 0
    }

    /// The warning text for an absolute destination, or `None` when the
    /// configured handling accepts it.
    fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
        match self.config.absolute_links {
            AbsoluteLinksOption::Ignore => None,
            AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
            AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
            AbsoluteLinksOption::RelativeToRoots => {
                Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
            }
        }
    }

    /// Report frontmatter values that read as relative destinations but point
    /// at nothing.
    ///
    /// Only existence is checked. The compact-path and self-referential
    /// suggestions stay out of frontmatter: both rewrite a destination, and a
    /// frontmatter value is only ever a guess at being one.
    fn check_front_matter(
        &self,
        ctx: &crate::lint_context::LintContext,
        base_path: &Path,
        search_paths: &[PathBuf],
        project_root: &Path,
        warnings: &mut Vec<LintWarning>,
    ) {
        if !self.config.check_frontmatter {
            return;
        }

        let ignored: HashSet<String> = self
            .config
            .ignore_frontmatter_fields
            .iter()
            .map(|field| field.to_lowercase())
            .collect();

        for link in frontmatter_values::link_destinations(ctx) {
            if link.field_is_in(&ignored) {
                continue;
            }

            let line = ctx.lines[link.line - 1].content(ctx.content);
            let url = &line[link.range.clone()];

            // A fragment belongs to MD051, which validates it against the
            // document's own headings.
            if self.is_external_url(url) || self.is_fragment_only_link(url) {
                continue;
            }

            let column = byte_to_char_count(line, link.range.start);
            let end_column = column + url.chars().count();

            if Self::is_absolute_path(url) {
                if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
                    warnings.push(LintWarning {
                        rule_name: Some(self.name().to_string()),
                        line: link.line,
                        column,
                        end_line: link.line,
                        end_column,
                        message,
                        severity: Severity::Warning,
                        fix: None,
                    });
                }
                continue;
            }

            if Self::relative_target_exists(url, base_path, search_paths) {
                continue;
            }

            warnings.push(LintWarning {
                rule_name: Some(self.name().to_string()),
                line: link.line,
                column,
                end_line: link.line,
                end_column,
                message: format!("Relative link '{url}' does not exist"),
                severity: Severity::Error,
                fix: None,
            });
        }
    }

    /// Whether a relative destination resolves to something on disk.
    ///
    /// The destination is stripped of its query and fragment, percent-decoded,
    /// then resolved against `base_path`, with two fallbacks: an `.html`/`.htm`
    /// target passes when the markdown source it is generated from exists, and
    /// any target passes when one of `search_paths` holds it.
    fn relative_target_exists(url: &str, base_path: &Path, search_paths: &[PathBuf]) -> bool {
        let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);

        // An extensionless link is also tried with each markdown extension.
        if file_exists_or_markdown_extension(&resolved_path) {
            return true;
        }

        if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
            && let (Some(stem), Some(parent)) = (
                resolved_path.file_stem().and_then(|s| s.to_str()),
                resolved_path.parent(),
            )
            && MARKDOWN_EXTENSIONS
                .iter()
                .any(|md_ext| file_exists_with_cache(&parent.join(format!("{stem}{md_ext}"))))
        {
            return true;
        }

        Self::exists_in_search_paths(&decoded_path, search_paths)
    }

    /// Whether any enabled check can offer a fix. Broken links and absolute
    /// links are reported without one, so the answer rests on the two options
    /// that rewrite a destination.
    fn produces_fixes(&self) -> bool {
        self.config.compact_paths || self.config.self_referential_links
    }

    /// The warning text for a link that points at its own file.
    fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
        match self_link {
            SelfReferentialLink::Fragment(fragment) => {
                format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
            }
            SelfReferentialLink::WholeFile => {
                format!("Relative link '{url}' points to the file it is in")
            }
        }
    }

    /// Whether two existing paths are the same file.
    ///
    /// Both sides are canonicalized, which settles symlinks and the platform's
    /// path representation; the lexical form is the fallback for a path the
    /// filesystem cannot answer for.
    fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
        // Cheap reject before the syscall: a different name is a different file.
        if resolved.file_name() != source_file.file_name() {
            return false;
        }
        match (resolved.canonicalize(), source_file.canonicalize()) {
            (Ok(link), Ok(source)) => link == source,
            _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
        }
    }

    /// Validate an absolute link by resolving it relative to MkDocs docs_dir.
    ///
    /// Returns `Some(warning_message)` if the link is broken, `None` if valid.
    /// Falls back to a generic warning if no mkdocs.yml is found.
    /// Validate an absolute link against the MkDocs `docs_dir`.
    fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
        let Some(docs_dir) = resolve_docs_dir(source_path) else {
            return Some(format!(
                "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
            ));
        };

        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);

        // MkDocs mode: an extensionless directory link must have index.md.
        // `require_index_for_dirs = true` enforces this for all directory hits.
        match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
            Resolution::Found => None,
            Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
                "Absolute link '{url}' resolves to directory '{}' which has no index.md",
                resolved.display()
            )),
            Resolution::NotFound { resolved } => Some(format!(
                "Absolute link '{url}' resolves to '{}' which does not exist",
                resolved.display()
            )),
        }
    }

    /// Validate an absolute link by resolving it against each configured root and the project root.
    ///
    /// Configured `roots` are tried first (first match wins), then the project
    /// root is tried as an implicit fallback. The fallback supports links
    /// written as literal absolute paths from the project root (e.g.
    /// `/content/en/foo.md`) alongside links written relative to a configured
    /// root (e.g. `/foo.md` with `roots = ["content/en"]`). A warning is
    /// emitted only when no root — configured or implicit — contains the target.
    fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);

        for root in roots {
            let root_path = Self::resolve_against_project_root(root, project_root);
            // Filesystem mode: an existing directory without trailing slash is valid.
            // `require_index_for_dirs = false` aligns with relative-link behavior. (#632)
            if matches!(
                Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
                Resolution::Found
            ) {
                return None;
            }
        }

        if matches!(
            // Filesystem mode: see above.
            Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
            Resolution::Found
        ) {
            return None;
        }

        let msg = if roots.is_empty() {
            format!("Absolute link '{url}' was not found under the project root")
        } else {
            format!("Absolute link '{url}' was not found under any configured root or the project root")
        };
        Some(msg)
    }

    /// Decode an absolute-link URL into a filesystem-relative path and a
    /// directory-link flag. Strips the leading `/`, query/fragment suffix, and
    /// percent-encoding.
    fn prepare_absolute_url(url: &str) -> (String, bool) {
        let relative_url = url.trim_start_matches('/');
        let file_path = Self::strip_query_and_fragment(relative_url);
        let decoded = Self::url_decode(file_path);
        let is_directory_link = url.ends_with('/') || decoded.is_empty();
        (decoded, is_directory_link)
    }

    /// Try to resolve a decoded absolute-link path under a single root directory.
    ///
    /// `require_index_for_dirs` controls how extensionless links that resolve to a
    /// directory are treated:
    ///
    /// - `true` (MkDocs / docs-dir mode): a directory must contain `index.md` to be
    ///   considered valid, even when the link has no trailing slash. This matches
    ///   MkDocs' URL routing convention where `/section` serves `section/index.md`.
    ///
    /// - `false` (roots / filesystem mode): an existing directory is accepted as a
    ///   valid target for an extensionless link, matching the behavior of relative
    ///   links (which use `path.exists()`). Only an explicit trailing-slash link
    ///   (`is_directory_link == true`) still requires `index.md`.
    ///
    /// Applies resolution strategies in order:
    /// 1. Directory-style links (explicit `/` suffix or `require_index_for_dirs`):
    ///    look for `<resolved>/index.md`; report `DirectoryWithoutIndex` on failure.
    /// 2. Filesystem-mode directory hit (`require_index_for_dirs == false` and
    ///    `is_directory_link == false`): accept the existing directory as `Found`.
    /// 3. Direct existence (with markdown-extension fallback for extensionless links).
    /// 4. `.html`/`.htm` links: look for a markdown source with the same stem.
    fn resolve_under_root_with_opts(
        root_path: &Path,
        decoded: &str,
        is_directory_link: bool,
        require_index_for_dirs: bool,
    ) -> Resolution {
        let resolved = root_path.join(decoded);

        let is_dir = resolved.is_dir();

        // When the link explicitly ends with `/` or the caller requires index.md
        // for all directory hits (MkDocs mode), apply the stricter check first.
        // Must be checked before `file_exists_or_markdown_extension` because
        // `path.exists()` returns `true` for directories.
        if is_directory_link || (require_index_for_dirs && is_dir) {
            let index_path = resolved.join("index.md");
            if file_exists_with_cache(&index_path) {
                return Resolution::Found;
            }
            if is_dir {
                return Resolution::DirectoryWithoutIndex { resolved };
            }
        }

        // Filesystem mode (roots): an existing directory without a trailing slash
        // is valid — mirrors how relative links accept directories via `path.exists()`.
        // Exclude decoded paths that end with `/`: a URL like `/guide/#intro` strips
        // the fragment to `guide/`, so `decoded` carries the trailing slash even though
        // `is_directory_link` is false (the raw URL ends with `#intro`, not `/`).
        let decoded_has_trailing_slash = decoded.ends_with('/');
        if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
            return Resolution::Found;
        }

        if file_exists_or_markdown_extension(&resolved) {
            return Resolution::Found;
        }

        // For .html/.htm links, accept a matching markdown source in the same
        // directory — supports doc sites that compile .md to .html.
        if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
        {
            let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
                let source_path = parent.join(format!("{stem}{md_ext}"));
                file_exists_with_cache(&source_path)
            });
            if has_md_source {
                return Resolution::Found;
            }
        }

        Resolution::NotFound { resolved }
    }
}

/// Outcome of trying to resolve an absolute link under a single root directory.
/// Carries the resolved path on the failure variants so callers can build
/// specific error messages without recomputing it.
enum Resolution {
    Found,
    DirectoryWithoutIndex { resolved: PathBuf },
    NotFound { resolved: PathBuf },
}

/// Search `re` in `line` starting at `expected_start`, accepting the match
/// only if it begins exactly there.
///
/// `Regex::captures_at` searches starting at the given offset but does not
/// require the match to *begin* there. A bracket's own destination that
/// fails to match at its own position (a fragment-only `(#bar)`, an empty
/// `()`) would otherwise silently slide forward and return a later,
/// unrelated match belonging to a different bracket on the same line.
/// Rejecting a non-anchored match treats that case as "no destination for
/// this bracket" instead of borrowing a sibling bracket's URL.
fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
    let caps = re.captures_at(line, expected_start)?;
    if caps.get(0)?.start() != expected_start {
        return None;
    }
    Some(caps)
}

impl Rule for MD057ExistingRelativeLinks {
    fn name(&self) -> &'static str {
        "MD057"
    }

    fn description(&self) -> &'static str {
        "Relative links should point to existing files"
    }

    fn category(&self) -> RuleCategory {
        RuleCategory::Link
    }

    fn skippable_by_category(&self) -> bool {
        // A frontmatter path is a link this rule resolves, and the document
        // holding it needs no link syntax anywhere else.
        !self.config.check_frontmatter
    }

    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
        ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
    }

    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
        let content = ctx.content;

        if content.is_empty() {
            return Ok(Vec::new());
        }

        // Early returns for performance. A document whose only destinations
        // sit in its frontmatter has no link syntax to find, so the body-link
        // shortcuts only apply when frontmatter is not being checked.
        let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
        if !has_body_links && !self.checks_front_matter_of(ctx) {
            return Ok(Vec::new());
        }

        // Reset the file existence cache for a fresh run
        reset_file_existence_cache();

        let mut warnings = Vec::new();

        // Read the explicit base path (set via `with_path()` in tests) once; it
        // doubles as both the per-file base path and the project root override
        // for absolute-link resolution.
        let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());

        // Project root used for absolute-link resolution against configured
        // `roots` and as the implicit fallback root. The explicit base wins
        // when set; otherwise the discovered project root is used.
        let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());

        // The file under check, as the filesystem sees it. Links are compared
        // against it to find the ones that point back at their own document.
        let self_path: Option<PathBuf> = ctx
            .source_file
            .as_ref()
            .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.clone()));

        // Determine base path for resolving relative links.
        // ALWAYS compute from ctx.source_file for each file - do not reuse cached base_path
        // This ensures each file resolves links relative to its own directory.
        let base_path: Option<PathBuf> = {
            if explicit_base.is_some() {
                explicit_base
            } else if let Some(ref resolved_file) = self_path {
                // Resolve symlinks to get the actual file location
                // This ensures relative links are resolved from the target's directory,
                // not the symlink's directory
                resolved_file
                    .parent()
                    .map(std::path::Path::to_path_buf)
                    .or_else(|| Some(CURRENT_DIR.clone()))
            } else {
                // No source file available - cannot validate relative links
                None
            }
        };

        // If we still don't have a base path, we can't validate relative links
        let Some(base_path) = base_path else {
            return Ok(warnings);
        };

        // Compute additional search paths for fallback link resolution
        let extra_search_paths =
            self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path, &project_root);

        // Use LintContext links instead of expensive regex parsing
        if !ctx.links.is_empty() {
            // Use LineIndex for correct position calculation across all line ending types
            let line_index = &ctx.line_index;

            // Pre-collected lines from context
            let lines = ctx.raw_lines();

            // Track which lines we've already processed to avoid duplicates
            // (ctx.links may have multiple entries for the same line, especially with malformed markdown)
            let mut processed_lines = std::collections::HashSet::new();

            for link in &ctx.links {
                let line_idx = link.line - 1;
                if line_idx >= lines.len() {
                    continue;
                }

                // Skip lines inside PyMdown blocks
                if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
                    continue;
                }

                // Skip if we've already processed this line
                if !processed_lines.insert(line_idx) {
                    continue;
                }

                let line = lines[line_idx];

                // Quick check for link pattern in this line
                if !line.contains("](") {
                    continue;
                }

                // Find all links in this line using optimized regex
                for link_match in LINK_START_REGEX.find_iter(line) {
                    // Skip image syntax (`![...]`) here, images are already fully
                    // validated by the dedicated ctx.images loop below, and processing
                    // them again here would duplicate that warning. A bang preceded by
                    // an odd number of backslashes is escaped, literal text per
                    // CommonMark, making the bracket a normal link that the image loop
                    // never sees, so it must stay in this loop.
                    if link_match.as_str().starts_with('!') {
                        let escapes = line[..link_match.start()]
                            .bytes()
                            .rev()
                            .take_while(|&b| b == b'\\')
                            .count();
                        if escapes % 2 == 0 {
                            continue;
                        }
                    }

                    let start_pos = link_match.start();
                    let end_pos = link_match.end();

                    // Calculate absolute position using LineIndex
                    let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
                    let absolute_start_pos = line_start_byte + start_pos;

                    // Skip if this link is in a code span
                    if ctx.is_in_code_span_byte(absolute_start_pos) {
                        continue;
                    }

                    // Skip if this link is in a math span (LaTeX $...$ or $$...$$)
                    if ctx.is_in_math_span(absolute_start_pos) {
                        continue;
                    }

                    // Skip if this link is inside a template shortcode tag. The
                    // tag is an argument list read by a template, so a path in it
                    // is resolved by the site generator's own rules rather than
                    // relative to this file.
                    if ctx.is_in_shortcode(absolute_start_pos) {
                        continue;
                    }

                    // Find the URL part after the link text
                    // Try angle-bracket regex first (handles URLs with parens like `<path/(with)/parens.md>`)
                    // Then fall back to normal URL regex. Both searches are anchored to
                    // this bracket's own position so a destination that cannot match
                    // here (fragment-only, empty) yields no URL instead of borrowing
                    // the next bracket's destination.
                    let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
                        .and_then(|caps| caps.get(1).map(|g| (caps, g)))
                        .or_else(|| {
                            extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
                                .and_then(|caps| caps.get(1).map(|g| (caps, g)))
                        });

                    if let Some((caps, url_group)) = caps_and_url {
                        let url = url_group.as_str().trim();

                        // Skip empty URLs
                        if url.is_empty() {
                            continue;
                        }

                        // Skip rustdoc intra-doc links (backtick-wrapped URLs)
                        // These are Rust API references, not file paths
                        // Example: [`f32::is_subnormal`], [`Vec::push`]
                        if url.starts_with('`') && url.ends_with('`') {
                            continue;
                        }

                        // Skip external URLs and fragment-only links
                        if self.is_external_url(url) || self.is_fragment_only_link(url) {
                            continue;
                        }

                        // Handle absolute paths based on config
                        if Self::is_absolute_path(url) {
                            if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
                                warnings.push(LintWarning {
                                    rule_name: Some(self.name().to_string()),
                                    line: link.line,
                                    column: byte_to_char_count(line, url_group.start()),
                                    end_line: link.line,
                                    end_column: byte_to_char_count(line, url_group.end()),
                                    message,
                                    severity: Severity::Warning,
                                    fix: None,
                                });
                            }
                            continue;
                        }

                        // Check for unnecessary path traversal (compact-paths)
                        // Reconstruct full URL including fragment (regex group 2)
                        // since url_group (group 1) contains only the path part
                        let full_url_for_compact = if let Some(frag) = caps.get(2) {
                            format!("{url}{}", frag.as_str())
                        } else {
                            url.to_string()
                        };
                        // A link back into the current file. Reported instead of
                        // the compaction below, whose shorter path would still
                        // be a link the reader should not follow, and instead
                        // of the existence check, which this target passes.
                        if let Some(self_link) = self.self_referential_link(
                            &full_url_for_compact,
                            &base_path,
                            &extra_search_paths,
                            self_path.as_deref(),
                        ) {
                            let url_start = url_group.start();
                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
                            let fix_byte_start = line_start_byte + url_start;
                            let fix_byte_end = line_start_byte + url_end;
                            warnings.push(LintWarning {
                                rule_name: Some(self.name().to_string()),
                                line: link.line,
                                column: byte_to_char_count(line, url_start),
                                end_line: link.line,
                                end_column: byte_to_char_count(line, url_end),
                                message: Self::self_referential_message(&full_url_for_compact, &self_link),
                                severity: Severity::Warning,
                                fix: match &self_link {
                                    SelfReferentialLink::Fragment(fragment) => {
                                        Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
                                    }
                                    SelfReferentialLink::WholeFile => None,
                                },
                            });
                            continue;
                        }

                        if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
                            let url_start = url_group.start();
                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
                            let fix_byte_start = line_start_byte + url_start;
                            let fix_byte_end = line_start_byte + url_end;
                            warnings.push(LintWarning {
                                rule_name: Some(self.name().to_string()),
                                line: link.line,
                                column: byte_to_char_count(line, url_start),
                                end_line: link.line,
                                end_column: byte_to_char_count(line, url_end),
                                message: format!(
                                    "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
                                ),
                                severity: Severity::Warning,
                                fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
                            });
                        }

                        if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
                            continue;
                        }

                        // File doesn't exist and no source file found
                        // Use actual URL position from regex capture group
                        // Note: capture group positions are absolute within the line string
                        let url_start = url_group.start();
                        let url_end = url_group.end();

                        warnings.push(LintWarning {
                            rule_name: Some(self.name().to_string()),
                            line: link.line,
                            column: byte_to_char_count(line, url_start),
                            end_line: link.line,
                            end_column: byte_to_char_count(line, url_end),
                            message: format!("Relative link '{url}' does not exist"),
                            severity: Severity::Error,
                            fix: None,
                        });
                    }
                }
            }
        }

        // Also process images - they have URLs already parsed
        for image in &ctx.images {
            // Skip images inside PyMdown blocks (MkDocs flavor)
            if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
                continue;
            }

            // A wiki embed names a vault entry, not a path relative to this
            // file: `![[diagram.png]]` resolves wherever the attachment lives.
            // The links loop already leaves `[[diagram.png]]` alone.
            if matches!(image.link_type, LinkType::WikiLink { .. }) {
                continue;
            }

            // Image syntax inside a template shortcode tag is a parameter the
            // template resolves, not a path relative to this file.
            if ctx.is_in_shortcode(image.byte_offset) {
                continue;
            }

            let url = image.url.as_ref();

            // Skip empty URLs
            if url.is_empty() {
                continue;
            }

            // Skip external URLs and fragment-only links
            if self.is_external_url(url) || self.is_fragment_only_link(url) {
                continue;
            }

            // Handle absolute paths based on config
            if Self::is_absolute_path(url) {
                if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
                    warnings.push(LintWarning {
                        rule_name: Some(self.name().to_string()),
                        line: image.line,
                        column: image.start_col + 1,
                        end_line: image.line,
                        end_column: image.start_col + 1 + url.chars().count(),
                        message,
                        severity: Severity::Warning,
                        fix: None,
                    });
                }
                continue;
            }

            // Check for unnecessary path traversal (compact-paths)
            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
                // Find the URL position within the image syntax using document byte offsets.
                // Search from image.byte_offset (the `!` character) to locate the URL string.
                let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
                    let fix_byte_start = image.byte_offset + url_offset;
                    let fix_byte_end = fix_byte_start + url.len();
                    Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
                });

                let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
                let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
                // The fix range is a document byte offset; the displayed column is
                // the corresponding character offset within the line.
                let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
                    byte_to_char_count(image_line, f.range.start - img_line_start_byte)
                });
                warnings.push(LintWarning {
                    rule_name: Some(self.name().to_string()),
                    line: image.line,
                    column: url_col,
                    end_line: image.line,
                    end_column: url_col + url.chars().count(),
                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
                    severity: Severity::Warning,
                    fix,
                });
            }

            if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
                continue;
            }

            // File doesn't exist and no source file found
            // Images already have correct position from parser
            warnings.push(LintWarning {
                rule_name: Some(self.name().to_string()),
                line: image.line,
                column: image.start_col + 1,
                end_line: image.line,
                end_column: image.start_col + 1 + url.chars().count(),
                message: format!("Relative link '{url}' does not exist"),
                severity: Severity::Error,
                fix: None,
            });
        }

        // Also process reference definitions: [ref]: ./path.md
        for ref_def in &ctx.reference_defs {
            let url = &ref_def.url;

            // Skip empty URLs
            if url.is_empty() {
                continue;
            }

            // Skip external URLs and fragment-only links
            if self.is_external_url(url) || self.is_fragment_only_link(url) {
                continue;
            }

            // Where this definition's destination sits, shared by every report
            // on it. Without a located destination a warning falls back to the
            // start of the definition's line and offers no fix.
            let url_range = Self::ref_def_url_range(ctx.content, ref_def);
            let (line, col) = url_range
                .as_ref()
                .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
            let end_col = col + url.chars().count();

            // Handle absolute paths based on config
            if Self::is_absolute_path(url) {
                if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
                    warnings.push(LintWarning {
                        rule_name: Some(self.name().to_string()),
                        line,
                        column: col,
                        end_line: line,
                        end_column: end_col,
                        message,
                        severity: Severity::Warning,
                        fix: None,
                    });
                }
                continue;
            }

            // A definition whose destination is the file holding it.
            if let Some(self_link) =
                self.self_referential_link(url, &base_path, &extra_search_paths, self_path.as_deref())
            {
                warnings.push(LintWarning {
                    rule_name: Some(self.name().to_string()),
                    line,
                    column: col,
                    end_line: line,
                    end_column: end_col,
                    message: Self::self_referential_message(url, &self_link),
                    severity: Severity::Warning,
                    fix: match (&self_link, &url_range) {
                        (SelfReferentialLink::Fragment(fragment), Some(range)) => {
                            Some(Fix::new(range.clone(), fragment.clone()))
                        }
                        _ => None,
                    },
                });
                continue;
            }

            // Check for unnecessary path traversal (compact-paths)
            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
                warnings.push(LintWarning {
                    rule_name: Some(self.name().to_string()),
                    line,
                    column: col,
                    end_line: line,
                    end_column: end_col,
                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
                    severity: Severity::Warning,
                    fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
                });
            }

            if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
                continue;
            }

            // File doesn't exist and no source file found
            warnings.push(LintWarning {
                rule_name: Some(self.name().to_string()),
                line,
                column: col,
                end_line: line,
                end_column: end_col,
                message: format!("Relative link '{url}' does not exist"),
                severity: Severity::Error,
                fix: None,
            });
        }

        self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);

        Ok(warnings)
    }

    fn fix_capability(&self) -> FixCapability {
        if self.produces_fixes() {
            FixCapability::ConditionallyFixable
        } else {
            FixCapability::Unfixable
        }
    }

    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
        if !self.produces_fixes() {
            return Ok(ctx.content.to_string());
        }

        let warnings = self.check(ctx)?;
        let warnings =
            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
        let mut content = ctx.content.to_string();

        // Collect fixable warnings (compact-paths) sorted by byte offset descending
        let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));

        // Applying fixes right-to-left lets each range stay valid against the
        // still-unshifted content to its left. A duplicate or overlapping fix
        // would otherwise be applied a second time against content already
        // rewritten by an earlier fix, corrupting it; skip any fix whose range
        // overlaps the one most recently applied.
        let mut last_applied_start: Option<usize> = None;
        for fix in fixes {
            if let Some(prev_start) = last_applied_start
                && fix.range.end > prev_start
            {
                continue;
            }
            if fix.range.end <= content.len() {
                content.replace_range(fix.range.clone(), &fix.replacement);
                last_applied_start = Some(fix.range.start);
            }
        }

        Ok(content)
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }

    crate::impl_rule_config_sections!(MD057Config);

    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
    where
        Self: Sized,
    {
        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
        // The flavor is deliberately not captured here: Obsidian attachment-folder
        // detection reads `ctx.flavor`, which resolves per file, so a rule built
        // once for a workspace still honors a per-file flavor override.
        Box::new(Self::from_config_struct(rule_config))
    }

    fn cross_file_scope(&self) -> CrossFileScope {
        CrossFileScope::Workspace
    }

    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
        // Use the shared utility for cross-file link extraction
        // This ensures consistent position tracking between CLI and LSP
        let links = extract_cross_file_links(ctx);
        for link in links.relative {
            index.add_cross_file_link(link);
        }
        // Root-relative links are not linted, but indexing them keeps the cached
        // index complete so the LSP can resolve them for find-references.
        for link in links.root_relative {
            index.add_root_relative_link(link);
        }
    }

    fn cross_file_check(
        &self,
        _file_path: &Path,
        _file_index: &FileIndex,
        _workspace_index: &crate::workspace_index::WorkspaceIndex,
    ) -> LintResult {
        // All link targets are already validated by check() on each per-file pass.
        // check() resolves relative links against the file's own directory, handles
        // configured search paths, and applies the absolute_links config.
        // Validating them here too would produce identical duplicate warnings for
        // every broken link. (#631)
        //
        // The cross_file_scope / contribute_to_index / workspace-index infrastructure
        // remains in place to support future cross-file analyses (e.g. heading-anchor
        // validation across files).
        Ok(Vec::new())
    }
}

/// Compute the shortest relative path from `from_dir` to `to_path`.
///
/// Both paths must be normalized (no `.` or `..` components).
/// Returns a relative `PathBuf` that navigates from `from_dir` to `to_path`.
fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
    let from_components: Vec<_> = from_dir.components().collect();
    let to_components: Vec<_> = to_path.components().collect();

    // Find common prefix length
    let common_len = from_components
        .iter()
        .zip(to_components.iter())
        .take_while(|(a, b)| a == b)
        .count();

    let mut result = PathBuf::new();

    // Go up for each remaining component in from_dir
    for _ in common_len..from_components.len() {
        result.push("..");
    }

    // Append remaining components from to_path
    for component in &to_components[common_len..] {
        result.push(component);
    }

    result
}

/// Check if a relative link path can be shortened.
///
/// Given the source directory and the raw link path, computes whether there's
/// a shorter equivalent path. Returns `Some(compact_path)` if the link can
/// be simplified, `None` if it's already optimal.
fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
    let link_path = Path::new(raw_link_path);

    // Only check paths that contain traversal (../ or ./)
    let has_traversal = link_path
        .components()
        .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));

    if !has_traversal {
        return None;
    }

    // Resolve: source_dir + raw_link_path, then normalize
    let combined = source_dir.join(link_path);
    let normalized_target = normalize_relative_path(&combined);

    // Compute shortest path from source_dir back to the normalized target
    let normalized_source = normalize_relative_path(source_dir);
    let shortest = shortest_relative_path(&normalized_source, &normalized_target);

    // Compare against the raw link path — if it differs, the path can be compacted
    if shortest != link_path {
        let compact = shortest.to_string_lossy().to_string();
        // Avoid suggesting empty path
        if compact.is_empty() {
            return None;
        }
        // Markdown links always use forward slashes regardless of platform
        Some(compact.replace('\\', "/"))
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
    use std::fs::File;
    use std::io::Write;
    use tempfile::tempdir;

    #[test]
    fn test_strip_query_and_fragment() {
        // Test query parameter stripping
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
            "file.png"
        );
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
            "file.png"
        );
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
            "file.png"
        );

        // Test fragment stripping
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
            "file.md"
        );
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
            "file.md"
        );

        // Test both query and fragment (query comes first, per RFC 3986)
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
            "file.md"
        );

        // Test no query or fragment
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
            "file.png"
        );

        // Test with path
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
            "path/to/image.png"
        );
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
            "path/to/image.png"
        );

        // Edge case: fragment before query (non-standard but possible)
        assert_eq!(
            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
            "file.md"
        );
    }

    #[test]
    fn test_url_decode() {
        // Simple space encoding
        assert_eq!(
            MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
            "penguin with space.jpg"
        );

        // Path with encoded spaces
        assert_eq!(
            MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
            "assets/my file name.png"
        );

        // Multiple encoded characters
        assert_eq!(
            MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
            "hello world!.md"
        );

        // Lowercase hex
        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");

        // Uppercase hex
        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");

        // Mixed case hex
        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");

        // No encoding - return as-is
        assert_eq!(
            MD057ExistingRelativeLinks::url_decode("normal-file.md"),
            "normal-file.md"
        );

        // Incomplete percent encoding - leave as-is
        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");

        // Percent at end - leave as-is
        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");

        // Invalid hex digits - leave as-is
        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");

        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
        assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");

        // Empty string
        assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");

        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
        assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");

        // Multiple consecutive encoded characters
        assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), "   ");

        // Encoded path separators
        assert_eq!(
            MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
            "path/to/file.md"
        );

        // Mixed encoded and non-encoded
        assert_eq!(
            MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
            "hello world/foo bar.md"
        );

        // Special characters that are commonly encoded
        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");

        // Percent at position that looks like encoding but isn't valid
        assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
    }

    #[test]
    fn test_url_encoded_filenames() {
        // Create a temporary directory for test files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create a file with spaces in the name
        let file_with_spaces = base_path.join("penguin with space.jpg");
        File::create(&file_with_spaces)
            .unwrap()
            .write_all(b"image data")
            .unwrap();

        // Create a subdirectory with spaces
        let subdir = base_path.join("my images");
        std::fs::create_dir(&subdir).unwrap();
        let nested_file = subdir.join("photo 1.png");
        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();

        // Test content with URL-encoded links
        let content = r#"
# Test Document with URL-Encoded Links

![Penguin](penguin%20with%20space.jpg)
![Photo](my%20images/photo%201.png)
![Missing](missing%20file.jpg)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only have one warning for the missing file
        assert_eq!(
            result.len(),
            1,
            "Should only warn about missing%20file.jpg. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("missing%20file.jpg"),
            "Warning should mention the URL-encoded filename"
        );
    }

    #[test]
    fn test_external_urls() {
        let rule = MD057ExistingRelativeLinks::new();

        // Common web protocols
        assert!(rule.is_external_url("https://example.com"));
        assert!(rule.is_external_url("http://example.com"));
        assert!(rule.is_external_url("ftp://example.com"));
        assert!(rule.is_external_url("www.example.com"));
        assert!(rule.is_external_url("example.com"));

        // Special URI schemes
        assert!(rule.is_external_url("file:///path/to/file"));
        assert!(rule.is_external_url("smb://server/share"));
        assert!(rule.is_external_url("macappstores://apps.apple.com/"));
        assert!(rule.is_external_url("mailto:user@example.com"));
        assert!(rule.is_external_url("tel:+1234567890"));
        assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
        assert!(rule.is_external_url("javascript:void(0)"));
        assert!(rule.is_external_url("ssh://git@github.com/repo"));
        assert!(rule.is_external_url("git://github.com/repo.git"));

        // Email addresses without mailto: protocol
        // These are clearly not file links and should be skipped
        assert!(rule.is_external_url("user@example.com"));
        assert!(rule.is_external_url("steering@kubernetes.io"));
        assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
        assert!(rule.is_external_url("user_name@sub.domain.com"));
        assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));

        // Template variables should be skipped (not checked as relative links)
        assert!(rule.is_external_url("{{URL}}")); // Handlebars/Mustache
        assert!(rule.is_external_url("{{#URL}}")); // Handlebars block helper
        assert!(rule.is_external_url("{{> partial}}")); // Handlebars partial
        assert!(rule.is_external_url("{{ variable }}")); // Mustache with spaces
        assert!(rule.is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
        assert!(rule.is_external_url("{{")); // Even partial matches (regex edge case)

        // Absolute paths are NOT external (handled separately via is_absolute_path)
        // By default they are ignored, but can be configured to warn
        assert!(!rule.is_external_url("/api/v1/users"));
        assert!(!rule.is_external_url("/blog/2024/release.html"));
        assert!(!rule.is_external_url("/react/hooks/use-state.html"));
        assert!(!rule.is_external_url("/pkg/runtime"));
        assert!(!rule.is_external_url("/doc/go1compat"));
        assert!(!rule.is_external_url("/index.html"));
        assert!(!rule.is_external_url("/assets/logo.png"));

        // But is_absolute_path should detect them
        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));

        // Framework path aliases should be skipped (resolved by build tools)
        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
        assert!(rule.is_external_url("~/assets/image.png"));
        assert!(rule.is_external_url("~/components/Button.vue"));
        assert!(rule.is_external_url("~assets/logo.svg")); // Nuxt style without /

        // @ prefix (common in Vue, webpack, Vite aliases)
        assert!(rule.is_external_url("@/components/Header.vue"));
        assert!(rule.is_external_url("@images/photo.jpg"));
        assert!(rule.is_external_url("@assets/styles.css"));

        // Relative paths should NOT be external (should be validated)
        assert!(!rule.is_external_url("./relative/path.md"));
        assert!(!rule.is_external_url("relative/path.md"));
        assert!(!rule.is_external_url("../parent/path.md"));
    }

    #[test]
    fn test_dot_com_only_skips_bare_domains() {
        let rule = MD057ExistingRelativeLinks::new();

        // Bare domains ending in .com are treated as external (skipped).
        assert!(rule.is_external_url("example.com"));
        assert!(rule.is_external_url("sub.example.com"));

        // A relative path that merely ends in ".com" must NOT be skipped:
        // it contains a path separator, so it is a relative file reference
        // that should be validated, not assumed external.
        assert!(!rule.is_external_url("../../vendor.com"));
        assert!(!rule.is_external_url("./vendor.com"));
        assert!(!rule.is_external_url("docs/vendor.com"));
    }

    #[test]
    fn test_framework_path_aliases() {
        // Create a temporary directory for test files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Test content with framework path aliases (should all be skipped)
        let content = r#"
# Framework Path Aliases

![Image 1](~/assets/penguin.jpg)
![Image 2](~assets/logo.svg)
![Image 3](@images/photo.jpg)
![Image 4](@/components/icon.svg)
[Link](@/pages/about.md)

This is a [real missing link](missing.md) that should be flagged.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only have one warning for the real missing link
        assert_eq!(
            result.len(),
            1,
            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("missing.md"),
            "Warning should be for missing.md"
        );
    }

    #[test]
    fn test_url_decode_security_path_traversal() {
        // Ensure URL decoding doesn't enable path traversal attacks
        // The decoded path is still validated against the base path
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create a file in the temp directory
        let file_in_base = base_path.join("safe.md");
        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();

        // Test with encoded path traversal attempt
        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
        let content = r#"
[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
[Safe link](safe.md)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // The traversal attempts should still be flagged as missing
        // (they don't exist relative to base_path after decoding)
        assert_eq!(
            result.len(),
            2,
            "Should have warnings for traversal attempts. Got: {result:?}"
        );
    }

    #[test]
    fn test_url_encoded_utf8_filenames() {
        // Test with actual UTF-8 encoded filenames
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create files with unicode names
        let cafe_file = base_path.join("café.md");
        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();

        let content = r#"
[Café link](caf%C3%A9.md)
[Missing unicode](r%C3%A9sum%C3%A9.md)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only warn about the missing file
        assert_eq!(
            result.len(),
            1,
            "Should only warn about missing résumé.md. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
            "Warning should mention the URL-encoded filename"
        );
    }

    #[test]
    fn test_url_encoded_emoji_filenames() {
        // URL-encoded emoji paths should be correctly resolved
        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create directory with emoji in name: 👤 Personal
        let emoji_dir = base_path.join("👤 Personal");
        std::fs::create_dir(&emoji_dir).unwrap();

        // Create file in that directory: TV Shows.md
        let file_path = emoji_dir.join("TV Shows.md");
        File::create(&file_path)
            .unwrap()
            .write_all(b"# TV Shows\n\nContent here.")
            .unwrap();

        // Test content with URL-encoded emoji link
        // %F0%9F%91%A4 = 👤, %20 = space
        let content = r#"
# Test Document

[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only warn about the missing file, not the valid emoji path
        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
        assert!(
            result[0].message.contains("Missing.md"),
            "Warning should be for Missing.md, got: {}",
            result[0].message
        );
    }

    #[test]
    fn test_no_warnings_without_base_path() {
        let rule = MD057ExistingRelativeLinks::new();
        let content = "[Link](missing.md)";

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(result.is_empty(), "Should have no warnings without base path");
    }

    #[test]
    fn test_existing_and_missing_links() {
        // Create a temporary directory for test files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create an existing file
        let exists_path = base_path.join("exists.md");
        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();

        // Verify the file exists
        assert!(exists_path.exists(), "exists.md should exist for this test");

        // Create test content with both existing and missing links
        let content = r#"
# Test Document

[Valid Link](exists.md)
[Invalid Link](missing.md)
[External Link](https://example.com)
[Media Link](image.jpg)
        "#;

        // Initialize rule with the base path (default: check all files including media)
        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        // Test the rule
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should have two warnings: missing.md and image.jpg (both don't exist)
        assert_eq!(result.len(), 2);
        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
        assert!(messages.iter().any(|m| m.contains("missing.md")));
        assert!(messages.iter().any(|m| m.contains("image.jpg")));
    }

    #[test]
    fn test_angle_bracket_links() {
        // Create a temporary directory for test files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create an existing file
        let exists_path = base_path.join("exists.md");
        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();

        // Create test content with angle bracket links
        let content = r#"
# Test Document

[Valid Link](<exists.md>)
[Invalid Link](<missing.md>)
[External Link](<https://example.com>)
    "#;

        // Test with default settings
        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should have one warning for missing.md
        assert_eq!(result.len(), 1, "Should have exactly one warning");
        assert!(
            result[0].message.contains("missing.md"),
            "Warning should mention missing.md"
        );
    }

    #[test]
    fn test_angle_bracket_links_with_parens() {
        // Create a temporary directory for test files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create directory structure with parentheses in path
        let app_dir = base_path.join("app");
        std::fs::create_dir(&app_dir).unwrap();
        let upload_dir = app_dir.join("(upload)");
        std::fs::create_dir(&upload_dir).unwrap();
        let page_file = upload_dir.join("page.tsx");
        File::create(&page_file)
            .unwrap()
            .write_all(b"export default function Page() {}")
            .unwrap();

        // Create test content with angle bracket links containing parentheses
        let content = r#"
# Test Document with Paths Containing Parens

[Upload Page](<app/(upload)/page.tsx>)
[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
[Missing](<app/(missing)/file.md>)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only have one warning for the missing file
        assert_eq!(
            result.len(),
            1,
            "Should have exactly one warning for missing file. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("app/(missing)/file.md"),
            "Warning should mention app/(missing)/file.md"
        );
    }

    #[test]
    fn test_all_file_types_checked() {
        // Create a temporary directory for test files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create a test with various file types - all should be checked
        let content = r#"
[Image Link](image.jpg)
[Video Link](video.mp4)
[Markdown Link](document.md)
[PDF Link](file.pdf)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should warn about all missing files regardless of extension
        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
    }

    #[test]
    fn test_code_span_detection() {
        let rule = MD057ExistingRelativeLinks::new();

        // Create a temporary directory for test files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let rule = rule.with_path(base_path);

        // Test with document structure
        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only find the real link, not the one in code
        assert_eq!(result.len(), 1, "Should only flag the real link");
        assert!(result[0].message.contains("nonexistent.md"));
    }

    #[test]
    fn test_inline_code_spans() {
        // Create a temporary directory for test files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create test content with links in inline code spans
        let content = r#"
# Test Document

This is a normal link: [Link](missing.md)

This is a code span with a link: `[Link](another-missing.md)`

Some more text with `inline code [Link](yet-another-missing.md) embedded`.

    "#;

        // Initialize rule with the base path
        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        // Test the rule
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only have warning for the normal link, not for links in code spans
        assert_eq!(result.len(), 1, "Should have exactly one warning");
        assert!(
            result[0].message.contains("missing.md"),
            "Warning should be for missing.md"
        );
        assert!(
            !result.iter().any(|w| w.message.contains("another-missing.md")),
            "Should not warn about link in code span"
        );
        assert!(
            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
            "Should not warn about link in inline code"
        );
    }

    #[test]
    fn test_extensionless_link_resolution() {
        // Create a temporary directory for test files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create a markdown file WITHOUT specifying .md extension in the link
        let page_path = base_path.join("page.md");
        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();

        // Test content with extensionless link that should resolve to page.md
        let content = r#"
# Test Document

[Link without extension](page)
[Link with extension](page.md)
[Missing link](nonexistent)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only have warning for nonexistent link
        // Both "page" and "page.md" should resolve to the same file
        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
        assert!(
            result[0].message.contains("nonexistent"),
            "Warning should be for 'nonexistent' not 'page'"
        );
    }

    // Cross-file validation tests
    #[test]
    fn test_cross_file_scope() {
        let rule = MD057ExistingRelativeLinks::new();
        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
    }

    #[test]
    fn test_contribute_to_index_extracts_markdown_links() {
        let rule = MD057ExistingRelativeLinks::new();
        let content = r#"
# Document

[Link to docs](./docs/guide.md)
[Link with fragment](./other.md#section)
[External link](https://example.com)
[Image link](image.png)
[Media file](video.mp4)
"#;

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let mut index = FileIndex::new();
        rule.contribute_to_index(&ctx, &mut index);

        // Should only index markdown file links
        assert_eq!(index.cross_file_links.len(), 2);

        // Check first link
        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
        assert_eq!(index.cross_file_links[0].fragment, "");

        // Check second link (with fragment)
        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
        assert_eq!(index.cross_file_links[1].fragment, "section");
    }

    #[test]
    fn test_contribute_to_index_skips_external_and_anchors() {
        let rule = MD057ExistingRelativeLinks::new();
        let content = r#"
# Document

[External](https://example.com)
[Another external](http://example.org)
[Fragment only](#section)
[FTP link](ftp://files.example.com)
[Mail link](mailto:test@example.com)
[WWW link](www.example.com)
"#;

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let mut index = FileIndex::new();
        rule.contribute_to_index(&ctx, &mut index);

        // Should not index any of these
        assert_eq!(index.cross_file_links.len(), 0);
    }

    #[test]
    fn test_cross_file_check_valid_link() {
        use crate::workspace_index::WorkspaceIndex;

        let rule = MD057ExistingRelativeLinks::new();

        // Create a workspace index with the target file
        let mut workspace_index = WorkspaceIndex::new();
        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());

        // Create file index with a link to an existing file
        let mut file_index = FileIndex::new();
        file_index.add_cross_file_link(CrossFileLinkIndex {
            target_path: "guide.md".to_string(),
            fragment: "".to_string(),
            line: 5,
            column: 1,
            origin: LinkOrigin::Body,
        });

        // Run cross-file check from docs/index.md
        let warnings = rule
            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
            .unwrap();

        // Should have no warnings - file exists
        assert!(warnings.is_empty());
    }

    #[test]
    fn test_cross_file_check_missing_link() {
        // cross_file_check delegates all validation to check() to avoid duplicates.
        // It always returns empty — the per-file check() path is authoritative.
        use crate::workspace_index::WorkspaceIndex;

        let rule = MD057ExistingRelativeLinks::new();
        let workspace_index = WorkspaceIndex::new();

        let mut file_index = FileIndex::new();
        file_index.add_cross_file_link(CrossFileLinkIndex {
            target_path: "missing.md".to_string(),
            fragment: "".to_string(),
            line: 5,
            column: 1,
            origin: LinkOrigin::Body,
        });

        let warnings = rule
            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
            .unwrap();

        // cross_file_check defers to check(); it produces no warnings of its own.
        assert!(
            warnings.is_empty(),
            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
        );
    }

    #[test]
    fn test_cross_file_check_parent_path() {
        use crate::workspace_index::WorkspaceIndex;

        let rule = MD057ExistingRelativeLinks::new();

        // Create a workspace index with the target file at the root
        let mut workspace_index = WorkspaceIndex::new();
        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());

        // Create file index with a parent path link
        let mut file_index = FileIndex::new();
        file_index.add_cross_file_link(CrossFileLinkIndex {
            target_path: "../readme.md".to_string(),
            fragment: "".to_string(),
            line: 5,
            column: 1,
            origin: LinkOrigin::Body,
        });

        // Run cross-file check from docs/guide.md
        let warnings = rule
            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
            .unwrap();

        // Should have no warnings - file exists at normalized path
        assert!(warnings.is_empty());
    }

    #[test]
    fn test_cross_file_check_html_link_with_md_source() {
        // Test that .html links are accepted when corresponding .md source exists
        // This supports mdBook and similar doc generators that compile .md to .html
        use crate::workspace_index::WorkspaceIndex;

        let rule = MD057ExistingRelativeLinks::new();

        // Create a workspace index with the .md source file
        let mut workspace_index = WorkspaceIndex::new();
        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());

        // Create file index with an .html link (from another rule like MD051)
        let mut file_index = FileIndex::new();
        file_index.add_cross_file_link(CrossFileLinkIndex {
            target_path: "guide.html".to_string(),
            fragment: "section".to_string(),
            line: 10,
            column: 5,
            origin: LinkOrigin::Body,
        });

        // Run cross-file check from docs/index.md
        let warnings = rule
            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
            .unwrap();

        // Should have no warnings - .md source exists for the .html link
        assert!(
            warnings.is_empty(),
            "Expected no warnings for .html link with .md source, got: {warnings:?}"
        );
    }

    #[test]
    fn test_cross_file_check_html_link_without_source() {
        // cross_file_check delegates all validation to check() to avoid duplicates.
        // Verifying that .html links without a matching .md source are caught is
        // already covered by test_html_link_with_md_source (check() path).
        use crate::workspace_index::WorkspaceIndex;

        let rule = MD057ExistingRelativeLinks::new();
        let workspace_index = WorkspaceIndex::new();

        let mut file_index = FileIndex::new();
        file_index.add_cross_file_link(CrossFileLinkIndex {
            target_path: "missing.html".to_string(),
            fragment: "".to_string(),
            line: 10,
            column: 5,
            origin: LinkOrigin::Body,
        });

        let warnings = rule
            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
            .unwrap();

        // cross_file_check defers to check(); it produces no warnings of its own.
        assert!(
            warnings.is_empty(),
            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
        );
    }

    #[test]
    fn test_normalize_path_function() {
        // Test simple cases
        assert_eq!(
            normalize_relative_path(Path::new("docs/guide.md")),
            PathBuf::from("docs/guide.md")
        );

        // Test current directory removal
        assert_eq!(
            normalize_relative_path(Path::new("./docs/guide.md")),
            PathBuf::from("docs/guide.md")
        );

        // Test parent directory resolution
        assert_eq!(
            normalize_relative_path(Path::new("docs/sub/../guide.md")),
            PathBuf::from("docs/guide.md")
        );

        // Test multiple parent directories
        assert_eq!(
            normalize_relative_path(Path::new("a/b/c/../../d.md")),
            PathBuf::from("a/d.md")
        );
    }

    #[test]
    fn test_html_link_with_md_source() {
        // Links to .html files should pass if corresponding .md source exists
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create guide.md (source file)
        let md_file = base_path.join("guide.md");
        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();

        let content = r#"
[Read the guide](guide.html)
[Also here](getting-started.html)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // guide.html passes (guide.md exists), getting-started.html fails
        assert_eq!(
            result.len(),
            1,
            "Should only warn about missing source. Got: {result:?}"
        );
        assert!(result[0].message.contains("getting-started.html"));
    }

    #[test]
    fn test_htm_link_with_md_source() {
        // .htm extension should also check for markdown source
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let md_file = base_path.join("page.md");
        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();

        let content = "[Page](page.htm)";

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Should not warn when .md source exists for .htm link"
        );
    }

    #[test]
    fn test_html_link_finds_various_markdown_extensions() {
        // Should find .mdx, .markdown, etc. as source files
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        File::create(base_path.join("doc.md")).unwrap();
        File::create(base_path.join("tutorial.mdx")).unwrap();
        File::create(base_path.join("guide.markdown")).unwrap();

        let content = r#"
[Doc](doc.html)
[Tutorial](tutorial.html)
[Guide](guide.html)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Should find all markdown variants as source files. Got: {result:?}"
        );
    }

    #[test]
    fn test_html_link_in_subdirectory() {
        // Should find markdown source in subdirectories
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let docs_dir = base_path.join("docs");
        std::fs::create_dir(&docs_dir).unwrap();
        File::create(docs_dir.join("guide.md"))
            .unwrap()
            .write_all(b"# Guide")
            .unwrap();

        let content = "[Guide](docs/guide.html)";

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert!(result.is_empty(), "Should find markdown source in subdirectory");
    }

    #[test]
    fn test_absolute_path_skipped_in_check() {
        // Test that absolute paths are skipped during link validation
        // This fixes the bug where /pkg/runtime was being flagged
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"
# Test Document

[Go Runtime](/pkg/runtime)
[Go Runtime with Fragment](/pkg/runtime#section)
[API Docs](/api/v1/users)
[Blog Post](/blog/2024/release.html)
[React Hook](/react/hooks/use-state.html)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should have NO warnings - all absolute paths should be skipped
        assert!(
            result.is_empty(),
            "Absolute paths should be skipped. Got warnings: {result:?}"
        );
    }

    #[test]
    fn test_absolute_path_skipped_in_cross_file_check() {
        // Test that absolute paths are skipped in cross_file_check()
        use crate::workspace_index::WorkspaceIndex;

        let rule = MD057ExistingRelativeLinks::new();

        // Create an empty workspace index (no files exist)
        let workspace_index = WorkspaceIndex::new();

        // Create file index with absolute path links (should be skipped)
        let mut file_index = FileIndex::new();
        file_index.add_cross_file_link(CrossFileLinkIndex {
            target_path: "/pkg/runtime.md".to_string(),
            fragment: "".to_string(),
            line: 5,
            column: 1,
            origin: LinkOrigin::Body,
        });
        file_index.add_cross_file_link(CrossFileLinkIndex {
            target_path: "/api/v1/users.md".to_string(),
            fragment: "section".to_string(),
            line: 10,
            column: 1,
            origin: LinkOrigin::Body,
        });

        // Run cross-file check
        let warnings = rule
            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
            .unwrap();

        // Should have NO warnings - absolute paths should be skipped
        assert!(
            warnings.is_empty(),
            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
        );
    }

    #[test]
    fn test_protocol_relative_url_not_skipped() {
        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
        // They should still be caught by is_external_url() though
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"
# Test Document

[External](//example.com/page)
[Another](//cdn.example.com/asset.js)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should have NO warnings - protocol-relative URLs are external and should be skipped
        assert!(
            result.is_empty(),
            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
        );
    }

    #[test]
    fn test_email_addresses_skipped() {
        // Test that email addresses without mailto: are skipped
        // These are clearly not file links (the @ symbol is definitive)
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"
# Test Document

[Contact](user@example.com)
[Steering](steering@kubernetes.io)
[Support](john.doe+filter@company.co.uk)
[User](user_name@sub.domain.com)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should have NO warnings - email addresses are clearly not file links and should be skipped
        assert!(
            result.is_empty(),
            "Email addresses should be skipped. Got warnings: {result:?}"
        );
    }

    #[test]
    fn test_email_addresses_vs_file_paths() {
        // Test that email addresses (anything with @) are skipped
        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"
# Test Document

[Email](user@example.com)  <!-- Should be skipped (email) -->
[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // All should be skipped - anything with @ is treated as an email
        assert!(
            result.is_empty(),
            "All email addresses should be skipped. Got: {result:?}"
        );
    }

    #[test]
    fn test_diagnostic_position_accuracy() {
        // Test that diagnostics point to the URL, not the link text
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Position markers:     0         1         2         3
        //                       0123456789012345678901234567890123456789
        let content = "prefix [text](missing.md) suffix";
        //             The URL "missing.md" starts at 0-indexed position 14
        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(result.len(), 1, "Should have exactly one warning");
        assert_eq!(result[0].line, 1, "Should be on line 1");
        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
    }

    #[test]
    fn test_diagnostic_position_non_ascii_link() {
        // Issue #670: columns are character offsets, not byte offsets. The CJK
        // prefix is multi-byte in UTF-8, so a byte offset over-counts the column.
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Character columns: 1:你 2:好 3:你 4:好 5:[ 6:你 7:好 8:] 9:( 10:n ...
        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 10
        // and ends past character column 21, i.e. end_column 22.
        let content = "你好你好[你好](not-exist.md) bar";

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(result.len(), 1, "Should have exactly one warning");
        assert_eq!(result[0].line, 1, "Should be on line 1");
        assert_eq!(
            result[0].column, 10,
            "Column must be a character offset, not a byte offset"
        );
        assert_eq!(result[0].end_column, 22, "End column must be character-based");
    }

    #[test]
    fn test_diagnostic_position_angle_brackets() {
        // Test position accuracy with angle bracket links
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Position markers:     0         1         2
        //                       012345678901234567890
        let content = "[link](<missing.md>)";
        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(result.len(), 1, "Should have exactly one warning");
        assert_eq!(result[0].line, 1, "Should be on line 1");
        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
    }

    #[test]
    fn test_diagnostic_position_multiline() {
        // Test that line numbers are correct for links on different lines
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"# Title
Some text on line 2
[link on line 3](missing1.md)
More text
[link on line 5](missing2.md)"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(result.len(), 2, "Should have two warnings");

        // First warning should be on line 3
        assert_eq!(result[0].line, 3, "First warning should be on line 3");
        assert!(result[0].message.contains("missing1.md"));

        // Second warning should be on line 5
        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
        assert!(result[1].message.contains("missing2.md"));
    }

    #[test]
    fn test_diagnostic_position_with_spaces() {
        // Test position with URLs that have spaces in parentheses
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = "[link]( missing.md )";
        //             0123456789012345678901
        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
        //             which is 1-indexed column 9

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(result.len(), 1, "Should have exactly one warning");
        // The regex captures the URL without leading/trailing spaces
        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
    }

    #[test]
    fn test_diagnostic_position_image() {
        // Test that image diagnostics also have correct positions
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = "![alt text](missing.jpg)";

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
        assert_eq!(result[0].line, 1);
        // Images use start_col from the parser, which should point to the URL
        assert!(result[0].column > 0, "Should have valid column position");
        assert!(result[0].message.contains("missing.jpg"));
    }

    #[test]
    fn test_diagnostic_position_non_ascii_image() {
        // Issue #670: image columns are character offsets, not byte offsets.
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Character columns: 1:你 2:好 3:你 4:好 5:! 6:[ 7:你 8:好 9:] 10:( 11:n ...
        // The image syntax starts at the '!' which is 1-indexed character column 5.
        let content = "你好你好![你好](not-exist.png)";

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
        assert_eq!(result[0].line, 1, "Should be on line 1");
        assert_eq!(
            result[0].column, 5,
            "Column must be a character offset, not a byte offset"
        );
        assert!(result[0].message.contains("not-exist.png"));
    }

    #[test]
    fn test_diagnostic_position_non_ascii_reference_def() {
        // Issue #670: reference-definition columns are character offsets. A
        // multi-byte label shifts the URL's byte offset away from its character
        // column.
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Character columns: 1:[ 2:你 3:好 4:] 5:: 6:space 7:n ...
        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 7.
        let content = "[你好]: not-exist.md";

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
        assert_eq!(result[0].line, 1, "Should be on line 1");
        assert_eq!(
            result[0].column, 7,
            "Column must be a character offset, not a byte offset"
        );
        assert_eq!(result[0].end_column, 19, "End column must be character-based");
    }

    #[test]
    fn test_wikilinks_skipped() {
        // Wikilinks should not trigger MD057 warnings
        // They use a different linking system (e.g., Obsidian, wiki software)
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"# Test Document

[[Microsoft#Windows OS]]
[[SomePage]]
[[Page With Spaces]]
[[path/to/page#section]]
[[page|Display Text]]

This is a [real missing link](missing.md) that should be flagged.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only warn about the regular markdown link, not wikilinks
        assert_eq!(
            result.len(),
            1,
            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("missing.md"),
            "Warning should be for missing.md, not wikilinks"
        );
    }

    #[test]
    fn test_wiki_embeds_skipped() {
        // A wiki embed names a vault entry, not a path relative to this file,
        // so `![[diagram.png]]` is not a missing relative link even though no
        // such file sits next to the document.
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"# Test Document

![[diagram.png]]
![[subfolder/diagram.png]]
![[diagram.png|300]]
![[Some Note]]

This is a [real missing link](missing.md) that should be flagged.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        for flavor in [
            crate::config::MarkdownFlavor::Obsidian,
            crate::config::MarkdownFlavor::Standard,
        ] {
            let ctx = crate::lint_context::LintContext::new(content, flavor, None);
            let result = rule.check(&ctx).unwrap();

            assert_eq!(
                result.len(),
                1,
                "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
            );
            assert!(result[0].message.contains("missing.md"));
        }
    }

    #[test]
    fn test_wikilinks_not_added_to_index() {
        // Wikilinks should not be added to the cross-file link index
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"# Test Document

[[Microsoft#Windows OS]]
[[SomePage#section]]
[Regular Link](other.md)
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);

        let mut file_index = FileIndex::new();
        rule.contribute_to_index(&ctx, &mut file_index);

        // Should only have the regular markdown link (if it's a markdown file)
        // Wikilinks should not be added
        let cross_file_links = &file_index.cross_file_links;
        assert_eq!(
            cross_file_links.len(),
            1,
            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
        );
        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
    }

    #[test]
    fn test_reference_definition_missing_file() {
        // Reference definitions [ref]: ./path.md should be checked
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"# Test Document

[test]: ./missing.md
[example]: ./nonexistent.html

Use [test] and [example] here.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should have warnings for both reference definitions
        assert_eq!(
            result.len(),
            2,
            "Should have warnings for missing reference definition targets. Got: {result:?}"
        );
        assert!(
            result.iter().any(|w| w.message.contains("missing.md")),
            "Should warn about missing.md"
        );
        assert!(
            result.iter().any(|w| w.message.contains("nonexistent.html")),
            "Should warn about nonexistent.html"
        );
    }

    #[test]
    fn test_reference_definition_existing_file() {
        // Reference definitions to existing files should NOT trigger warnings
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create an existing file
        let exists_path = base_path.join("exists.md");
        File::create(&exists_path)
            .unwrap()
            .write_all(b"# Existing file")
            .unwrap();

        let content = r#"# Test Document

[test]: ./exists.md

Use [test] here.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should have NO warnings since the file exists
        assert!(
            result.is_empty(),
            "Should not warn about existing file. Got: {result:?}"
        );
    }

    #[test]
    fn test_reference_definition_external_url_skipped() {
        // Reference definitions with external URLs should be skipped
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"# Test Document

[google]: https://google.com
[example]: http://example.org
[mail]: mailto:test@example.com
[ftp]: ftp://files.example.com
[local]: ./missing.md

Use [google], [example], [mail], [ftp], [local] here.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only warn about the local missing file, not external URLs
        assert_eq!(
            result.len(),
            1,
            "Should only warn about local missing file. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("missing.md"),
            "Warning should be for missing.md"
        );
    }

    #[test]
    fn test_reference_definition_fragment_only_skipped() {
        // Reference definitions with fragment-only URLs should be skipped
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"# Test Document

[section]: #my-section

Use [section] here.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should have NO warnings for fragment-only links
        assert!(
            result.is_empty(),
            "Should not warn about fragment-only reference. Got: {result:?}"
        );
    }

    #[test]
    fn test_reference_definition_column_position() {
        // Test that column position points to the URL in the reference definition
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Position markers:     0         1         2
        //                       0123456789012345678901
        let content = "[ref]: ./missing.md";
        //             The URL "./missing.md" starts at 0-indexed position 7
        //             which is 1-indexed column 8

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(result.len(), 1, "Should have exactly one warning");
        assert_eq!(result[0].line, 1, "Should be on line 1");
        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
    }

    #[test]
    fn test_reference_definition_html_with_md_source() {
        // Reference definitions to .html files should pass if corresponding .md source exists
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create guide.md (source file)
        let md_file = base_path.join("guide.md");
        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();

        let content = r#"# Test Document

[guide]: ./guide.html
[missing]: ./missing.html

Use [guide] and [missing] here.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // guide.html passes (guide.md exists), missing.html fails
        assert_eq!(
            result.len(),
            1,
            "Should only warn about missing source. Got: {result:?}"
        );
        assert!(result[0].message.contains("missing.html"));
    }

    #[test]
    fn test_reference_definition_url_encoded() {
        // Reference definitions with URL-encoded paths should be decoded before checking
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create a file with spaces in the name
        let file_with_spaces = base_path.join("file with spaces.md");
        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();

        let content = r#"# Test Document

[spaces]: ./file%20with%20spaces.md
[missing]: ./missing%20file.md

Use [spaces] and [missing] here.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only warn about the missing file
        assert_eq!(
            result.len(),
            1,
            "Should only warn about missing URL-encoded file. Got: {result:?}"
        );
        assert!(result[0].message.contains("missing%20file.md"));
    }

    #[test]
    fn test_inline_and_reference_both_checked() {
        // Both inline links and reference definitions should be checked
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let content = r#"# Test Document

[inline link](./inline-missing.md)
[ref]: ./ref-missing.md

Use [ref] here.
"#;

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should warn about both the inline link and the reference definition
        assert_eq!(
            result.len(),
            2,
            "Should warn about both inline and reference links. Got: {result:?}"
        );
        assert!(
            result.iter().any(|w| w.message.contains("inline-missing.md")),
            "Should warn about inline-missing.md"
        );
        assert!(
            result.iter().any(|w| w.message.contains("ref-missing.md")),
            "Should warn about ref-missing.md"
        );
    }

    #[test]
    fn test_footnote_definitions_not_flagged() {
        // Regression test for issue #286: footnote definitions should not be
        // treated as reference definitions and flagged as broken links
        let rule = MD057ExistingRelativeLinks::default();

        let content = r#"# Title

A footnote[^1].

[^1]: [link](https://www.google.com).
"#;

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
        );
    }

    #[test]
    fn test_footnote_with_relative_link_inside() {
        // Footnotes containing relative links should not be checked
        // (the footnote content is not a URL, it's content that may contain links)
        let rule = MD057ExistingRelativeLinks::default();

        let content = r#"# Title

See the footnote[^1].

[^1]: Check out [this file](./existing.md) for more info.
[^2]: Also see [missing](./does-not-exist.md).
"#;

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
        // but the footnote definition itself should not be treated as a reference definition
        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
        for warning in &result {
            assert!(
                !warning.message.contains("[this file]"),
                "Footnote content should not be treated as URL: {warning:?}"
            );
            assert!(
                !warning.message.contains("[missing]"),
                "Footnote content should not be treated as URL: {warning:?}"
            );
        }
    }

    #[test]
    fn test_mixed_footnotes_and_reference_definitions() {
        // Ensure regular reference definitions are still checked while footnotes are skipped
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let content = r#"# Title

A footnote[^1] and a [ref link][myref].

[^1]: This is a footnote with [link](https://example.com).

[myref]: ./missing-file.md "This should be checked"
"#;

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should only warn about the regular reference definition, not the footnote
        assert_eq!(
            result.len(),
            1,
            "Should only warn about the regular reference definition. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("missing-file.md"),
            "Should warn about missing-file.md in reference definition"
        );
    }

    #[test]
    fn test_absolute_links_ignore_by_default() {
        // By default, absolute links are ignored (not validated)
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let content = r#"# Links

[API docs](/api/v1/users)
[Blog post](/blog/2024/release.html)
![Logo](/assets/logo.png)

[ref]: /docs/reference.md
"#;

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // No warnings - absolute links are ignored by default
        assert!(
            result.is_empty(),
            "Absolute links should be ignored by default. Got: {result:?}"
        );
    }

    #[test]
    fn test_absolute_links_warn_config() {
        // When configured to warn, absolute links should generate warnings
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let config = MD057Config {
            absolute_links: AbsoluteLinksOption::Warn,
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);

        let content = r#"# Links

[API docs](/api/v1/users)
[Blog post](/blog/2024/release.html)
"#;

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        // Should have 2 warnings for the 2 absolute links
        assert_eq!(
            result.len(),
            2,
            "Should warn about both absolute links. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("cannot be validated locally"),
            "Warning should explain why: {}",
            result[0].message
        );
        assert!(
            result[0].message.contains("/api/v1/users"),
            "Warning should include the link path"
        );
    }

    #[test]
    fn test_absolute_links_warn_images() {
        // Images with absolute paths should also warn when configured
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let config = MD057Config {
            absolute_links: AbsoluteLinksOption::Warn,
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);

        let content = r#"# Images

![Logo](/assets/logo.png)
"#;

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(
            result.len(),
            1,
            "Should warn about absolute image path. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("/assets/logo.png"),
            "Warning should include the image path"
        );
    }

    #[test]
    fn test_absolute_links_warn_reference_definitions() {
        // Reference definitions with absolute paths should also warn when configured
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let config = MD057Config {
            absolute_links: AbsoluteLinksOption::Warn,
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);

        let content = r#"# Reference

See the [docs][ref].

[ref]: /docs/reference.md
"#;

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(
            result.len(),
            1,
            "Should warn about absolute reference definition. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("/docs/reference.md"),
            "Warning should include the reference path"
        );
    }

    #[test]
    fn test_search_paths_inline_link() {
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // Create an "assets" directory with an image
        let assets_dir = base_path.join("assets");
        std::fs::create_dir_all(&assets_dir).unwrap();
        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();

        let config = MD057Config {
            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);

        let content = "# Test\n\n[Photo](photo.png)\n";
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Should find photo.png via search-paths. Got: {result:?}"
        );
    }

    #[test]
    fn test_search_paths_image() {
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let assets_dir = base_path.join("attachments");
        std::fs::create_dir_all(&assets_dir).unwrap();
        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();

        let config = MD057Config {
            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);

        let content = "# Test\n\n![Diagram](diagram.svg)\n";
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Should find diagram.svg via search-paths. Got: {result:?}"
        );
    }

    #[test]
    fn test_search_paths_reference_definition() {
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let assets_dir = base_path.join("images");
        std::fs::create_dir_all(&assets_dir).unwrap();
        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();

        let config = MD057Config {
            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);

        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Should find logo.png via search-paths in reference definition. Got: {result:?}"
        );
    }

    #[test]
    fn test_search_paths_still_warns_when_truly_missing() {
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let assets_dir = base_path.join("assets");
        std::fs::create_dir_all(&assets_dir).unwrap();

        let config = MD057Config {
            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);

        let content = "# Test\n\n![Missing](nonexistent.png)\n";
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(
            result.len(),
            1,
            "Should still warn when file doesn't exist in any search path. Got: {result:?}"
        );
    }

    #[test]
    fn test_search_paths_nonexistent_directory() {
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let config = MD057Config {
            search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);

        let content = "# Test\n\n![Missing](photo.png)\n";
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(
            result.len(),
            1,
            "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
        );
    }

    #[test]
    fn test_obsidian_attachment_folder_named() {
        let temp_dir = tempdir().unwrap();
        let vault = temp_dir.path().join("vault");
        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
        std::fs::create_dir_all(vault.join("notes")).unwrap();

        std::fs::write(
            vault.join(".obsidian/app.json"),
            r#"{"attachmentFolderPath": "Attachments"}"#,
        )
        .unwrap();
        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();

        let notes_dir = vault.join("notes");
        let source_file = notes_dir.join("test.md");
        std::fs::write(&source_file, "# Test\n\n![Photo](photo.png)\n").unwrap();

        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);

        let content = "# Test\n\n![Photo](photo.png)\n";
        let ctx =
            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
        );
    }

    #[test]
    fn test_obsidian_attachment_same_folder_as_file() {
        let temp_dir = tempdir().unwrap();
        let vault = temp_dir.path().join("vault-rf");
        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
        std::fs::create_dir_all(vault.join("notes")).unwrap();

        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();

        // Image in the same directory as the file — default behavior, no extra search needed
        let notes_dir = vault.join("notes");
        let source_file = notes_dir.join("test.md");
        std::fs::write(&source_file, "placeholder").unwrap();
        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();

        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);

        let content = "# Test\n\n![Photo](photo.png)\n";
        let ctx =
            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
        );
    }

    #[test]
    fn test_obsidian_not_triggered_without_obsidian_flavor() {
        let temp_dir = tempdir().unwrap();
        let vault = temp_dir.path().join("vault-nf");
        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
        std::fs::create_dir_all(vault.join("notes")).unwrap();

        std::fs::write(
            vault.join(".obsidian/app.json"),
            r#"{"attachmentFolderPath": "Attachments"}"#,
        )
        .unwrap();
        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();

        let notes_dir = vault.join("notes");
        let source_file = notes_dir.join("test.md");
        std::fs::write(&source_file, "placeholder").unwrap();

        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);

        let content = "# Test\n\n![Photo](photo.png)\n";
        // Standard flavor — NOT Obsidian
        let ctx =
            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
        let result = rule.check(&ctx).unwrap();

        assert_eq!(
            result.len(),
            1,
            "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
        );
    }

    #[test]
    fn test_search_paths_combined_with_obsidian() {
        let temp_dir = tempdir().unwrap();
        let vault = temp_dir.path().join("vault-combo");
        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
        std::fs::create_dir_all(vault.join("notes")).unwrap();

        std::fs::write(
            vault.join(".obsidian/app.json"),
            r#"{"attachmentFolderPath": "Attachments"}"#,
        )
        .unwrap();
        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
        std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();

        let notes_dir = vault.join("notes");
        let source_file = notes_dir.join("test.md");
        std::fs::write(&source_file, "placeholder").unwrap();

        let extra_assets_dir = vault.join("extra-assets");
        let config = MD057Config {
            search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);

        // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths
        let content = "# Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
        let ctx =
            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
        );
    }

    #[test]
    fn test_obsidian_attachment_subfolder_under_file() {
        let temp_dir = tempdir().unwrap();
        let vault = temp_dir.path().join("vault-sub");
        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();

        std::fs::write(
            vault.join(".obsidian/app.json"),
            r#"{"attachmentFolderPath": "./assets"}"#,
        )
        .unwrap();
        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();

        let notes_dir = vault.join("notes");
        let source_file = notes_dir.join("test.md");
        std::fs::write(&source_file, "placeholder").unwrap();

        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);

        let content = "# Test\n\n![Photo](photo.png)\n";
        let ctx =
            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
        );
    }

    #[test]
    fn test_obsidian_attachment_vault_root() {
        let temp_dir = tempdir().unwrap();
        let vault = temp_dir.path().join("vault-root");
        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
        std::fs::create_dir_all(vault.join("notes")).unwrap();

        // Empty string = vault root
        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
        std::fs::write(vault.join("photo.png"), "fake").unwrap();

        let notes_dir = vault.join("notes");
        let source_file = notes_dir.join("test.md");
        std::fs::write(&source_file, "placeholder").unwrap();

        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);

        let content = "# Test\n\n![Photo](photo.png)\n";
        let ctx =
            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
        );
    }

    #[test]
    fn test_search_paths_multiple_directories() {
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let dir_a = base_path.join("dir-a");
        let dir_b = base_path.join("dir-b");
        std::fs::create_dir_all(&dir_a).unwrap();
        std::fs::create_dir_all(&dir_b).unwrap();
        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();

        let config = MD057Config {
            search_paths: vec![
                dir_a.to_string_lossy().into_owned(),
                dir_b.to_string_lossy().into_owned(),
            ],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);

        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "Should find files across multiple search paths. Got: {result:?}"
        );
    }

    /// MD057 validates every link target in `check()`, so its `cross_file_check`
    /// deliberately reports nothing: emitting there too would double every broken
    /// link warning.
    ///
    /// The target here does not exist anywhere the rule would look, so a
    /// `cross_file_check` that started resolving paths would report it and fail
    /// this test. The paired `check()` call is the positive control proving the
    /// link really is broken, which is what keeps the empty result meaningful.
    #[test]
    fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};

        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let file_path = base_path.join("README.md");
        let content = "# Readme\n\n[Guide](missing-guide.md)\n";
        std::fs::write(&file_path, content).unwrap();

        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);

        let ctx = crate::lint_context::LintContext::new(
            content,
            crate::config::MarkdownFlavor::Standard,
            Some(file_path.clone()),
        );
        let per_file = rule.check(&ctx).unwrap();
        assert_eq!(
            per_file.len(),
            1,
            "control: check() is the pass that reports the broken link. Got: {per_file:?}"
        );

        let mut file_index = FileIndex::default();
        file_index.cross_file_links.push(CrossFileLinkIndex {
            target_path: "missing-guide.md".to_string(),
            fragment: String::new(),
            line: 3,
            column: 1,
            origin: LinkOrigin::Body,
        });

        let result = rule
            .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
            .unwrap();

        assert!(
            result.is_empty(),
            "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
        );
    }

    #[test]
    fn test_check_clears_stale_cache() {
        // Verify that check() resets the file existence cache so stale entries from
        // a previous lint cycle do not suppress valid warnings.
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        // Seed the cache with a stale "exists" entry for a file that is NOT on disk.
        let phantom_path = base_path.join("phantom.md");
        {
            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
            cache.insert(phantom_path.clone(), true);
        }

        let content = "[phantom](phantom.md)\n";
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let warnings = rule.check(&ctx).unwrap();

        // check() must reset the cache; stale "exists=true" must not suppress the warning.
        assert_eq!(
            warnings.len(),
            1,
            "check() should report missing file after clearing stale cache. Got: {warnings:?}"
        );
        assert!(warnings[0].message.contains("phantom.md"));
    }

    #[test]
    fn test_check_does_not_carry_over_cache_between_runs() {
        // Two consecutive check() calls should each start with a fresh cache.
        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        let content = "[missing](nonexistent.md)\n";
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);

        // First run: file doesn't exist — warning expected.
        let warnings_1 = rule.check(&ctx).unwrap();
        assert_eq!(warnings_1.len(), 1, "First run should detect missing file");

        // Inject a stale "exists = true" entry for the resolved path.
        let nonexistent_path = base_path.join("nonexistent.md");
        {
            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
            cache.insert(nonexistent_path.clone(), true);
        }

        // Second run: cache says file exists, but check() should reset it first.
        let warnings_2 = rule.check(&ctx).unwrap();
        assert_eq!(
            warnings_2.len(),
            1,
            "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
        );
    }

    // --- Bug #631: duplicate warnings for broken relative links ---

    /// Regression test: a single broken relative link must produce exactly one
    /// warning across both check() and cross_file_check(). Previously, each
    /// code path emitted an identical warning independently, causing duplicates.
    #[test]
    fn test_no_duplicate_warnings_for_broken_relative_link() {
        use crate::workspace_index::WorkspaceIndex;

        let temp_dir = tempdir().unwrap();
        let base_path = temp_dir.path();

        // The broken link target does NOT exist on disk.
        let source_file = base_path.join("index.md");
        std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();

        let content = "[broken](does/not/exist.md)\n";

        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);

        // Collect warnings from check() (per-file path)
        let ctx = crate::lint_context::LintContext::new(
            content,
            crate::config::MarkdownFlavor::Standard,
            Some(source_file.clone()),
        );
        let check_warnings = rule.check(&ctx).unwrap();

        // Collect warnings from cross_file_check() (workspace-index path)
        let mut file_index = FileIndex::new();
        rule.contribute_to_index(&ctx, &mut file_index);
        let workspace_index = WorkspaceIndex::new();
        let cross_warnings = rule
            .cross_file_check(&source_file, &file_index, &workspace_index)
            .unwrap();

        let total = check_warnings.len() + cross_warnings.len();
        assert_eq!(
            total, 1,
            "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
             check={check_warnings:?}, cross={cross_warnings:?}"
        );
    }

    // --- Bug #632: absolute directory links incorrectly flagged ---

    /// With absolute-links = "relative_to_roots", links to existing targets must
    /// be accepted for all four cases: {relative, absolute} x {file, directory}.
    #[test]
    fn test_absolute_dir_link_accepted_relative_to_roots() {
        let temp_dir = tempdir().unwrap();
        let root = temp_dir.path();

        // Create directory `d` with a file inside (but no index.md)
        let dir_d = root.join("d");
        std::fs::create_dir_all(&dir_d).unwrap();
        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();

        // Content exercises all four matrix cells:
        //   relative file, relative dir, absolute file, absolute dir
        let content = "\
[absolute dir](/d)\n\
[relative dir](d)\n\
[absolute file](/d/foo.md)\n\
[relative file](d/foo.md)\n";

        let config = MD057Config {
            absolute_links: AbsoluteLinksOption::RelativeToRoots,
            roots: vec![],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
        );
    }

    /// A directory link with a trailing slash and no index.md should be reported
    /// as invalid under relative_to_roots (docs-convention: trailing slash implies index.md).
    #[test]
    fn test_absolute_trailing_slash_dir_link_requires_index() {
        let temp_dir = tempdir().unwrap();
        let root = temp_dir.path();

        // Create directory `d` WITHOUT index.md
        let dir_d = root.join("d");
        std::fs::create_dir_all(&dir_d).unwrap();
        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();

        // Trailing slash signals "this is a directory index" — index.md must exist.
        let content = "[dir with slash](/d/)\n";

        let config = MD057Config {
            absolute_links: AbsoluteLinksOption::RelativeToRoots,
            roots: vec![],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);

        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(
            result.len(),
            1,
            "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
        );
    }

    /// The docs_dir (MkDocs) variant must still flag a directory link when index.md
    /// is absent. This is tested via the full check() path with RelativeToDocs config
    /// and a real mkdocs.yml pointing at a docs dir that contains the directory target.
    #[test]
    fn test_docs_dir_variant_still_enforces_index_md() {
        let temp_dir = tempdir().unwrap();
        let root = temp_dir.path();

        // Create a minimal mkdocs.yml pointing at a "docs" directory
        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();

        // Create docs/section/ WITHOUT index.md
        let docs_dir = root.join("docs");
        std::fs::create_dir_all(&docs_dir).unwrap();
        let section_dir = docs_dir.join("section");
        std::fs::create_dir_all(&section_dir).unwrap();
        std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();

        // Create the source markdown file inside docs/
        let source_file = docs_dir.join("index.md");
        std::fs::write(&source_file, "[sec](/section)\n").unwrap();

        let config = MD057Config {
            absolute_links: AbsoluteLinksOption::RelativeToDocs,
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);

        let content = "[sec](/section)\n";
        let ctx = crate::lint_context::LintContext::new(
            content,
            crate::config::MarkdownFlavor::Standard,
            Some(source_file.clone()),
        );
        let result = rule.check(&ctx).unwrap();

        // MkDocs enforces index.md for directory links, so this should be flagged.
        assert_eq!(
            result.len(),
            1,
            "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
        );
        assert!(
            result[0].message.contains("index.md") || result[0].message.contains("section"),
            "Message should mention the directory or missing index.md: {}",
            result[0].message
        );
    }

    /// Regression test for the edge case where a trailing-slash directory URL has a
    /// fragment suffix (e.g. `/guide/#intro`). After stripping the fragment, the
    /// decoded path is `guide/` (ends with `/`), but `is_directory_link` was computed
    /// from `url.ends_with('/')` which is false when the URL ends with `#intro`.
    /// The fix must still treat such links as directory links and require index.md.
    #[test]
    fn test_trailing_slash_with_fragment_treated_as_directory_link() {
        let temp_dir = tempdir().unwrap();
        let root = temp_dir.path();

        // Create directory `guide` WITHOUT index.md
        let guide_dir = root.join("guide");
        std::fs::create_dir_all(&guide_dir).unwrap();
        std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();

        // /guide/#intro has a trailing slash before the fragment — must require index.md
        let content = "[guide with fragment](/guide/#intro)\n";

        let config = MD057Config {
            absolute_links: AbsoluteLinksOption::RelativeToRoots,
            roots: vec![],
            ..Default::default()
        };
        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();

        assert_eq!(
            result.len(),
            1,
            "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
        );
    }
}

#[cfg(test)]
mod self_referential_links_tests {
    use super::*;
    use tempfile::tempdir;

    /// A document written to `dir/<name>`, checked as itself.
    fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
        let source_file = dir.join(name);
        std::fs::write(&source_file, content).unwrap();
        let rule = MD057ExistingRelativeLinks::from_config_struct(config);
        let ctx =
            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
        rule.check(&ctx).unwrap()
    }

    fn enabled() -> MD057Config {
        MD057Config {
            self_referential_links: true,
            ..Default::default()
        }
    }

    #[test]
    fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
        let temp_dir = tempdir().unwrap();
        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());

        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
        assert_eq!(
            result[0].message,
            "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
        );
        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
        assert_eq!(fix.replacement, "#level-2-heading");
        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
    }

    #[test]
    fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
        let temp_dir = tempdir().unwrap();
        let content = "# Title\n\nSee [this file](test.md).\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());

        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
        assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
        assert!(
            result[0].fix.is_none(),
            "Dropping the link would change the document, so there is no fix"
        );
    }

    #[test]
    fn test_the_check_is_off_by_default() {
        let temp_dir = tempdir().unwrap();
        let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());

        assert!(result.is_empty(), "Off by default. Got: {result:?}");
    }

    #[test]
    fn test_a_link_to_another_file_is_left_alone() {
        let temp_dir = tempdir().unwrap();
        std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
        let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());

        assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
    }

    #[test]
    fn test_a_self_link_written_with_traversal_reports_once() {
        let temp_dir = tempdir().unwrap();
        let sub_dir = temp_dir.path().join("sub");
        std::fs::create_dir_all(&sub_dir).unwrap();

        let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
        let config = MD057Config {
            self_referential_links: true,
            compact_paths: true,
            ..Default::default()
        };
        let result = check_as_file(&sub_dir, "test.md", content, config);

        assert_eq!(
            result.len(),
            1,
            "A compacted path would still be a link back to this file. Got: {result:?}"
        );
        assert_eq!(
            result[0].message,
            "Relative link '../sub/test.md' points to the file it is in"
        );
    }

    #[test]
    fn test_compact_paths_still_reports_a_link_to_another_file() {
        let temp_dir = tempdir().unwrap();
        let sub_dir = temp_dir.path().join("sub");
        std::fs::create_dir_all(&sub_dir).unwrap();
        std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();

        let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
        let config = MD057Config {
            self_referential_links: true,
            compact_paths: true,
            ..Default::default()
        };
        let result = check_as_file(&sub_dir, "test.md", content, config);

        assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
        assert_eq!(
            result[0].message,
            "Relative link '../sub/other.md' can be simplified to 'other.md'"
        );
    }

    #[test]
    fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
        let temp_dir = tempdir().unwrap();
        let content = "# Title\n\nSee [this file](test#title).\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());

        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
        assert_eq!(
            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
            Some("#title"),
            "Got: {result:?}"
        );
    }

    #[test]
    fn test_a_reference_definition_pointing_at_its_own_file() {
        let temp_dir = tempdir().unwrap();
        let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());

        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
        assert_eq!(fix.replacement, "#level-2-heading");
        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
    }

    #[test]
    fn test_a_reference_definition_whose_label_repeats_the_destination() {
        let temp_dir = tempdir().unwrap();
        let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());

        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
        // The label reads the same as the destination, so an unanchored search
        // would rewrite the label and orphan the usage above.
        assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
        let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
            .fix(&crate::lint_context::LintContext::new(
                content,
                crate::config::MarkdownFlavor::Standard,
                Some(temp_dir.path().join("test.md")),
            ))
            .unwrap();
        assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
        assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
    }

    #[test]
    fn test_a_self_link_resolved_through_a_search_path() {
        let temp_dir = tempdir().unwrap();
        let guide_dir = temp_dir.path().join("docs/guide");
        std::fs::create_dir_all(&guide_dir).unwrap();
        let config = MD057Config {
            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
            ..enabled()
        };
        let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
        let result = check_as_file(&guide_dir, "test.md", content, config);

        assert_eq!(
            result.len(),
            1,
            "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
        );
        assert_eq!(
            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
            Some("#title"),
            "Got: {result:?}"
        );
    }

    #[test]
    fn test_a_target_next_to_the_document_outranks_a_search_path() {
        let temp_dir = tempdir().unwrap();
        let guide_dir = temp_dir.path().join("docs/guide");
        std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
        std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
        let config = MD057Config {
            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
            ..enabled()
        };
        let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
        let result = check_as_file(&guide_dir, "test.md", content, config);

        assert!(
            result.is_empty(),
            "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
        );
    }

    #[test]
    fn test_an_image_pointing_at_its_own_file_is_not_reported() {
        let temp_dir = tempdir().unwrap();
        let content = "# Title\n\n![not a navigation link](test.md)\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());

        assert!(
            result.is_empty(),
            "An image is not a link the reader follows. Got: {result:?}"
        );
    }

    #[test]
    fn test_a_query_string_is_reported_without_a_suggestion() {
        let temp_dir = tempdir().unwrap();
        let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());

        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
        assert!(
            result[0].fix.is_none(),
            "A query does not survive losing its path. Got: {result:?}"
        );
    }

    #[test]
    fn test_fix_rewrites_the_document_and_settles() {
        let temp_dir = tempdir().unwrap();
        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
        let source_file = temp_dir.path().join("test.md");
        std::fs::write(&source_file, content).unwrap();

        let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
        let ctx = crate::lint_context::LintContext::new(
            content,
            crate::config::MarkdownFlavor::Standard,
            Some(source_file.clone()),
        );
        let fixed = rule.fix(&ctx).unwrap();
        assert_eq!(
            fixed,
            "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
        );

        let refixed =
            crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
        assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
    }

    #[test]
    fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
        let unfixable = MD057ExistingRelativeLinks::default();
        assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);

        let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
        assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
    }

    #[test]
    fn test_the_option_is_read_from_kebab_and_snake_case() {
        let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
        assert!(kebab.self_referential_links);

        let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
        assert!(snake.self_referential_links);
    }

    fn front_matter_checked() -> MD057Config {
        MD057Config {
            check_frontmatter: true,
            ..Default::default()
        }
    }

    #[test]
    fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
        let temp_dir = tempdir().unwrap();
        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());

        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
        assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
        assert_eq!(result[0].line, 2);
        assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
        assert_eq!(result[0].end_column, 23);
    }

    #[test]
    fn test_frontmatter_paths_are_not_checked_by_default() {
        let temp_dir = tempdir().unwrap();
        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());

        assert!(
            result.is_empty(),
            "Frontmatter is only checked on request. Got: {result:?}"
        );
    }

    #[test]
    fn test_an_existing_frontmatter_path_is_not_reported() {
        let temp_dir = tempdir().unwrap();
        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
        let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());

        assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
        assert_eq!(result[0].line, 3);
    }

    #[test]
    fn test_an_ignored_frontmatter_field_is_not_checked() {
        let temp_dir = tempdir().unwrap();
        let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
        let config = MD057Config {
            check_frontmatter: true,
            ignore_frontmatter_fields: vec!["Image".to_string()],
            ..Default::default()
        };
        let result = check_as_file(temp_dir.path(), "test.md", content, config);

        assert_eq!(
            result.len(),
            1,
            "The ignored field is skipped and the other is not. Got: {result:?}"
        );
        assert_eq!(result[0].line, 3);
    }

    #[test]
    fn test_an_external_frontmatter_url_is_not_reported() {
        let temp_dir = tempdir().unwrap();
        let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());

        assert!(
            result.is_empty(),
            "An external URL has no local target. Got: {result:?}"
        );
    }

    #[test]
    fn test_a_frontmatter_fragment_is_left_to_md051() {
        let temp_dir = tempdir().unwrap();
        let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());

        assert!(
            result.is_empty(),
            "A fragment names a heading, not a file. Got: {result:?}"
        );
    }

    #[test]
    fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
        let temp_dir = tempdir().unwrap();
        let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";

        let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
        assert!(
            ignored.is_empty(),
            "Absolute paths are ignored by default. Got: {ignored:?}"
        );

        let warning_config = MD057Config {
            check_frontmatter: true,
            absolute_links: AbsoluteLinksOption::Warn,
            ..Default::default()
        };
        let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
        assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
        assert_eq!(
            warned[0].message,
            "Absolute link '/docs/guide.md' cannot be validated locally"
        );
    }

    #[test]
    fn test_a_frontmatter_path_carrying_a_query_is_checked() {
        let temp_dir = tempdir().unwrap();
        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
        let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());

        assert_eq!(
            result.len(),
            1,
            "A query names no file, so only the missing target is reported. Got: {result:?}"
        );
        assert_eq!(result[0].line, 2);
        assert_eq!(
            result[0].message,
            "Relative link 'docs/missing.md?raw=true' does not exist"
        );
    }

    #[test]
    fn test_prose_in_frontmatter_is_not_read_as_a_path() {
        let temp_dir = tempdir().unwrap();
        let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());

        assert!(
            result.is_empty(),
            "Only path-shaped values are destinations. Got: {result:?}"
        );
    }
}
