//! Config loading and parsing.
//!
//! Phase 4 lands a richer schema:
//!
//! - `[general]` — homepage, leader char, favicon toggle.
//! - `[startup]` — session restore, new-tab URL.
//! - `[search]` — default engine + per-engine URL templates.
//! - `[theme]` — accent + dark/light mode.
//! - `[privacy]` — telemetry / clear-on-exit.
//! - `[keymap.<mode>]` — `"j" = "scroll_down"` style entries that
//!   parse into [`buffr_modal::PageAction`]. `<mode>` is one of
//!   `normal`, `visual`, `command`, `hint`; `insert` is rejected by
//!   [`validate`] because Insert mode forwards every key to the page.
//! - `[engines]` — default browser backend + per-domain routing rules.
//!
//! XDG path resolution via `directories::ProjectDirs::from("sh",
//! "kryptic", "buffr")`. Loader returns `(Config, ConfigSource)` so
//! callers can distinguish "user file loaded" from "defaults". Errors
//! carry line/column spans extracted from `toml::de::Error::span()`.
//!
//! Hot reload via [`watch`] which spawns a `notify` debounced watcher
//! and re-parses + re-validates on filesystem events.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use hjkl_config::AppConfig;
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;

pub use buffr_modal::{PageAction, PageMode};

mod keybinding;
mod loader;
pub mod search;
mod watcher;

pub use keybinding::{KeyBinding, KeyBindingError, parse_action};
pub use loader::{ConfigSource, default_config_path, load, load_from_path};
pub use search::{InputKind, classify_input, resolve_input};
pub use watcher::{ConfigWatcher, watch};

/// Top-level config.
///
/// Implements [`hjkl_config::AppConfig`] (`APPLICATION = "buffr"`) so the
/// loader resolves paths through `hjkl-config`'s XDG-everywhere
/// resolver — same `~/.config/buffr/config.toml` on Linux, macOS, and
/// Windows.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub general: General,
    pub startup: Startup,
    pub search: Search,
    pub theme: Theme,
    pub privacy: Privacy,
    pub downloads: DownloadsConfig,
    pub hint: HintConfig,
    pub crash_reporter: CrashReporterConfig,
    pub updates: UpdateConfig,
    pub accessibility: AccessibilityConfig,
    pub idle_inhibit: IdleInhibitConfig,
    /// `[engines]` — browser backend routing.
    ///
    /// `default` names the engine id used when no rule matches.
    /// `rules` is an ordered list of `[[engines.rules]]` entries each
    /// carrying a `match` glob and an `engine` id. Rules are tested in
    /// declaration order; the first matching rule wins.
    pub engines: Engines,
    #[serde(deserialize_with = "deserialize_keymap")]
    pub keymap: HashMap<PageMode, HashMap<String, KeyBinding>>,
}

impl AppConfig for Config {
    #[cfg(not(debug_assertions))]
    const APPLICATION: &'static str = "buffr";
    #[cfg(debug_assertions)]
    const APPLICATION: &'static str = "buffr-debug";
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct General {
    pub homepage: String,
    pub leader: String,
    /// Render per-tab favicons. When `false` the tab strip skips favicon
    /// rendering entirely (and the host suppresses the CEF favicon
    /// download to save the network round-trip).
    pub show_favicons: bool,
}

impl Default for General {
    fn default() -> Self {
        Self {
            homepage: "buffr://new".into(),
            leader: " ".into(),
            show_favicons: true,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Startup {
    pub restore_session: bool,
    pub new_tab_url: String,
}

impl Default for Startup {
    fn default() -> Self {
        Self {
            restore_session: false,
            new_tab_url: "about:blank".into(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Search {
    pub default_engine: String,
    pub engines: HashMap<String, SearchEngine>,
}

impl Default for Search {
    fn default() -> Self {
        let mut engines = HashMap::new();
        engines.insert(
            "duckduckgo".into(),
            SearchEngine {
                url: "https://duckduckgo.com/?q={query}".into(),
                prefix: Some("ddg".into()),
            },
        );
        engines.insert(
            "google".into(),
            SearchEngine {
                url: "https://www.google.com/search?q={query}".into(),
                prefix: Some("g".into()),
            },
        );
        Self {
            default_engine: "duckduckgo".into(),
            engines,
        }
    }
}

#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SearchEngine {
    pub url: String,
    /// Optional shortcut keyword. When set, an omnibar input of
    /// `<prefix> <query>` routes to this engine instead of
    /// [`Search::default_engine`]. Keep these short and memorable
    /// (`g`, `gh`, `ddg`, `wiki`). Prefix collisions across engines
    /// are rejected by [`Config::validate`].
    pub prefix: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Theme {
    /// Base accent. Drives the statusline mode block, tab-strip
    /// active-tab indicator, and the bg/fg/border tints derived in
    /// `buffr-ui`. All other semantic colours below default to fixed
    /// signals (cert green/red, private pink, etc.) but are exposed
    /// here so users can re-tint them.
    pub accent: String,
    /// Cert-state secure indicator (lock dot, find counts).
    pub cert_secure: String,
    /// Cert-state insecure indicator.
    pub cert_insecure: String,
    /// PRIVATE marker on the right-hand statusline cell.
    pub private: String,
    /// Page-load progress bar at the top of the statusline.
    pub progress: String,
    /// Update-available indicator (`* upd`).
    pub update: String,
    /// Phase 6 accessibility: when `true`, the chrome (statusline,
    /// tab strip, input bar, prompt) renders with a high-contrast
    /// palette instead of the accent-tinted default. See
    /// `docs/site/accessibility.md` for the colour values.
    pub high_contrast: bool,
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            accent: "#7aa2f7".into(),
            cert_secure: "#66e08a".into(),
            cert_insecure: "#e05a5a".into(),
            private: "#ffc8c8".into(),
            progress: "#66c2ff".into(),
            update: "#e0c85a".into(),
            high_contrast: false,
        }
    }
}

/// Parse a leading-`#` hex colour string (`#RRGGBB`) into a packed
/// BGRA `u32` with alpha 0xFF — the format buffr-ui expects. Returns
/// `None` if the input isn't a 7-char `#xxxxxx` hex string. `#RGB`
/// shorthand is intentionally not supported; the config schema only
/// emits 6-digit hex.
pub fn parse_hex_rgb(s: &str) -> Option<u32> {
    let s = s.trim();
    // `s.len()` is a *byte* count, so a 7-byte string containing a
    // multi-byte codepoint (`"#a€aa"`) would pass a length-only guard and
    // then panic when sliced at fixed byte offsets. Require ASCII first so
    // byte offsets and char offsets are guaranteed to agree, and use
    // `get()` so any residual mismatch yields `None` instead of a panic.
    if !s.is_ascii() || s.len() != 7 || !s.starts_with('#') {
        return None;
    }
    let r = u32::from_str_radix(s.get(1..3)?, 16).ok()?;
    let g = u32::from_str_radix(s.get(3..5)?, 16).ok()?;
    let b = u32::from_str_radix(s.get(5..7)?, 16).ok()?;
    Some(0xFF_00_00_00 | (r << 16) | (g << 8) | b)
}

/// `[updates]` section — Phase 6 update channel.
///
/// Off-by-default would make us silently fall behind on security fixes,
/// so the default is `enabled = true` with a 24 h interval. This
/// performs **one** GET per `check_interval_hours` against the GitHub
/// releases API; no PII is sent. To opt out fully set
/// `enabled = false` — that path makes **zero** network calls. See
/// `docs/site/privacy.md`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct UpdateConfig {
    pub enabled: bool,
    pub check_interval_hours: u32,
    /// `owner/repo` slug. Forks point this at their own repo.
    pub github_repo: String,
}

impl Default for UpdateConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            check_interval_hours: 24,
            github_repo: "kryptic-sh/buffr".into(),
        }
    }
}

/// `[accessibility]` section — Phase 6 a11y pass.
///
/// Web content goes through CEF's renderer accessibility tree (which
/// platform screen readers read directly). Native chrome (statusline,
/// tab strip, prompts) is keyboard-first; a screen-reader bridge is
/// post-1.0 because it requires platform-specific bindings (AT-SPI on
/// Linux, NSAccessibility on macOS, MSAA on Windows). See
/// `docs/site/accessibility.md`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AccessibilityConfig {
    /// Enable CEF's renderer accessibility tree by passing the
    /// `--force-renderer-accessibility` switch through
    /// `App::on_before_command_line_processing`. Default `false` —
    /// the tree is non-trivial overhead for users without an AT.
    pub force_renderer_accessibility: bool,
}

/// `[idle_inhibit]` section — issue #22 idle-inhibit (prevent screen lock).
///
/// When `enabled`, buffr acquires a platform idle-inhibitor whenever a video
/// signal is active in the focused window. The inhibitor is released the moment
/// video stops or focus is lost (when `require_focus = true`). No platform
/// action is taken when `enabled = false`.
///
/// `inhibit_audio_only` broadens the trigger to include audio-only streams
/// (e.g. music playback). Off by default — listening to music should not keep
/// the screen on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IdleInhibitConfig {
    /// Master switch. `true` (the default) allows the inhibitor to be
    /// acquired when a video signal is active. Set to `false` to disable
    /// the feature entirely.
    pub enabled: bool,
    /// When `true`, audio-only activity (no video signal) also triggers
    /// the inhibitor. Default `false`.
    pub inhibit_audio_only: bool,
    /// When `true`, the inhibitor is only held while the buffr window has
    /// OS-level focus. Default `true`. Set to `false` to inhibit even
    /// when the window is in the background (useful for PiP setups).
    pub require_focus: bool,
}

impl Default for IdleInhibitConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            inhibit_audio_only: false,
            require_focus: true,
        }
    }
}

// ── Engine routing ────────────────────────────────────────────────────────

fn default_engine_id() -> String {
    "cef".to_string()
}

/// One registered engine instance inside `[[engines.instances]]`.
///
/// ```toml
/// [[engines.instances]]
/// id      = "cef-a"
/// backend = "cef"
///
/// [[engines.instances]]
/// id       = "cef-b"
/// backend  = "cef"
/// data_dir = "/tmp/cef-b-cache"  # accepted; no effect today
/// ```
///
/// `id` is referenced by `engines.default` and `[[engines.rules]]`.
/// `backend` selects the runtime crate: only `"cef"` is supported.
/// `data_dir` is parsed and passed to the backend, but the CEF backend
/// discards it — no per-engine `RequestContext` is created, so every
/// instance shares CEF's process-global `root_cache_path`. See
/// `docs/site/config.md` and kryptic-sh/buffr#158.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EngineInstance {
    /// Unique id referenced by `engines.default` and routing rules.
    /// Lower-case ASCII recommended.
    pub id: String,
    /// Which backend crate hosts this instance. Only `"cef"` is
    /// supported.
    pub backend: String,
    /// Optional per-instance data directory. Parsed and plumbed to the
    /// backend, but the CEF backend discards it — every instance shares
    /// the process-global `root_cache_path`. Kept so configs survive the
    /// eventual per-engine isolation work (kryptic-sh/buffr#158).
    #[serde(default)]
    pub data_dir: Option<String>,
}

/// `[engines]` section — browser backend routing.
///
/// ```toml
/// [engines]
/// default = "cef"
///
/// [[engines.instances]]
/// id      = "cef"
/// backend = "cef"
///
/// [[engines.rules]]
/// match = "*.figma.com"
/// engine = "webkit"
/// ```
///
/// The `default` engine id is used for every URL that does not match a rule.
/// `instances` lists the engine backends to construct at startup. When empty,
/// a single default `EngineInstance { id: "cef", backend: "cef" }` is
/// synthesised so single-engine configs require no changes.
/// Rules are evaluated in declaration order; the first match wins. Engine ids
/// must correspond to a declared instance — validated by [`validate`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Engines {
    /// Engine id used when no rule matches. Defaults to `"cef"`.
    #[serde(default = "default_engine_id")]
    pub default: String,
    /// Registered engine instances. When empty a default `cef` instance is
    /// synthesised at runtime (see [`Engines::effective_instances`]).
    #[serde(default)]
    pub instances: Vec<EngineInstance>,
    /// Ordered list of per-domain routing rules. Evaluated in declaration
    /// order; the first matching rule wins.
    #[serde(default)]
    pub rules: Vec<EngineRule>,
}

impl Default for Engines {
    fn default() -> Self {
        Self {
            default: default_engine_id(),
            instances: Vec::new(),
            rules: Vec::new(),
        }
    }
}

impl Engines {
    /// Return the effective instance list.
    ///
    /// When `instances` is empty (the common single-engine case), returns a
    /// one-element slice containing a synthesised `{ id: "cef", backend: "cef"
    /// }` so callers always see at least one instance. When non-empty the
    /// explicit list is returned unchanged.
    pub fn effective_instances(&self) -> std::borrow::Cow<'_, [EngineInstance]> {
        if self.instances.is_empty() {
            std::borrow::Cow::Owned(vec![EngineInstance {
                id: "cef".to_string(),
                backend: "cef".to_string(),
                data_dir: None,
            }])
        } else {
            std::borrow::Cow::Borrowed(&self.instances)
        }
    }
}

/// One per-domain engine routing rule inside `[[engines.rules]]`.
///
/// `match` is a glob pattern tested against the URL's host component
/// (case-insensitive). Examples: `"*.figma.com"`, `"figma.com"`. The field
/// is named `pattern` in Rust (because `match` is a keyword) and serialised
/// as `match` in TOML via `#[serde(rename = "match")]`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EngineRule {
    /// Glob pattern for the URL host. See [`Engines`] for examples.
    #[serde(rename = "match")]
    pub pattern: String,
    /// Engine id to route matching URLs to.
    pub engine: String,
}

/// Categories of locally-stored data the shutdown hook can wipe when
/// listed in `[privacy] clear_on_exit`. Variants map onto independent
/// teardown paths in `apps/buffr/src/main.rs::run_clear_on_exit`. Adding
/// a variant here is the only place a new clearable category needs to
/// register — the shutdown hook matches exhaustively.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ClearableData {
    /// Cookies, via the CEF global `CookieManager::delete_cookies`.
    Cookies,
    /// CEF cache directory (`<root_cache_path>/Cache`).
    Cache,
    /// `buffr-history` SQLite store — every visit row.
    History,
    /// `buffr-bookmarks` SQLite store — every bookmark + tag. Listed
    /// here is destructive and only honored when explicit.
    Bookmarks,
    /// `buffr-downloads` SQLite store — every row regardless of status.
    Downloads,
    /// CEF localStorage / IndexedDB tree
    /// (`<root_cache_path>/Local Storage`).
    LocalStorage,
}

/// Default schemes suppressed by [`buffr_history`].
pub const DEFAULT_SKIP_SCHEMES: &[&str] = &["about", "cef", "chrome", "data", "file"];

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Privacy {
    pub enable_telemetry: bool,
    pub clear_on_exit: Vec<ClearableData>,
    /// URL schemes whose visits are never recorded in history. Matching
    /// is case-insensitive on the scheme component. The default list
    /// (`about`, `cef`, `chrome`, `data`, `file`) covers internal
    /// browser pages and privacy-sensitive local URLs. Add entries to
    /// suppress additional schemes (e.g. `"javascript"`, `"blob"`);
    /// remove entries to record them (unusual but supported).
    pub skip_schemes: Vec<String>,
}

impl Default for Privacy {
    fn default() -> Self {
        Self {
            enable_telemetry: false,
            clear_on_exit: Vec::new(),
            skip_schemes: DEFAULT_SKIP_SCHEMES.iter().map(|s| s.to_string()).collect(),
        }
    }
}

/// `[crash_reporter]` section — Phase 6 opt-in panic reporter.
///
/// Off by default. Reports are written to
/// `<data>/crashes/<timestamp>.json` for the user to inspect or submit
/// by hand; nothing is uploaded.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CrashReporterConfig {
    /// Master switch. `false` (the default) leaves the default panic
    /// hook in place; `true` installs the buffr panic hook on startup.
    pub enabled: bool,
    /// Auto-purge cutoff. Reports older than this many days are
    /// removed by `--purge-crashes`. Default `30`.
    pub purge_after_days: u32,
}

impl Default for CrashReporterConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            purge_after_days: 30,
        }
    }
}

/// `[hint]` section — Phase 3 follow-by-letter labels.
///
/// `alphabet` is the ordered character list used to mint hint labels.
/// Default mirrors Vimium's home-row plus upper-row; users can prefer
/// `"abcdefghijklmnopqrstuvwxyz"` for full-keyboard reachability or
/// trim to a smaller set if they only use the home row.
///
/// Validation:
///
/// - non-empty
/// - no duplicates
/// - ASCII only (the injected JS asserts ASCII so we keep the
///   alphabet ASCII end-to-end)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HintConfig {
    pub alphabet: String,
}

impl Default for HintConfig {
    fn default() -> Self {
        Self {
            // Mirrors `buffr_core::DEFAULT_HINT_ALPHABET`. We can't
            // import that as a const here (would create a config →
            // core dep cycle); duplicate the literal and keep them in
            // sync via the `default_hint_alphabet_matches_core` test in
            // `buffr-core/src/hint.rs`.
            alphabet: "asdfghjkl;weruio".into(),
        }
    }
}

/// `[downloads]` section. Phase 5: governs where files land, whether
/// buffr opens them on completion, and (post-Phase 3) whether the
/// chrome surfaces a save-as dialog.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DownloadsConfig {
    /// User-specified default directory. `None` (the default) means
    /// "resolve at runtime via [`resolve_default_dir`]".
    pub default_dir: Option<PathBuf>,
    /// Open the downloaded file once it finishes via the platform's
    /// open command (`xdg-open` / `open` / `start`). Default `false`.
    pub open_on_finish: bool,
    /// Show a save-as dialog for every download. Default `false` —
    /// downloads silently land in `default_dir`. When `true`, CEF's
    /// `BeforeDownloadCallback::cont` receives `show_dialog = 1` which
    /// triggers the OS native Save-As dialog; the notification strip is
    /// suppressed because the user already gets OS-level feedback.
    pub ask_each_time: bool,
    /// Surface a chrome notification strip when a download starts or
    /// finishes (only relevant when `ask_each_time = false`). Default
    /// `true`. Set to `false` to opt out of the strip entirely.
    pub show_notifications: bool,
}

impl Default for DownloadsConfig {
    fn default() -> Self {
        Self {
            default_dir: None,
            open_on_finish: false,
            ask_each_time: false,
            show_notifications: true,
        }
    }
}

/// Resolve the effective default download directory.
///
/// Resolution order:
///
/// 1. `cfg.downloads.default_dir` if `Some` and absolute. Tilde and
///    other shell expansions are *not* applied — TOML carries no
///    shell.
/// 2. `directories::UserDirs::download_dir()` — the XDG
///    `XDG_DOWNLOAD_DIR` on Linux, `~/Downloads` on macOS,
///    `%USERPROFILE%\Downloads` on Windows.
/// 3. `$HOME/Downloads` (Unix) / `%USERPROFILE%\Downloads` (Windows)
///    as a last-resort fallback.
/// 4. Current working directory if even that's unavailable — we never
///    return `None` so callers don't have to handle a missing default.
pub fn resolve_default_dir(cfg: &DownloadsConfig) -> PathBuf {
    if let Some(p) = cfg.default_dir.as_ref() {
        return p.clone();
    }
    if let Some(d) = dirs::download_dir() {
        return d;
    }
    // `$HOME/Downloads` fallback. `home_dir()` is deprecated on stable
    // for cross-platform reasons but we only need a best-effort
    // fallback — if it's wrong the user can set `default_dir`.
    #[allow(deprecated)]
    if let Some(home) = std::env::home_dir() {
        return home.join("Downloads");
    }
    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}

/// Errors the loader / validator can produce.
#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("io error reading {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("error: {message}\n  at {}:{line}:{col}\n   | {snippet}", path.display())]
    Parse {
        path: PathBuf,
        message: String,
        line: usize,
        col: usize,
        snippet: String,
    },
    #[error("config validation failed: {message}{}", location.as_ref().map(|l| format!(" ({l})")).unwrap_or_default())]
    Validate {
        message: String,
        location: Option<String>,
    },
}

/// Build a snapshot of every line/column for byte offsets in `src`.
/// Toml's `Error::span()` returns a byte range; we map the start to a
/// `(line, col, line_text)` triple.
pub(crate) fn locate(src: &str, byte_offset: usize) -> (usize, usize, String) {
    let clamped = byte_offset.min(src.len());
    let mut line_start = 0usize;
    let mut line_no = 1usize;
    for (i, ch) in src.char_indices() {
        if i >= clamped {
            break;
        }
        if ch == '\n' {
            line_no += 1;
            line_start = i + ch.len_utf8();
        }
    }
    let line_end = src[line_start..]
        .find('\n')
        .map(|n| line_start + n)
        .unwrap_or(src.len());
    let line_text = src[line_start..line_end].to_string();
    let col = clamped.saturating_sub(line_start) + 1;
    (line_no, col, line_text)
}

/// `[keymap]` deserializes as `HashMap<PageMode, HashMap<String, KeyBinding>>`,
/// but we want unknown modes to error out rather than silently slip past.
/// The standard `HashMap<PageMode, _>` deserializer surfaces unknown keys
/// as serde errors automatically (since `PageMode` is `deny_unknown_fields`
/// over its rename_all snake_case variants). The wrapper exists so we can
/// later add a normalization pass without changing the public type.
fn deserialize_keymap<'de, D>(
    deserializer: D,
) -> Result<HashMap<PageMode, HashMap<String, KeyBinding>>, D::Error>
where
    D: Deserializer<'de>,
{
    HashMap::<PageMode, HashMap<String, KeyBinding>>::deserialize(deserializer)
}

/// Message for a `[keymap.insert]` section. Shared by [`validate`] and
/// the tests that pin the wording, since it is the only thing telling a
/// user why their binding was refused and how to get out of Insert.
const INSERT_KEYMAP_REJECTED: &str = "[keymap.insert] is not supported: Insert mode forwards every key to the page, \
     so a binding there would shadow typing. Press <Esc> to leave Insert mode; \
     put the binding under [keymap.normal] instead.";

/// Validate a parsed `Config`. Called after `toml::from_str` succeeds.
///
/// Checks:
///
/// - `general.leader` is exactly one character.
/// - Every `[theme]` colour is a parseable `#RRGGBB` string.
/// - `crash_reporter.purge_after_days` is greater than zero.
/// - `search.default_engine` references a defined engine.
/// - No `[keymap.insert]` section is present (see [`INSERT_KEYMAP_REJECTED`]).
/// - Every keymap binding string parses via `buffr_modal::parse_keys`.
pub fn validate(cfg: &Config) -> Result<(), ConfigError> {
    let leader_chars: Vec<char> = cfg.general.leader.chars().collect();
    if leader_chars.len() != 1 {
        return Err(ConfigError::Validate {
            message: format!(
                "general.leader must be exactly one character (got {:?})",
                cfg.general.leader
            ),
            location: Some("general.leader".into()),
        });
    }

    // Hint alphabet validation. Surface any of the three failure modes
    // (empty, duplicate, non-ASCII) as a [`ConfigError::Validate`] so
    // the user gets a friendly toml-aware message instead of a panic
    // deep inside `BrowserHost::new`.
    {
        let alpha = &cfg.hint.alphabet;
        if alpha.is_empty() {
            return Err(ConfigError::Validate {
                message: "hint.alphabet must be non-empty".into(),
                location: Some("hint.alphabet".into()),
            });
        }
        if !alpha.is_ascii() {
            return Err(ConfigError::Validate {
                message: format!("hint.alphabet must be ASCII (got {alpha:?})"),
                location: Some("hint.alphabet".into()),
            });
        }
        let mut seen = std::collections::HashSet::new();
        for c in alpha.chars() {
            if !seen.insert(c) {
                return Err(ConfigError::Validate {
                    message: format!("hint.alphabet contains duplicate character {c:?}"),
                    location: Some("hint.alphabet".into()),
                });
            }
        }
        if alpha.chars().count() < 2 {
            return Err(ConfigError::Validate {
                message: "hint.alphabet must contain at least 2 characters".into(),
                location: Some("hint.alphabet".into()),
            });
        }
    }

    // -- updates ------------------------------------------------------
    // Validate the repo shape so a typo doesn't surface as a 404 GET
    // burned each launch.
    {
        let repo = cfg.updates.github_repo.as_str();
        // GitHub repos are `owner/repo` — exactly one slash, no empty
        // segments. Anything else routes to a 404 we'd rather catch
        // here than at HTTP time.
        let slash_count = repo.matches('/').count();
        if slash_count != 1 || repo.starts_with('/') || repo.ends_with('/') {
            return Err(ConfigError::Validate {
                message: format!(
                    "updates.github_repo must be of the form \"owner/repo\" (got {repo:?})"
                ),
                location: Some("updates.github_repo".into()),
            });
        }
        if cfg.updates.check_interval_hours == 0 {
            return Err(ConfigError::Validate {
                message: "updates.check_interval_hours must be > 0".into(),
                location: Some("updates.check_interval_hours".into()),
            });
        }
    }

    // -- theme ----------------------------------------------------------
    // `build_palette` falls back to the built-in default for any colour
    // it can't parse, so without this check `accent = "not-a-color"`
    // silently reverts to the built-in blue while `--check-config`
    // reports success.
    {
        let colours: [(&str, &str); 6] = [
            ("theme.accent", cfg.theme.accent.as_str()),
            ("theme.cert_secure", cfg.theme.cert_secure.as_str()),
            ("theme.cert_insecure", cfg.theme.cert_insecure.as_str()),
            ("theme.private", cfg.theme.private.as_str()),
            ("theme.progress", cfg.theme.progress.as_str()),
            ("theme.update", cfg.theme.update.as_str()),
        ];
        for (field, value) in colours {
            if parse_hex_rgb(value).is_none() {
                return Err(ConfigError::Validate {
                    message: format!("{field} must be a `#RRGGBB` hex colour (got {value:?})"),
                    location: Some(field.into()),
                });
            }
        }
    }

    // -- crash_reporter ---------------------------------------------------
    // `0` would make `--purge-crashes` delete every report, including the
    // one just written for the crash the user is trying to inspect.
    if cfg.crash_reporter.purge_after_days == 0 {
        return Err(ConfigError::Validate {
            message: "crash_reporter.purge_after_days must be > 0".into(),
            location: Some("crash_reporter.purge_after_days".into()),
        });
    }

    if !cfg.search.engines.contains_key(&cfg.search.default_engine) {
        return Err(ConfigError::Validate {
            message: format!(
                "search.default_engine = {:?} but no [search.engines.{}] block defined",
                cfg.search.default_engine, cfg.search.default_engine
            ),
            location: Some("search.default_engine".into()),
        });
    }
    // Reject duplicate prefixes — the omnibar dispatcher maps prefix → engine
    // and a collision would silently pick whichever engine the HashMap iterates
    // first. Empty-string prefix is also rejected (would match every input).
    let mut seen: HashMap<&str, &str> = HashMap::new();
    for (name, engine) in &cfg.search.engines {
        if let Some(prefix) = &engine.prefix {
            if prefix.is_empty() {
                return Err(ConfigError::Validate {
                    message: format!(
                        "search.engines.{name}.prefix must not be empty (omit the field instead)"
                    ),
                    location: Some(format!("search.engines.{name}.prefix")),
                });
            }
            if let Some(other) = seen.insert(prefix.as_str(), name.as_str()) {
                return Err(ConfigError::Validate {
                    message: format!(
                        "search.engines.{name}.prefix = {prefix:?} collides with search.engines.{other}.prefix"
                    ),
                    location: Some(format!("search.engines.{name}.prefix")),
                });
            }
        }
    }

    // `[keymap.insert]` parses (PageMode has an `insert` variant, used
    // by `enter_mode("insert")` action values) but is not bindable:
    // Insert mode forwards every key to the page, so the engine
    // short-circuits before the trie and a binding stored there can
    // only ever misfire in another mode. Reject the whole section in
    // its own pass so the error is deterministic no matter which order
    // the keymap HashMap iterates.
    if cfg.keymap.contains_key(&PageMode::Insert) {
        return Err(ConfigError::Validate {
            message: INSERT_KEYMAP_REJECTED.into(),
            location: Some("keymap.insert".into()),
        });
    }

    for (mode, bindings) in &cfg.keymap {
        for keys in bindings.keys() {
            // An empty key parses to an empty chord slice and would
            // install the action at the trie root (A7), corrupting
            // ambiguity resolution. Reject it before parse_keys.
            if keys.is_empty() {
                return Err(ConfigError::Validate {
                    message: format!("keymap.{} has an empty binding key", mode_name(*mode)),
                    location: Some(format!("keymap.{}", mode_name(*mode))),
                });
            }
            if let Err(e) = buffr_modal::parse_keys(keys) {
                return Err(ConfigError::Validate {
                    message: format!(
                        "keymap.{} binding {:?} failed to parse: {}",
                        mode_name(*mode),
                        keys,
                        e
                    ),
                    location: Some(format!("keymap.{}.{:?}", mode_name(*mode), keys)),
                });
            }
        }
    }

    // -- engines --------------------------------------------------------------
    // Validate that `default` is non-empty, that explicit instance ids are
    // unique and non-empty, that `default` references a known instance, and
    // that every rule has a non-empty `match` pattern and `engine` id that
    // references a known instance.
    //
    // We intentionally do NOT validate engine ids against the runtime
    // registry here — the config crate has no knowledge of registered
    // backends; deep runtime checks belong in `engine_router`.
    {
        if cfg.engines.default.is_empty() {
            return Err(ConfigError::Validate {
                message: "engines.default must not be empty".into(),
                location: Some("engines.default".into()),
            });
        }

        // Validate explicit instances (when present).
        if !cfg.engines.instances.is_empty() {
            let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
            for (i, inst) in cfg.engines.instances.iter().enumerate() {
                if inst.id.is_empty() {
                    return Err(ConfigError::Validate {
                        message: format!("engines.instances[{i}].id must not be empty"),
                        location: Some(format!("engines.instances[{i}].id")),
                    });
                }
                if inst.backend.is_empty() {
                    return Err(ConfigError::Validate {
                        message: format!("engines.instances[{i}].backend must not be empty"),
                        location: Some(format!("engines.instances[{i}].backend")),
                    });
                }
                if !seen_ids.insert(inst.id.as_str()) {
                    return Err(ConfigError::Validate {
                        message: format!(
                            "engines.instances[{i}].id = {:?} is a duplicate",
                            inst.id
                        ),
                        location: Some(format!("engines.instances[{i}].id")),
                    });
                }
            }
            // `default` must reference a declared instance.
            let known: Vec<&str> = cfg
                .engines
                .instances
                .iter()
                .map(|i| i.id.as_str())
                .collect();
            if !known.contains(&cfg.engines.default.as_str()) {
                return Err(ConfigError::Validate {
                    message: format!(
                        "engines.default = {:?} does not match any engines.instances id (known: {})",
                        cfg.engines.default,
                        known.join(", ")
                    ),
                    location: Some("engines.default".into()),
                });
            }
            // Every rule's `engine` must reference a declared instance.
            for (i, rule) in cfg.engines.rules.iter().enumerate() {
                if rule.pattern.is_empty() {
                    return Err(ConfigError::Validate {
                        message: format!("engines.rules[{i}].match must not be empty"),
                        location: Some(format!("engines.rules[{i}].match")),
                    });
                }
                if rule.engine.is_empty() {
                    return Err(ConfigError::Validate {
                        message: format!("engines.rules[{i}].engine must not be empty"),
                        location: Some(format!("engines.rules[{i}].engine")),
                    });
                }
                if !known.contains(&rule.engine.as_str()) {
                    return Err(ConfigError::Validate {
                        message: format!(
                            "engines.rules[{i}].engine = {:?} does not match any engines.instances id (known: {})",
                            rule.engine,
                            known.join(", ")
                        ),
                        location: Some(format!("engines.rules[{i}].engine")),
                    });
                }
            }
        } else {
            // No explicit instances — just validate the rules' non-emptiness.
            // The synthesised default instance id is "cef"; `default` must be
            // "cef" or the user has set a non-empty custom value that will be
            // caught at router build time.
            for (i, rule) in cfg.engines.rules.iter().enumerate() {
                if rule.pattern.is_empty() {
                    return Err(ConfigError::Validate {
                        message: format!("engines.rules[{i}].match must not be empty"),
                        location: Some(format!("engines.rules[{i}].match")),
                    });
                }
                if rule.engine.is_empty() {
                    return Err(ConfigError::Validate {
                        message: format!("engines.rules[{i}].engine must not be empty"),
                        location: Some(format!("engines.rules[{i}].engine")),
                    });
                }
            }
        }
    }

    Ok(())
}

/// Apply the user's [`Config`] keymap on top of [`buffr_modal::Keymap::default_bindings`].
///
/// Phase 4 strategy: take the built-in defaults for the configured leader,
/// then for every `(mode, keys, action)` in the user config rebind on top.
/// `buffr_modal::Keymap::bind` overwrites existing entries, so the user's
/// table is effectively a per-binding override.
pub fn build_keymap(cfg: &Config) -> Result<buffr_modal::Keymap, ConfigError> {
    let leader = cfg
        .general
        .leader
        .chars()
        .next()
        .ok_or_else(|| ConfigError::Validate {
            message: "general.leader is empty".into(),
            location: Some("general.leader".into()),
        })?;
    let mut km = buffr_modal::Keymap::default_bindings(leader);
    for (mode, bindings) in &cfg.keymap {
        for (keys, binding) in bindings {
            km.bind(*mode, keys, binding.action.clone())
                .map_err(|e| ConfigError::Validate {
                    message: format!(
                        "failed to bind keymap.{}.{:?}: {}",
                        mode_name(*mode),
                        keys,
                        e
                    ),
                    location: Some(format!("keymap.{}.{:?}", mode_name(*mode), keys)),
                })?;
        }
    }
    Ok(km)
}

fn mode_name(mode: PageMode) -> &'static str {
    match mode {
        PageMode::Normal => "normal",
        PageMode::Visual => "visual",
        PageMode::Command => "command",
        PageMode::Hint => "hint",
        PageMode::Insert => "insert",
    }
}

/// Serialize `cfg` back to TOML. Used by `--print-config`.
pub fn to_toml_string(cfg: &Config) -> Result<String, toml::ser::Error> {
    toml::to_string_pretty(cfg)
}

/// Convenience: load + validate + return path source.
pub fn load_and_validate(path: Option<&Path>) -> Result<(Config, ConfigSource), ConfigError> {
    let (cfg, src) = match path {
        Some(p) => load_from_path(p)?,
        None => load()?,
    };
    validate(&cfg)?;
    Ok((cfg, src))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_round_trip() {
        let cfg = Config::default();
        let s = to_toml_string(&cfg).unwrap();
        let parsed: Config = toml::from_str(&s).unwrap();
        assert_eq!(parsed, cfg);
    }

    #[test]
    fn parse_error_carries_location() {
        let bad = "[general]\nhomepage = 12 = oops\n";
        let err = toml::from_str::<Config>(bad).unwrap_err();
        let span = err.span().expect("span available");
        let (line, _col, text) = locate(bad, span.start);
        assert_eq!(line, 2);
        assert!(text.contains("homepage"));
    }

    #[test]
    fn validate_leader_single_char_ok() {
        let mut cfg = Config::default();
        cfg.general.leader = "\\".into();
        validate(&cfg).unwrap();
    }

    #[test]
    fn validate_leader_empty_errors() {
        let mut cfg = Config::default();
        cfg.general.leader = String::new();
        let err = validate(&cfg).unwrap_err();
        assert!(matches!(err, ConfigError::Validate { .. }));
    }

    #[test]
    fn validate_leader_multi_char_errors() {
        let mut cfg = Config::default();
        cfg.general.leader = "abc".into();
        let err = validate(&cfg).unwrap_err();
        assert!(matches!(err, ConfigError::Validate { .. }));
    }

    #[test]
    fn parse_hex_rgb_accepts_valid() {
        assert_eq!(parse_hex_rgb("#000000"), Some(0xFF_00_00_00));
        assert_eq!(parse_hex_rgb("#ffffff"), Some(0xFF_FF_FF_FF));
        assert_eq!(parse_hex_rgb("  #7aa2f7  "), Some(0xFF_7A_A2_F7));
    }

    #[test]
    fn parse_hex_rgb_rejects_non_ascii_without_panicking() {
        // 7 *bytes* but only 5 chars — the old fixed byte-offset slicing
        // split the `€` codepoint and panicked (H2).
        // Each of these is exactly 7 bytes with a multi-byte codepoint
        // straddling one of the `[1..3]` / `[3..5]` / `[5..7]` boundaries.
        for s in [
            "#a\u{20ac}aa",
            "#\u{20ac}aaa",
            "#aa\u{20ac}a",
            "#aaa\u{20ac}",
        ] {
            assert_eq!(s.len(), 7, "{s:?} should be 7 bytes");
            assert_eq!(parse_hex_rgb(s), None, "{s:?} should not parse");
        }
        // Assorted other non-ASCII shapes must not panic either.
        assert_eq!(parse_hex_rgb("\u{20ac}aaaaa"), None);
        assert_eq!(parse_hex_rgb("#\u{1F600}aa"), None);
        assert_eq!(parse_hex_rgb("\u{1F600}\u{1F600}"), None);
        assert_eq!(parse_hex_rgb("#\u{e9}\u{e9}\u{e9}"), None);
    }

    #[test]
    fn parse_hex_rgb_rejects_malformed_ascii() {
        assert_eq!(parse_hex_rgb(""), None);
        assert_eq!(parse_hex_rgb("#fff"), None);
        assert_eq!(parse_hex_rgb("7aa2f7"), None);
        assert_eq!(parse_hex_rgb("#zzzzzz"), None);
        assert_eq!(parse_hex_rgb("not-a-color"), None);
    }

    #[test]
    fn validate_rejects_bad_theme_colour() {
        for field in [
            "accent",
            "cert_secure",
            "cert_insecure",
            "private",
            "progress",
            "update",
        ] {
            let mut cfg = Config::default();
            match field {
                "accent" => cfg.theme.accent = "not-a-color".into(),
                "cert_secure" => cfg.theme.cert_secure = "not-a-color".into(),
                "cert_insecure" => cfg.theme.cert_insecure = "not-a-color".into(),
                "private" => cfg.theme.private = "not-a-color".into(),
                "progress" => cfg.theme.progress = "not-a-color".into(),
                "update" => cfg.theme.update = "not-a-color".into(),
                _ => unreachable!(),
            }
            let err = validate(&cfg).unwrap_err();
            match err {
                ConfigError::Validate { message, location } => {
                    assert!(message.contains("not-a-color"), "unexpected msg: {message}");
                    assert_eq!(location.as_deref(), Some(format!("theme.{field}").as_str()));
                }
                other => panic!("expected Validate error, got {other:?}"),
            }
        }
    }

    #[test]
    fn validate_rejects_non_ascii_theme_colour() {
        let mut cfg = Config::default();
        cfg.theme.accent = "#a\u{20ac}aa".into();
        assert!(matches!(validate(&cfg), Err(ConfigError::Validate { .. })));
    }

    #[test]
    fn validate_accepts_default_theme() {
        validate(&Config::default()).unwrap();
    }

    #[test]
    fn validate_rejects_zero_purge_after_days() {
        let mut cfg = Config::default();
        cfg.crash_reporter.purge_after_days = 0;
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, location } => {
                assert!(
                    message.contains("purge_after_days"),
                    "unexpected: {message}"
                );
                assert_eq!(location.as_deref(), Some("crash_reporter.purge_after_days"));
            }
            other => panic!("expected Validate error, got {other:?}"),
        }
    }

    #[test]
    fn validate_default_engine_must_exist() {
        let mut cfg = Config::default();
        cfg.search.default_engine = "ddg".into();
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, .. } => assert!(message.contains("ddg")),
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn validate_rejects_duplicate_search_prefixes() {
        let mut cfg = Config::default();
        // Both engines now claim prefix "g" — pick one explicitly.
        if let Some(ddg) = cfg.search.engines.get_mut("duckduckgo") {
            ddg.prefix = Some("g".into());
        }
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, .. } => {
                assert!(message.contains("collides"), "unexpected msg: {message}");
            }
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn validate_rejects_empty_search_prefix() {
        let mut cfg = Config::default();
        if let Some(ddg) = cfg.search.engines.get_mut("duckduckgo") {
            ddg.prefix = Some(String::new());
        }
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, .. } => {
                assert!(message.contains("empty"), "unexpected msg: {message}");
            }
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn keymap_binding_parses_unit_variant() {
        let toml = r#"
[keymap.normal]
"j" = "scroll_down"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let bindings = cfg.keymap.get(&PageMode::Normal).unwrap();
        assert_eq!(bindings.get("j").unwrap().action, PageAction::ScrollDown(1));
    }

    #[test]
    fn keymap_binding_parses_count_variant() {
        let toml = r#"
[keymap.normal]
"5j" = "scroll_down(5)"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let bindings = cfg.keymap.get(&PageMode::Normal).unwrap();
        assert_eq!(
            bindings.get("5j").unwrap().action,
            PageAction::ScrollDown(5)
        );
    }

    #[test]
    fn keymap_binding_malformed_errors() {
        let toml = r#"
[keymap.normal]
"j" = "fly"
"#;
        let err = toml::from_str::<Config>(toml).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("fly") || msg.contains("unknown"));
    }

    #[test]
    fn validate_rejects_keymap_insert_section() {
        let toml = r#"
[keymap.insert]
"<F2>" = "tab_new"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        // The section parses — PageMode has an `insert` variant — so
        // validation is the only thing standing between the user and a
        // binding filed under the wrong mode.
        assert!(cfg.keymap.contains_key(&PageMode::Insert));
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, location } => {
                assert_eq!(message, INSERT_KEYMAP_REJECTED);
                assert_eq!(location.as_deref(), Some("keymap.insert"));
                // Actionable: names the section, says why, says the way out.
                assert!(message.contains("[keymap.insert]"), "{message}");
                assert!(
                    message.contains("forwards every key to the page"),
                    "{message}"
                );
                assert!(message.contains("<Esc>"), "{message}");
            }
            other => panic!("expected Validate error, got {other:?}"),
        }
    }

    #[test]
    fn validate_rejects_empty_keymap_binding_key() {
        // A7: `"" = "action"` parses to an empty chord slice and
        // installs the action at the trie ROOT, corrupting ambiguity
        // resolution (abandoned prefixes fire the root action on
        // timeout). Validation must reject it up front.
        let toml = r#"
[keymap.normal]
"" = "tab_new"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, location } => {
                assert!(
                    message.contains("empty binding key"),
                    "unexpected msg: {message}"
                );
                assert_eq!(location.as_deref(), Some("keymap.normal"));
            }
            other => panic!("expected Validate error, got {other:?}"),
        }
    }

    #[test]
    fn validate_accepts_the_other_keymap_modes() {
        // Companion to `validate_rejects_keymap_insert_section`: the
        // insert check must not be a blanket keymap rejection.
        let toml = r#"
[keymap.normal]
"j" = "scroll_down"

[keymap.visual]
"y" = "yank_selection"

[keymap.command]
"<Esc>" = "enter_mode(\"normal\")"

[keymap.hint]
"<Esc>" = "enter_mode(\"normal\")"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.keymap.len(), 4);
        validate(&cfg).expect("non-insert keymap sections must validate");
        // …and they actually build into a keymap.
        let km = build_keymap(&cfg).expect("build_keymap");
        assert!(matches!(
            km.lookup(PageMode::Visual, &buffr_modal::parse_keys("y").unwrap()),
            buffr_modal::Lookup::Match(PageAction::YankSelection)
        ));
    }

    #[test]
    fn build_keymap_refuses_insert_bindings() {
        // Defence in depth for the hot-reload path, which builds a
        // keymap from a config it just parsed.
        let toml = r#"
[keymap.insert]
"<F2>" = "tab_new"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        match build_keymap(&cfg).unwrap_err() {
            ConfigError::Validate { message, .. } => {
                assert!(message.contains("keymap.insert"), "{message}");
                assert!(message.contains("not bindable"), "{message}");
            }
            other => panic!("expected Validate error, got {other:?}"),
        }
    }

    #[test]
    fn keymap_find_with_forward_arg() {
        let toml = r#"
[keymap.normal]
"/" = "find(forward = true)"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        let bindings = cfg.keymap.get(&PageMode::Normal).unwrap();
        assert_eq!(
            bindings.get("/").unwrap().action,
            PageAction::Find { forward: true }
        );
    }

    #[test]
    fn downloads_section_defaults() {
        let cfg = Config::default();
        assert!(cfg.downloads.default_dir.is_none());
        assert!(!cfg.downloads.open_on_finish);
        assert!(!cfg.downloads.ask_each_time);
        assert!(cfg.downloads.show_notifications);
    }

    #[test]
    fn downloads_section_parses_from_toml() {
        let toml = r#"
[downloads]
default_dir = "/tmp/buffr-dl"
open_on_finish = true
ask_each_time = false
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(
            cfg.downloads.default_dir.as_deref(),
            Some(std::path::Path::new("/tmp/buffr-dl"))
        );
        assert!(cfg.downloads.open_on_finish);
        assert!(!cfg.downloads.ask_each_time);
        // show_notifications defaults to true when absent.
        assert!(cfg.downloads.show_notifications);
    }

    #[test]
    fn downloads_show_notifications_parses() {
        let toml = r#"
[downloads]
show_notifications = false
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(!cfg.downloads.show_notifications);
    }

    #[test]
    fn downloads_unknown_field_rejected() {
        let toml = r#"
[downloads]
weird = "nope"
"#;
        let err = toml::from_str::<Config>(toml).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("weird") || msg.contains("unknown"));
    }

    #[test]
    fn resolve_default_dir_uses_explicit_value() {
        let cfg = DownloadsConfig {
            default_dir: Some(PathBuf::from("/tmp/explicit")),
            ..Default::default()
        };
        assert_eq!(resolve_default_dir(&cfg), PathBuf::from("/tmp/explicit"));
    }

    #[test]
    fn resolve_default_dir_falls_back_to_user_dirs() {
        let cfg = DownloadsConfig::default();
        // We can't predict the exact path on every test host but the
        // call must produce *some* path — never panic, never empty.
        let p = resolve_default_dir(&cfg);
        assert!(!p.as_os_str().is_empty());
    }

    #[test]
    fn privacy_default_skip_schemes_contains_canonical_list() {
        let p = Privacy::default();
        for scheme in DEFAULT_SKIP_SCHEMES {
            assert!(
                p.skip_schemes.iter().any(|s| s == scheme),
                "default skip_schemes missing {scheme:?}"
            );
        }
        assert_eq!(p.skip_schemes.len(), DEFAULT_SKIP_SCHEMES.len());
    }

    #[test]
    fn privacy_skip_schemes_parses_from_toml() {
        let toml = r#"
[privacy]
skip_schemes = ["javascript", "blob"]
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.privacy.skip_schemes, vec!["javascript", "blob"]);
    }

    #[test]
    fn privacy_clear_on_exit_parses_known_variants() {
        let toml = r#"
[privacy]
enable_telemetry = false
clear_on_exit = ["cookies", "cache", "history", "bookmarks", "downloads", "local_storage"]
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(
            cfg.privacy.clear_on_exit,
            vec![
                ClearableData::Cookies,
                ClearableData::Cache,
                ClearableData::History,
                ClearableData::Bookmarks,
                ClearableData::Downloads,
                ClearableData::LocalStorage,
            ]
        );
    }

    #[test]
    fn privacy_clear_on_exit_unknown_variant_rejected() {
        let toml = r#"
[privacy]
clear_on_exit = ["everything"]
"#;
        let err = toml::from_str::<Config>(toml).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("everything") || msg.contains("unknown"));
    }

    #[test]
    fn hint_section_default() {
        let cfg = Config::default();
        assert_eq!(cfg.hint.alphabet, "asdfghjkl;weruio");
    }

    #[test]
    fn hint_section_parses_custom_alphabet() {
        let toml = r#"
[hint]
alphabet = "abcdef"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.hint.alphabet, "abcdef");
        validate(&cfg).unwrap();
    }

    #[test]
    fn hint_alphabet_empty_rejected() {
        let mut cfg = Config::default();
        cfg.hint.alphabet = String::new();
        let err = validate(&cfg).unwrap_err();
        assert!(matches!(err, ConfigError::Validate { .. }));
    }

    #[test]
    fn hint_alphabet_duplicate_rejected() {
        let mut cfg = Config::default();
        cfg.hint.alphabet = "abca".into();
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, .. } => assert!(message.contains("duplicate")),
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn hint_alphabet_non_ascii_rejected() {
        let mut cfg = Config::default();
        cfg.hint.alphabet = "αβγ".into();
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, .. } => assert!(message.contains("ASCII")),
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn hint_alphabet_single_char_rejected() {
        let mut cfg = Config::default();
        cfg.hint.alphabet = "a".into();
        let err = validate(&cfg).unwrap_err();
        assert!(matches!(err, ConfigError::Validate { .. }));
    }

    #[test]
    fn idle_inhibit_defaults() {
        let cfg = Config::default();
        assert!(cfg.idle_inhibit.enabled);
        assert!(!cfg.idle_inhibit.inhibit_audio_only);
        assert!(cfg.idle_inhibit.require_focus);
    }

    #[test]
    fn idle_inhibit_parses_from_toml() {
        let toml = r#"
[idle_inhibit]
enabled = false
inhibit_audio_only = true
require_focus = false
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(!cfg.idle_inhibit.enabled);
        assert!(cfg.idle_inhibit.inhibit_audio_only);
        assert!(!cfg.idle_inhibit.require_focus);
    }

    #[test]
    fn idle_inhibit_unknown_field_rejected() {
        let toml = r#"
[idle_inhibit]
weird = "nope"
"#;
        let err = toml::from_str::<Config>(toml).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("weird") || msg.contains("unknown"));
    }

    #[test]
    fn hint_unknown_field_rejected() {
        let toml = r#"
[hint]
weird = "x"
"#;
        let err = toml::from_str::<Config>(toml).unwrap_err();
        assert!(err.to_string().contains("weird") || err.to_string().contains("unknown"));
    }

    #[test]
    fn crash_reporter_section_default() {
        let cfg = Config::default();
        assert!(!cfg.crash_reporter.enabled);
        assert_eq!(cfg.crash_reporter.purge_after_days, 30);
    }

    #[test]
    fn crash_reporter_section_parses_from_toml() {
        let toml = r#"
[crash_reporter]
enabled = true
purge_after_days = 7
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert!(cfg.crash_reporter.enabled);
        assert_eq!(cfg.crash_reporter.purge_after_days, 7);
    }

    #[test]
    fn crash_reporter_unknown_field_rejected() {
        let toml = r#"
[crash_reporter]
weird = "x"
"#;
        let err = toml::from_str::<Config>(toml).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("weird") || msg.contains("unknown"));
    }

    #[test]
    fn full_config_roundtrip_parses() {
        let toml = r##"
[general]
homepage = "https://example.com"
leader = "\\"
show_favicons = true

[startup]
restore_session = false
new_tab_url = "about:blank"

[search]
default_engine = "duckduckgo"

[search.engines.duckduckgo]
url = "https://duckduckgo.com/?q={query}"

[theme]
accent = "#7aa2f7"
cert_secure = "#66e08a"
cert_insecure = "#e05a5a"
private = "#ffc8c8"
progress = "#66c2ff"
update = "#e0c85a"

[privacy]
enable_telemetry = false
clear_on_exit = []
skip_schemes = ["about", "cef", "chrome", "data", "file"]

[downloads]
open_on_finish = false
ask_each_time = false

[keymap.normal]
"j" = "scroll_down"
"k" = "scroll_up"
"gg" = "scroll_top"
"##;
        let cfg: Config = toml::from_str(toml).unwrap();
        validate(&cfg).unwrap();
        assert_eq!(cfg.general.homepage, "https://example.com");
    }

    // ── engines section ───────────────────────────────────────────────────────

    #[test]
    fn config_engines_default_is_cef_when_omitted() {
        let cfg = Config::default();
        assert_eq!(cfg.engines.default, "cef");
        assert!(cfg.engines.rules.is_empty());
    }

    #[test]
    fn config_engines_rules_parse() {
        let toml = r#"
[engines]
default = "cef"

[[engines.rules]]
match = "*.figma.com"
engine = "webkit"

[[engines.rules]]
match = "example.com"
engine = "cef"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.engines.default, "cef");
        assert_eq!(cfg.engines.rules.len(), 2);
        assert_eq!(cfg.engines.rules[0].pattern, "*.figma.com");
        assert_eq!(cfg.engines.rules[0].engine, "webkit");
        assert_eq!(cfg.engines.rules[1].pattern, "example.com");
        assert_eq!(cfg.engines.rules[1].engine, "cef");
    }

    #[test]
    fn config_engines_round_trip() {
        let mut cfg = Config::default();
        cfg.engines.rules.push(crate::EngineRule {
            pattern: "*.figma.com".into(),
            engine: "webkit".into(),
        });
        let s = to_toml_string(&cfg).unwrap();
        let parsed: Config = toml::from_str(&s).unwrap();
        assert_eq!(parsed.engines, cfg.engines);
    }

    #[test]
    fn config_engines_load_rejects_empty_default() {
        let mut cfg = Config::default();
        cfg.engines.default = String::new();
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, location } => {
                assert!(message.contains("engines.default"), "msg: {message}");
                assert_eq!(location.as_deref(), Some("engines.default"));
            }
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn config_engines_load_rejects_empty_rule_pattern() {
        let mut cfg = Config::default();
        cfg.engines.rules.push(crate::EngineRule {
            pattern: String::new(),
            engine: "cef".into(),
        });
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, .. } => {
                assert!(message.contains("match"), "msg: {message}");
            }
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn config_engines_load_rejects_empty_rule_engine() {
        let mut cfg = Config::default();
        cfg.engines.rules.push(crate::EngineRule {
            pattern: "*.example.com".into(),
            engine: String::new(),
        });
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, .. } => {
                assert!(message.contains("engine"), "msg: {message}");
            }
            _ => panic!("expected Validate error"),
        }
    }

    // ── engines.instances section (Phase 3) ───────────────────────────────────

    #[test]
    fn engines_instances_default_synthesized_when_empty() {
        let cfg = Config::default();
        assert!(cfg.engines.instances.is_empty(), "raw list is empty");
        let eff = cfg.engines.effective_instances();
        assert_eq!(eff.len(), 1);
        assert_eq!(eff[0].id, "cef");
        assert_eq!(eff[0].backend, "cef");
        assert!(eff[0].data_dir.is_none());
    }

    #[test]
    fn engines_instances_explicit_list_returned_unchanged() {
        let toml = r#"
[engines]
default = "cef-a"

[[engines.instances]]
id      = "cef-a"
backend = "cef"

[[engines.instances]]
id      = "cef-b"
backend = "cef"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        validate(&cfg).unwrap();
        assert_eq!(cfg.engines.instances.len(), 2);
        let eff = cfg.engines.effective_instances();
        assert_eq!(eff.len(), 2);
        assert_eq!(eff[0].id, "cef-a");
        assert_eq!(eff[1].id, "cef-b");
    }

    #[test]
    fn engines_default_must_reference_known_instance() {
        let mut cfg = Config::default();
        cfg.engines.instances.push(crate::EngineInstance {
            id: "cef-a".into(),
            backend: "cef".into(),
            data_dir: None,
        });
        cfg.engines.default = "unknown".into();
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, location } => {
                assert!(message.contains("unknown"), "msg: {message}");
                assert_eq!(location.as_deref(), Some("engines.default"));
            }
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn engines_rule_engine_must_reference_known_instance() {
        let mut cfg = Config::default();
        cfg.engines.instances.push(crate::EngineInstance {
            id: "cef".into(),
            backend: "cef".into(),
            data_dir: None,
        });
        cfg.engines.rules.push(crate::EngineRule {
            pattern: "*.example.com".into(),
            engine: "webkit".into(), // not declared
        });
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, .. } => {
                assert!(message.contains("webkit"), "msg: {message}");
            }
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn engines_duplicate_instance_id_rejected() {
        let mut cfg = Config::default();
        cfg.engines.instances.push(crate::EngineInstance {
            id: "cef".into(),
            backend: "cef".into(),
            data_dir: None,
        });
        cfg.engines.instances.push(crate::EngineInstance {
            id: "cef".into(), // duplicate
            backend: "cef".into(),
            data_dir: None,
        });
        let err = validate(&cfg).unwrap_err();
        match err {
            ConfigError::Validate { message, .. } => {
                assert!(message.contains("duplicate"), "msg: {message}");
            }
            _ => panic!("expected Validate error"),
        }
    }

    #[test]
    fn engines_instance_data_dir_parses() {
        let toml = r#"
[engines]
default = "cef"

[[engines.instances]]
id       = "cef"
backend  = "cef"
data_dir = "/tmp/cef-cache"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        validate(&cfg).unwrap();
        assert_eq!(
            cfg.engines.instances[0].data_dir.as_deref(),
            Some("/tmp/cef-cache")
        );
    }

    #[test]
    fn engines_instance_round_trip() {
        let mut cfg = Config::default();
        cfg.engines.instances.push(crate::EngineInstance {
            id: "cef".into(),
            backend: "cef".into(),
            data_dir: Some("/tmp/test".into()),
        });
        let s = to_toml_string(&cfg).unwrap();
        let parsed: Config = toml::from_str(&s).unwrap();
        assert_eq!(parsed.engines.instances.len(), 1);
        assert_eq!(
            parsed.engines.instances[0].data_dir.as_deref(),
            Some("/tmp/test")
        );
    }

    // ── Additional engines tests ──────────────────────────────────────────────

    #[test]
    fn engines_serialize_round_trips_through_toml() {
        // Build a config with a non-trivial engines section, serialize,
        // parse back and assert equality.
        let mut cfg = Config::default();
        cfg.engines.instances.push(crate::EngineInstance {
            id: "cef".into(),
            backend: "cef".into(),
            data_dir: None,
        });
        cfg.engines.instances.push(crate::EngineInstance {
            id: "wpe".into(),
            backend: "webkit".into(),
            data_dir: Some("/tmp/wpe-data".into()),
        });
        cfg.engines.default = "cef".into();
        cfg.engines.rules.push(crate::EngineRule {
            pattern: "*.figma.com".into(),
            engine: "wpe".into(),
        });

        let toml_str = to_toml_string(&cfg).unwrap();
        let parsed: Config = toml::from_str(&toml_str).unwrap();

        assert_eq!(parsed.engines.default, cfg.engines.default);
        assert_eq!(parsed.engines.instances.len(), 2);
        assert_eq!(parsed.engines.instances[0].id, "cef");
        assert_eq!(parsed.engines.instances[1].id, "wpe");
        assert_eq!(parsed.engines.instances[1].backend, "webkit");
        assert_eq!(
            parsed.engines.instances[1].data_dir.as_deref(),
            Some("/tmp/wpe-data")
        );
        assert_eq!(parsed.engines.rules.len(), 1);
        assert_eq!(parsed.engines.rules[0].pattern, "*.figma.com");
        assert_eq!(parsed.engines.rules[0].engine, "wpe");
        assert_eq!(parsed.engines, cfg.engines);
    }

    #[test]
    fn engines_rule_field_is_match_in_toml() {
        // The Rust struct field `pattern` must serialize to `match` in TOML.
        let mut cfg = Config::default();
        cfg.engines.rules.push(crate::EngineRule {
            pattern: "*.example.com".into(),
            engine: "cef".into(),
        });
        let toml_str = to_toml_string(&cfg).unwrap();

        // The serialized TOML must contain `match = "*.example.com"` not
        // `pattern = "*.example.com"`.
        assert!(
            toml_str.contains(r#"match = "*.example.com""#),
            "expected `match` key in TOML, got:\n{toml_str}"
        );
        assert!(
            !toml_str.contains("pattern ="),
            "`pattern` must not appear in TOML output"
        );
    }

    #[test]
    fn engines_rule_match_key_parses_correctly() {
        // Verify that the `match` key in TOML is read back as `pattern` in Rust.
        let toml = r#"
[engines]
default = "cef"

[[engines.rules]]
match = "*.example.com"
engine = "cef"
"#;
        let cfg: Config = toml::from_str(toml).unwrap();
        assert_eq!(cfg.engines.rules[0].pattern, "*.example.com");
        assert_eq!(cfg.engines.rules[0].engine, "cef");
    }
}
