use anyhow::{Context, Result, anyhow};
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};

/// Env var pointing at an explicit config file path. Wins over the discovered
/// search hierarchy; missing-file is an error, not a silent fall-through.
pub const ENV_CONFIG: &str = "DSC_CONFIG";

/// Env var pointing at the user config-home directory. Defaults to
/// `$XDG_CONFIG_HOME/dsc`, which itself defaults to `~/.config/dsc`.
/// `dsc` looks for `dsc.toml` inside this directory.
pub const ENV_CONFIG_HOME: &str = "DSC_CONFIG_HOME";

fn deserialize_opt_string_empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
    D: Deserializer<'de>,
{
    let value = Option::<String>::deserialize(deserializer)?;
    Ok(value.and_then(|s| if s.is_empty() { None } else { Some(s) }))
}

fn deserialize_opt_u64_zero_as_none<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
    D: Deserializer<'de>,
{
    let value = Option::<u64>::deserialize(deserializer)?;
    Ok(value.and_then(|v| if v == 0 { None } else { Some(v) }))
}

/// Top-level configuration for dsc.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Config {
    #[serde(default)]
    pub discourse: Vec<DiscourseConfig>,
    #[serde(default)]
    pub harden: HardenConfig,
}

/// User overrides for `dsc harden` defaults. Every field is optional;
/// anything left unset falls back to the built-in defaults applied in
/// `commands::harden::resolve_options`. CLI flags override this block on
/// a per-run basis.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct HardenConfig {
    /// Username for the new sudo-enabled non-root account. Default: `discourse`.
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub new_user: Option<String>,
    /// SSH port to move the daemon to in stage 2. Default: 2227.
    #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
    pub ssh_port: Option<u64>,
    /// URL to fetch the Docker installer from. Default: `https://get.docker.com`.
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub docker_install_url: Option<String>,
    /// Whether to install Docker rootless. Default: true.
    #[serde(default)]
    pub docker_rootless: Option<bool>,
    /// Swap file size in GB. 0 to skip. Default: 2.
    #[serde(default)]
    pub swap_size_gb: Option<u32>,
    /// Cap on journald disk use. Default: `500M`.
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub journald_max_use: Option<String>,
    /// Timezone to set via `timedatectl`. Default: `UTC`.
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub timezone: Option<String>,
    /// Whether to enable unattended security upgrades. Default: true.
    #[serde(default)]
    pub unattended_security_upgrades: Option<bool>,
    /// Whether to install fail2ban. Default: true.
    #[serde(default)]
    pub fail2ban: Option<bool>,
    /// Whether to install mosh and open UDP 60000-61000. Default: false.
    #[serde(default)]
    pub mosh: Option<bool>,
    /// Override sshd `Ciphers` line. Defaults to dsc's policy overlay
    /// (drop legacy algorithms while preserving upstream defaults).
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub sshd_ciphers: Option<String>,
    /// Override sshd `KexAlgorithms` line. Defaults to dsc's policy overlay
    /// (prefer PQ-hybrid first, disable legacy SHA-1 DH groups).
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub sshd_kex: Option<String>,
    /// Override sshd `MACs` line. Defaults to dsc's policy overlay
    /// (disable legacy SHA-1/MD5 and short UMAC variants).
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub sshd_macs: Option<String>,
    /// Extra ufw `allow` rules applied after the standard set
    /// (e.g. `["3000/tcp", "192.168.1.0/24"]`).
    #[serde(default)]
    pub extra_ufw_allow: Option<Vec<String>>,
}

/// Configuration for a single Discourse install.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct DiscourseConfig {
    pub name: String,
    pub baseurl: String,
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub fullname: Option<String>,
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub apikey: Option<String>,
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub api_username: Option<String>,
    #[serde(default)]
    pub tags: Option<Vec<String>>,
    #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
    pub changelog_topic_id: Option<u64>,
    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
    pub ssh_host: Option<String>,
    #[serde(default)]
    pub docker_rootless: Option<bool>,
}

/// Load configuration from a TOML file.
pub fn load_config(path: &Path) -> Result<Config> {
    if !path.exists() {
        return Ok(Config::default());
    }
    let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    let config: Config = toml::from_str(&raw).with_context(|| "parsing config")?;
    warn_on_discourse_names(&config);
    Ok(config)
}

/// Save configuration to a TOML file.
pub fn save_config(path: &Path, config: &Config) -> Result<()> {
    let raw = toml::to_string_pretty(config).with_context(|| "serializing config")?;
    write_config_file(path, raw.as_bytes())?;
    Ok(())
}

#[cfg(unix)]
fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
    use std::io::Write;
    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

    let mut file = fs::OpenOptions::new()
        .create(true)
        .truncate(true)
        .write(true)
        .mode(0o600)
        .open(path)
        .with_context(|| format!("writing {}", path.display()))?;
    file.write_all(raw)
        .with_context(|| format!("writing {}", path.display()))?;

    let metadata = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
    let mode = metadata.permissions().mode() & 0o777;
    if mode & 0o077 != 0
        && let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600))
    {
        eprintln!(
            "Warning: unable to tighten permissions on {}: {}",
            path.display(),
            err
        );
    }
    Ok(())
}

#[cfg(not(unix))]
fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
    fs::write(path, raw).with_context(|| format!("writing {}", path.display()))?;
    Ok(())
}

/// Find a discourse by name.
pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
    config.discourse.iter().find(|d| d.name == name)
}

/// Find a discourse by name (mutable).
pub fn find_discourse_mut<'a>(
    config: &'a mut Config,
    name: &str,
) -> Option<&'a mut DiscourseConfig> {
    config.discourse.iter_mut().find(|d| d.name == name)
}

fn warn_on_discourse_names(config: &Config) {
    for discourse in &config.discourse {
        if discourse.name.chars().any(|ch| ch.is_whitespace()) {
            eprintln!(
                "Warning: discourse name '{}' contains whitespace. Prefer a short, slugified name without spaces; use 'fullname' for display.",
                discourse.name
            );
        }
    }
}

/// Where the active config came from. Used by `dsc config` to label the
/// active path so the user understands why a file outside the standard
/// hierarchy is in use.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigSource {
    /// Explicit `--config`/`-c` flag.
    Flag(PathBuf),
    /// `$DSC_CONFIG` env var.
    EnvVar(PathBuf),
    /// First existing path from the search hierarchy.
    Discovered(PathBuf),
    /// No file found anywhere; fallback to `./dsc.toml` (created on first
    /// write command).
    Default(PathBuf),
}

impl ConfigSource {
    /// Resolved path, regardless of how it was selected.
    pub fn path(&self) -> &Path {
        match self {
            Self::Flag(p) | Self::EnvVar(p) | Self::Discovered(p) | Self::Default(p) => p,
        }
    }

    /// Short human label for the source, e.g. `via --config flag`.
    pub fn label(&self) -> &'static str {
        match self {
            Self::Flag(_) => "via --config flag",
            Self::EnvVar(_) => "via $DSC_CONFIG",
            Self::Discovered(_) => "from search hierarchy",
            Self::Default(_) => "default (no config found)",
        }
    }
}

/// Resolve which config file to use, honouring the documented precedence:
///
/// 1. `--config <path>` / `-c` flag
/// 2. `$DSC_CONFIG` env var
/// 3. `./dsc.toml`
/// 4. `$DSC_CONFIG_HOME/dsc.toml` (default: `$XDG_CONFIG_HOME/dsc` -> `~/.config/dsc`)
/// 5. `$XDG_CONFIG_DIRS` entries (Unix only)
/// 6. `/etc/dsc/dsc.toml`, `/etc/dsc.toml`, `/usr/local/etc/dsc.toml` (Unix only)
///
/// Explicit selectors (1, 2) error if the named file does not exist; the
/// discovered hierarchy (3-6) silently skips missing entries. If nothing
/// matches, falls back to `./dsc.toml`.
pub fn resolve_config_source(flag: Option<PathBuf>) -> Result<ConfigSource> {
    resolve_config_source_with_env(flag, |k| std::env::var_os(k))
}

fn resolve_config_source_with_env<F>(flag: Option<PathBuf>, env: F) -> Result<ConfigSource>
where
    F: Fn(&str) -> Option<OsString> + Copy,
{
    if let Some(path) = flag {
        if !path.exists() {
            return Err(anyhow!(
                "config file not found: {} (specified via --config)",
                path.display()
            ));
        }
        return Ok(ConfigSource::Flag(path));
    }

    if let Some(raw) = env(ENV_CONFIG) {
        let path = PathBuf::from(raw);
        if !path.exists() {
            return Err(anyhow!(
                "config file not found: {} (specified via ${})",
                path.display(),
                ENV_CONFIG
            ));
        }
        return Ok(ConfigSource::EnvVar(path));
    }

    let candidates = config_search_paths_with_env(env);
    if let Some(found) = candidates.into_iter().find(|c| c.exists()) {
        return Ok(ConfigSource::Discovered(found));
    }

    Ok(ConfigSource::Default(PathBuf::from("dsc.toml")))
}

/// Returns the ordered list of candidate paths that `dsc` searches for a
/// config file when neither `--config` nor `$DSC_CONFIG` is set.
///
/// Order (first match wins):
/// 1. `./dsc.toml`
/// 2. `$DSC_CONFIG_HOME/dsc.toml` (default: `$XDG_CONFIG_HOME/dsc` -> `~/.config/dsc`)
/// 3. `$XDG_CONFIG_DIRS` entries as `<dir>/dsc/dsc.toml` (Unix only)
/// 4. `/etc/dsc/dsc.toml` (Unix only)
/// 5. `/etc/dsc.toml` (Unix only)
/// 6. `/usr/local/etc/dsc.toml` (Unix only)
pub fn config_search_paths() -> Vec<PathBuf> {
    config_search_paths_with_env(|k| std::env::var_os(k))
}

fn config_search_paths_with_env<F>(env: F) -> Vec<PathBuf>
where
    F: Fn(&str) -> Option<OsString>,
{
    let mut candidates = vec![PathBuf::from("dsc.toml")];

    // $DSC_CONFIG_HOME -> $XDG_CONFIG_HOME/dsc -> $HOME/.config/dsc
    let config_home: Option<PathBuf> = env(ENV_CONFIG_HOME)
        .map(PathBuf::from)
        .or_else(|| env("XDG_CONFIG_HOME").map(|x| PathBuf::from(x).join("dsc")))
        .or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config").join("dsc")));
    if let Some(dir) = config_home {
        candidates.push(dir.join("dsc.toml"));
    }

    #[cfg(unix)]
    {
        if let Some(xdg_config_dirs) = env("XDG_CONFIG_DIRS") {
            for dir in std::env::split_paths(&xdg_config_dirs) {
                candidates.push(dir.join("dsc").join("dsc.toml"));
            }
        } else {
            candidates.push(PathBuf::from("/etc/xdg/dsc/dsc.toml"));
        }
        candidates.push(PathBuf::from("/etc/dsc/dsc.toml"));
        candidates.push(PathBuf::from("/etc/dsc.toml"));
        candidates.push(PathBuf::from("/usr/local/etc/dsc.toml"));
    }

    candidates
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use std::ffi::{OsStr, OsString};
    use std::path::PathBuf;

    /// Build an env lookup closure over a fixed map. `None` for missing.
    fn env_from<'a>(
        pairs: &'a HashMap<&'static str, OsString>,
    ) -> impl Fn(&str) -> Option<OsString> + Copy + 'a {
        move |k: &str| pairs.get(k).cloned()
    }

    fn osstr<S: AsRef<OsStr>>(s: S) -> OsString {
        s.as_ref().to_os_string()
    }

    #[test]
    fn flag_wins_over_env_and_discovery() {
        let dir = tempfile::tempdir().expect("tempdir");
        let flag_file = dir.path().join("flag.toml");
        let env_file = dir.path().join("env.toml");
        std::fs::write(&flag_file, "").unwrap();
        std::fs::write(&env_file, "").unwrap();

        let mut env = HashMap::new();
        env.insert(ENV_CONFIG, osstr(&env_file));
        let source =
            resolve_config_source_with_env(Some(flag_file.clone()), env_from(&env)).unwrap();
        assert!(matches!(source, ConfigSource::Flag(_)));
        assert_eq!(source.path(), flag_file);
    }

    #[test]
    fn missing_flag_path_errors() {
        let dir = tempfile::tempdir().expect("tempdir");
        let missing = dir.path().join("nope.toml");
        let env: HashMap<&'static str, OsString> = HashMap::new();
        let err = resolve_config_source_with_env(Some(missing), env_from(&env)).unwrap_err();
        assert!(err.to_string().contains("--config"));
    }

    #[test]
    fn dsc_config_env_wins_over_discovery() {
        let dir = tempfile::tempdir().expect("tempdir");
        let env_file = dir.path().join("env.toml");
        std::fs::write(&env_file, "").unwrap();
        let home_dir = dir.path().join("home");
        let dsc_dir = home_dir.join(".config").join("dsc");
        std::fs::create_dir_all(&dsc_dir).unwrap();
        std::fs::write(dsc_dir.join("dsc.toml"), "").unwrap();

        let mut env = HashMap::new();
        env.insert(ENV_CONFIG, osstr(&env_file));
        env.insert("HOME", osstr(&home_dir));
        let source = resolve_config_source_with_env(None, env_from(&env)).unwrap();
        assert!(matches!(source, ConfigSource::EnvVar(_)));
        assert_eq!(source.path(), env_file);
    }

    #[test]
    fn missing_dsc_config_env_path_errors() {
        let dir = tempfile::tempdir().expect("tempdir");
        let missing = dir.path().join("missing.toml");
        let mut env = HashMap::new();
        env.insert(ENV_CONFIG, osstr(&missing));
        let err = resolve_config_source_with_env(None, env_from(&env)).unwrap_err();
        assert!(err.to_string().contains("$DSC_CONFIG"));
    }

    #[test]
    fn dsc_config_home_redirects_step_4() {
        let dir = tempfile::tempdir().expect("tempdir");
        let custom_home = dir.path().join("custom");
        std::fs::create_dir_all(&custom_home).unwrap();
        std::fs::write(custom_home.join("dsc.toml"), "").unwrap();

        let mut env = HashMap::new();
        env.insert(ENV_CONFIG_HOME, osstr(&custom_home));
        let candidates = config_search_paths_with_env(env_from(&env));

        // Step 1: ./dsc.toml; step 2: $DSC_CONFIG_HOME/dsc.toml
        assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
        assert_eq!(candidates[1], custom_home.join("dsc.toml"));
    }

    #[test]
    fn unset_config_home_reproduces_home_config_dsc() {
        // With nothing set except HOME, step 2 must resolve to
        // $HOME/.config/dsc/dsc.toml (today's behaviour).
        let dir = tempfile::tempdir().expect("tempdir");
        let home = dir.path().to_path_buf();
        let mut env = HashMap::new();
        env.insert("HOME", osstr(&home));
        let candidates = config_search_paths_with_env(env_from(&env));
        assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
        assert_eq!(
            candidates[1],
            home.join(".config").join("dsc").join("dsc.toml")
        );
    }

    #[test]
    fn xdg_config_home_default_used_when_dsc_config_home_unset() {
        // $XDG_CONFIG_HOME set, $DSC_CONFIG_HOME unset -> step 2 is
        // $XDG_CONFIG_HOME/dsc/dsc.toml.
        let dir = tempfile::tempdir().expect("tempdir");
        let xdg = dir.path().join("xdg");
        let mut env = HashMap::new();
        env.insert("XDG_CONFIG_HOME", osstr(&xdg));
        let candidates = config_search_paths_with_env(env_from(&env));
        assert_eq!(candidates[1], xdg.join("dsc").join("dsc.toml"));
    }

    #[test]
    fn dsc_config_home_overrides_xdg_config_home() {
        let dir = tempfile::tempdir().expect("tempdir");
        let xdg = dir.path().join("xdg");
        let dsc_home = dir.path().join("custom_dsc_home");
        let mut env = HashMap::new();
        env.insert("XDG_CONFIG_HOME", osstr(&xdg));
        env.insert(ENV_CONFIG_HOME, osstr(&dsc_home));
        let candidates = config_search_paths_with_env(env_from(&env));
        assert_eq!(candidates[1], dsc_home.join("dsc.toml"));
    }

    #[test]
    fn unset_everything_resolution_matches_legacy_order() {
        // Regression guard: with no env set, search order must be
        // exactly:
        //   1. ./dsc.toml
        //   (no step 2: no HOME -> no config-home candidate)
        //   3+. Unix system paths
        let env: HashMap<&'static str, OsString> = HashMap::new();
        let candidates = config_search_paths_with_env(env_from(&env));
        assert_eq!(candidates[0], PathBuf::from("dsc.toml"));
        #[cfg(unix)]
        {
            assert!(candidates.contains(&PathBuf::from("/etc/xdg/dsc/dsc.toml")));
            assert!(candidates.contains(&PathBuf::from("/etc/dsc/dsc.toml")));
            assert!(candidates.contains(&PathBuf::from("/etc/dsc.toml")));
            assert!(candidates.contains(&PathBuf::from("/usr/local/etc/dsc.toml")));
        }
    }

    #[test]
    fn no_config_anywhere_returns_default() {
        let dir = tempfile::tempdir().expect("tempdir");
        // Point HOME at an empty dir so step 2 misses too. CWD-relative
        // `dsc.toml` may or may not exist depending on test runner pwd,
        // so we just assert the Default variant is reachable when nothing
        // is set.
        let mut env = HashMap::new();
        env.insert("HOME", osstr(dir.path()));
        // Only assert the source type when ./dsc.toml truly does not exist.
        if !PathBuf::from("dsc.toml").exists() {
            let source = resolve_config_source_with_env(None, env_from(&env)).unwrap();
            assert!(matches!(source, ConfigSource::Default(_)));
        }
    }
}
