//! Daemon configuration loaded from `~/.spacesh/config.toml`.
//!
//! The file is optional; every field has a sane fallback so a missing or
//! partial config never breaks startup.

use std::path::Path;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct TerminalConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub font_family: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub font_size: Option<u16>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct AppearanceConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub theme: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub accent: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Config {
    /// Shell launched for plain (no-command) panels. When unset, the daemon
    /// auto-detects the user's login shell.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_shell: Option<String>,
    #[serde(default)]
    pub terminal: TerminalConfig,
    #[serde(default)]
    pub appearance: AppearanceConfig,
}

impl Config {
    /// Resolve to a client-facing view, applying defaults and the shell resolver.
    pub fn to_view(&self) -> spacesh_proto::config_view::ConfigView {
        spacesh_proto::config_view::ConfigView {
            default_shell: self.resolved_shell(),
            font_family: self.terminal.font_family.clone().unwrap_or_else(|| "JetBrains Mono".into()),
            font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20),
            theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()),
            accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()),
        }
    }

    /// Shell for a plain panel using THIS in-memory config
    /// (env -> config -> passwd -> $SHELL -> /bin/sh).
    pub fn resolved_shell(&self) -> String {
        if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } }
        if let Some(s) = &self.default_shell { if !s.is_empty() { return s.clone(); } }
        if let Some(s) = login_shell() { return s; }
        if let Ok(s) = std::env::var("SHELL") { if !s.is_empty() { return s; } }
        "/bin/sh".into()
    }

    /// Load `~/.spacesh/config.toml`. Any error (missing file, bad TOML) yields defaults.
    pub fn load() -> Self {
        let Ok(dir) = crate::lifecycle::spacesh_dir() else { return Self::default() };
        Self::from_path(&dir.join("config.toml"))
    }

    pub fn from_path(path: &Path) -> Self {
        match std::fs::read_to_string(path) {
            Ok(s) => toml::from_str(&s).unwrap_or_default(),
            Err(_) => Self::default(),
        }
    }

    /// Persist to `~/.spacesh/config.toml`.
    pub fn save(&self) -> std::io::Result<()> {
        let dir = crate::lifecycle::spacesh_dir()
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
        self.save_to(&dir.join("config.toml"))
    }

    /// Persist to an arbitrary path. Creates the parent directory if needed.
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let s = toml::to_string_pretty(self)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
        std::fs::write(path, s)
    }
}

/// Resolve the shell to spawn for a plain panel.
///
/// Order: `SPACESH_SHELL` env → `config.toml` `default_shell` → login shell
/// from the passwd DB → `$SHELL` → `/bin/sh`. The passwd lookup matters under
/// launchd, where `$SHELL` is typically absent (so a bash fallback would win).
// retained for the env-override test and potential startup use
#[allow(dead_code)]
pub fn default_shell() -> String {
    if let Ok(s) = std::env::var("SPACESH_SHELL") {
        if !s.is_empty() { return s; }
    }
    if let Some(s) = Config::load().default_shell {
        if !s.is_empty() { return s; }
    }
    if let Some(s) = login_shell() {
        return s;
    }
    if let Ok(s) = std::env::var("SHELL") {
        if !s.is_empty() { return s; }
    }
    "/bin/sh".into()
}

/// The current user's login shell from the passwd database (`getpwuid`).
#[cfg(unix)]
fn login_shell() -> Option<String> {
    use std::ffi::CStr;
    // SAFETY: getpwuid returns a pointer into a static buffer valid until the
    // next libc passwd call; we copy the string out immediately on this thread.
    unsafe {
        let pw = libc::getpwuid(libc::getuid());
        if pw.is_null() { return None; }
        let shell = (*pw).pw_shell;
        if shell.is_null() { return None; }
        let s = CStr::from_ptr(shell).to_str().ok()?;
        if s.is_empty() { None } else { Some(s.to_string()) }
    }
}

#[cfg(not(unix))]
fn login_shell() -> Option<String> { None }

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

    #[test]
    fn missing_file_is_default() {
        let c = Config::from_path(Path::new("/no/such/spacesh/config.toml"));
        assert!(c.default_shell.is_none());
    }

    #[test]
    fn parses_default_shell() {
        let dir = std::env::temp_dir().join("spacesh-cfg-test");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("config.toml");
        std::fs::write(&path, "default_shell = \"/bin/zsh\"\n").unwrap();
        let c = Config::from_path(&path);
        assert_eq!(c.default_shell.as_deref(), Some("/bin/zsh"));
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn env_override_wins() {
        let _serial = crate::test_support::serial();
        std::env::set_var("SPACESH_SHELL", "/tmp/fake-shell");
        let s = default_shell();
        std::env::remove_var("SPACESH_SHELL");
        assert_eq!(s, "/tmp/fake-shell");
    }

    #[test]
    fn parses_terminal_and_appearance() {
        let dir = std::env::temp_dir().join(format!("spacesh-cfg-sections-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("config.toml");
        std::fs::write(&path,
            "default_shell = \"/bin/zsh\"\n[terminal]\nfont_family = \"Menlo\"\nfont_size = 15\n[appearance]\ntheme = \"light\"\naccent = \"teal\"\n").unwrap();
        let c = Config::from_path(&path);
        assert_eq!(c.terminal.font_family.as_deref(), Some("Menlo"));
        assert_eq!(c.terminal.font_size, Some(15));
        assert_eq!(c.appearance.theme.as_deref(), Some("light"));
        assert_eq!(c.appearance.accent.as_deref(), Some("teal"));
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn missing_sections_default() {
        let c = Config::from_path(Path::new("/no/such/cfg.toml"));
        assert!(c.terminal.font_family.is_none());
        assert!(c.appearance.theme.is_none());
    }

    #[test]
    fn to_view_applies_defaults_and_clamp() {
        let mut c = Config::default();
        c.terminal.font_size = Some(99);
        let v = c.to_view();
        assert_eq!(v.font_size, 20);
        assert_eq!(v.theme, "dark");
        assert_eq!(v.accent, "blue");
        assert!(!v.font_family.is_empty());
    }

    #[test]
    fn save_then_reload_round_trips() {
        let dir = std::env::temp_dir().join(format!("spacesh-cfg-save-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("config.toml");
        let mut c = Config::default();
        c.terminal.font_size = Some(14);
        c.appearance.accent = Some("purple".into());
        c.save_to(&path).unwrap();
        let back = Config::from_path(&path);
        assert_eq!(back.terminal.font_size, Some(14));
        assert_eq!(back.appearance.accent.as_deref(), Some("purple"));
        let _ = std::fs::remove_file(&path);
    }
}
