//! Editor-wide preferences persistence on disk.
//!
//! The Stage-6 [`Settings`](oxide_engine::settings::Settings) framework
//! defines *what* is persisted (named sections, each owning a typed value).
//! This module defines *where* — the user-scoped file the editor reads on
//! startup and writes on every change, so a binding remap or theme tweak
//! survives a restart.
//!
//! # Location
//!
//! Linux: `$XDG_CONFIG_HOME/oxide/editor.ron`, falling back to
//! `$HOME/.config/oxide/editor.ron`. The directory is created on demand;
//! the path is the same one a Windows port would use once Stage-16 ships
//! game export (Windows resolution lands then, not here).
//!
//! # Format
//!
//! The file is exactly the RON map [`Settings::export`] produces:
//! `{ "section.name": "(field: value, …)", … }`. Each value is itself a
//! RON-encoded string of that section's typed value. Loading does no
//! schema validation — unknown sections are skipped by `Settings::import`,
//! so removing a section in code never breaks an old file.

use std::collections::BTreeMap;
use std::ffi::OsString;
use std::io;
use std::path::PathBuf;

/// Resolves the absolute path to the editor's preferences file, or `None`
/// if the OS provides no usable home / config directory (a stripped-down
/// container, an unusual launcher environment, …).
pub fn config_path() -> Option<PathBuf> {
    resolve_config_path(|k| std::env::var_os(k))
}

/// Resolution rules, factored so tests can inject env state without racing
/// on the real process environment. Returns the first of:
///
/// 1. `$XDG_CONFIG_HOME/oxide/editor.ron`
/// 2. `$HOME/.config/oxide/editor.ron`
/// 3. `None` if neither is set.
fn resolve_config_path(env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
    let base = env("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config")))?;
    Some(base.join("oxide").join("editor.ron"))
}

/// Loads the preferences file, returning the same `BTreeMap` shape
/// [`Settings::import`](oxide_engine::settings::Settings::import) consumes.
///
/// Returns `None` when no file exists yet (a fresh install) or it can't be
/// parsed — both cases are silently treated as "no saved preferences" so
/// the editor falls back to the code-defined defaults. A returned `Some`
/// is the file's contents verbatim; the caller decides what to import.
pub fn load() -> Option<BTreeMap<String, String>> {
    let path = config_path()?;
    let text = std::fs::read_to_string(&path).ok()?;
    ron::from_str(&text).ok()
}

/// Writes `map` to the preferences file, creating the parent directory if
/// necessary. The map is the output of
/// [`Settings::export`](oxide_engine::settings::Settings::export); the
/// editor calls this from the host runner whenever a binding edit or
/// other settings change flips a dirty flag.
pub fn save(map: &BTreeMap<String, String>) -> io::Result<()> {
    let path = config_path().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::NotFound,
            "no $XDG_CONFIG_HOME or $HOME — cannot resolve editor preferences path",
        )
    })?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let text = ron::ser::to_string_pretty(map, ron::ser::PrettyConfig::default())
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    std::fs::write(path, text)
}

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

    /// A throw-away env stub built from a closure — keeps each test free of
    /// process-global env mutation, so the suite can run in parallel.
    fn env<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
        move |k| {
            map.iter()
                .find(|(kk, _)| *kk == k)
                .map(|(_, v)| OsString::from(*v))
        }
    }

    #[test]
    fn config_path_uses_xdg_when_set() {
        let p = resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x")])).unwrap();
        assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
    }

    #[test]
    fn config_path_prefers_xdg_over_home_when_both_set() {
        let p =
            resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x"), ("HOME", "/tmp/h")])).unwrap();
        assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
    }

    #[test]
    fn config_path_falls_back_to_home_dot_config() {
        let p = resolve_config_path(env(&[("HOME", "/tmp/h")])).unwrap();
        assert_eq!(p, PathBuf::from("/tmp/h/.config/oxide/editor.ron"));
    }

    #[test]
    fn config_path_is_none_when_no_env_available() {
        let p = resolve_config_path(env(&[]));
        assert!(p.is_none());
    }

    #[test]
    fn save_then_load_round_trips_the_exported_map() {
        // Direct file I/O test that doesn't go through config_path — write
        // to a temp file with a known shape and confirm the RON round-trip
        // matches what `Settings::export` produces.
        let scratch = std::env::temp_dir().join(format!(
            "oxide_editor_prefs_roundtrip_{}.ron",
            std::process::id()
        ));
        let _ = std::fs::remove_file(&scratch);

        let mut map = BTreeMap::new();
        map.insert(
            "input.bindings".to_string(),
            "(bindings: {\"Jump\": [Key(KeyW)]})".to_string(),
        );
        let text = ron::ser::to_string_pretty(&map, ron::ser::PrettyConfig::default()).unwrap();
        std::fs::write(&scratch, &text).unwrap();

        let read_back = std::fs::read_to_string(&scratch).unwrap();
        let parsed: BTreeMap<String, String> = ron::from_str(&read_back).unwrap();
        assert_eq!(parsed, map);

        let _ = std::fs::remove_file(&scratch);
    }
}
