pub mod helpers;
pub mod settings;
mod watched_file;
use crate::helpers::expand_tilde;
pub use crate::settings::ConfigSettings;
use crate::watched_file::WatchedFile;
use log::debug;
use log::info;
use regex::Regex;
use std::env;
use std::fmt::Debug;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};

static CACHE_DIR: &str = "codebook";
static GLOBAL_CONFIG_FILE: &str = "codebook.toml";
static USER_CONFIG_FILES: [&str; 2] = ["codebook.toml", ".codebook.toml"];

/// Errors from loading or saving Codebook configuration.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("failed to read config file {path}: {source}")]
    Read { path: PathBuf, source: io::Error },
    #[error("failed to parse config file {path}: {source}")]
    Parse {
        path: PathBuf,
        source: toml::de::Error,
    },
    #[error("failed to write config file {path}: {source}")]
    Write { path: PathBuf, source: io::Error },
    #[error("failed to serialize config: {0}")]
    Serialize(#[from] toml::ser::Error),
    #[error(transparent)]
    Io(#[from] io::Error),
}

/// The main trait for Codebook configuration.
///
/// The `add_*` methods only mutate in-memory settings and report whether the
/// entry was newly added; persisting is a separate, fallible step (see
/// `CodebookConfigFile::save`).
pub trait CodebookConfig: Sync + Send + Debug {
    fn add_word(&self, word: &str) -> bool;
    fn add_word_global(&self, word: &str) -> bool;
    fn add_ignore(&self, file: &str) -> bool;
    fn add_include(&self, file: &str) -> bool;
    fn get_dictionary_ids(&self) -> Vec<String>;
    fn should_ignore_path(&self, path: &Path) -> bool;
    fn should_include_path(&self, path: &Path) -> bool;
    fn is_allowed_word(&self, word: &str) -> bool;
    fn should_flag_word(&self, word: &str) -> bool;
    fn get_ignore_patterns(&self) -> Vec<Regex>;
    fn get_min_word_length(&self) -> usize;
    fn should_check_tag(&self, tag: &str) -> bool;
    fn cache_dir(&self) -> &Path;

    /// Resolve settings with overrides applied for a specific file path.
    /// Returns None if no overrides match (callers should use base config methods).
    fn resolve_for_file(&self, _relative_path: &Path) -> Option<Arc<ConfigSettings>> {
        None
    }
}

/// Internal mutable state
#[derive(Debug)]
struct ConfigInner {
    /// Project-specific config file watcher
    project_config: WatchedFile<ConfigSettings>,
    /// Global config file watcher
    global_config: WatchedFile<ConfigSettings>,
    /// Current snapshot
    snapshot: Arc<ConfigSettings>,
}

#[derive(Debug)]
pub struct CodebookConfigFile {
    /// Single lock protecting all mutable state
    inner: RwLock<ConfigInner>,
    /// Directory for caching
    pub cache_dir: PathBuf,
}

impl Default for CodebookConfigFile {
    fn default() -> Self {
        let inner = ConfigInner {
            project_config: WatchedFile::new(None),
            global_config: WatchedFile::new(None),
            snapshot: Arc::new(ConfigSettings::default()),
        };

        Self {
            inner: RwLock::new(inner),
            cache_dir: helpers::default_cache_dir(),
        }
    }
}

impl CodebookConfigFile {
    /// Load configuration by searching for both global and project-specific configs
    pub fn load(current_dir: Option<&Path>) -> Result<Self, ConfigError> {
        Self::load_with_overrides(current_dir, None, None)
    }

    /// Load configuration with explicit global and/or project config overrides.
    ///
    /// When `project_config_path` is provided, auto-discovery is skipped: the override
    /// path is used even if the file does not yet exist (it will be created on save).
    pub fn load_with_overrides(
        current_dir: Option<&Path>,
        global_config_path: Option<PathBuf>,
        project_config_path: Option<PathBuf>,
    ) -> Result<Self, ConfigError> {
        debug!("Initializing CodebookConfig");

        let current_dir = match current_dir {
            Some(p) => PathBuf::from(p),
            None => env::current_dir()?,
        };

        Self::load_configs(&current_dir, global_config_path, project_config_path)
    }

    /// Load both global and project configuration
    fn load_configs(
        start_dir: &Path,
        global_config_override: Option<PathBuf>,
        project_config_override: Option<PathBuf>,
    ) -> Result<Self, ConfigError> {
        // Canonicalize so a relative start_dir (like the CLI's default ".") can
        // walk up past the current directory: Path::new(".").parent() is ""
        // and then None, so the search would never reach real parent folders.
        let start_dir = start_dir
            .canonicalize()
            .unwrap_or_else(|_| start_dir.to_path_buf());
        let start_dir = start_dir.as_path();

        let config = Self::default();
        let mut inner = config.inner.write().unwrap();

        // First, try to load global config. Expand tildes here so loading and
        // saving agree on the real path; a `~` taken literally would make the
        // existing config invisible and let a later save overwrite it.
        let global_config_path = match global_config_override {
            Some(path) => Some(expand_tilde(&path).unwrap_or(path)),
            None => Self::find_global_config_path(),
        };

        if let Some(global_path) = global_config_path {
            let global_config = WatchedFile::new(Some(global_path.clone()));

            if global_path.exists() {
                // Strict at startup: an existing-but-invalid config is an
                // error the user should hear about, not a silent fallback to
                // defaults. Mid-session reload stays lenient (keeps the last
                // good config) since there's state to fall back to there.
                inner.global_config =
                    global_config.load(|path| Self::load_settings_from_file(path))?;
                debug!("Loaded global config from {}", global_path.display());
            } else {
                info!("No global config found, using default");
                inner.global_config = global_config;
            }
        }

        // Then resolve the project config path: use the override if provided,
        // otherwise search up from start_dir.
        let project_path = match project_config_override.map(|p| expand_tilde(&p).unwrap_or(p)) {
            Some(override_path) => {
                if override_path.exists() {
                    Some(override_path)
                } else {
                    log::warn!(
                        "Project config override {} does not exist; using defaults until the file is created (e.g., by adding a word).",
                        override_path.display()
                    );
                    // Honor the override path so future saves create the file there
                    // and don't fall back to auto-discovery.
                    Some(override_path)
                }
            }
            None => Self::find_project_config(start_dir),
        };

        if let Some(project_path) = project_path {
            let project_config = WatchedFile::new(Some(project_path.clone()));

            if project_path.exists() {
                inner.project_config =
                    project_config.load(|path| Self::load_settings_from_file(path))?;
                debug!("Loaded project config from {}", project_path.display());
            } else {
                inner.project_config = project_config;
            }
        } else {
            info!("No project config found, using default");
            // Set path to start_dir if no config is found
            let default_path = start_dir.join(USER_CONFIG_FILES[0]);
            inner.project_config = WatchedFile::new(Some(default_path));
        }

        // Calculate initial effective settings
        Self::rebuild_snapshot(&mut inner);

        drop(inner);
        Ok(config)
    }
    /// Find the platform-specific global config directory and file path
    fn find_global_config_path() -> Option<PathBuf> {
        // On Linux/macOS XDG_CONFIG_HOME, fallback to ~/.config
        if cfg!(unix) {
            // First try XDG_CONFIG_HOME environment variable (Linux/macOS)
            if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME")
                && !xdg_config_home.is_empty()
            {
                let path = PathBuf::from(xdg_config_home)
                    .join("codebook")
                    .join(GLOBAL_CONFIG_FILE);
                return Some(path);
            }
            if let Some(home) = dirs::home_dir() {
                let path = home
                    .join(".config")
                    .join("codebook")
                    .join(GLOBAL_CONFIG_FILE);
                return Some(path);
            }
        }

        // On Windows, use dirs::config_dir() (typically %APPDATA%)
        if cfg!(windows)
            && let Some(config_dir) = dirs::config_dir()
        {
            return Some(config_dir.join("codebook").join(GLOBAL_CONFIG_FILE));
        }

        None
    }

    /// Find project configuration by searching up from the current directory
    fn find_project_config(start_dir: &Path) -> Option<PathBuf> {
        let config_files = USER_CONFIG_FILES;

        // Start from the given directory and walk up to root
        let mut current_dir = Some(start_dir.to_path_buf());

        while let Some(dir) = current_dir {
            // Try each possible config filename in the current directory
            for config_name in &config_files {
                let config_path = dir.join(config_name);
                if config_path.is_file() {
                    return Some(config_path);
                }
            }

            // Move to parent directory
            current_dir = dir.parent().map(PathBuf::from);
        }

        None
    }

    /// Load settings from a file
    fn load_settings_from_file<P: AsRef<Path>>(path: P) -> Result<ConfigSettings, ConfigError> {
        let path = path.as_ref();
        let content = fs::read_to_string(path).map_err(|source| ConfigError::Read {
            path: path.to_path_buf(),
            source,
        })?;

        toml::from_str(&content).map_err(|source| ConfigError::Parse {
            path: path.to_path_buf(),
            source,
        })
    }

    /// Calculate the effective settings based on global and project settings
    fn calculate_effective_settings(
        project_config: &WatchedFile<ConfigSettings>,
        global_config: &WatchedFile<ConfigSettings>,
    ) -> ConfigSettings {
        let project = project_config
            .content()
            .cloned()
            .unwrap_or_else(ConfigSettings::default);

        if project.use_global {
            if let Some(global) = global_config.content() {
                let mut effective = global.clone();
                effective.merge(project);
                effective
            } else {
                project
            }
        } else {
            project
        }
    }

    /// Get current configuration snapshot (cheap to clone)
    fn snapshot(&self) -> Arc<ConfigSettings> {
        self.inner.read().unwrap().snapshot.clone()
    }

    /// Reload both global and project configurations, only reading files if
    /// they've changed. Returns true when the effective settings changed.
    /// Never fails: a config that can no longer be loaded keeps its last good
    /// content (see WatchedFile::reload_if_changed).
    pub fn reload(&self) -> bool {
        let mut inner = self.inner.write().unwrap();
        let mut changed = false;

        // Check and reload global config if changed
        let (new_global, global_changed) = inner
            .global_config
            .clone()
            .reload_if_changed(|path| Self::load_settings_from_file(path));

        if global_changed {
            debug!("Global config reloaded");
            inner.global_config = new_global;
            changed = true;
        }

        // Check and reload project config if changed
        let (new_project, project_changed) = inner
            .project_config
            .clone()
            .reload_if_changed(|path| Self::load_settings_from_file(path));

        if project_changed {
            debug!("Project config reloaded");
            inner.project_config = new_project;
            changed = true;
        }

        // Recalculate effective settings if anything changed
        if changed {
            Self::rebuild_snapshot(&mut inner);
        }

        changed
    }

    /// Save the project configuration to its file
    pub fn save(&self) -> Result<(), ConfigError> {
        let mut inner = self.inner.write().unwrap();
        let watched = &inner.project_config;
        Self::save_watched(watched, "project")?;
        inner.project_config = inner.project_config.clone().restamped();
        Ok(())
    }

    /// Save the global configuration to its file
    pub fn save_global(&self) -> Result<(), ConfigError> {
        let mut inner = self.inner.write().unwrap();
        let watched = &inner.global_config;
        Self::save_watched(watched, "global")?;
        inner.global_config = inner.global_config.clone().restamped();
        Ok(())
    }

    /// Write a watched config's content to its file. The caller must restamp
    /// the watched file afterwards so the write isn't seen as an external change.
    fn save_watched(watched: &WatchedFile<ConfigSettings>, label: &str) -> Result<(), ConfigError> {
        let Some(path) = watched.path() else {
            return Ok(());
        };
        let Some(settings) = watched.content() else {
            return Ok(());
        };

        let content = toml::to_string_pretty(settings)?;
        info!("Saving {label} configuration to {}", path.display());
        let write_err = |source| ConfigError::Write {
            path: path.to_path_buf(),
            source,
        };
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).map_err(write_err)?;
        }
        fs::write(path, content).map_err(write_err)
    }
    /// Clean the cache directory
    pub fn clean_cache(&self) {
        let dir_path = self.cache_dir.clone();
        // Check if the path exists and is a directory
        if !dir_path.is_dir() {
            return;
        }

        // Safety check: Ensure CACHE_DIR is in the path
        let path_str = dir_path.to_string_lossy();
        if !path_str.contains(CACHE_DIR) {
            log::error!(
                "Cache directory path '{path_str}' doesn't contain '{CACHE_DIR}', refusing to clean"
            );
            return;
        }

        // Read directory entries
        if let Ok(entries) = fs::read_dir(dir_path) {
            for entry in entries.flatten() {
                let path = entry.path();

                if path.is_dir() {
                    // If it's a directory, recursively remove it
                    let _ = fs::remove_dir_all(path);
                } else {
                    // If it's a file, remove it
                    let _ = fs::remove_file(path);
                }
            }
        }
    }

    /// Get path to project config if it exists
    pub fn project_config_path(&self) -> Option<PathBuf> {
        self.inner
            .read()
            .unwrap()
            .project_config
            .path()
            .map(|p| p.to_path_buf())
    }

    /// Get path to global config if it exists
    pub fn global_config_path(&self) -> Option<PathBuf> {
        self.inner
            .read()
            .unwrap()
            .global_config
            .path()
            .map(|p| p.to_path_buf())
    }

    fn rebuild_snapshot(inner: &mut ConfigInner) {
        let effective =
            Self::calculate_effective_settings(&inner.project_config, &inner.global_config);
        inner.snapshot = Arc::new(effective);
    }

    fn update_project_settings<F>(&self, update: F) -> bool
    where
        F: FnOnce(&mut ConfigSettings) -> bool,
    {
        let mut inner = self.inner.write().unwrap();
        let mut settings = inner
            .project_config
            .content()
            .cloned()
            .unwrap_or_else(ConfigSettings::default);

        if !update(&mut settings) {
            return false;
        }

        inner.project_config = inner.project_config.clone().with_content_value(settings);
        Self::rebuild_snapshot(&mut inner);
        true
    }

    fn update_global_settings<F>(&self, update: F) -> bool
    where
        F: FnOnce(&mut ConfigSettings) -> bool,
    {
        let mut inner = self.inner.write().unwrap();
        let mut settings = inner
            .global_config
            .content()
            .cloned()
            .unwrap_or_else(ConfigSettings::default);

        if !update(&mut settings) {
            return false;
        }

        inner.global_config = inner.global_config.clone().with_content_value(settings);
        Self::rebuild_snapshot(&mut inner);
        true
    }
}

impl CodebookConfig for CodebookConfigFile {
    /// Add a word to the project configs allowlist
    fn add_word(&self, word: &str) -> bool {
        self.update_project_settings(|settings| settings.insert_word(word))
    }
    /// Add a word to the global configs allowlist
    fn add_word_global(&self, word: &str) -> bool {
        self.update_global_settings(|settings| settings.insert_word(word))
    }

    /// Add a file to the ignore list
    fn add_ignore(&self, file: &str) -> bool {
        self.update_project_settings(|settings| settings.insert_ignore(file))
    }

    /// Add a file to the include list
    fn add_include(&self, file: &str) -> bool {
        self.update_project_settings(|settings| settings.insert_include(file))
    }

    /// Get dictionary IDs from effective configuration
    fn get_dictionary_ids(&self) -> Vec<String> {
        let snapshot = self.snapshot();
        snapshot.dictionary_ids()
    }

    /// Check if a path is included based on the effective configuration
    fn should_include_path(&self, path: &Path) -> bool {
        let snapshot = self.snapshot();
        snapshot.should_include_path(path)
    }

    /// Check if a path should be ignored based on the effective configuration
    fn should_ignore_path(&self, path: &Path) -> bool {
        let snapshot = self.snapshot();
        snapshot.should_ignore_path(path)
    }

    /// Check if a word is in the effective allowlist
    fn is_allowed_word(&self, word: &str) -> bool {
        let snapshot = self.snapshot();
        snapshot.is_allowed_word(word)
    }

    /// Check if a word should be flagged according to effective configuration
    fn should_flag_word(&self, word: &str) -> bool {
        let snapshot = self.snapshot();
        snapshot.should_flag_word(word)
    }

    /// Get the list of user-defined ignore patterns, compiled when the
    /// config was parsed. Regex clones are cheap (internally Arc'd).
    fn get_ignore_patterns(&self) -> Vec<Regex> {
        self.snapshot().ignore_patterns.clone()
    }

    /// Get the minimum word length which should be checked
    fn get_min_word_length(&self) -> usize {
        self.snapshot().min_word_length()
    }

    fn should_check_tag(&self, tag: &str) -> bool {
        self.snapshot().should_check_tag(tag)
    }

    fn cache_dir(&self) -> &Path {
        &self.cache_dir
    }

    /// Resolve settings with overrides applied for a specific file path.
    fn resolve_for_file(&self, relative_path: &Path) -> Option<Arc<ConfigSettings>> {
        let snapshot = self.snapshot();
        if snapshot.overrides.is_empty() {
            return None;
        }
        if !snapshot
            .overrides
            .iter()
            .any(|o| o.matches_path(relative_path))
        {
            return None;
        }
        Some(Arc::new(snapshot.resolve_for_path(relative_path)))
    }
}

#[derive(Debug)]
pub struct CodebookConfigMemory {
    settings: RwLock<ConfigSettings>,
    cache_dir: PathBuf,
}

impl Default for CodebookConfigMemory {
    fn default() -> Self {
        Self {
            settings: RwLock::new(ConfigSettings::default()),
            cache_dir: helpers::default_cache_dir(),
        }
    }
}

impl CodebookConfigMemory {
    pub fn new(settings: ConfigSettings) -> Self {
        Self {
            settings: RwLock::new(settings),
            cache_dir: helpers::default_cache_dir(),
        }
    }
}

impl CodebookConfigMemory {
    /// Get current configuration snapshot (cheap to clone)
    fn snapshot(&self) -> Arc<ConfigSettings> {
        Arc::new(self.settings.read().unwrap().clone())
    }
}

impl CodebookConfig for CodebookConfigMemory {
    fn add_word(&self, word: &str) -> bool {
        let mut settings = self.settings.write().unwrap();
        settings.insert_word(word)
    }

    fn add_word_global(&self, word: &str) -> bool {
        self.add_word(word)
    }

    fn add_ignore(&self, file: &str) -> bool {
        let mut settings = self.settings.write().unwrap();
        settings.insert_ignore(file)
    }

    fn add_include(&self, file: &str) -> bool {
        let mut settings = self.settings.write().unwrap();
        settings.insert_include(file)
    }

    fn get_dictionary_ids(&self) -> Vec<String> {
        let snapshot = self.snapshot();
        snapshot.dictionary_ids()
    }

    fn should_include_path(&self, path: &Path) -> bool {
        let snapshot = self.snapshot();
        snapshot.should_include_path(path)
    }

    fn should_ignore_path(&self, path: &Path) -> bool {
        let snapshot = self.snapshot();
        snapshot.should_ignore_path(path)
    }

    fn is_allowed_word(&self, word: &str) -> bool {
        let snapshot = self.snapshot();
        snapshot.is_allowed_word(word)
    }

    fn should_flag_word(&self, word: &str) -> bool {
        let snapshot = self.snapshot();
        snapshot.should_flag_word(word)
    }

    fn get_ignore_patterns(&self) -> Vec<Regex> {
        self.settings.read().unwrap().ignore_patterns.clone()
    }

    fn get_min_word_length(&self) -> usize {
        self.snapshot().min_word_length()
    }

    fn should_check_tag(&self, tag: &str) -> bool {
        self.snapshot().should_check_tag(tag)
    }

    fn cache_dir(&self) -> &Path {
        &self.cache_dir
    }
}

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

    #[derive(Debug, Clone, Copy)]
    pub enum ConfigType {
        Project,
        Global,
    }

    // Helper function for tests
    fn load_from_file<P: AsRef<Path>>(
        config_type: ConfigType,
        path: P,
    ) -> Result<CodebookConfigFile, io::Error> {
        let config = CodebookConfigFile::default();
        let mut inner = config.inner.write().unwrap();

        if let Ok(settings) = CodebookConfigFile::load_settings_from_file(&path) {
            let mut watched = WatchedFile::new(Some(path.as_ref().to_path_buf()));
            watched = watched.with_content_value(settings);
            match config_type {
                ConfigType::Project => inner.project_config = watched,
                ConfigType::Global => inner.global_config = watched,
            }
            CodebookConfigFile::rebuild_snapshot(&mut inner);
        }

        drop(inner);
        Ok(config)
    }

    #[test]
    fn test_save_global_creates_directories() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let global_dir = temp_dir.path().join("deep").join("nested").join("dir");
        let config_path = global_dir.join("codebook.toml");

        // Create config with a path that doesn't exist yet
        // Create a config with the global path set
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            let mut global_config = WatchedFile::new(Some(config_path.clone()));
            global_config = global_config.with_content_value(ConfigSettings::default());
            inner.global_config = global_config;
        }

        // Directory doesn't exist yet
        assert!(!global_dir.exists());

        // Save should create directories
        config.save_global()?;

        // Now directory and file should exist
        assert!(global_dir.exists());
        assert!(config_path.exists());

        Ok(())
    }

    #[test]
    fn test_add_word() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");

        // Create initial config
        // Create a config with the project path set
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            inner.project_config = WatchedFile::new(Some(config_path.clone()));
        }
        config.save()?;

        // Add a word
        config.add_word("testword");
        config.save()?;

        // Reload config and verify
        let loaded_config = load_from_file(ConfigType::Project, &config_path)?;
        assert!(loaded_config.is_allowed_word("testword"));

        Ok(())
    }

    #[test]
    fn test_add_word_global() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");

        // Create initial config
        // Create a config with the global path set
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            let global_config = WatchedFile::new(Some(config_path.clone()));
            inner.global_config = global_config.with_content_value(ConfigSettings::default());
        }
        config.save_global()?;

        // Add a word
        config.add_word_global("testword");
        config.save_global()?;

        // Reload config and verify
        let loaded_config = load_from_file(ConfigType::Global, &config_path)?;
        assert!(loaded_config.is_allowed_word("testword"));

        Ok(())
    }

    #[test]
    fn test_ignore_patterns() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");
        let mut file = File::create(&config_path)?;
        let a = r#"
        ignore_patterns = [
            "^[ATCG]+$",
            "\\d{3}-\\d{2}-\\d{4}"  # Social Security Number format
        ]
        "#;
        file.write_all(a.as_bytes())?;

        let config = load_from_file(ConfigType::Project, &config_path)?;
        let patterns = config.snapshot().ignore_patterns.clone();
        assert!(patterns.iter().any(|p| p.as_str() == "^[ATCG]+$"));
        assert!(
            patterns
                .iter()
                .any(|p| p.as_str() == "\\d{3}-\\d{2}-\\d{4}")
        );
        let patterns = config.get_ignore_patterns();
        assert!(patterns.len() == 2);
        Ok(())
    }

    #[test]
    fn test_reload_ignore_patterns() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");

        // Create initial config with DNA pattern
        let mut file = File::create(&config_path)?;
        write!(
            file,
            r#"
            ignore_patterns = [
                "^[ATCG]+$"
            ]
            "#
        )?;

        let config = load_from_file(ConfigType::Project, &config_path)?;
        assert!(config.get_ignore_patterns().len() == 1);

        // Update config with new pattern
        let mut file = File::create(&config_path)?;
        let a = r#"
        ignore_patterns = [
            "^[ATCG]+$",
            "\\d{3}-\\d{2}-\\d{4}"
        ]
        "#;
        file.write_all(a.as_bytes())?;

        // Reload and verify both patterns work
        config.reload();
        assert!(config.get_ignore_patterns().len() == 2);

        // Update config to remove all patterns
        let mut file = File::create(&config_path)?;
        write!(
            file,
            r#"
            ignore_patterns = []
            "#
        )?;

        // Reload and verify no patterns match
        config.reload();
        assert!(config.get_ignore_patterns().is_empty());

        Ok(())
    }

    #[test]
    fn test_config_recursive_search() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let sub_dir = temp_dir.path().join("sub");
        let sub_sub_dir = sub_dir.join("subsub");
        fs::create_dir_all(&sub_sub_dir)?;

        let config_path = temp_dir.path().join("codebook.toml");
        let mut file = File::create(&config_path)?;
        write!(
            file,
            r#"
            dictionaries = ["en_US"]
            words = ["testword"]
            flag_words = ["todo"]
            ignore_paths = ["target/**/*"]
            "#
        )?;

        let config = CodebookConfigFile::load_configs(&sub_sub_dir, None, None)?;
        assert!(config.snapshot().words.contains(&"testword".to_string()));

        // Check that the config file path is stored (canonicalized, since
        // load_configs resolves symlinks like macOS's /var -> /private/var)
        assert_eq!(
            config.project_config_path(),
            Some(config_path.canonicalize()?)
        );
        Ok(())
    }

    #[test]
    fn test_config_recursive_search_from_relative_dir() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let sub_dir = temp_dir.path().join("sub");
        fs::create_dir_all(&sub_dir)?;

        let config_path = temp_dir.path().join("codebook.toml");
        fs::write(&config_path, r#"words = ["parentword"]"#)?;

        // Search up from "." like the CLI does when no --root is given.
        let original_dir = env::current_dir()?;
        env::set_current_dir(&sub_dir)?;
        let result = CodebookConfigFile::load_configs(Path::new("."), None, None);
        env::set_current_dir(original_dir)?;

        let config = result?;
        assert!(config.is_allowed_word("parentword"));
        assert_eq!(
            config.project_config_path(),
            Some(temp_dir.path().canonicalize()?.join("codebook.toml"))
        );
        Ok(())
    }

    #[test]
    fn test_global_config_override_is_used() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let workspace_dir = temp_dir.path().join("workspace");
        fs::create_dir_all(&workspace_dir)?;
        let custom_global_dir = temp_dir.path().join("global");
        fs::create_dir_all(&custom_global_dir)?;
        let override_path = custom_global_dir.join("codebook.toml");

        fs::write(
            &override_path,
            r#"
            words = ["customword"]
            "#,
        )?;

        let config = CodebookConfigFile::load_with_overrides(
            Some(workspace_dir.as_path()),
            Some(override_path.clone()),
            None,
        )?;

        assert_eq!(config.global_config_path(), Some(override_path));
        assert!(config.is_allowed_word("customword"));
        Ok(())
    }

    #[test]
    fn test_project_config_override_is_used() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let workspace_dir = temp_dir.path().join("workspace");
        fs::create_dir_all(&workspace_dir)?;

        // Drop a default-named file at the workspace root that should NOT be loaded.
        fs::write(
            workspace_dir.join("codebook.toml"),
            r#"words = ["rootword"]"#,
        )?;

        let nested_dir = workspace_dir.join("toolConfig");
        fs::create_dir_all(&nested_dir)?;
        let override_path = nested_dir.join("codebook.toml");
        fs::write(&override_path, r#"words = ["nestedword"]"#)?;

        let config = CodebookConfigFile::load_with_overrides(
            Some(workspace_dir.as_path()),
            None,
            Some(override_path.clone()),
        )?;

        assert_eq!(config.project_config_path(), Some(override_path));
        assert!(config.is_allowed_word("nestedword"));
        assert!(!config.is_allowed_word("rootword"));
        Ok(())
    }

    #[test]
    fn test_project_config_override_missing_file_used_on_save() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let workspace_dir = temp_dir.path().join("workspace");
        fs::create_dir_all(&workspace_dir)?;
        // Auto-discovery would pick this up if the override were not honored.
        fs::write(
            workspace_dir.join("codebook.toml"),
            r#"words = ["rootword"]"#,
        )?;

        // Override points to a path that doesn't exist yet, inside a nested dir
        // that doesn't exist either.
        let override_path = workspace_dir.join("toolConfig").join("codebook.toml");

        let config = CodebookConfigFile::load_with_overrides(
            Some(workspace_dir.as_path()),
            None,
            Some(override_path.clone()),
        )?;

        // The override path is honored even before the file exists.
        assert_eq!(config.project_config_path(), Some(override_path.clone()));
        assert!(!override_path.exists());
        // Auto-discovery should NOT have loaded the root file.
        assert!(!config.is_allowed_word("rootword"));

        // Adding a word and saving should create both the directory and the file
        // at the override location.
        assert!(config.add_word("newword"));
        config.save()?;
        assert!(override_path.exists());

        let written = fs::read_to_string(&override_path)?;
        assert!(written.contains("newword"));
        Ok(())
    }

    #[test]
    fn test_should_ignore_path() {
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            let mut settings = inner
                .project_config
                .content()
                .cloned()
                .unwrap_or_else(ConfigSettings::default);
            settings.ignore_paths.push("target/**/*".to_string());
            inner.project_config = inner.project_config.clone().with_content_value(settings);

            // Recalculate effective settings
            let effective = CodebookConfigFile::calculate_effective_settings(
                &inner.project_config,
                &inner.global_config,
            );
            inner.snapshot = Arc::new(effective);
        }

        assert!(config.should_ignore_path("target/debug/build".as_ref()));
        assert!(!config.should_ignore_path("src/main.rs".as_ref()));
    }

    #[test]
    fn test_should_include_path() {
        // Empty include_paths: everything is included
        let config = CodebookConfigFile::default();
        assert!(config.should_include_path("src/main.rs".as_ref()));
        assert!(config.should_include_path("target/debug/build".as_ref()));

        // Non-empty include_paths: only matching paths are included
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            let mut settings = inner
                .project_config
                .content()
                .cloned()
                .unwrap_or_else(ConfigSettings::default);
            settings.include_paths.push("**/*.rs".to_string());
            inner.project_config = inner.project_config.clone().with_content_value(settings);

            // Recalculate effective settings
            let effective = CodebookConfigFile::calculate_effective_settings(
                &inner.project_config,
                &inner.global_config,
            );
            inner.snapshot = Arc::new(effective);
        }

        assert!(config.should_include_path("src/main.rs".as_ref()));
        assert!(!config.should_include_path("src/main.py".as_ref()));
        assert!(!config.should_include_path("README.md".as_ref()));
    }

    #[test]
    fn test_reload() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");

        // Create initial config
        // Create a config with the project path set
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            inner.project_config = WatchedFile::new(Some(config_path.clone()));
        }
        config.save()?;

        // Add a word to the toml file
        let mut file = File::create(&config_path)?;
        write!(
            file,
            r#"
            words = ["testword"]
            "#
        )?;

        // Reload config and verify
        config.reload();
        assert!(config.is_allowed_word("testword"));

        Ok(())
    }

    #[test]
    fn test_reload_when_deleted() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");

        // Create initial config
        // Create a config with the project path set
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            inner.project_config = WatchedFile::new(Some(config_path.clone()));
        }
        config.save()?;

        // Add a word to the toml file
        let mut file = File::create(&config_path)?;
        write!(
            file,
            r#"
            words = ["testword"]
            "#
        )?;

        // Reload config and verify
        config.reload();
        assert!(config.is_allowed_word("testword"));

        // Delete the config file
        fs::remove_file(&config_path)?;

        // Reload config and verify
        config.reload();
        assert!(!config.is_allowed_word("testword"));

        Ok(())
    }

    #[test]
    fn test_add_word_case() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");

        // Create initial config
        // Create a config with the project path set
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            inner.project_config = WatchedFile::new(Some(config_path.clone()));
        }
        config.save()?;

        // Add a word with mixed case
        config.add_word("TestWord");
        config.save()?;

        // Reload config and verify with different cases
        let loaded_config = load_from_file(ConfigType::Global, &config_path)?;
        assert!(loaded_config.is_allowed_word("testword"));
        assert!(loaded_config.is_allowed_word("TESTWORD"));
        assert!(loaded_config.is_allowed_word("TestWord"));

        Ok(())
    }

    #[test]
    fn test_add_word_global_case() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");

        // Create initial config
        // Create a config with the global path set
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            let global_config = WatchedFile::new(Some(config_path.clone()));
            inner.global_config = global_config.with_content_value(ConfigSettings::default());
        }
        config.save_global()?;

        // Add a word with mixed case
        config.add_word_global("TestWord");
        config.save_global()?;

        // Reload config and verify with different cases
        let loaded_config = load_from_file(ConfigType::Global, &config_path)?;
        assert!(loaded_config.is_allowed_word("testword"));
        assert!(loaded_config.is_allowed_word("TESTWORD"));
        assert!(loaded_config.is_allowed_word("TestWord"));

        Ok(())
    }

    #[test]
    fn test_global_and_project_config() -> Result<(), ConfigError> {
        // Create temporary directories for global and project configs
        let global_temp = TempDir::new().unwrap();
        let project_temp = TempDir::new().unwrap();

        // Set up global config path
        let global_config_dir = global_temp.path().join("codebook");
        fs::create_dir_all(&global_config_dir)?;
        let global_config_path = global_config_dir.join("codebook.toml");

        // Create global config with some settings
        let mut global_file = File::create(&global_config_path)?;
        write!(
            global_file,
            r#"
            dictionaries = ["en_US", "fr_FR"]
            words = ["globalword1", "globalword2"]
            flag_words = ["globaltodo"]
            "#
        )?;

        // Create project config with some different settings
        let project_config_path = project_temp.path().join("codebook.toml");
        let mut project_file = File::create(&project_config_path)?;
        write!(
            project_file,
            r#"
            words = ["projectword"]
            flag_words = ["projecttodo"]
            use_global = true
            "#
        )?;

        // Create a mock config with our test paths
        // Create a config with both paths
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            inner.global_config = WatchedFile::new(Some(global_config_path.clone()));
            inner.project_config = WatchedFile::new(Some(project_config_path.clone()));
        }

        // Manually load both configs to test merging
        {
            let mut inner = config.inner.write().unwrap();
            if let Ok(global_settings) =
                CodebookConfigFile::load_settings_from_file(&global_config_path)
            {
                inner.global_config = inner
                    .global_config
                    .clone()
                    .with_content_value(global_settings);
            }
            if let Ok(project_settings) =
                CodebookConfigFile::load_settings_from_file(&project_config_path)
            {
                inner.project_config = inner
                    .project_config
                    .clone()
                    .with_content_value(project_settings);
            }

            // Recalculate effective settings after loading both configs
            let effective = CodebookConfigFile::calculate_effective_settings(
                &inner.project_config,
                &inner.global_config,
            );
            inner.snapshot = Arc::new(effective);
        }

        // Verify merged results
        assert!(config.is_allowed_word("globalword1")); // From global
        assert!(config.is_allowed_word("projectword")); // From project
        assert!(config.should_flag_word("globaltodo")); // From global
        assert!(config.should_flag_word("projecttodo")); // From project

        // Verify dictionaries came from global
        let dictionaries = config.get_dictionary_ids();
        assert_eq!(dictionaries.len(), 2);
        assert!(dictionaries.contains(&"en_us".to_string()));
        assert!(dictionaries.contains(&"fr_fr".to_string()));

        // Now test with use_global = false
        let mut project_file = File::create(config.project_config_path().unwrap())?;
        write!(
            project_file,
            r#"
            words = ["projectword"]
            flag_words = ["projecttodo"]
            use_global = false
            "#
        )?;

        // Reload
        config.reload();

        // Now should only see project words
        assert!(config.is_allowed_word("projectword")); // From project
        assert!(!config.is_allowed_word("globalword1")); // Not used from global
        assert!(config.should_flag_word("projecttodo")); // From project
        assert!(!config.should_flag_word("globaltodo")); // Not used from global

        Ok(())
    }

    // --- Override integration tests ---

    #[test]
    fn test_resolve_for_file_no_overrides() {
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            let settings = ConfigSettings {
                words: vec!["base".to_string()],
                ..Default::default()
            };
            inner.project_config = inner.project_config.clone().with_content_value(settings);
            CodebookConfigFile::rebuild_snapshot(&mut inner);
        }

        // No overrides, should return None
        assert!(config.resolve_for_file(Path::new("src/main.rs")).is_none());
    }

    #[test]
    fn test_resolve_for_file_with_matching_override() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");
        let mut file = File::create(&config_path)?;
        write!(
            file,
            r#"
            words = ["base"]

            [[overrides]]
            paths = ["**/*.md"]
            extra_words = ["markdown"]
            "#
        )?;

        let config = load_from_file(ConfigType::Project, &config_path)?;

        // .md file should get override
        let resolved = config.resolve_for_file(Path::new("README.md"));
        assert!(resolved.is_some());
        let settings = resolved.unwrap();
        assert!(settings.is_allowed_word("base"));
        assert!(settings.is_allowed_word("markdown"));

        // .rs file should not match
        assert!(config.resolve_for_file(Path::new("src/main.rs")).is_none());

        Ok(())
    }

    #[test]
    fn test_resolve_for_file_global_and_project_overrides() -> Result<(), ConfigError> {
        let global_temp = TempDir::new().unwrap();
        let project_temp = TempDir::new().unwrap();

        // Global config with an override
        let global_config_path = global_temp.path().join("codebook.toml");
        fs::write(
            &global_config_path,
            r#"
            words = ["globalbase"]

            [[overrides]]
            paths = ["**/*.md"]
            extra_words = ["fromglobal"]
            "#,
        )?;

        // Project config with an override
        let project_config_path = project_temp.path().join("codebook.toml");
        fs::write(
            &project_config_path,
            r#"
            words = ["projectbase"]

            [[overrides]]
            paths = ["**/*.md"]
            extra_words = ["fromproject"]
            "#,
        )?;

        // Load both configs
        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            if let Ok(global_settings) =
                CodebookConfigFile::load_settings_from_file(&global_config_path)
            {
                inner.global_config =
                    WatchedFile::new(Some(global_config_path)).with_content_value(global_settings);
            }
            if let Ok(project_settings) =
                CodebookConfigFile::load_settings_from_file(&project_config_path)
            {
                inner.project_config = WatchedFile::new(Some(project_config_path))
                    .with_content_value(project_settings);
            }
            let effective = CodebookConfigFile::calculate_effective_settings(
                &inner.project_config,
                &inner.global_config,
            );
            inner.snapshot = Arc::new(effective);
        }

        // Resolve for a .md file — both overrides should apply
        let resolved = config.resolve_for_file(Path::new("docs/guide.md"));
        assert!(resolved.is_some());
        let settings = resolved.unwrap();
        assert!(settings.is_allowed_word("globalbase"));
        assert!(settings.is_allowed_word("projectbase"));
        assert!(settings.is_allowed_word("fromglobal"));
        assert!(settings.is_allowed_word("fromproject"));

        Ok(())
    }

    #[test]
    fn test_resolve_for_file_use_global_false_ignores_global_overrides() -> Result<(), ConfigError>
    {
        let global_temp = TempDir::new().unwrap();
        let project_temp = TempDir::new().unwrap();

        let global_config_path = global_temp.path().join("codebook.toml");
        fs::write(
            &global_config_path,
            r#"
            words = ["globalbase"]

            [[overrides]]
            paths = ["**/*.md"]
            extra_words = ["fromglobal"]
            "#,
        )?;

        let project_config_path = project_temp.path().join("codebook.toml");
        fs::write(
            &project_config_path,
            r#"
            words = ["projectbase"]
            use_global = false

            [[overrides]]
            paths = ["**/*.md"]
            extra_words = ["fromproject"]
            "#,
        )?;

        let config = CodebookConfigFile::default();
        {
            let mut inner = config.inner.write().unwrap();
            if let Ok(global_settings) =
                CodebookConfigFile::load_settings_from_file(&global_config_path)
            {
                inner.global_config =
                    WatchedFile::new(Some(global_config_path)).with_content_value(global_settings);
            }
            if let Ok(project_settings) =
                CodebookConfigFile::load_settings_from_file(&project_config_path)
            {
                inner.project_config = WatchedFile::new(Some(project_config_path))
                    .with_content_value(project_settings);
            }
            let effective = CodebookConfigFile::calculate_effective_settings(
                &inner.project_config,
                &inner.global_config,
            );
            inner.snapshot = Arc::new(effective);
        }

        // With use_global = false, global overrides should be ignored
        let resolved = config.resolve_for_file(Path::new("README.md"));
        assert!(resolved.is_some());
        let settings = resolved.unwrap();
        assert!(settings.is_allowed_word("projectbase"));
        assert!(settings.is_allowed_word("fromproject"));
        // Global words and overrides should NOT be present
        assert!(!settings.is_allowed_word("globalbase"));
        assert!(!settings.is_allowed_word("fromglobal"));

        Ok(())
    }

    #[test]
    fn test_save_preserves_overrides() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");
        fs::write(
            &config_path,
            r#"
            words = ["base"]

            [[overrides]]
            paths = ["**/*.md"]
            extra_words = ["markdown"]
            "#,
        )?;

        let config = load_from_file(ConfigType::Project, &config_path)?;

        // Add a word and save
        config.add_word("newword");
        config.save()?;

        // Reload and verify overrides are preserved
        let reloaded = load_from_file(ConfigType::Project, &config_path)?;
        assert!(reloaded.is_allowed_word("base"));
        assert!(reloaded.is_allowed_word("newword"));

        // Override should still work
        let resolved = reloaded.resolve_for_file(Path::new("README.md"));
        assert!(resolved.is_some());
        assert!(resolved.unwrap().is_allowed_word("markdown"));

        Ok(())
    }

    #[test]
    fn test_reload_picks_up_override_changes() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");
        fs::write(
            &config_path,
            r#"
            words = ["base"]
            "#,
        )?;

        let config = load_from_file(ConfigType::Project, &config_path)?;

        // No overrides initially
        assert!(config.resolve_for_file(Path::new("README.md")).is_none());

        // Update config with overrides
        fs::write(
            &config_path,
            r#"
            words = ["base"]

            [[overrides]]
            paths = ["**/*.md"]
            extra_words = ["markdown"]
            "#,
        )?;

        config.reload();

        // Now overrides should apply
        let resolved = config.resolve_for_file(Path::new("README.md"));
        assert!(resolved.is_some());
        assert!(resolved.unwrap().is_allowed_word("markdown"));

        Ok(())
    }

    #[cfg(not(windows))]
    #[test]
    fn test_tilde_in_global_override_is_expanded_at_load() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();

        let config = CodebookConfigFile::load_with_overrides(
            Some(temp_dir.path()),
            Some(PathBuf::from("~/nonexistent-codebook-test/codebook.toml")),
            None,
        )?;

        // The stored path must be the real home path, so load and save agree
        let global_path = config.global_config_path().unwrap();
        let home = dirs::home_dir().unwrap();
        assert!(
            global_path.starts_with(&home),
            "expected {} to start with {}",
            global_path.display(),
            home.display()
        );
        Ok(())
    }

    #[test]
    fn test_load_fails_on_invalid_config() {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");
        fs::write(&config_path, r#"words = [invalid !!! toml"#).unwrap();

        // Strict at startup: an existing-but-broken config is an error, not
        // a silent fallback to defaults (contrast with reload, which keeps
        // the last good config).
        let result = CodebookConfigFile::load_with_overrides(
            Some(temp_dir.path()),
            Some(temp_dir.path().join("global.toml")),
            None,
        );
        let err = result.expect_err("invalid config should fail to load");
        assert!(matches!(err, ConfigError::Parse { .. }));
        assert!(err.to_string().contains("codebook.toml"));
    }

    #[test]
    fn test_load_fails_on_invalid_ignore_pattern() {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");
        fs::write(&config_path, r#"ignore_patterns = ["[invalid"]"#).unwrap();

        let result = CodebookConfigFile::load_with_overrides(
            Some(temp_dir.path()),
            Some(temp_dir.path().join("global.toml")),
            None,
        );
        let err = result.expect_err("invalid regex should fail to load");
        assert!(matches!(err, ConfigError::Parse { .. }));
        assert!(err.to_string().contains("invalid regex pattern '[invalid'"));
    }

    #[test]
    fn test_reload_keeps_config_on_parse_error() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");
        fs::write(&config_path, r#"words = ["frobnicate"]"#)?;

        // Point the global config into the temp dir so a real user config
        // can't leak into the test.
        let config = CodebookConfigFile::load_with_overrides(
            Some(temp_dir.path()),
            Some(temp_dir.path().join("global.toml")),
            None,
        )?;
        assert!(config.is_allowed_word("frobnicate"));

        // Simulate a mid-edit save of broken TOML
        fs::write(&config_path, r#"words = [invalid !!! toml"#)?;
        assert!(!config.reload(), "parse error should not count as a change");
        assert!(
            config.is_allowed_word("frobnicate"),
            "last good config should survive a parse error"
        );

        // Once the file is valid again, the reload picks it up
        fs::write(&config_path, r#"words = ["frobnicate", "fixed"]"#)?;
        assert!(config.reload());
        assert!(config.is_allowed_word("fixed"));
        Ok(())
    }

    #[test]
    fn test_save_does_not_trigger_spurious_reload() -> Result<(), ConfigError> {
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("codebook.toml");
        fs::write(&config_path, r#"words = ["zebra"]"#)?;

        let config = CodebookConfigFile::load_with_overrides(
            Some(temp_dir.path()),
            Some(temp_dir.path().join("global.toml")),
            None,
        )?;

        config.add_word("frobnicate");
        config.save()?;

        // Our own save must not read back as an external change
        assert!(!config.reload());
        assert!(config.is_allowed_word("frobnicate"));
        assert!(config.is_allowed_word("zebra"));
        Ok(())
    }
}
