use std::borrow::Cow;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};

use crate::Formatter;
use crate::core::config::FluffConfig;
use crate::core::linter::common::{BatchRenderedResult, ParsedString, RenderedFile};
use crate::core::linter::linted_file::LintedFile;
use crate::core::linter::linting_result::LintingResult;
use crate::core::rules::noqa::IgnoreMask;
use crate::core::rules::{ErasedRule, Exception, LintPhase, RulePack};
use crate::rules::get_ruleset;
use crate::templaters::raw::RawTemplater;
use crate::templaters::{ProcessingMode, TEMPLATERS, Templater};
use hashbrown::{HashMap, HashSet};
use itertools::Itertools;
use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _};
use smol_str::{SmolStr, ToSmolStr};
use sqruff_lib_core::dialects::Dialect;
use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet};
use sqruff_lib_core::errors::{
    SQLBaseError, SQLFluffUserError, SQLLexError, SQLLintError, SQLParseError, SQLTemplaterError,
};
use sqruff_lib_core::helpers;
use sqruff_lib_core::linter::compute_anchor_edit_info;
use sqruff_lib_core::parser::Parser;
use sqruff_lib_core::parser::segments::{ErasedSegment, Tables};
use sqruff_lib_core::templaters::TemplatedFile;
use walkdir::WalkDir;

pub struct Linter {
    config: FluffConfig,
    formatter: Option<Arc<dyn Formatter>>,
    templater: &'static dyn Templater,
    rules: OnceLock<Vec<ErasedRule>>,

    /// include_parse_errors is a flag to indicate whether to include parse errors in the output
    include_parse_errors: bool,
}

impl Linter {
    pub fn new(
        config: FluffConfig,
        formatter: Option<Arc<dyn Formatter>>,
        templater: Option<&'static dyn Templater>,
        include_parse_errors: bool,
    ) -> Result<Linter, String> {
        let templater: &'static dyn Templater = match templater {
            Some(templater) => templater,
            None => Linter::get_templater(&config)?,
        };
        Ok(Linter {
            config,
            formatter,
            templater,
            rules: OnceLock::new(),
            include_parse_errors,
        })
    }

    pub fn get_templater(config: &FluffConfig) -> Result<&'static dyn Templater, String> {
        let templater_name = config.get("templater", "core").as_string();
        match templater_name {
            Some(name) => match TEMPLATERS.into_iter().find(|t| t.name() == name) {
                Some(t) => Ok(t),
                None => {
                    let available: Vec<&str> = TEMPLATERS.iter().map(|t| t.name()).collect();
                    Err(format!(
                        "Unknown templater '{}'. Available templaters: {}",
                        name,
                        available.join(", ")
                    ))
                }
            },
            None => Ok(&RawTemplater),
        }
    }

    /// Lint strings directly.
    pub fn lint_string_wrapped(
        &mut self,
        sql: &str,
        fix: bool,
    ) -> Result<LintedFile, SQLFluffUserError> {
        let filename = "<string input>".to_owned();
        self.lint_string(sql, Some(filename), fix)
    }

    /// Parse a string.
    pub fn parse_string(
        &self,
        tables: &Tables,
        sql: &str,
        filename: Option<String>,
    ) -> Result<ParsedString, SQLFluffUserError> {
        let f_name = filename.unwrap_or_else(|| "<string>".to_string());

        // Scan the raw file for config commands.
        self.config.process_raw_file_for_config(sql);
        let rendered = self.render_string(sql, f_name.clone(), &self.config)?;

        Ok(self.parse_rendered(tables, rendered))
    }

    /// Lint a string.
    pub fn lint_string(
        &self,
        sql: &str,
        filename: Option<String>,
        fix: bool,
    ) -> Result<LintedFile, SQLFluffUserError> {
        let tables = Tables::default();
        let parsed = self.parse_string(&tables, sql, filename)?;

        // Lint the file and return the LintedFile
        self.lint_parsed(&tables, parsed, fix)
    }

    /// ignorer is an optional argument that takes in a function that returns a bool based on the
    /// path passed to it. If the function returns true, the path is ignored.
    pub fn lint_paths(
        &mut self,
        mut paths: Vec<PathBuf>,
        fix: bool,
        ignorer: &(dyn Fn(&Path) -> bool + Send + Sync),
    ) -> Result<LintingResult, SQLFluffUserError> {
        if paths.is_empty() {
            paths.push(std::env::current_dir().unwrap());
        }

        let mut expanded_paths = Vec::new();

        for path in paths {
            if path.is_file() {
                expanded_paths.push(path.to_string_lossy().to_string());
            } else {
                expanded_paths.extend(self.paths_from_path(
                    path,
                    None,
                    None,
                    None,
                    None,
                    Some(ignorer),
                ));
            };
        }

        let paths: Vec<String> = expanded_paths
            .into_iter()
            .filter(|path| {
                let should_ignore = ignorer(Path::new(path));
                if should_ignore {
                    log::debug!(
                        "Filtering out ignored file '{}' from final processing list",
                        path
                    );
                }
                !should_ignore
            })
            .collect_vec();

        let mut files = Vec::with_capacity(paths.len());

        match self.templater.processing_mode() {
            ProcessingMode::Parallel => {
                let results: Vec<_> = paths
                    .par_iter()
                    .map(|path| {
                        let rendered = self.render_file(path.clone());
                        self.lint_rendered(rendered, fix)
                    })
                    .collect();
                for result in results {
                    files.push(result?);
                }
            }
            ProcessingMode::Batch => {
                // Use batch processing for templaters that support it (e.g., dbt).
                // This allows sharing expensive initialization (manifest loading) across files.
                let batch_results = self.render_files_batch(&paths);
                for result in batch_results {
                    match result {
                        BatchRenderedResult::Rendered(rendered) => {
                            files.push(self.lint_rendered(rendered, fix)?);
                        }
                        BatchRenderedResult::Skipped { filename, reason } => {
                            if let Some(formatter) = &self.formatter {
                                formatter.dispatch_file_skip(&filename, &reason);
                            }
                        }
                    }
                }
            }
            ProcessingMode::Sequential => {
                for path in &paths {
                    let rendered = self.render_file(path.clone());
                    files.push(self.lint_rendered(rendered, fix)?);
                }
            }
        }

        Ok(LintingResult::new(files))
    }

    pub fn get_rulepack(&self) -> Result<RulePack, SQLFluffUserError> {
        let rs = get_ruleset();
        rs.get_rulepack(&self.config)
    }

    pub fn render_file(&self, fname: String) -> RenderedFile {
        let in_str = std::fs::read_to_string(&fname).unwrap();
        match self.render_string(&in_str, fname.clone(), &self.config) {
            Ok(rendered) => rendered,
            Err(err) => {
                log::error!("Failed to template file {}: {:?}", fname, err);
                let source_str = Self::normalise_newlines(&in_str).to_string();
                RenderedFile {
                    templated_file: TemplatedFile::new(
                        source_str.clone(),
                        fname.clone(),
                        None,
                        None,
                        None,
                    )
                    .expect("Creating raw TemplatedFile should not fail"),
                    templater_violations: vec![SQLTemplaterError::new(format!(
                        "Failed to template file {fname}: {err}"
                    ))],
                    filename: fname,
                    source_str,
                }
            }
        }
    }

    /// Render multiple files in a batch using the templater's batch processing.
    ///
    /// This is more efficient for templaters like dbt that have expensive
    /// initialization (manifest loading) that can be shared across files.
    pub fn render_files_batch(&self, fnames: &[String]) -> Vec<BatchRenderedResult> {
        if fnames.is_empty() {
            return Vec::new();
        }

        // Check dialect before processing
        if let Some(_error) = self.config.verify_dialect_specified() {
            // Return error rendered files for all files
            return fnames
                .iter()
                .map(|fname| {
                    let source_str = std::fs::read_to_string(fname).unwrap_or_default();
                    BatchRenderedResult::Rendered(RenderedFile {
                        templated_file: TemplatedFile::new(
                            source_str.clone(),
                            fname.clone(),
                            None,
                            None,
                            None,
                        )
                        .expect("Creating raw TemplatedFile should not fail"),
                        templater_violations: vec![],
                        filename: fname.clone(),
                        source_str,
                    })
                })
                .collect();
        }

        // Read all files and prepare for batch processing
        let files: Vec<(String, String)> = fnames
            .iter()
            .map(|fname| {
                let content = std::fs::read_to_string(fname).unwrap_or_default();
                let normalized = Self::normalise_newlines(&content).to_string();
                (normalized, fname.clone())
            })
            .collect();

        // Convert to slice of references for the process method
        let file_refs: Vec<(&str, &str)> = files
            .iter()
            .map(|(content, fname)| (content.as_str(), fname.as_str()))
            .collect();

        // Process all files in batch
        let results = self
            .templater
            .process(&file_refs, &self.config, &self.formatter);

        // Convert results to BatchRenderedResults, preserving order
        results
            .into_iter()
            .zip(files.iter())
            .map(|(result, (source_str, fname))| match result {
                Ok(templated_file) => BatchRenderedResult::Rendered(RenderedFile {
                    templated_file,
                    templater_violations: vec![],
                    filename: fname.clone(),
                    source_str: source_str.clone(),
                }),
                Err(err) => {
                    let err_str = err.to_string();
                    if let Some(reason) = err_str.strip_prefix("SKIP:") {
                        return BatchRenderedResult::Skipped {
                            filename: fname.clone(),
                            reason: reason.to_string(),
                        };
                    }
                    log::error!("Failed to template file {}: {:?}", fname, err);
                    // Return a minimal RenderedFile with the templater error as a
                    // violation. This prevents linting the raw source (which contains
                    // template syntax like {{ }}) and producing false positive LT01
                    // spacing errors.
                    BatchRenderedResult::Rendered(RenderedFile {
                        templated_file: TemplatedFile::new(
                            source_str.clone(),
                            fname.clone(),
                            None,
                            None,
                            None,
                        )
                        .expect("Creating raw TemplatedFile should not fail"),
                        templater_violations: vec![SQLTemplaterError::new(format!(
                            "Failed to template file {fname}: {err}"
                        ))],
                        filename: fname.clone(),
                        source_str: source_str.clone(),
                    })
                }
            })
            .collect()
    }

    pub fn lint_rendered(
        &self,
        rendered: RenderedFile,
        fix: bool,
    ) -> Result<LintedFile, SQLFluffUserError> {
        let tables = Tables::default();
        let parsed = self.parse_rendered(&tables, rendered);
        self.lint_parsed(&tables, parsed, fix)
    }

    pub fn lint_parsed(
        &self,
        tables: &Tables,
        parsed_string: ParsedString,
        fix: bool,
    ) -> Result<LintedFile, SQLFluffUserError> {
        let mut violations = parsed_string.violations;

        let (patches, ignore_mask, initial_linting_errors) = match parsed_string.tree {
            Some(erased_segment) => {
                let (tree, ignore_mask, initial_linting_errors) = self.lint_fix_parsed(
                    tables,
                    erased_segment,
                    &parsed_string.templated_file,
                    fix,
                )?;
                let patches = tree.iter_patches(&parsed_string.templated_file);
                (patches, ignore_mask, initial_linting_errors)
            }
            None => (Vec::new(), None, Vec::new()),
        };
        violations.extend(initial_linting_errors.into_iter().map_into());

        // Filter violations with ignore mask
        if let Some(ignore_mask) = &ignore_mask {
            violations.retain(|violation| !ignore_mask.is_masked(violation, None));
        }

        // TODO Need to error out unused noqas
        let linted_file = LintedFile::new(
            parsed_string.filename,
            patches,
            parsed_string.templated_file,
            violations,
            ignore_mask,
        );

        if let Some(formatter) = &self.formatter {
            formatter.dispatch_file_violations(&linted_file);
        }

        Ok(linted_file)
    }

    pub fn lint_fix_parsed(
        &self,
        tables: &Tables,
        mut tree: ErasedSegment,
        templated_file: &TemplatedFile,
        fix: bool,
    ) -> Result<(ErasedSegment, Option<IgnoreMask>, Vec<SQLLintError>), SQLFluffUserError> {
        let mut initial_violations = Vec::new();
        let phases: &[_] = if fix {
            &[LintPhase::Main, LintPhase::Post]
        } else {
            &[LintPhase::Main]
        };
        let mut previous_versions: HashSet<(SmolStr, bool)> =
            [(tree.raw().to_smolstr(), false)].into_iter().collect();

        // If we are fixing then we want to loop up to the runaway_limit, otherwise just
        // once for linting.
        let loop_limit = if fix { 10 } else { 1 };
        // Look for comment segments which might indicate lines to ignore.
        let (ignore_mask, violations): (Option<IgnoreMask>, Vec<SQLBaseError>) = {
            let disable_noqa = self
                .config
                .get("disable_noqa", "core")
                .as_bool()
                .unwrap_or(false);
            if disable_noqa {
                (None, Vec::new())
            } else {
                let (ignore_mask, errors) = IgnoreMask::from_tree(&tree);
                (Some(ignore_mask), errors)
            }
        };

        initial_violations.extend(violations.into_iter().map_into());

        let mut anchor_info = HashMap::default();

        for phase in phases {
            let loop_limit = if *phase == LintPhase::Main {
                loop_limit
            } else {
                2
            };
            let rules = self.rules()?;
            let filtered_rules;
            let mut rules_this_phase: &[ErasedRule] = if phases.len() > 1 {
                filtered_rules = rules
                    .iter()
                    .filter(|rule| rule.lint_phase() == *phase)
                    .cloned()
                    .collect_vec();
                &filtered_rules
            } else {
                rules
            };

            for loop_ in 0..loop_limit {
                let is_first_linter_pass = *phase == phases[0] && loop_ == 0;
                let mut changed = false;

                if is_first_linter_pass {
                    rules_this_phase = self.rules()?;
                }

                for rule in rules_this_phase {
                    anchor_info.clear();

                    // Performance: After first loop pass, skip rules that don't do fixes. Any
                    // results returned won't be seen by the user anyway (linting errors ADDED by
                    // rules changing SQL, are not reported back to the user - only initial linting
                    // errors), so there's absolutely no reason to run them.
                    if fix && !is_first_linter_pass && !rule.is_fix_compatible() {
                        continue;
                    }

                    let result = crate::core::rules::crawl(
                        rule,
                        tables,
                        &self.config.dialect,
                        templated_file,
                        tree.clone(),
                        &self.config,
                        &mut |mut result| {
                            if ignore_mask.as_ref().is_none_or(|ignore_mask| {
                                !ignore_mask.is_masked(&result, rule.into())
                            }) {
                                compute_anchor_edit_info(
                                    &mut anchor_info,
                                    std::mem::take(&mut result.fixes),
                                );

                                if is_first_linter_pass {
                                    initial_violations.extend(result.to_linting_error(rule));
                                }
                            }
                        },
                    );

                    if let Err(Exception) = result {
                        if is_first_linter_pass {
                            initial_violations.push(
                                SQLLintError::new(
                                    "Unexpected exception. Could you open an issue at https://github.com/quarylabs/sqruff",
                                    tree.clone(),
                                    false,
                                ),
                            );
                        }

                        continue;
                    }

                    if fix && !anchor_info.is_empty() {
                        let (new_tree, _, _) = tree.apply_fixes(&mut anchor_info);
                        let has_source_fixes = !new_tree.get_all_source_fixes().is_empty();

                        // For loop detection, we check raw and whether we have source_fixes.
                        // Source fixes don't change the tree raw, so once we have source_fixes
                        // and raw is unchanged, we're done.
                        let loop_check_tuple = (new_tree.raw().to_smolstr(), has_source_fixes);

                        if previous_versions.insert(loop_check_tuple) {
                            tree = new_tree;
                            changed = true;
                            continue;
                        }
                    }
                }

                if fix && !changed {
                    break;
                }
            }
        }

        Ok((tree, ignore_mask, initial_violations))
    }

    /// Template the file.
    pub fn render_string(
        &self,
        sql: &str,
        filename: String,
        config: &FluffConfig,
    ) -> Result<RenderedFile, SQLFluffUserError> {
        let sql = Self::normalise_newlines(sql);

        if let Some(error) = config.verify_dialect_specified() {
            return Err(error);
        }

        let templater_violations = vec![];
        let mut results = self.templater.process(
            &[(sql.as_ref(), filename.as_str())],
            config,
            &self.formatter,
        );

        match results.pop() {
            Some(Ok(templated_file)) => Ok(RenderedFile {
                templated_file,
                templater_violations,
                filename,
                source_str: sql.to_string(),
            }),
            Some(Err(err)) => Err(SQLFluffUserError::new(format!(
                "Failed to template file {filename} with error {err:?}"
            ))),
            None => Err(SQLFluffUserError::new(format!(
                "Templater returned no results for file {filename}"
            ))),
        }
    }

    /// Parse a rendered file.
    pub fn parse_rendered(&self, tables: &Tables, rendered: RenderedFile) -> ParsedString {
        let templater_violations = rendered.templater_violations.clone();
        if !templater_violations.is_empty() {
            // If the templater reported violations (e.g., dbt/jinja templater
            // failed), skip parsing. This prevents false positive lint errors
            // (like LT01 spacing violations on `{{ }}` template syntax) that
            // would occur if we tried to parse the raw source as SQL.
            let violations: Vec<SQLBaseError> = templater_violations
                .into_iter()
                .map(SQLBaseError::from)
                .collect();
            return ParsedString {
                tree: None,
                violations,
                templated_file: rendered.templated_file,
                filename: rendered.filename,
                source_str: rendered.source_str,
            };
        }

        let mut violations = Vec::new();
        let tokens = if rendered.templated_file.is_templated() {
            let (t, lvs) = Self::lex_templated_file(
                tables,
                rendered.templated_file.clone(),
                &self.config.dialect,
            );
            if !lvs.is_empty() {
                unimplemented!("violations.extend(lvs);")
            }
            t
        } else {
            None
        };

        let parsed: Option<ErasedSegment>;
        if let Some(token_list) = tokens {
            let (p, pvs) =
                Self::parse_tokens(tables, &token_list, &self.config, self.include_parse_errors);
            parsed = p;
            violations.extend(pvs.into_iter().map_into());
        } else {
            parsed = None;
        };

        ParsedString {
            tree: parsed,
            violations,
            templated_file: rendered.templated_file,
            filename: rendered.filename,
            source_str: rendered.source_str,
        }
    }

    fn parse_tokens(
        tables: &Tables,
        tokens: &[ErasedSegment],
        config: &FluffConfig,
        include_parse_errors: bool,
    ) -> (Option<ErasedSegment>, Vec<SQLParseError>) {
        let parser: Parser = config.into();
        let mut violations: Vec<SQLParseError> = Vec::new();

        let parsed = match parser.parse(tables, tokens) {
            Ok(parsed) => parsed,
            Err(error) => {
                violations.push(error);
                None
            }
        };

        if include_parse_errors && let Some(parsed) = &parsed {
            let unparsables = parsed.recursive_crawl(
                &SyntaxSet::single(SyntaxKind::Unparsable),
                true,
                &SyntaxSet::EMPTY,
                true,
            );

            violations.extend(unparsables.into_iter().map(|segment| SQLParseError {
                description: "Unparsable section".into(),
                segment: segment.into(),
            }));
        };

        (parsed, violations)
    }

    /// Lex a templated file.
    pub fn lex_templated_file(
        tables: &Tables,
        templated_file: TemplatedFile,
        dialect: &Dialect,
    ) -> (Option<Vec<ErasedSegment>>, Vec<SQLLexError>) {
        let mut violations: Vec<SQLLexError> = vec![];
        log::debug!("LEXING RAW ({})", templated_file.name());
        // Get the lexer
        let lexer = dialect.lexer();
        // Lex the file and log any problems
        let (tokens, lex_vs) = lexer.lex(tables, templated_file);

        violations.extend(lex_vs);

        if tokens.is_empty() {
            return (None, violations);
        }

        (tokens.into(), violations)
    }

    /// Normalise newlines to unix-style line endings.
    fn normalise_newlines(string: &str) -> Cow<'_, str> {
        lazy_regex::regex!("\r\n|\r").replace_all(string, "\n")
    }

    // Return a set of sql file paths from a potentially more ambiguous path string.
    // Here we also deal with the .sqlfluffignore file if present.
    // When a path to a file to be linted is explicitly passed
    // we look for ignore files in all directories that are parents of the file,
    // up to the current directory.
    // If the current directory is not a parent of the file we only
    // look for an ignore file in the direct parent of the file.
    fn paths_from_path(
        &self,
        path: PathBuf,
        ignore_file_name: Option<String>,
        ignore_non_existent_files: Option<bool>,
        ignore_files: Option<bool>,
        working_path: Option<String>,
        ignorer: Option<&(dyn Fn(&Path) -> bool + Send + Sync)>,
    ) -> Vec<String> {
        let ignore_file_name = ignore_file_name.unwrap_or_else(|| String::from(".sqlfluffignore"));
        let ignore_non_existent_files = ignore_non_existent_files.unwrap_or(false);
        let ignore_files = ignore_files.unwrap_or(true);
        let _working_path =
            working_path.unwrap_or_else(|| std::env::current_dir().unwrap().display().to_string());

        let Ok(metadata) = std::fs::metadata(&path) else {
            if ignore_non_existent_files {
                return Vec::new();
            } else {
                panic!("Specified path does not exist. Check it/they exist(s): {path:?}");
            }
        };

        // Files referred to exactly are also ignored if
        // matched, but we warn the users when that happens
        let is_exact_file = metadata.is_file();

        let mut path_walk = if is_exact_file {
            let path = Path::new(&path);
            let dirpath = path.parent().unwrap().to_str().unwrap().to_string();
            let files = vec![path.file_name().unwrap().to_str().unwrap().to_string()];
            vec![(dirpath, None, files)]
        } else {
            let walkdir = WalkDir::new(&path);
            let entries: Vec<_> = if let Some(ignorer) = ignorer {
                // Apply ignorer during traversal to skip ignored directories entirely
                walkdir
                    .into_iter()
                    .filter_entry(|entry| {
                        let should_ignore = ignorer(entry.path());
                        if should_ignore {
                            let path_type = if entry.file_type().is_dir() {
                                "directory"
                            } else {
                                "file"
                            };
                            log::debug!(
                                "Skipping {} '{}' during file discovery traversal",
                                path_type,
                                entry.path().display()
                            );
                        }
                        !should_ignore
                    })
                    .filter_map(Result::ok)
                    .collect()
            } else {
                // No ignorer provided, use original behavior
                walkdir.into_iter().filter_map(Result::ok).collect()
            };

            // Group entries by directory to maintain the original data structure
            let mut dir_files: HashMap<String, Vec<String>> = HashMap::new();

            for entry in entries {
                if entry.file_type().is_file() {
                    let dirpath = entry.path().parent().unwrap().to_str().unwrap().to_string();
                    let filename = entry.file_name().to_str().unwrap().to_string();
                    dir_files.entry(dirpath).or_default().push(filename);
                }
            }

            dir_files
                .into_iter()
                .map(|(dirpath, files)| (dirpath, None, files))
                .collect_vec()
        };

        // TODO:
        // let ignore_file_paths = ConfigLoader.find_ignore_config_files(
        //     path=path, working_path=working_path, ignore_file_name=ignore_file_name
        // );
        let ignore_file_paths: Vec<String> = Vec::new();

        // Add paths that could contain "ignore files"
        // to the path_walk list
        let path_walk_ignore_file: Vec<(String, Option<()>, Vec<String>)> = ignore_file_paths
            .iter()
            .map(|ignore_file_path| {
                let ignore_file_path = Path::new(ignore_file_path);

                // Extracting the directory name from the ignore file path
                let dir_name = ignore_file_path
                    .parent()
                    .unwrap()
                    .to_str()
                    .unwrap()
                    .to_string();

                // Only one possible file, since we only
                // have one "ignore file name"
                let file_name = vec![
                    ignore_file_path
                        .file_name()
                        .unwrap()
                        .to_str()
                        .unwrap()
                        .to_string(),
                ];

                (dir_name, None, file_name)
            })
            .collect();

        path_walk.extend(path_walk_ignore_file);

        let mut buffer = Vec::new();
        let mut ignores = HashMap::new();
        let sql_file_exts = self.config.sql_file_exts();

        for (dirpath, _, filenames) in path_walk {
            for fname in filenames {
                let fpath = Path::new(&dirpath).join(&fname);

                // Handle potential .sqlfluffignore files
                if ignore_files && fname == ignore_file_name {
                    let file = File::open(&fpath).unwrap();
                    let lines = BufReader::new(file).lines();
                    let spec = lines.map_while(Result::ok); // Simple placeholder for pathspec logic
                    ignores.insert(dirpath.clone(), spec.collect::<Vec<String>>());

                    // We don't need to process the ignore file any further
                    continue;
                }

                // We won't purge files *here* because there's an edge case
                // that the ignore file is processed after the sql file.

                // Scan for remaining files
                for ext in sql_file_exts {
                    // is it a sql file?
                    if fname.to_lowercase().ends_with(ext) {
                        buffer.push(fpath.clone());
                    }
                }
            }
        }

        let mut filtered_buffer = HashSet::new();

        for fpath in buffer {
            let npath = helpers::normalize(&fpath).to_str().unwrap().to_string();
            filtered_buffer.insert(npath);
        }

        let mut files = filtered_buffer.into_iter().collect_vec();
        files.sort();
        files
    }

    pub fn config(&self) -> &FluffConfig {
        &self.config
    }

    pub fn config_mut(&mut self) -> &mut FluffConfig {
        self.rules = OnceLock::new();
        &mut self.config
    }

    pub fn rules(&self) -> Result<&[ErasedRule], SQLFluffUserError> {
        if let Some(rules) = self.rules.get() {
            return Ok(rules);
        }
        let rulepack = self.get_rulepack()?;
        let _ = self.rules.set(rulepack.rules);
        Ok(self.rules.get().unwrap())
    }

    pub fn formatter(&self) -> Option<&Arc<dyn Formatter>> {
        self.formatter.as_ref()
    }

    pub fn formatter_mut(&mut self) -> Option<&mut Arc<dyn Formatter>> {
        self.formatter.as_mut()
    }
}

#[cfg(test)]
mod tests {
    use sqruff_lib_core::parser::segments::Tables;

    use crate::core::config::FluffConfig;
    use crate::core::linter::core::Linter;

    fn normalise_paths(paths: Vec<String>) -> Vec<String> {
        paths
            .into_iter()
            .map(|path| path.replace(['/', '\\'], "."))
            .collect()
    }

    #[test]
    fn test_linter_path_from_paths_dir() {
        // Test extracting paths from directories.
        let lntr = Linter::new(
            FluffConfig::new(<_>::default(), None, None),
            None,
            None,
            false,
        )
        .unwrap();
        let paths =
            lntr.paths_from_path("test/fixtures/lexer".into(), None, None, None, None, None);
        let expected = vec![
            "test.fixtures.lexer.basic.sql",
            "test.fixtures.lexer.block_comment.sql",
            "test.fixtures.lexer.inline_comment.sql",
        ];
        assert_eq!(normalise_paths(paths), expected);
    }

    #[test]
    fn test_linter_path_from_paths_default() {
        // Test .sql files are found by default.
        let lntr = Linter::new(
            FluffConfig::new(<_>::default(), None, None),
            None,
            None,
            false,
        )
        .unwrap();
        let paths = normalise_paths(lntr.paths_from_path(
            "test/fixtures/linter".into(),
            None,
            None,
            None,
            None,
            None,
        ));
        assert!(paths.contains(&"test.fixtures.linter.passing.sql".to_string()));
        assert!(paths.contains(&"test.fixtures.linter.passing_cap_extension.SQL".to_string()));
        assert!(!paths.contains(&"test.fixtures.linter.discovery_file.txt".to_string()));
    }

    #[test]
    fn test_linter_path_from_paths_exts() {
        // Assuming Linter is initialized with a configuration similar to Python's
        // FluffConfig
        let config =
            FluffConfig::new(<_>::default(), None, None).with_sql_file_exts(vec![".txt".into()]);
        let lntr = Linter::new(config, None, None, false).unwrap();

        let paths =
            lntr.paths_from_path("test/fixtures/linter".into(), None, None, None, None, None);

        // Normalizing paths as in the Python version
        let normalized_paths = normalise_paths(paths);

        // Assertions as per the Python test
        assert!(!normalized_paths.contains(&"test.fixtures.linter.passing.sql".into()));
        assert!(
            !normalized_paths.contains(&"test.fixtures.linter.passing_cap_extension.SQL".into())
        );
        assert!(normalized_paths.contains(&"test.fixtures.linter.discovery_file.txt".into()));
    }

    #[test]
    fn test_linter_path_from_paths_file() {
        let lntr = Linter::new(
            FluffConfig::new(<_>::default(), None, None),
            None,
            None,
            false,
        )
        .unwrap();
        let paths = lntr.paths_from_path(
            "test/fixtures/linter/indentation_errors.sql".into(),
            None,
            None,
            None,
            None,
            None,
        );

        assert_eq!(
            normalise_paths(paths),
            &["test.fixtures.linter.indentation_errors.sql"]
        );
    }

    // test__linter__skip_large_bytes
    // test__linter__path_from_paths__not_exist
    // test__linter__path_from_paths__not_exist_ignore
    // test__linter__path_from_paths__explicit_ignore
    // test__linter__path_from_paths__sqlfluffignore_current_directory
    // test__linter__path_from_paths__dot
    // test__linter__path_from_paths__ignore
    // test__linter__lint_string_vs_file
    // test__linter__get_violations_filter_rules
    // test__linter__linting_result__sum_dicts
    // test__linter__linting_result__combine_dicts
    // test__linter__linting_result_check_tuples_by_path
    // test__linter__linting_result_get_violations
    // test__linter__linting_parallel_thread
    // test_lint_path_parallel_wrapper_exception
    // test__linter__get_runner_processes
    // test__linter__linting_unexpected_error_handled_gracefully
    #[test]
    fn test_linter_empty_file() {
        let linter = Linter::new(
            FluffConfig::new(<_>::default(), None, None),
            None,
            None,
            false,
        )
        .unwrap();
        let tables = Tables::default();
        let parsed = linter.parse_string(&tables, "", None).unwrap();

        assert!(parsed.violations.is_empty());
    }

    // test__linter__mask_templated_violations
    // test__linter__encoding
    // test_delayed_exception
    // test__attempt_to_change_templater_warning

    #[test]
    #[ignore = "The implementation of Lexer::lex_templated_file is required"]
    fn test_advanced_api_methods() {
        let sql = "
        WITH cte AS (
            SELECT * FROM tab_a
        )
        SELECT
            cte.col_a,
            tab_b.col_b
        FROM cte
        INNER JOIN tab_b;
        "
        .to_string();

        let linter = Linter::new(
            FluffConfig::new(<_>::default(), None, None),
            None,
            None,
            false,
        )
        .unwrap();
        let tables = Tables::default();
        let _parsed = linter.parse_string(&tables, &sql, None).unwrap();
    }

    #[test]
    fn test_normalise_newlines() {
        let in_str = "SELECT\r\n foo\n FROM \r \n\r bar;";
        let out_str = "SELECT\n foo\n FROM \n \n\n bar;";

        assert_eq!(Linter::normalise_newlines(in_str), out_str);
    }

    /// Regression test for https://github.com/quarylabs/sqruff/issues/2354
    /// When a templater fails (e.g., dbt/jinja can't find a project), the
    /// fallback should not produce false positive LT01 violations on template
    /// syntax like `{{ ref('stg_users') }}`.
    #[test]
    fn test_templater_error_skips_linting() {
        use crate::core::linter::common::RenderedFile;
        use sqruff_lib_core::errors::SQLTemplaterError;
        use sqruff_lib_core::templaters::TemplatedFile;

        let source =
            "SELECT *\nFROM {{ ref('stg_users') }}\nWHERE created_at > '{{ var(\"start_date\") }}'";
        let linter = Linter::new(
            FluffConfig::new(<_>::default(), None, None),
            None,
            None,
            false,
        )
        .unwrap();

        // Simulate a failed templater by creating a RenderedFile with
        // templater_violations (this is what render_files_batch does when
        // the dbt/jinja templater fails).
        let rendered = RenderedFile {
            templated_file: TemplatedFile::new(
                source.to_string(),
                "test.sql".to_string(),
                None,
                None,
                None,
            )
            .unwrap(),
            templater_violations: vec![SQLTemplaterError::new(
                "Failed to template file: dbt project not found".to_string(),
            )],
            filename: "test.sql".to_string(),
            source_str: source.to_string(),
        };

        let result = linter.lint_rendered(rendered, false).unwrap();
        let violations = result.violations();

        // Should have exactly 1 violation: the templater error.
        // Should NOT have any LT01 spacing violations.
        assert_eq!(violations.len(), 1);
        assert!(violations[0].desc().contains("Failed to template file"));
        assert!(
            !violations.iter().any(|v| v.rule_code() == "LT01"),
            "Should not have LT01 false positives on template syntax"
        );
    }
}
