use std::{path::PathBuf, time::Duration};

use crate::util::{config_file, parse_bool};

#[derive(Debug, Clone)]
pub struct Config {
    pub refresh_interval: Duration,
    pub extra_regions: Vec<String>,
    pub redact_default: Option<bool>,
    pub grouped_default: Option<bool>,
    pub theme: String,
    /// Glyph set: `"unicode"` (default), `"ascii"` for low-feature
    /// terminals, `"powerline"` (alias `"nerd"`) for Powerline-patched /
    /// Nerd Fonts, or `"auto"` to probe the terminal at startup and pick
    /// powerline if its support is detected, unicode otherwise.
    pub icons: String,
    pub notify_bell: bool,
    pub required_tags: Vec<String>,
    /// Per-profile theme override. Key = AWS profile name, value = theme
    /// name (matches the same names `theme = …` accepts). Lets the
    /// operator pin a high-contrast / dark / light theme to a specific
    /// profile so the visual cue says "you're in prod" without reading
    /// the breadcrumb. Most prod incidents start with "I thought I was
    /// in staging."
    pub profile_themes: std::collections::HashMap<String, String>,
    /// Named accounts reachable via `sts:AssumeRole`. Key = the friendly
    /// name the operator uses with `:account NAME`; value is the full
    /// AssumeRole spec — `role_arn`, `source_profile`, optional
    /// `external_id`, optional `region` override. Lines in `config.toml`
    /// use the form `accounts.NAME.field = "value"`, mirroring the
    /// `metric.LABEL.field` shape that the rest of the config uses.
    pub accounts: std::collections::HashMap<String, AccountSpec>,
    /// Per-environment runbook URLs. Key = env name; value = a URL the
    /// operator wants surfaced during triage. Lines in `config.toml` use
    /// `runbooks.ENV = "https://…"`. Shown in the `:why` overlay.
    pub runbooks: std::collections::HashMap<String, String>,
    /// Per-env read-only locks. When `safety_envs.get(env_name) ==
    /// Some(true)`, destructive actions against that env are refused
    /// even when the global `--read-only` toggle is off. Borrowed from
    /// pgman's `safety.databases.NAME.read_only` pattern. Lines in
    /// `config.toml` use `safety.envs.NAME.read_only = true`.
    pub safety_envs: std::collections::HashMap<String, bool>,
    /// Per-account read-only locks. Same shape as `safety_envs` but
    /// matched against the *active account name* (the `:account NAME`
    /// key for AssumeRole'd accounts, or the AWS profile name
    /// otherwise). Lines use `safety.accounts.NAME.read_only = true`.
    pub safety_accounts: std::collections::HashMap<String, bool>,
    /// Optional outbound webhook for audit-line fan-out. Each audit
    /// line written to `~/.cache/ebman/audit.log` is also POSTed to
    /// this URL as JSON (fire-and-forget; failures don't block or
    /// alarm). Body shape: `{"text": "<audit line>", "action": "...",
    /// "target": "...", "account": "...", "profile": "...", "region":
    /// "...", "at": "<rfc3339>"}`. The top-level `text` makes the
    /// body Slack-incoming-webhook-compatible out of the box. Lines
    /// in `config.toml` use `notify_webhook = "https://..."`.
    pub notify_webhook: Option<String>,
    /// User-defined command aliases. Key = the bare name typed
    /// after `:` (no leading colon); value = the full command line
    /// the alias expands to. Lines in `config.toml` use
    /// `alias.NAME = "command line"`. Expansion happens in
    /// `execute_command` before the dispatch match — args typed
    /// after the alias name are appended to the expansion, so
    /// `alias.dp = "deploy --auto-rollback 5m"` + `:dp build-900`
    /// becomes `:deploy --auto-rollback 5m build-900`.
    ///
    /// Named `command_aliases` to distinguish from the existing
    /// `:alias <env> <label>` env-rename feature (state.toml-
    /// persisted, lives on `App.aliases`). Command aliases are
    /// config.toml-only.
    pub command_aliases: std::collections::HashMap<String, String>,
    /// Lint rule IDs to skip globally. CSV-in-string form in
    /// `config.toml`: `lint.disable = "EBL001,EBL006"`. Mirrors
    /// the existing `extra_regions` / `required_tags` conventions.
    /// Project-local `.ebman/ebman.toml` can extend (never
    /// override) this set via `[lint]\ndisable = ["EBL001"]`.
    pub lint_disable: Vec<String>,
}

/// A named `sts:AssumeRole` target. The operator typically pins one of
/// these per child account and switches between them via `:account
/// NAME`. `source_profile` carries the base creds (so chained role
/// hops still resolve), `external_id` is optional but required by some
/// trust policies, `region` is optional (falls back to the source
/// profile's / env default).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccountSpec {
    pub role_arn: String,
    pub source_profile: Option<String>,
    pub external_id: Option<String>,
    pub region: Option<String>,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            refresh_interval: Duration::from_secs(15),
            extra_regions: Vec::new(),
            redact_default: None,
            grouped_default: None,
            theme: "dark".into(),
            icons: "unicode".into(),
            notify_bell: false,
            required_tags: Vec::new(),
            profile_themes: std::collections::HashMap::new(),
            accounts: std::collections::HashMap::new(),
            runbooks: std::collections::HashMap::new(),
            safety_envs: std::collections::HashMap::new(),
            safety_accounts: std::collections::HashMap::new(),
            notify_webhook: None,
            command_aliases: std::collections::HashMap::new(),
            lint_disable: Vec::new(),
        }
    }
}

pub fn load() -> Config {
    let path = config_path();
    let Ok(text) = std::fs::read_to_string(&path) else {
        return Config::default();
    };
    parse(&text)
}

/// Sugar for the `ebman lint` CLI: just the global
/// `lint.disable` list, no other fields. Composed with the
/// project-level disables in `project::load_lint_disables_from_cwd`.
pub fn load_lint_disables() -> Vec<String> {
    load().lint_disable
}

pub fn parse(text: &str) -> Config {
    let mut cfg = Config::default();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let Some((key, raw_val)) = line.split_once('=') else {
            continue;
        };
        let key = key.trim();
        let value = raw_val.trim().trim_matches('"').to_string();
        match key {
            "refresh_interval_secs" => {
                if let Ok(n) = value.parse::<u64>() {
                    if n > 0 {
                        cfg.refresh_interval = Duration::from_secs(n);
                    }
                }
            }
            "extra_regions" => {
                cfg.extra_regions = value
                    .split(',')
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect();
            }
            "redact_default" => cfg.redact_default = parse_bool(&value),
            "grouped_default" => cfg.grouped_default = parse_bool(&value),
            "theme" => cfg.theme = value,
            "icons" => cfg.icons = value,
            "notify_bell" => {
                if let Some(b) = parse_bool(&value) {
                    cfg.notify_bell = b;
                }
            }
            "lint.disable" => {
                cfg.lint_disable = value
                    .split(',')
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect();
            }
            "notify_webhook" => {
                // Empty string disables; treat the same as the key
                // being absent so a `notify_webhook = ""` line acts as
                // an explicit off-switch without removing the key.
                cfg.notify_webhook = if value.is_empty() { None } else { Some(value) };
            }
            "required_tags" => {
                cfg.required_tags = value
                    .split(',')
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect();
            }
            "profile_themes" => {
                // Format: `prod:high-contrast,staging:dark,default:light`.
                // Whitespace around tokens is tolerated; entries without a
                // `:` separator are skipped. Empty profile / empty theme
                // are skipped so a stray trailing comma can't smuggle in
                // a `"" → ""` mapping.
                cfg.profile_themes = parse_profile_themes(&value);
            }
            other if other.starts_with("runbooks.") => {
                // `runbooks.ENV = "url"`. The key after the dot is the
                // whole env name (EB env names don't contain dots).
                let name = other.trim_start_matches("runbooks.").trim();
                if !name.is_empty() && !value.is_empty() {
                    cfg.runbooks.insert(name.to_string(), value);
                }
            }
            other if other.starts_with("accounts.") => {
                // `accounts.NAME.field = "value"`. Split on the dots so
                // multi-line specs accumulate into one HashMap entry per
                // NAME. Unknown fields are ignored so a future field
                // addition can degrade gracefully on older binaries.
                let rest = other.trim_start_matches("accounts.");
                let Some((name, field)) = rest.split_once('.') else {
                    continue;
                };
                let entry = cfg.accounts.entry(name.to_string()).or_default();
                match field.trim() {
                    "role_arn" => entry.role_arn = value,
                    "source_profile" => entry.source_profile = Some(value),
                    "external_id" => entry.external_id = Some(value),
                    "region" => entry.region = Some(value),
                    _ => {}
                }
            }
            // `safety.envs.NAME.read_only = true` and
            // `safety.accounts.NAME.read_only = true`. Only one field
            // today (read_only); the dotted shape leaves room for
            // future fields (statement_timeout-equivalent, etc.).
            other if other.starts_with("safety.envs.") => {
                let rest = other.trim_start_matches("safety.envs.");
                let Some((name, field)) = rest.split_once('.') else {
                    continue;
                };
                if field.trim() == "read_only" {
                    if let Some(b) = parse_bool(&value) {
                        cfg.safety_envs.insert(name.to_string(), b);
                    }
                }
            }
            other if other.starts_with("alias.") => {
                // `alias.NAME = "command line"`. The NAME comes
                // after the dot; nested dots in NAME are rejected
                // (would conflict with future hierarchical
                // config-key plans). Empty NAME or empty value:
                // skip silently — operators sometimes leave
                // `alias. = ""` while editing.
                let name = other.trim_start_matches("alias.").trim();
                if name.is_empty() || name.contains('.') || value.is_empty() {
                    continue;
                }
                cfg.command_aliases.insert(name.to_string(), value);
            }
            other if other.starts_with("safety.accounts.") => {
                let rest = other.trim_start_matches("safety.accounts.");
                let Some((name, field)) = rest.split_once('.') else {
                    continue;
                };
                if field.trim() == "read_only" {
                    if let Some(b) = parse_bool(&value) {
                        cfg.safety_accounts.insert(name.to_string(), b);
                    }
                }
            }
            _ => {}
        }
    }
    cfg
}

pub fn config_path() -> PathBuf {
    config_file("config.toml")
}

/// Serialise the config back to disk. Round-trips the parse format and
/// over-writes the user's existing file. Used by the `:settings` form.
/// Atomic — writes to a sibling `.tmp` and renames into place so a
/// crash mid-write can't truncate `config.toml`.
pub fn save(cfg: &Config) -> std::io::Result<()> {
    let path = config_path();
    let body = serialize(cfg);
    crate::util::write_atomic(&path, &body)
}

/// Pure: render a `Config` into the TOML-ish line-oriented format the
/// parser reads. Used by `save` and unit tests.
pub fn serialize(cfg: &Config) -> String {
    let mut out = String::new();
    out.push_str("# ebman configuration — written by :settings; hand-edits welcome\n\n");
    out.push_str(&format!(
        "refresh_interval_secs = {}\n",
        cfg.refresh_interval.as_secs()
    ));
    out.push_str(&format!(
        "extra_regions = \"{}\"\n",
        cfg.extra_regions.join(",")
    ));
    if let Some(b) = cfg.redact_default {
        out.push_str(&format!("redact_default = {b}\n"));
    }
    if let Some(b) = cfg.grouped_default {
        out.push_str(&format!("grouped_default = {b}\n"));
    }
    out.push_str(&format!("theme = \"{}\"\n", cfg.theme));
    out.push_str(&format!("icons = \"{}\"\n", cfg.icons));
    out.push_str(&format!("notify_bell = {}\n", cfg.notify_bell));
    if let Some(url) = &cfg.notify_webhook {
        out.push_str(&format!("notify_webhook = \"{url}\"\n"));
    }
    if !cfg.lint_disable.is_empty() {
        out.push_str(&format!(
            "lint.disable = \"{}\"\n",
            cfg.lint_disable.join(",")
        ));
    }
    if !cfg.command_aliases.is_empty() {
        // Sort so repeated serialize cycles don't churn the file
        // when the HashMap iteration order shuffles.
        let mut pairs: Vec<(&String, &String)> = cfg.command_aliases.iter().collect();
        pairs.sort_by(|a, b| a.0.cmp(b.0));
        for (name, expansion) in pairs {
            out.push_str(&format!("alias.{name} = \"{expansion}\"\n"));
        }
    }
    if !cfg.required_tags.is_empty() {
        out.push_str(&format!(
            "required_tags = \"{}\"\n",
            cfg.required_tags.join(",")
        ));
    }
    if !cfg.profile_themes.is_empty() {
        // Sort entries so repeated serialize cycles don't churn the file
        // when the HashMap iteration order shuffles.
        let mut pairs: Vec<(&String, &String)> = cfg.profile_themes.iter().collect();
        pairs.sort_by(|a, b| a.0.cmp(b.0));
        let joined = pairs
            .iter()
            .map(|(k, v)| format!("{k}:{v}"))
            .collect::<Vec<_>>()
            .join(",");
        out.push_str(&format!("profile_themes = \"{joined}\"\n"));
    }
    if !cfg.runbooks.is_empty() {
        // Sorted so repeated serialize cycles don't churn the file.
        let mut pairs: Vec<(&String, &String)> = cfg.runbooks.iter().collect();
        pairs.sort_by(|a, b| a.0.cmp(b.0));
        for (env, url) in pairs {
            out.push_str(&format!("runbooks.{env} = \"{url}\"\n"));
        }
    }
    if !cfg.safety_envs.is_empty() {
        let mut pairs: Vec<(&String, &bool)> = cfg.safety_envs.iter().collect();
        pairs.sort_by(|a, b| a.0.cmp(b.0));
        for (env, ro) in pairs {
            out.push_str(&format!("safety.envs.{env}.read_only = {ro}\n"));
        }
    }
    if !cfg.safety_accounts.is_empty() {
        let mut pairs: Vec<(&String, &bool)> = cfg.safety_accounts.iter().collect();
        pairs.sort_by(|a, b| a.0.cmp(b.0));
        for (acct, ro) in pairs {
            out.push_str(&format!("safety.accounts.{acct}.read_only = {ro}\n"));
        }
    }
    out
}

/// Pure: parse a `prof:theme,prof:theme` string into a map. Empty / `:`
/// -free / blank-key / blank-value tokens are skipped. Whitespace around
/// each side of the colon is trimmed so the operator can format it for
/// readability.
pub fn parse_profile_themes(raw: &str) -> std::collections::HashMap<String, String> {
    let mut out = std::collections::HashMap::new();
    for token in raw.split(',') {
        let Some((k, v)) = token.split_once(':') else {
            continue;
        };
        let key = k.trim();
        let val = v.trim();
        if key.is_empty() || val.is_empty() {
            continue;
        }
        out.insert(key.to_string(), val.to_string());
    }
    out
}

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

    #[test]
    fn parse_overrides_defaults() {
        let text = r#"
refresh_interval_secs = 30
extra_regions = "us-gov-east-1, cn-north-1"
redact_default = true
grouped_default = false
"#;
        let cfg = parse(text);
        assert_eq!(cfg.refresh_interval, Duration::from_secs(30));
        assert_eq!(
            cfg.extra_regions,
            vec!["us-gov-east-1".to_string(), "cn-north-1".to_string()]
        );
        assert_eq!(cfg.redact_default, Some(true));
        assert_eq!(cfg.grouped_default, Some(false));
    }

    #[test]
    fn parse_profile_themes_happy_path() {
        let map = parse_profile_themes("prod:high-contrast,staging:dark,default:light");
        assert_eq!(map.get("prod"), Some(&"high-contrast".to_string()));
        assert_eq!(map.get("staging"), Some(&"dark".to_string()));
        assert_eq!(map.get("default"), Some(&"light".to_string()));
        assert_eq!(map.len(), 3);
    }

    #[test]
    fn parse_profile_themes_trims_whitespace_and_skips_malformed() {
        // Trailing comma, missing colon, blank key, blank value all
        // produce no entries rather than panicking or yielding ""→"".
        let map = parse_profile_themes(
            "  prod : high-contrast , noseparator , :empty-key , empty-value: , ",
        );
        assert_eq!(map.get("prod"), Some(&"high-contrast".to_string()));
        assert_eq!(map.len(), 1);
    }

    #[test]
    fn parse_profile_themes_empty_returns_empty_map() {
        assert!(parse_profile_themes("").is_empty());
    }

    #[test]
    fn parse_accounts_collects_multiline_specs() {
        let text = r#"
accounts.prod.role_arn = "arn:aws:iam::111122223333:role/EbmanReadOnly"
accounts.prod.source_profile = "default"
accounts.prod.region = "eu-west-2"
accounts.staging.role_arn = "arn:aws:iam::555555555555:role/EbmanReadOnly"
accounts.staging.external_id = "abc-xyz"
"#;
        let cfg = parse(text);
        assert_eq!(cfg.accounts.len(), 2);
        let prod = cfg.accounts.get("prod").expect("prod entry");
        assert_eq!(
            prod.role_arn,
            "arn:aws:iam::111122223333:role/EbmanReadOnly"
        );
        assert_eq!(prod.source_profile.as_deref(), Some("default"));
        assert_eq!(prod.region.as_deref(), Some("eu-west-2"));
        assert_eq!(prod.external_id, None);
        let staging = cfg.accounts.get("staging").expect("staging entry");
        assert_eq!(staging.external_id.as_deref(), Some("abc-xyz"));
        assert_eq!(staging.source_profile, None);
    }

    #[test]
    fn parse_accounts_ignores_unknown_field() {
        // Future-compat: a field we don't recognise should be ignored
        // rather than dropping the whole entry.
        let cfg = parse(
            "accounts.prod.role_arn = \"arn:…\"\n\
             accounts.prod.future_field = \"whatever\"\n",
        );
        let prod = cfg.accounts.get("prod").expect("prod entry");
        assert_eq!(prod.role_arn, "arn:…");
    }

    #[test]
    fn parse_runbooks_maps_env_to_url() {
        let cfg = parse(
            "runbooks.uflexi-prod = \"https://wiki/runbook/prod\"\n\
             runbooks.uflexi-staging = \"https://wiki/runbook/staging\"\n",
        );
        assert_eq!(cfg.runbooks.len(), 2);
        assert_eq!(
            cfg.runbooks.get("uflexi-prod").map(String::as_str),
            Some("https://wiki/runbook/prod")
        );
        // Blank URL is skipped — a stray `runbooks.x =` can't smuggle in
        // an empty mapping.
        assert!(parse("runbooks.x = \"\"\n").runbooks.is_empty());
    }

    #[test]
    fn runbooks_round_trip_through_serialize() {
        let mut cfg = Config::default();
        cfg.runbooks.insert("prod".into(), "https://rb/prod".into());
        let reparsed = parse(&serialize(&cfg));
        assert_eq!(
            reparsed.runbooks.get("prod").map(String::as_str),
            Some("https://rb/prod")
        );
    }

    #[test]
    fn parse_safety_envs_and_accounts() {
        let cfg = parse(
            "safety.envs.uflexi-prod.read_only = true\n\
             safety.envs.uflexi-staging.read_only = false\n\
             safety.accounts.prod.read_only = true\n",
        );
        assert_eq!(cfg.safety_envs.get("uflexi-prod"), Some(&true));
        assert_eq!(cfg.safety_envs.get("uflexi-staging"), Some(&false));
        assert_eq!(cfg.safety_accounts.get("prod"), Some(&true));
        // Unknown field under safety.envs.NAME is ignored.
        let cfg = parse("safety.envs.x.future_field = \"whatever\"\n");
        assert!(cfg.safety_envs.is_empty());
    }

    #[test]
    fn safety_round_trips_through_serialize() {
        let mut cfg = Config::default();
        cfg.safety_envs.insert("uflexi-prod".into(), true);
        cfg.safety_accounts.insert("prod".into(), true);
        let reparsed = parse(&serialize(&cfg));
        assert_eq!(reparsed.safety_envs.get("uflexi-prod"), Some(&true));
        assert_eq!(reparsed.safety_accounts.get("prod"), Some(&true));
    }

    #[test]
    fn parse_writes_profile_themes_into_config() {
        // End-to-end check: a config file with `profile_themes = "..."`
        // ends up in cfg.profile_themes correctly.
        let cfg = parse("profile_themes = \"prod:high-contrast,staging:dark\"\n");
        assert_eq!(cfg.profile_themes.len(), 2);
        assert_eq!(
            cfg.profile_themes.get("prod"),
            Some(&"high-contrast".to_string())
        );
    }

    #[test]
    fn parse_ignores_zero_interval() {
        let cfg = parse("refresh_interval_secs = 0\n");
        assert_eq!(cfg.refresh_interval, Duration::from_secs(15));
    }

    #[test]
    fn parse_empty_returns_defaults() {
        let cfg = parse("");
        assert_eq!(cfg.refresh_interval, Duration::from_secs(15));
        assert!(cfg.extra_regions.is_empty());
        assert!(cfg.redact_default.is_none());
    }

    #[test]
    fn parse_icons_auto_is_preserved() {
        let cfg = parse("icons = \"auto\"\n");
        assert_eq!(cfg.icons, "auto");
    }

    #[test]
    fn serialize_round_trips_full_config() {
        let mut profile_themes = std::collections::HashMap::new();
        profile_themes.insert("prod".into(), "high-contrast".into());
        profile_themes.insert("staging".into(), "dark".into());
        let cfg = Config {
            refresh_interval: Duration::from_secs(45),
            extra_regions: vec!["eu-south-2".into(), "ap-southeast-4".into()],
            redact_default: Some(true),
            grouped_default: Some(false),
            theme: "high-contrast".into(),
            icons: "powerline".into(),
            notify_bell: true,
            required_tags: vec!["Owner".into(), "Env".into()],
            profile_themes,
            accounts: std::collections::HashMap::new(),
            runbooks: std::collections::HashMap::new(),
            safety_envs: std::collections::HashMap::new(),
            safety_accounts: std::collections::HashMap::new(),
            notify_webhook: Some("https://hooks.slack.com/services/EXAMPLE".into()),
            command_aliases: {
                let mut m = std::collections::HashMap::new();
                m.insert("dp".to_string(), "deploy --auto-rollback 5m".to_string());
                m.insert(
                    "shipit".to_string(),
                    "promote-env staging prod --wait-for-green 5m".to_string(),
                );
                m
            },
            lint_disable: vec!["EBL003".into(), "EBL006".into()],
        };

        let body = serialize(&cfg);
        let reparsed = parse(&body);
        assert_eq!(reparsed.refresh_interval, cfg.refresh_interval);
        assert_eq!(reparsed.extra_regions, cfg.extra_regions);
        assert_eq!(reparsed.redact_default, cfg.redact_default);
        assert_eq!(reparsed.grouped_default, cfg.grouped_default);
        assert_eq!(reparsed.theme, cfg.theme);
        assert_eq!(reparsed.icons, cfg.icons);
        assert_eq!(reparsed.notify_bell, cfg.notify_bell);
        assert_eq!(reparsed.required_tags, cfg.required_tags);
        assert_eq!(reparsed.profile_themes, cfg.profile_themes);
        assert_eq!(reparsed.notify_webhook, cfg.notify_webhook);
        assert_eq!(reparsed.command_aliases, cfg.command_aliases);
        assert_eq!(reparsed.lint_disable, cfg.lint_disable);
    }

    #[test]
    fn parse_lint_disable_csv_collects_into_vec() {
        let body = "lint.disable = \"EBL001, EBL003 ,EBL006\"\n";
        let cfg = parse(body);
        assert_eq!(cfg.lint_disable, vec!["EBL001", "EBL003", "EBL006"]);
    }

    #[test]
    fn parse_lint_disable_skips_empty_tokens() {
        // Trailing comma / double-comma should not produce empty
        // rule IDs that would silently never match.
        let body = "lint.disable = \"EBL001,,EBL003,\"\n";
        let cfg = parse(body);
        assert_eq!(cfg.lint_disable, vec!["EBL001", "EBL003"]);
    }

    #[test]
    fn parse_alias_lines_collect_into_command_aliases() {
        let body = "alias.dp = \"deploy --auto-rollback 5m\"\nalias.foo = \"rebuild\"\n";
        let cfg = parse(body);
        assert_eq!(cfg.command_aliases.len(), 2);
        assert_eq!(
            cfg.command_aliases.get("dp").map(|s| s.as_str()),
            Some("deploy --auto-rollback 5m"),
        );
        assert_eq!(
            cfg.command_aliases.get("foo").map(|s| s.as_str()),
            Some("rebuild"),
        );
    }

    #[test]
    fn parse_alias_rejects_dotted_names_and_empty_values() {
        // Dotted alias names would conflict with hierarchical
        // config-key conventions if we ever add `alias.NAME.field`
        // style. Skip silently rather than erroring.
        let body = "alias.dp.prod = \"deploy\"\nalias.empty = \"\"\nalias. = \"x\"\n";
        let cfg = parse(body);
        assert!(cfg.command_aliases.is_empty());
    }

    #[test]
    fn serialize_round_trips_default_config() {
        let cfg = Config::default();
        let body = serialize(&cfg);
        let reparsed = parse(&body);
        assert_eq!(reparsed.refresh_interval, cfg.refresh_interval);
        assert_eq!(reparsed.theme, cfg.theme);
        assert_eq!(reparsed.icons, cfg.icons);
        assert!(reparsed.extra_regions.is_empty());
        assert!(reparsed.required_tags.is_empty());
    }
}
