use eyre::{Context, Result};
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;

pub struct UserConfig {
    path: PathBuf,
    table: toml::Table,
}

const TEMPLATE: &str = r#"admin_user_email = ""
admin_user_name = ""
admin_user_password = ""

baikal_admin_password = ""
baikal_subdomain = ""

bichon_encryption_password = ""
bichon_subdomain = ""
bichon_tailscale_ip = ""

blocky_subdomain = ""

grimmory_admin_password = ""
grimmory_admin_user = ""
grimmory_db_password = ""
grimmory_subdomain = ""

cloudflare_dns_api_token = ""

colporteur_feeds_password = ""
colporteur_freshrss_sync = false
colporteur_subdomain = ""

domain = ""

freshrss_subdomain = ""

hostname = ""

headscale_subdomain = ""

navidrome_subdomain = ""

openclaw_claude_ai_session_key = ""
openclaw_claude_web_cookie = ""
openclaw_claude_web_session_key = ""
openclaw_gateway_token = ""

paperless_admin_password = ""
paperless_admin_user = ""
paperless_db_password = ""
paperless_secret_key = ""
paperless_subdomain = ""
paperless_tailscale_ip = ""

restic_password = ""
restic_repository = ""

ssh_port = 22022

tailscale_api_key = ""
tailscale_authkey = ""
tailscale_login_server = ""

webdav_password = ""
webdav_subdomain = ""

yourls_admin_password = ""
yourls_admin_user = ""
yourls_api_signature = ""
yourls_cookiekey = ""
yourls_db_password = ""
yourls_subdomain = ""
"#;

const SENSITIVE_SUFFIXES: &[&str] = &["password", "key", "token", "secret", "cookie", "signature"];

impl UserConfig {
    pub fn path() -> Result<PathBuf> {
        dirs::config_dir()
            .map(|p| p.join("auberge/config.toml"))
            .ok_or_else(|| eyre::eyre!("Could not determine XDG config directory"))
    }

    pub fn load() -> Result<Self> {
        let path = Self::path()?;
        if !path.exists() {
            eyre::bail!(
                "Config file not found at {}. Run `auberge config init` to create it.",
                path.display()
            );
        }
        let contents = fs::read_to_string(&path)
            .wrap_err_with(|| format!("Failed to read {}", path.display()))?;
        let table: toml::Table =
            toml::from_str(&contents).wrap_err("Failed to parse config.toml")?;
        Ok(Self { path, table })
    }

    pub fn init() -> Result<PathBuf> {
        let path = Self::path()?;
        if path.exists() {
            eyre::bail!("Config already exists at {}", path.display());
        }
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .wrap_err_with(|| format!("Failed to create {}", parent.display()))?;
        }
        fs::write(&path, TEMPLATE).wrap_err("Failed to write config template")?;
        Self::enforce_permissions(&path)?;
        Ok(path)
    }

    pub fn keys(&self) -> Vec<String> {
        self.table.keys().cloned().collect()
    }

    pub fn get(&self, key: &str) -> Option<String> {
        self.table.get(key).and_then(value_to_string)
    }

    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
        self.table
            .insert(key.to_string(), toml::Value::String(value.to_string()));
        self.save()
    }

    pub fn remove(&mut self, key: &str) -> Result<bool> {
        if self.table.remove(key).is_none() {
            return Ok(false);
        }
        self.save()?;
        Ok(true)
    }

    pub fn keys_redacted(&self) -> Vec<(String, String)> {
        let mut result = Vec::new();
        for (key, val) in &self.table {
            let is_sensitive = SENSITIVE_SUFFIXES.iter().any(|s| key.contains(s));
            let display = if is_sensitive {
                match val {
                    toml::Value::String(s) if s.is_empty() => "(empty)".to_string(),
                    toml::Value::String(_) => "****".to_string(),
                    other => value_to_string(other).unwrap_or_default(),
                }
            } else {
                value_to_string(val).unwrap_or_default()
            };
            result.push((key.clone(), display));
        }
        result
    }

    pub fn validate_required(&self, keys: &[&str]) -> Vec<String> {
        keys.iter()
            .filter(|&&key| match self.table.get(key) {
                None => true,
                Some(toml::Value::String(s)) => s.trim().is_empty(),
                _ => false,
            })
            .map(|&k| k.to_string())
            .collect()
    }

    pub fn flatten_for_ansible(&self) -> Result<BTreeMap<String, String>> {
        flatten_toml(&self.table)
    }

    fn save(&self) -> Result<()> {
        let contents = toml::to_string_pretty(&self.table).wrap_err("Failed to serialize TOML")?;
        fs::write(&self.path, contents)
            .wrap_err_with(|| format!("Failed to write {}", self.path.display()))?;
        Self::enforce_permissions(&self.path)?;
        Ok(())
    }

    fn enforce_permissions(path: &PathBuf) -> Result<()> {
        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
            .wrap_err_with(|| format!("Failed to set permissions on {}", path.display()))
    }
}

fn value_to_string(v: &toml::Value) -> Option<String> {
    match v {
        toml::Value::String(s) => Some(s.clone()),
        toml::Value::Integer(i) => Some(i.to_string()),
        toml::Value::Boolean(b) => Some(b.to_string()),
        toml::Value::Float(f) => Some(f.to_string()),
        _ => None,
    }
}

fn resolve_value(v: &str) -> Result<String> {
    if let Some(rest) = v.strip_prefix("!!") {
        return Ok(format!("!{rest}"));
    }
    if let Some(cmd) = v.strip_prefix('!') {
        use std::process::Stdio;
        let output = std::process::Command::new("sh")
            .arg("-c")
            .arg(cmd)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .output()
            .wrap_err("Failed to execute shell command")?;
        if !output.status.success() {
            let code = output
                .status
                .code()
                .map_or("signal".to_string(), |c| c.to_string());
            eyre::bail!("Shell command failed (exit {code})");
        }
        let stdout =
            String::from_utf8(output.stdout).wrap_err("Shell command output is not valid UTF-8")?;
        let resolved = stdout.trim().to_string();
        if resolved.is_empty() {
            eyre::bail!("Shell command produced empty output");
        }
        return Ok(resolved);
    }
    Ok(v.to_string())
}

fn flatten_toml(table: &toml::Table) -> Result<BTreeMap<String, String>> {
    let mut result = BTreeMap::new();
    for (key, value) in table {
        match value {
            toml::Value::Table(inner) => result.extend(flatten_toml(inner)?),
            other => {
                if let Some(s) = value_to_string(other) {
                    let resolved = resolve_value(&s)
                        .wrap_err_with(|| format!("Failed to resolve config key '{key}'"))?;
                    result.insert(key.clone(), resolved);
                }
            }
        }
    }
    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::os::unix::fs::PermissionsExt;

    #[test]
    fn test_keys_returns_all_key_names() {
        let toml_str = r#"
            domain = "example.com"
            admin_user_name = "alice"
            ssh_port = 22022
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        let mut keys = config.keys();
        keys.sort();
        assert_eq!(keys, vec!["admin_user_name", "domain", "ssh_port"]);
    }

    #[test]
    fn test_flatten_toml() {
        let toml_str = r#"
            domain = "example.com"
            ssh_port = 22022
            admin_user_name = "alice"
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let flat = flatten_toml(&table).unwrap();
        assert_eq!(flat.get("domain").unwrap(), "example.com");
        assert_eq!(flat.get("ssh_port").unwrap(), "22022");
        assert_eq!(flat.get("admin_user_name").unwrap(), "alice");
    }

    #[test]
    fn test_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        let config_path = dir.path().join("auberge/config.toml");
        fs::create_dir_all(config_path.parent().unwrap()).unwrap();
        fs::write(&config_path, TEMPLATE).unwrap();
        fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600)).unwrap();

        let mut config = UserConfig {
            path: config_path.clone(),
            table: toml::from_str(TEMPLATE).unwrap(),
        };

        config.set("admin_user_name", "bob").unwrap();
        assert_eq!(config.get("admin_user_name").unwrap(), "bob");

        let reloaded_contents = fs::read_to_string(&config_path).unwrap();
        let reloaded_table: toml::Table = toml::from_str(&reloaded_contents).unwrap();
        let reloaded = UserConfig {
            path: config_path,
            table: reloaded_table,
        };
        assert_eq!(reloaded.get("admin_user_name").unwrap(), "bob");
    }

    #[test]
    fn test_permissions_enforced() {
        let dir = tempfile::tempdir().unwrap();
        let config_path = dir.path().join("auberge/config.toml");
        fs::create_dir_all(config_path.parent().unwrap()).unwrap();
        fs::write(&config_path, TEMPLATE).unwrap();

        UserConfig::enforce_permissions(&config_path).unwrap();
        let perms = fs::metadata(&config_path).unwrap().permissions();
        assert_eq!(perms.mode() & 0o777, 0o600);
    }

    #[test]
    fn test_template_parses() {
        let table: toml::Table = toml::from_str(TEMPLATE).unwrap();
        assert!(table.contains_key("domain"));
        assert!(table.contains_key("admin_user_name"));
        assert!(table.contains_key("cloudflare_dns_api_token"));
        assert!(table.contains_key("tailscale_authkey"));
    }

    #[test]
    fn test_get_nonexistent_key() {
        let table: toml::Table = toml::from_str(TEMPLATE).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        assert!(config.get("nonexistent_key").is_none());
    }

    #[test]
    fn test_remove_key() {
        let dir = tempfile::tempdir().unwrap();
        let config_path = dir.path().join("auberge/config.toml");
        fs::create_dir_all(config_path.parent().unwrap()).unwrap();
        fs::write(&config_path, TEMPLATE).unwrap();
        fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600)).unwrap();

        let mut config = UserConfig {
            path: config_path,
            table: toml::from_str(TEMPLATE).unwrap(),
        };
        config.set("admin_user_name", "test").unwrap();
        assert!(config.remove("admin_user_name").unwrap());
        assert!(config.get("admin_user_name").is_none());
    }

    #[test]
    fn test_set_upserts_new_key() {
        let dir = tempfile::tempdir().unwrap();
        let config_path = dir.path().join("auberge/config.toml");
        fs::create_dir_all(config_path.parent().unwrap()).unwrap();
        fs::write(&config_path, TEMPLATE).unwrap();
        fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600)).unwrap();

        let mut config = UserConfig {
            path: config_path,
            table: toml::from_str(TEMPLATE).unwrap(),
        };

        assert!(config.get("brand_new_key").is_none());
        config.set("brand_new_key", "hello").unwrap();
        assert_eq!(config.get("brand_new_key").unwrap(), "hello");
    }

    #[test]
    fn test_keys_redacted() {
        let toml_str = r#"
            admin_user_name = "alice"
            cloudflare_dns_api_token = "secret123"
            baikal_admin_password = ""
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        let items = config.keys_redacted();
        let map: BTreeMap<_, _> = items.into_iter().collect();
        assert_eq!(map.get("admin_user_name").unwrap(), "alice");
        assert_eq!(map.get("cloudflare_dns_api_token").unwrap(), "****");
        assert_eq!(map.get("baikal_admin_password").unwrap(), "(empty)");
    }

    #[test]
    fn test_validate_required_catches_empty_strings() {
        let toml_str = r#"
            domain = "example.com"
            admin_user_name = ""
            ssh_port = 22022
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        let missing = config.validate_required(&["domain", "admin_user_name", "ssh_port"]);
        assert_eq!(missing, vec!["admin_user_name"]);
    }

    #[test]
    fn test_validate_required_catches_missing_keys() {
        let toml_str = r#"
            domain = "example.com"
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        let missing = config.validate_required(&["domain", "admin_user_name"]);
        assert_eq!(missing, vec!["admin_user_name"]);
    }

    #[test]
    fn test_validate_required_catches_whitespace_only_values() {
        let toml_str = r#"
            domain = "  "
            admin_user_name = "	"
            ssh_port = 22022
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        let missing = config.validate_required(&["domain", "admin_user_name", "ssh_port"]);
        assert_eq!(missing, vec!["domain", "admin_user_name"]);
    }

    #[test]
    fn test_validate_required_returns_empty_when_all_set() {
        let toml_str = r#"
            domain = "example.com"
            admin_user_name = "alice"
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        let missing = config.validate_required(&["domain", "admin_user_name"]);
        assert!(missing.is_empty());
    }

    #[test]
    fn test_flatten_for_ansible() {
        let toml_str = r#"
            domain = "example.com"
            ssh_port = 22022
            baikal_admin_password = "secret"
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        let flat = config.flatten_for_ansible().unwrap();
        assert_eq!(flat.get("domain").unwrap(), "example.com");
        assert_eq!(flat.get("ssh_port").unwrap(), "22022");
        assert_eq!(flat.get("baikal_admin_password").unwrap(), "secret");
    }

    #[test]
    fn test_resolve_value_plain_string() {
        assert_eq!(resolve_value("hello").unwrap(), "hello");
        assert_eq!(resolve_value("secret123").unwrap(), "secret123");
        assert_eq!(resolve_value("").unwrap(), "");
    }

    #[test]
    fn test_resolve_value_escaped_bang() {
        assert_eq!(resolve_value("!!literal-bang").unwrap(), "!literal-bang");
        assert_eq!(resolve_value("!!pass foo").unwrap(), "!pass foo");
    }

    #[cfg(unix)]
    #[test]
    fn test_resolve_value_shell_command() {
        let result = resolve_value("!echo mysecret").unwrap();
        assert_eq!(result, "mysecret");
    }

    #[cfg(unix)]
    #[test]
    fn test_resolve_value_shell_command_trims_whitespace() {
        let result = resolve_value("!printf '  trimmed  '").unwrap();
        assert_eq!(result, "trimmed");
    }

    #[cfg(unix)]
    #[test]
    fn test_resolve_value_shell_command_nonzero_exit_fails() {
        let err = resolve_value("!false").unwrap_err();
        assert!(err.to_string().contains("Shell command failed"));
    }

    #[cfg(unix)]
    #[test]
    fn test_resolve_value_shell_command_empty_output_fails() {
        let err = resolve_value("!true").unwrap_err();
        assert!(err.to_string().contains("empty output"));
    }

    #[cfg(unix)]
    #[test]
    fn test_flatten_for_ansible_resolves_command() {
        let toml_str = r#"
            domain = "example.com"
            baikal_admin_password = "!echo cmdpassword"
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        let flat = config.flatten_for_ansible().unwrap();
        assert_eq!(flat.get("domain").unwrap(), "example.com");
        assert_eq!(flat.get("baikal_admin_password").unwrap(), "cmdpassword");
    }

    #[test]
    fn test_flatten_for_ansible_resolves_escaped_bang() {
        let toml_str = r#"
            domain = "example.com"
            baikal_admin_password = "!!literal"
        "#;
        let table: toml::Table = toml::from_str(toml_str).unwrap();
        let config = UserConfig {
            path: PathBuf::from("/tmp/fake"),
            table,
        };
        let flat = config.flatten_for_ansible().unwrap();
        assert_eq!(flat.get("baikal_admin_password").unwrap(), "!literal");
    }
}
