//! 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>,
    /// Background-theme name (Warp-style full-window fill). "none" = solid.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub background: Option<String>,
    /// Absolute path to a custom background image (used when background == "custom").
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub background_image: Option<String>,
}

/// Built-in resume args for known agents, used when config has no override.
/// (command basename, resume args)
const DEFAULT_RESUME: &[(&str, &[&str])] = &[
    ("claude", &["--continue"]),
    ("codex", &["resume"]),
];

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ResumeConfig {
    /// command basename -> args that continue its previous session.
    #[serde(default)]
    pub commands: std::collections::HashMap<String, Vec<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,
    #[serde(default)]
    pub resume: ResumeConfig,
    /// How often (seconds) the daemon dumps changed grids to disk.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub snapshot_interval_secs: Option<u64>,
    /// Log/notify shell-command status (OSC 133 / fallback) in plain panels.
    /// Off by default — only agent activity (claude/codex/… hooks) is logged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub log_shell_commands: Option<bool>,
}

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()),
            background: self.appearance.background.clone().unwrap_or_else(|| "none".into()),
            background_image: self.appearance.background_image.clone().unwrap_or_default(),
            log_shell_commands: self.log_shell_commands(),
        }
    }

    /// Whether shell-command status events are logged (default false).
    pub fn log_shell_commands(&self) -> bool {
        self.log_shell_commands.unwrap_or(false)
    }

    /// 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)
    }

    /// Resume args for a command, by basename: user map → built-in default → None.
    pub fn resume_args(&self, command: &str) -> Option<Vec<String>> {
        let base = std::path::Path::new(command)
            .file_name()
            .map(|s| s.to_string_lossy().to_string())
            .unwrap_or_else(|| command.to_string());
        if let Some(args) = self.resume.commands.get(&base) {
            return Some(args.clone());
        }
        DEFAULT_RESUME.iter()
            .find(|(name, _)| *name == base)
            .map(|(_, args)| args.iter().map(|s| s.to_string()).collect())
    }

    /// Snapshot dump cadence in seconds (config → default 5, clamped to [1, 3600]).
    pub fn snapshot_interval_secs(&self) -> u64 {
        self.snapshot_interval_secs.unwrap_or(5).clamp(1, 3600)
    }
}

/// 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 user's full PATH for spawning panels: their login-shell PATH (which sources
/// `.zprofile`/`.zshrc`), merged with the daemon's current PATH and common install
/// dirs. Cached for the daemon's lifetime.
///
/// Why: when the GUI launches the daemon (Finder/launchd), the inherited PATH is
/// minimal (`/usr/bin:/bin:…`), so agents like `claude`, `codex`, `gemini` — installed
/// in `~/.local/bin`, npm-global, or Homebrew — aren't found and the panel exits
/// immediately with "Process exited". A bare `/bin/zsh` still works, which is why
/// shells launched fine but agents didn't.
pub fn enriched_path() -> String {
    use std::collections::HashSet;
    use std::sync::OnceLock;
    static CACHE: OnceLock<String> = OnceLock::new();
    CACHE
        .get_or_init(|| {
            let mut dirs: Vec<String> = Vec::new();
            let mut seen: HashSet<String> = HashSet::new();
            let mut merge = |src: &str| {
                for d in src.split(':') {
                    if !d.is_empty() && seen.insert(d.to_string()) {
                        dirs.push(d.to_string());
                    }
                }
            };
            if let Some(p) = login_shell_path() {
                merge(&p);
            }
            if let Ok(p) = std::env::var("PATH") {
                merge(&p);
            }
            merge(&fallback_path_dirs());
            dirs.join(":")
        })
        .clone()
}

/// Whether `cmd` is an executable resolvable on the spawn PATH (or an existing path
/// if it contains a slash). Used to only offer agents the user actually has installed.
pub fn is_installed(cmd: &str) -> bool {
    use std::os::unix::fs::PermissionsExt;
    if cmd.is_empty() {
        return false;
    }
    let is_exec = |p: &std::path::Path| {
        p.metadata().map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)
    };
    if cmd.contains('/') {
        return is_exec(std::path::Path::new(cmd));
    }
    enriched_path().split(':').any(|dir| !dir.is_empty() && is_exec(&std::path::Path::new(dir).join(cmd)))
}

/// Common locations user-installed CLIs land in, as a colon-joined fallback.
fn fallback_path_dirs() -> String {
    let mut v = vec![
        "/opt/homebrew/bin".to_string(),
        "/usr/local/bin".to_string(),
        "/usr/bin".to_string(),
        "/bin".to_string(),
        "/usr/sbin".to_string(),
        "/sbin".to_string(),
    ];
    if let Ok(home) = std::env::var("HOME") {
        if !home.is_empty() {
            for d in [".local/bin", ".npm-global/bin", ".cargo/bin", ".bun/bin", ".deno/bin", ".volta/bin", "go/bin"] {
                v.push(format!("{home}/{d}"));
            }
        }
    }
    v.join(":")
}

/// Capture PATH from the user's login+interactive shell so rc-file PATH edits apply.
/// Parses `env` output (the exported PATH is colon-joined regardless of shell, so this
/// works for fish too, where `$PATH` would otherwise print space-separated).
#[cfg(unix)]
fn login_shell_path() -> Option<String> {
    let shell = login_shell().or_else(|| std::env::var("SHELL").ok())?;
    let out = std::process::Command::new(&shell)
        .args(["-lic", "env"])
        .output()
        .ok()?;
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .find_map(|l| l.strip_prefix("PATH="))
        .map(|p| p.trim().to_string())
        .filter(|p| !p.is_empty())
}

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

/// 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);
    }

    #[test]
    fn resume_args_user_then_default_then_none() {
        let mut c = Config::default();
        // built-in defaults present without any config
        assert_eq!(c.resume_args("claude").as_deref(), Some(&["--continue".to_string()][..]));
        assert_eq!(c.resume_args("codex").as_deref(), Some(&["resume".to_string()][..]));
        // a path is reduced to its basename before lookup
        assert_eq!(c.resume_args("/usr/local/bin/claude").as_deref(), Some(&["--continue".to_string()][..]));
        // unknown command → None
        assert_eq!(c.resume_args("bash"), None);
        // user override wins over the default
        c.resume.commands.insert("claude".into(), vec!["--resume".into(), "last".into()]);
        assert_eq!(c.resume_args("claude"), Some(vec!["--resume".into(), "last".into()]));
    }

    #[test]
    fn snapshot_interval_defaults_to_5s() {
        let c = Config::default();
        assert_eq!(c.snapshot_interval_secs(), 5);
    }

    #[test]
    fn parses_resume_table_and_interval() {
        let dir = std::env::temp_dir().join(format!("spacesh-cfg-resume-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("config.toml");
        std::fs::write(&path,
            "snapshot_interval_secs = 10\n[resume.commands]\ngemini = [\"--resume\"]\n").unwrap();
        let c = Config::from_path(&path);
        assert_eq!(c.snapshot_interval_secs(), 10);
        assert_eq!(c.resume_args("gemini"), Some(vec!["--resume".into()]));
        let _ = std::fs::remove_file(&path);
    }
}
