//! File discovery, path utilities, and pattern expansion

use core::error::Error;
use ignore::WalkBuilder;
use ignore::overrides::OverrideBuilder;
use rumdl_config::resolve_rule_names;
use rumdl_lib::config as rumdl_config;
use rumdl_lib::discovery::{
    ExcludeMatchers, ExplicitIncludeMatchers, MARKDOWN_EXTENSIONS, MarkdownWalkOptions, apply_markdown_walk_options,
    expand_directory_pattern, has_markdown_extension, normalize_pattern_for_base, path_relative_to,
};
use rumdl_lib::rule::Rule;
use std::collections::HashSet;
use std::path::Path;

pub fn get_enabled_rules_from_checkargs(args: &crate::CheckArgs, config: &rumdl_config::Config) -> Vec<Box<dyn Rule>> {
    // 1. Initialize all available rules using from_config only
    let all_rules: Vec<Box<dyn Rule>> = rumdl_lib::rules::all_rules(config);

    // 2. Determine the final list of enabled rules based on precedence
    let final_rules: Vec<Box<dyn Rule>>;

    // CLI flags (resolved to canonical IDs)
    let cli_enable_set: Option<HashSet<String>> = args.enable.as_deref().map(resolve_rule_names);
    let cli_disable_set: Option<HashSet<String>> = args.disable.as_deref().map(resolve_rule_names);
    let cli_extend_enable_set: Option<HashSet<String>> = args.extend_enable.as_deref().map(resolve_rule_names);
    let cli_extend_disable_set: Option<HashSet<String>> = args.extend_disable.as_deref().map(resolve_rule_names);

    // CLI --enable acts like Ruff --select: explicit selection overrides config
    // rule selection, including config `enable` and config `extend-enable`.
    // CLI extend flags remain additive/subtractive within this explicit scope.
    if let Some(enabled_cli) = &cli_enable_set {
        let cli_enable_all = enabled_cli.iter().any(|v| v.eq_ignore_ascii_case("all"));
        let cli_extend_enable_all = cli_extend_enable_set
            .as_ref()
            .is_some_and(|s| s.iter().any(|v| v.eq_ignore_ascii_case("all")));
        let cli_extend_disable_all = cli_extend_disable_set
            .as_ref()
            .is_some_and(|s| s.iter().any(|v| v.eq_ignore_ascii_case("all")));

        let mut current_rules = if cli_enable_all || cli_extend_enable_all {
            all_rules
        } else {
            all_rules
                .into_iter()
                .filter(|rule| enabled_cli.contains(rule.name()))
                .collect::<Vec<_>>()
        };

        if !cli_extend_enable_all && let Some(extend_enabled_cli) = &cli_extend_enable_set {
            let already_enabled: HashSet<&str> = current_rules.iter().map(|r| r.name()).collect();
            let additional: Vec<Box<dyn Rule>> = rumdl_lib::rules::all_rules(config)
                .into_iter()
                .filter(|rule| extend_enabled_cli.contains(rule.name()) && !already_enabled.contains(rule.name()))
                .collect();
            current_rules.extend(additional);
        }

        if cli_extend_disable_all {
            current_rules.clear();
        } else {
            if let Some(extend_disabled_cli) = &cli_extend_disable_set {
                current_rules.retain(|rule| !extend_disabled_cli.contains(rule.name()));
            }
            if let Some(disabled_cli) = &cli_disable_set {
                current_rules.retain(|rule| !disabled_cli.contains(rule.name()));
            }
        }

        final_rules = current_rules;

        // 4. Print enabled rules if verbose
        if args.verbose {
            println!("Enabled rules:");
            for rule in &final_rules {
                println!("  - {} ({})", rule.name(), rule.description());
            }
            println!();
        }

        return final_rules;
    }

    // Config rule lists are guaranteed canonical by the runtime invariant
    // enforced in `Config::canonicalize_rule_lists` (see `src/config/types.rs`),
    // so a plain string set suffices here. CLI flags are still resolved above
    // because they come from raw user input that hasn't been canonicalised.
    let config_enable_set: HashSet<String> = config.global.enable.iter().cloned().collect();
    let config_disable_set: HashSet<String> = config.global.disable.iter().cloned().collect();
    let config_extend_enable_set: HashSet<String> = config.global.extend_enable.iter().cloned().collect();
    let config_extend_disable_set: HashSet<String> = config.global.extend_disable.iter().cloned().collect();

    let config_enable_all = config.global.enable.iter().any(|s| s.eq_ignore_ascii_case("all"));
    let opt_in_set = rumdl_lib::rules::opt_in_rules();

    // Combine all extend-enable sources (config + CLI) into one set
    let mut combined_extend_enable: HashSet<String> = config_extend_enable_set;
    if let Some(ref cli_ee) = cli_extend_enable_set {
        combined_extend_enable.extend(cli_ee.iter().cloned());
    }

    // Combine all extend-disable sources (config + CLI) into one set
    let mut combined_extend_disable: HashSet<String> = config_extend_disable_set;
    if let Some(ref cli_ed) = cli_extend_disable_set {
        combined_extend_disable.extend(cli_ed.iter().cloned());
    }

    // Check for "ALL" keyword in extend-enable (case-insensitive)
    let extend_enable_all = combined_extend_enable.iter().any(|s| s.eq_ignore_ascii_case("all"));
    // Check for "all" keyword in extend-disable (case-insensitive)
    let extend_disable_all = combined_extend_disable.iter().any(|s| s.eq_ignore_ascii_case("all"));

    // Step 1: Determine the base rule set
    let mut current_rules = if extend_enable_all {
        // extend-enable: ["ALL"] → all rules including opt-in
        all_rules
    } else if config_enable_all {
        // enable: ["ALL"] → all rules including opt-in
        all_rules
    } else if !config_enable_set.is_empty() || config.global.enable_is_explicit {
        // Explicit enable list (possibly empty) → only those rules
        all_rules
            .into_iter()
            .filter(|rule| config_enable_set.contains(rule.name()))
            .collect::<Vec<_>>()
    } else {
        // No explicit enable → all non-opt-in rules
        all_rules
            .into_iter()
            .filter(|rule| !opt_in_set.contains(rule.name()))
            .collect::<Vec<_>>()
    };

    // Step 2: Apply additive extend-enable (add rules not already present)
    // Skip if extend_enable_all was already handled in step 1
    if !extend_enable_all && !combined_extend_enable.is_empty() {
        let already_enabled: HashSet<&str> = current_rules.iter().map(|r| r.name()).collect();
        let additional: Vec<Box<dyn Rule>> = rumdl_lib::rules::all_rules(config)
            .into_iter()
            .filter(|rule| combined_extend_enable.contains(rule.name()) && !already_enabled.contains(rule.name()))
            .collect();
        current_rules.extend(additional);
    }

    // Step 3: Apply disables (subtractive, all sources)
    if extend_disable_all {
        current_rules.clear();
    } else {
        if !config_disable_set.is_empty() {
            current_rules.retain(|rule| !config_disable_set.contains(rule.name()));
        }
        if !combined_extend_disable.is_empty() {
            current_rules.retain(|rule| !combined_extend_disable.contains(rule.name()));
        }
        if let Some(disabled_cli) = &cli_disable_set {
            current_rules.retain(|rule| !disabled_cli.contains(rule.name()));
        }
    }

    final_rules = current_rules;

    // 4. Print enabled rules if verbose
    if args.verbose {
        println!("Enabled rules:");
        for rule in &final_rules {
            println!("  - {} ({})", rule.name(), rule.description());
        }
        println!();
    }

    final_rules
}

/// Canonicalize a file path to resolve symlinks and prevent duplicate linting.
///
/// Returns the canonical path if successful, or the original path if canonicalization
/// fails (e.g., file doesn't exist yet, permission denied, network path).
#[inline]
fn canonicalize_path_safe(path_str: &str) -> String {
    Path::new(path_str)
        .canonicalize()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|_| path_str.to_string())
}

/// Convert an absolute file path to a relative path for display purposes.
///
/// Tries to make the path relative to project_root first, then falls back to CWD.
/// If neither works, returns the original path unchanged.
///
/// This improves readability in CI logs and terminal output by showing
/// `docs/guide.md:12:5` instead of `/home/runner/work/myproj/docs/guide.md:12:5`.
pub fn to_display_path(file_path: &str, project_root: Option<&Path>) -> String {
    let path = Path::new(file_path);

    // Canonicalize the file path once (handles symlinks)
    let canonical_file = path.canonicalize().ok();
    let effective_path = canonical_file.as_deref().unwrap_or(path);

    // Try project root first (preferred for consistent output across the project)
    if let Some(root) = project_root
        && let Some(relative) = strip_base_prefix(effective_path, root)
    {
        return normalize_separators(relative);
    }

    // Fall back to CWD-relative
    if let Ok(cwd) = std::env::current_dir()
        && let Some(relative) = strip_base_prefix(effective_path, &cwd)
    {
        return normalize_separators(relative);
    }

    // If all else fails, return as-is
    normalize_separators(file_path.to_string())
}

/// Resolve the path string to show in output for a file.
///
/// With `show_full_path` the path is shown as-is (not relativized); otherwise it
/// is relativized via [`to_display_path`]. In both cases the result uses `/`
/// separators for consistent output across platforms.
pub fn resolve_display_path(file_path: &str, show_full_path: bool, project_root: Option<&Path>) -> String {
    if show_full_path {
        normalize_separators(file_path.to_string())
    } else {
        to_display_path(file_path, project_root)
    }
}

/// Normalize path separators to `/` for consistent cross-platform output.
///
/// Only the platform's native separator is converted: on Windows `\` becomes `/`.
/// On Unix this is a no-op, where `\` is a legal filename character that must be
/// preserved.
fn normalize_separators(path: String) -> String {
    if cfg!(windows) { path.replace('\\', "/") } else { path }
}

/// Try to strip a base path prefix from a file path.
/// Handles canonicalization of the base path to resolve symlinks.
pub(super) fn strip_base_prefix(file_path: &Path, base: &Path) -> Option<String> {
    // Canonicalize base to resolve symlinks (e.g., /tmp -> /private/tmp on macOS)
    let canonical_base = base.canonicalize().ok()?;

    // Try stripping the canonical base prefix
    if let Ok(relative) = file_path.strip_prefix(&canonical_base) {
        return Some(relative.to_string_lossy().to_string());
    }

    // Also try with non-canonical base (for cases where file_path wasn't canonicalized)
    if let Ok(relative) = file_path.strip_prefix(base) {
        return Some(relative.to_string_lossy().to_string());
    }

    None
}

pub fn find_markdown_files(
    paths: &[String],
    args: &crate::CheckArgs,
    config: &rumdl_config::Config,
    project_root: Option<&std::path::Path>,
) -> Result<Vec<String>, Box<dyn Error>> {
    let mut file_paths = Vec::new();

    // Determine if running in discovery mode (e.g., "rumdl ." or "rumdl check ." or "rumdl check")
    let is_discovery_mode = paths.is_empty() || paths == ["."];

    // Track whether config-based include patterns are active in discovery mode
    let has_config_include = is_discovery_mode && !config.global.include.is_empty();

    // Include patterns are matched relative to the same base the walker's
    // overrides use, so `~` is expanded and an absolute pattern under that base
    // is rewritten relative to it (see `normalize_pattern_for_base`).
    let include_base = project_root
        .map(Path::to_path_buf)
        .or_else(|| std::env::current_dir().ok());
    let normalize_include = |pattern: &str| normalize_pattern_for_base(pattern, include_base.as_deref());
    let config_include: Vec<String> = config
        .global
        .include
        .iter()
        .map(|pattern| normalize_include(pattern))
        .collect();

    // Config include patterns that explicitly name files beyond the standard
    // markdown extensions (e.g. `**/*.md.jinja`). These widen both the
    // walker's type filter and the final lintable-file filter below, so that
    // config include reaches the same files the equivalent CLI --include does.
    let explicit_includes = if has_config_include {
        ExplicitIncludeMatchers::new(&config_include)
    } else {
        ExplicitIncludeMatchers::new(&[])
    };

    // --- Determine Effective Include/Exclude Patterns ---

    // Include patterns: CLI > Config (only in discovery mode) > Default (only in discovery mode)
    let final_include_patterns: Vec<String> = if let Some(cli_include) = args.include.as_deref() {
        // 1. CLI --include always wins
        cli_include
            .split(',')
            .map(|p| p.trim())
            .filter(|p| !p.is_empty())
            .map(normalize_include)
            .collect()
    } else if is_discovery_mode && !config.global.include.is_empty() {
        // 2. Config include is used ONLY in discovery mode if specified
        config_include.clone()
    } else if is_discovery_mode {
        // 3. Default: Don't add include patterns as overrides - the type filter already handles
        // selecting markdown files (lines 183-199). Using overrides here would bypass gitignore
        // because overrides take precedence over gitignore in the ignore crate.
        Vec::new()
    } else {
        // 4. Explicit path mode: No includes applied by default. Walk starts from explicit paths.
        Vec::new()
    };

    // Exclude patterns: CLI > Config (but disabled if --no-exclude is set)
    let raw_exclude_patterns: Vec<String> = if args.no_exclude {
        Vec::new() // Disable all exclusions
    } else if let Some(cli_exclude) = args.exclude.as_deref() {
        cli_exclude
            .split(',')
            .map(|p| p.trim().to_string())
            .filter(|p| !p.is_empty())
            .collect()
    } else {
        config.global.exclude.clone()
    };

    // Expand directory-only patterns to also match their contents (for the
    // walker overrides; ExcludeMatchers applies the same expansion itself)
    let final_exclude_patterns: Vec<String> = raw_exclude_patterns
        .iter()
        .flat_map(|p| expand_directory_pattern(p))
        .collect();

    // Debug: Log exclude patterns
    if args.verbose {
        eprintln!("Exclude patterns: {final_exclude_patterns:?}");
    }
    let exclude_matchers = ExcludeMatchers::new(&raw_exclude_patterns);
    for (pattern, error) in &exclude_matchers.invalid {
        eprintln!("Warning: Invalid exclude pattern '{pattern}': {error}");
    }
    let canonical_project_root = project_root.and_then(|root| root.canonicalize().ok());
    // --- End Pattern Determination ---

    // --- Split explicit paths into named files and directory roots ---
    // A file named on the command line is trusted as-is: it is linted even
    // without a markdown extension and bypasses the walker's type/ignore
    // filters, subject only to exclude patterns (issue #99). Directory
    // arguments are walked below exactly like discovery-mode roots, so a
    // mixed invocation checks the union of both (issue #741).
    let mut explicit_files: Vec<String> = Vec::new();
    let mut explicit_dirs: Vec<&str> = Vec::new();

    if !is_discovery_mode {
        for path_str in paths {
            let path = Path::new(path_str);
            if !path.exists() {
                return Err(format!("File not found: {path_str}").into());
            }
            if !path.is_file() {
                explicit_dirs.push(path_str.as_str());
                continue;
            }
            // Convert to relative path for pattern matching
            // This ensures patterns like "docs/*" work with both relative and absolute paths
            let cleaned_path = if path.is_absolute() {
                // Try to make it relative to the current directory
                // Use canonicalized paths to handle symlinks (e.g., /tmp -> /private/tmp on macOS)
                if let Ok(cwd) = std::env::current_dir() {
                    // Canonicalize both paths to resolve symlinks
                    if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), path.canonicalize()) {
                        if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
                            relative.to_string_lossy().to_string()
                        } else {
                            // Path is absolute but not under cwd, keep as-is
                            path_str.clone()
                        }
                    } else {
                        // Canonicalization failed, keep path as-is
                        path_str.clone()
                    }
                } else {
                    path_str.clone()
                }
            } else if let Some(stripped) = path_str.strip_prefix("./") {
                stripped.to_string()
            } else {
                path_str.clone()
            };

            // Check if this file should be excluded based on exclude patterns
            // This is the default behavior to match user expectations and avoid
            // duplication between rumdl config and pre-commit config (issue #99)
            if !exclude_matchers.is_empty() {
                // Compute path relative to project_root for pattern matching
                // This ensures patterns like "subdir/file.md" work regardless of cwd
                let path_for_matching = canonical_project_root
                    .as_deref()
                    .and_then(|root| path_relative_to(path, root))
                    .unwrap_or_else(|| cleaned_path.clone());
                // Absolute patterns (written literally or produced by `~`
                // expansion) match the absolute path instead.
                if let Some(pattern) = exclude_matchers.matched_pattern_for_file(Some(&path_for_matching), path) {
                    // Excluding an explicitly provided file is a deliberate config choice, so
                    // this is an informational notice, not a warning, and it is surfaced only
                    // under --verbose. This keeps explicit-path mode as quiet as discovery
                    // mode (which excludes silently) while still letting `--verbose` explain
                    // why a named file was skipped. --silent suppresses it entirely.
                    if args.verbose && !args.silent {
                        let display_path = normalize_separators(cleaned_path.clone());
                        eprintln!(
                            "{display_path} ignored because of exclude pattern '{pattern}'. Use --no-exclude to override"
                        );
                    }
                } else {
                    explicit_files.push(canonicalize_path_safe(&cleaned_path));
                }
            } else {
                explicit_files.push(canonicalize_path_safe(&cleaned_path));
            }
        }

        // Nothing to walk when every argument is a file. Returns the explicit
        // set even if exclusions emptied it, so the caller reports "no files"
        // instead of silently falling back to a cwd walk.
        if explicit_dirs.is_empty() {
            explicit_files.sort();
            explicit_files.dedup();
            return Ok(explicit_files);
        }
    }

    // --- Configure ignore::WalkBuilder over the directory roots ---
    // Discovery mode walks the cwd (`.`); explicit mode walks only the
    // directory arguments (named files were collected above).
    let mut walk_builder = if is_discovery_mode {
        WalkBuilder::new(paths.first().map(String::as_str).unwrap_or("."))
    } else {
        let mut builder = WalkBuilder::new(explicit_dirs[0]);
        for dir in &explicit_dirs[1..] {
            builder.add(dir);
        }
        builder
    };

    // --- Add Lintable File Type Filter ---
    // CLI --include: no type filter (user controls which files to process)
    // Config include: expanded filter (markdown + rust + explicitly named
    // files, since the user spelled those out)
    // Default: markdown-only filter
    if args.include.is_none() {
        let mut types_builder = ignore::types::TypesBuilder::new();
        types_builder.add_defaults();
        for ext in MARKDOWN_EXTENSIONS {
            types_builder.add("markdown", &format!("*.{ext}"))?;
        }
        // Type globs match case-sensitively; cover the conventional
        // capitalized R Markdown extension explicitly.
        types_builder.add("markdown", "*.Rmd")?;
        types_builder.select("markdown");
        if has_config_include {
            // Config include is active: also allow Rust files for doc comment linting
            types_builder.add("rustdoc", "*.rs")?;
            types_builder.select("rustdoc");
        }
        if !explicit_includes.is_empty() {
            // Type names must be purely alphanumeric in the ignore crate.
            for glob in explicit_includes.file_name_globs() {
                types_builder.add("configinclude", glob)?;
            }
            types_builder.select("configinclude");
        }
        let types = types_builder.build()?;
        walk_builder.types(types);
    }
    // -----------------------------------------

    // Apply overrides using the determined patterns
    if !final_include_patterns.is_empty() || !final_exclude_patterns.is_empty() {
        // Use project_root as the pattern base for OverrideBuilder
        // The walker paths are relative to the walk roots, but the ignore crate
        // handles the path matching internally when both are consistent directories
        let pattern_base = project_root.unwrap_or(Path::new("."));
        let mut override_builder = OverrideBuilder::new(pattern_base);

        // Add includes (these act as positive filters)
        for pattern in &final_include_patterns {
            // Important: In ignore crate, bare patterns act as includes if no exclude (!) is present.
            // If we add excludes later, these includes ensure *only* matching files are considered.
            // If no excludes are added, these effectively define the set of files to walk.
            if let Err(e) = override_builder.add(pattern) {
                eprintln!("Warning: Invalid include pattern '{pattern}': {e}");
            }
        }

        // Add excludes (these filter *out* files) - MUST start with '!'
        for pattern in &final_exclude_patterns {
            // Ensure exclude patterns start with '!' for ignore crate overrides
            let exclude_rule = if pattern.starts_with('!') {
                pattern.clone() // Already formatted
            } else {
                format!("!{pattern}")
            };
            if let Err(e) = override_builder.add(&exclude_rule) {
                eprintln!("Warning: Invalid exclude pattern '{pattern}': {e}");
            }
        }

        // Build and apply the overrides
        match override_builder.build() {
            Ok(overrides) => {
                walk_builder.overrides(overrides);
            }
            Err(e) => {
                eprintln!("Error building path overrides: {e}");
            }
        };
    }

    // Configure ignore handling *SECOND*: gitignore family per config,
    // hidden files included, .markdownlintignore honored. Shared with the
    // LSP workspace scan so both walk the same files.
    apply_markdown_walk_options(
        &mut walk_builder,
        &MarkdownWalkOptions {
            respect_gitignore: config.global.respect_gitignore,
            skip_vendor_dirs: false,
        },
    );

    // --- Execute Walk ---

    for result in walk_builder.build() {
        match result {
            Ok(entry) => {
                let path = entry.path();
                // We are primarily interested in files. ignore crate handles dir traversal.
                // Check if it's a file and if it wasn't explicitly excluded by overrides
                if entry.file_type().is_some_and(|file_type| file_type.is_file()) {
                    let file_path = path.to_string_lossy().to_string();
                    // Clean the path before pushing
                    let cleaned_path = if let Some(stripped) = file_path.strip_prefix("./") {
                        stripped.to_string()
                    } else {
                        file_path
                    };
                    file_paths.push(canonicalize_path_safe(&cleaned_path));
                }
            }
            Err(err) => {
                // Only show generic walking errors for directories, not for missing files
                if is_discovery_mode {
                    eprintln!("Error walking directory: {err}");
                }
            }
        }
    }

    // Remove duplicate paths if WalkBuilder might yield them (e.g. multiple input paths)
    file_paths.sort();
    file_paths.dedup();

    // --- Post-walk exclude pattern filtering ---
    // The ignore crate's overrides may not work correctly when the walker path prefix
    // differs from the config file location. Apply exclude patterns manually here.
    // This also carries absolute patterns, which the walker's overrides cannot
    // express: the ignore crate anchors a leading `/` to the walk root.
    if !exclude_matchers.is_empty() {
        file_paths.retain(|file_path| {
            let path = Path::new(file_path);
            // Compute path relative to project_root for pattern matching. Without
            // a project root, or outside it, only the absolute form applies.
            let path_for_matching = canonical_project_root
                .as_deref()
                .and_then(|root| path_relative_to(path, root));

            // Check if any exclude pattern matches
            !exclude_matchers.excludes_file(path_for_matching.as_deref(), path)
        });
    }

    // --- Final Lintable File Filter ---
    // CLI --include: no extension filter (user controls which files to process)
    // Config include: allow markdown + rust extensions + explicitly named files
    // Default: markdown-only extensions
    if args.include.is_none() {
        // Explicit include patterns are matched against the same base the
        // walker overrides use, so the full pattern path applies: a broad
        // sibling pattern must not inherit another pattern's allowance for
        // files that merely share its name.
        let explicit_include_base = canonical_project_root.clone().or_else(|| std::env::current_dir().ok());
        file_paths.retain(|path_str| {
            let path = Path::new(path_str);
            let is_rust = has_config_include && path.extension().is_some_and(|ext| ext.to_str() == Some("rs"));
            if has_markdown_extension(path) || is_rust {
                return true;
            }
            if explicit_includes.is_empty() {
                return false;
            }
            match explicit_include_base
                .as_deref()
                .and_then(|base| path_relative_to(path, base))
            {
                Some(relative) => explicit_includes.matches_relative_path(&relative),
                // Outside the pattern base only unanchored patterns can
                // still apply; matching the full path covers those.
                None => explicit_includes.matches_relative_path(path_str),
            }
        });
    }
    // -------------------------------------

    // Union with the explicitly named files. Both sides hold canonicalized
    // paths, so a file that is both named and found by the directory walk
    // dedups away.
    file_paths.extend(explicit_files);
    file_paths.sort();
    file_paths.dedup();

    Ok(file_paths)
}
