// config.rs --- User configuration loader (T M2.10).

//! Loads `~/.config/pmacs/init.lua` on startup.
//!
//! The contract has three pieces:
//!
//! 1. **Missing config is not an error.** If neither
//!    `$XDG_CONFIG_HOME/pmacs/init.lua` nor `$HOME/.config/pmacs/init.lua`
//!    exists, the editor starts as if no config had been requested.
//! 2. **Broken config does not prevent startup.** If the file exists
//!    but the chunk fails to parse or raises at runtime, the error is
//!    captured (visible in the `*errors*` buffer and in the status
//!    line) but the editor still launches with the builtin defaults.
//! 3. **`require` resolves against the config dir** so a user's
//!    `init.lua` can split across multiple files. We do not ship a
//!    package manager (per spec); resolution is the conventional Lua
//!    `package.path` extension.
//!
//! # Testability
//!
//! Production callers use the no-arg [`load_user_config`], which reads
//! `XDG_CONFIG_HOME`/`HOME` from the live environment. Tests --- which
//! must not mutate the process environment, both because pmacs forbids
//! `unsafe` (and `set_var` is `unsafe` in edition 2024) and because
//! parallel test threads would race --- pass an explicit directory via
//! [`load_user_config_at`].

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

use mlua::Lua;

use crate::lua::LuaHost;

/// File name of the user's main config chunk.
pub const INIT_FILE: &str = "init.lua";

/// Subdirectory under the config root that pmacs owns.
pub const CONFIG_SUBDIR: &str = "pmacs";

/// Resolve the user's config directory from the live environment.
///
/// `$XDG_CONFIG_HOME/pmacs` if set and non-empty, else
/// `$HOME/.config/pmacs`. Returns `None` if neither environment
/// variable is set --- in that case there is no place for pmacs to
/// look, and config loading is silently skipped.
#[must_use]
pub fn user_config_dir() -> Option<PathBuf> {
    resolve_config_dir(
        std::env::var_os("XDG_CONFIG_HOME").as_deref(),
        std::env::var_os("HOME").as_deref(),
    )
}

/// Pure resolution rule, factored out so tests can drive both arms
/// without touching the process environment.
#[must_use]
pub fn resolve_config_dir(
    xdg: Option<&std::ffi::OsStr>,
    home: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
    if let Some(xdg) = xdg
        && !xdg.is_empty()
    {
        return Some(PathBuf::from(xdg).join(CONFIG_SUBDIR));
    }
    let home = home?;
    Some(PathBuf::from(home).join(".config").join(CONFIG_SUBDIR))
}

/// Path to the user's `init.lua`, if a config dir is resolvable.
#[must_use]
pub fn user_config_path() -> Option<PathBuf> {
    user_config_dir().map(|d| d.join(INIT_FILE))
}

/// Append the user's config directory to Lua's `package.path` so
/// `require("name")` finds `dir/name.lua` and `dir/name/init.lua`.
///
/// # Errors
///
/// Returns the underlying [`mlua::Error`] if the package table is
/// missing or the assignment fails. Both indicate a corrupt Lua state.
pub fn install_package_path(lua: &Lua, dir: &Path) -> mlua::Result<()> {
    let dir_str = dir.display().to_string();
    let pkg: mlua::Table = lua.globals().get("package")?;
    let existing: String = pkg.get("path").unwrap_or_default();
    let prepended = format!("{dir_str}/?.lua;{dir_str}/?/init.lua;{existing}");
    pkg.set("path", prepended)?;
    Ok(())
}

/// Load and evaluate the user's `init.lua` from the live environment.
///
/// Equivalent to [`load_user_config_at`] applied to the directory
/// returned by [`user_config_dir`]. A no-op if no config directory is
/// resolvable.
pub fn load_user_config(host: &mut LuaHost) {
    if let Some(dir) = user_config_dir() {
        load_user_config_at(host, &dir);
    }
}

/// Load and evaluate `dir/init.lua` if it exists, after registering
/// `dir` on the Lua `package.path`.
///
/// All failures (file missing, read I/O failure, Lua parse or runtime
/// error) are non-fatal. Lua errors flow through [`LuaHost::eval`]
/// into the host's error log and the `*errors*` buffer; I/O errors
/// are silently skipped --- the editor still starts.
pub fn load_user_config_at(host: &mut LuaHost, dir: &Path) {
    // Even if init.lua is absent, register the directory on
    // package.path so a user who later writes `require` chunks at
    // runtime can resolve them against their config dir without
    // having to reset the path.
    let _ = install_package_path(host.lua(), dir);

    let init = dir.join(INIT_FILE);
    let source = match std::fs::read_to_string(&init) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
        Err(_) => {
            // Other I/O errors (permission denied, unreadable, etc.)
            // are also non-fatal --- a user who's misconfigured their
            // home directory shouldn't lose the editor over it.
            return;
        }
    };
    // The leading `@` follows Lua's debug-info convention for a chunk
    // loaded from a file: stack traces show the path verbatim.
    let label = format!("@{}", init.display());
    let _ = host.eval(Some(&label), &source);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn xdg_config_home_takes_priority() {
        let xdg = OsString::from("/some/xdg");
        let home = OsString::from("/should/not/be/used");
        let dir = resolve_config_dir(Some(&xdg), Some(&home)).unwrap();
        assert_eq!(dir, PathBuf::from("/some/xdg/pmacs"));
    }

    #[test]
    fn empty_xdg_falls_through_to_home() {
        let xdg = OsString::from("");
        let home = OsString::from("/home/test");
        let dir = resolve_config_dir(Some(&xdg), Some(&home)).unwrap();
        assert_eq!(dir, PathBuf::from("/home/test/.config/pmacs"));
    }

    #[test]
    fn missing_xdg_falls_back_to_home_dot_config() {
        let home = OsString::from("/home/test");
        let dir = resolve_config_dir(None, Some(&home)).unwrap();
        assert_eq!(dir, PathBuf::from("/home/test/.config/pmacs"));
    }

    #[test]
    fn neither_set_yields_none() {
        assert!(resolve_config_dir(None, None).is_none());
    }

    #[test]
    fn missing_init_lua_is_a_silent_noop() {
        let dir = tempfile::TempDir::new().unwrap();
        let mut host = LuaHost::new().unwrap();
        load_user_config_at(&mut host, dir.path());
        assert!(host.errors().is_empty(), "no config -> no errors");
    }

    #[test]
    fn broken_init_lua_is_captured_and_does_not_panic() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(dir.path().join(INIT_FILE), "this is not valid lua )").unwrap();

        let mut host = LuaHost::new().unwrap();
        load_user_config_at(&mut host, dir.path());

        let last = host.last_error().expect("error captured");
        assert!(last.source.as_deref().unwrap().contains("init.lua"));
        // Errors-buffer is populated.
        let id = host.errors_buffer_id().expect("buffer exists");
        let reg = host.registry().borrow();
        let buf = reg.get(id).unwrap();
        assert!(!buf.is_empty(), "errors buffer should not be empty");
    }

    #[test]
    fn runtime_error_in_init_lua_is_captured_with_path() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(dir.path().join(INIT_FILE), "error('user broke it')").unwrap();

        let mut host = LuaHost::new().unwrap();
        load_user_config_at(&mut host, dir.path());

        let last = host.last_error().expect("error captured");
        assert!(last.message.contains("user broke it"));
        assert!(last.source.as_deref().unwrap().contains("init.lua"));
    }

    #[test]
    fn valid_init_lua_runs_and_can_set_globals() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(dir.path().join(INIT_FILE), "USER_FLAG = 42").unwrap();

        let mut host = LuaHost::new().unwrap();
        load_user_config_at(&mut host, dir.path());
        assert!(host.errors().is_empty());
        let v: i64 = host.lua().globals().get("USER_FLAG").unwrap();
        assert_eq!(v, 42);
    }

    #[test]
    fn package_path_includes_config_dir() {
        let lua = Lua::new();
        install_package_path(&lua, Path::new("/cfg")).unwrap();
        let p: String = lua
            .globals()
            .get::<mlua::Table>("package")
            .unwrap()
            .get("path")
            .unwrap();
        assert!(
            p.starts_with("/cfg/?.lua;/cfg/?/init.lua;"),
            "package.path: {p}"
        );
    }

    #[test]
    fn user_init_can_rebind_a_key_in_under_five_lines() {
        // Acceptance: a user-written config of fewer than five lines can
        // rebind a key. Demonstrate via the editor's own attach_editor
        // path so the keymap is the real default keymap.
        use crate::editor_core::EditorCore;
        use std::cell::RefCell;
        use std::rc::Rc;

        let dir = tempfile::TempDir::new().unwrap();
        // Three lines: blank, comment, two statements via single-line each.
        // Two statements over two lines = 2 lines total. Well under 5.
        std::fs::write(
            dir.path().join(INIT_FILE),
            "pmacs.keymap.unbind { scope = 'global', sequence = 'C-a' }\n\
             pmacs.keymap.bind { scope = 'global', sequence = 'C-a', command = 'editor.cancel' }\n",
        )
        .unwrap();

        let registry: crate::lua_bindings::SharedRegistry =
            Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
        let core = Rc::new(RefCell::new(EditorCore::new(registry.clone())));
        let mut host = LuaHost::with_registry(registry).unwrap();
        host.attach_editor(&core).expect("attach builtins");
        load_user_config_at(&mut host, dir.path());
        assert!(host.errors().is_empty(), "errors: {:?}", host.errors());

        // After the override, C-a now resolves to editor.cancel.
        let kms = host.keymaps().borrow();
        let chord = crate::key::parse_sequence("C-a").unwrap();
        match kms.resolve(&chord, None, &[]) {
            crate::keymap_stack::StackResolution::Bound(rb) => {
                assert_eq!(rb.binding.command, "editor.cancel");
            }
            other => panic!("expected Bound, got {other:?}"),
        }
    }
}
