use std::path::PathBuf;

/// Display name for a provider used in `# purple:group` headers.
/// Mirrors `providers::provider_display_name()` without a cross-module dependency.
fn provider_group_display_name(name: &str) -> &str {
    match name {
        "digitalocean" => "DigitalOcean",
        "vultr" => "Vultr",
        "linode" => "Linode",
        "hetzner" => "Hetzner",
        "upcloud" => "UpCloud",
        "proxmox" => "Proxmox VE",
        "aws" => "AWS EC2",
        "scaleway" => "Scaleway",
        "gcp" => "GCP",
        "azure" => "Azure",
        "tailscale" => "Tailscale",
        "oracle" => "Oracle Cloud",
        other => other,
    }
}

/// Represents the entire SSH config file as a sequence of elements.
/// Preserves the original structure for round-trip fidelity.
#[derive(Debug, Clone)]
pub struct SshConfigFile {
    pub elements: Vec<ConfigElement>,
    pub path: PathBuf,
    /// Whether the original file used CRLF line endings.
    pub crlf: bool,
    /// Whether the original file started with a UTF-8 BOM.
    pub bom: bool,
}

/// An Include directive that references other config files.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct IncludeDirective {
    pub raw_line: String,
    pub pattern: String,
    pub resolved_files: Vec<IncludedFile>,
}

/// A file resolved from an Include directive.
#[derive(Debug, Clone)]
pub struct IncludedFile {
    pub path: PathBuf,
    pub elements: Vec<ConfigElement>,
}

/// A single element in the config file.
#[derive(Debug, Clone)]
pub enum ConfigElement {
    /// A Host block: the "Host <pattern>" line plus all indented directives.
    HostBlock(HostBlock),
    /// A comment, blank line, or global directive not inside a Host block.
    GlobalLine(String),
    /// An Include directive referencing other config files (read-only).
    Include(IncludeDirective),
}

/// A parsed Host block with its directives.
#[derive(Debug, Clone)]
pub struct HostBlock {
    /// The host alias/pattern (the value after "Host").
    pub host_pattern: String,
    /// The original raw "Host ..." line for faithful reproduction.
    pub raw_host_line: String,
    /// Parsed directives inside this block.
    pub directives: Vec<Directive>,
}

/// A directive line inside a Host block.
#[derive(Debug, Clone)]
pub struct Directive {
    /// The directive key (e.g., "HostName", "User", "Port").
    pub key: String,
    /// The directive value.
    pub value: String,
    /// The original raw line (preserves indentation, inline comments).
    pub raw_line: String,
    /// Whether this is a comment-only or blank line inside a host block.
    pub is_non_directive: bool,
}

/// Convenience view for the TUI — extracted from a HostBlock.
#[derive(Debug, Clone)]
pub struct HostEntry {
    pub alias: String,
    pub hostname: String,
    pub user: String,
    pub port: u16,
    pub identity_file: String,
    pub proxy_jump: String,
    /// If this host comes from an included file, the file path.
    pub source_file: Option<PathBuf>,
    /// User-added tags from purple:tags comment.
    pub tags: Vec<String>,
    /// Provider-synced tags from purple:provider_tags comment.
    pub provider_tags: Vec<String>,
    /// Whether a purple:provider_tags comment exists (distinguishes "never migrated" from "empty").
    pub has_provider_tags: bool,
    /// Cloud provider label from purple:provider comment (e.g. "do", "vultr").
    pub provider: Option<String>,
    /// Number of tunnel forwarding directives.
    pub tunnel_count: u16,
    /// Password source from purple:askpass comment (e.g. "keychain", "op://...", "pass:...").
    pub askpass: Option<String>,
    /// Provider metadata from purple:meta comment (region, plan, etc.).
    pub provider_meta: Vec<(String, String)>,
    /// Unix timestamp when the host was marked stale (disappeared from provider sync).
    pub stale: Option<u64>,
}

impl Default for HostEntry {
    fn default() -> Self {
        Self {
            alias: String::new(),
            hostname: String::new(),
            user: String::new(),
            port: 22,
            identity_file: String::new(),
            proxy_jump: String::new(),
            source_file: None,
            tags: Vec::new(),
            provider_tags: Vec::new(),
            has_provider_tags: false,
            provider: None,
            tunnel_count: 0,
            askpass: None,
            provider_meta: Vec::new(),
            stale: None,
        }
    }
}

impl HostEntry {
    /// Build the SSH command string for this host.
    /// Includes `-F <config_path>` when the config is non-default so the alias
    /// resolves correctly when pasted into a terminal.
    /// Shell-quotes both the config path and alias to prevent injection.
    pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
        let escaped = self.alias.replace('\'', "'\\''");
        let default = dirs::home_dir()
            .map(|h| h.join(".ssh/config"))
            .unwrap_or_default();
        if config_path == default {
            format!("ssh -- '{}'", escaped)
        } else {
            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
            format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
        }
    }
}

/// Convenience view for pattern Host blocks in the TUI.
#[derive(Debug, Clone, Default)]
pub struct PatternEntry {
    pub pattern: String,
    pub hostname: String,
    pub user: String,
    pub port: u16,
    pub identity_file: String,
    pub proxy_jump: String,
    pub tags: Vec<String>,
    pub askpass: Option<String>,
    pub source_file: Option<PathBuf>,
    /// All non-comment directives as key-value pairs for display.
    pub directives: Vec<(String, String)>,
}

/// Returns true if the host pattern contains wildcards, character classes,
/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
/// These are SSH match patterns, not concrete hosts.
pub fn is_host_pattern(pattern: &str) -> bool {
    pattern.contains('*')
        || pattern.contains('?')
        || pattern.contains('[')
        || pattern.starts_with('!')
        || pattern.contains(' ')
        || pattern.contains('\t')
}

/// Match a text string against an SSH host pattern.
/// Supports `*` (any sequence), `?` (single char), `[charset]` (character class),
/// `[!charset]`/`[^charset]` (negated class), `[a-z]` (ranges) and `!pattern` (negation).
pub fn ssh_pattern_match(pattern: &str, text: &str) -> bool {
    if let Some(rest) = pattern.strip_prefix('!') {
        return !match_glob(rest, text);
    }
    match_glob(pattern, text)
}

/// Core glob matcher without negation prefix handling.
/// Empty text only matches empty pattern.
fn match_glob(pattern: &str, text: &str) -> bool {
    if text.is_empty() {
        return pattern.is_empty();
    }
    if pattern.is_empty() {
        return false;
    }
    let pat: Vec<char> = pattern.chars().collect();
    let txt: Vec<char> = text.chars().collect();
    glob_match(&pat, &txt)
}

/// Iterative glob matching with star-backtracking.
fn glob_match(pat: &[char], txt: &[char]) -> bool {
    let mut pi = 0;
    let mut ti = 0;
    let mut star: Option<(usize, usize)> = None; // (pattern_pos, text_pos)

    while ti < txt.len() {
        if pi < pat.len() && pat[pi] == '?' {
            pi += 1;
            ti += 1;
        } else if pi < pat.len() && pat[pi] == '*' {
            star = Some((pi + 1, ti));
            pi += 1;
        } else if pi < pat.len() && pat[pi] == '[' {
            if let Some((matches, end)) = match_char_class(pat, pi, txt[ti]) {
                if matches {
                    pi = end;
                    ti += 1;
                } else if let Some((spi, sti)) = star {
                    let sti = sti + 1;
                    star = Some((spi, sti));
                    pi = spi;
                    ti = sti;
                } else {
                    return false;
                }
            } else if let Some((spi, sti)) = star {
                // Malformed class: backtrack
                let sti = sti + 1;
                star = Some((spi, sti));
                pi = spi;
                ti = sti;
            } else {
                return false;
            }
        } else if pi < pat.len() && pat[pi] == txt[ti] {
            pi += 1;
            ti += 1;
        } else if let Some((spi, sti)) = star {
            let sti = sti + 1;
            star = Some((spi, sti));
            pi = spi;
            ti = sti;
        } else {
            return false;
        }
    }

    while pi < pat.len() && pat[pi] == '*' {
        pi += 1;
    }
    pi == pat.len()
}

/// Parse and match a `[...]` character class starting at `pat[start]`.
/// Returns `Some((matched, end_index))` where `end_index` is past `]`.
/// Returns `None` if no closing `]` is found.
fn match_char_class(pat: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
    let mut i = start + 1;
    if i >= pat.len() {
        return None;
    }

    let negate = pat[i] == '!' || pat[i] == '^';
    if negate {
        i += 1;
    }

    let mut matched = false;
    while i < pat.len() && pat[i] != ']' {
        if i + 2 < pat.len() && pat[i + 1] == '-' && pat[i + 2] != ']' {
            let lo = pat[i];
            let hi = pat[i + 2];
            if ch >= lo && ch <= hi {
                matched = true;
            }
            i += 3;
        } else {
            matched |= pat[i] == ch;
            i += 1;
        }
    }

    if i >= pat.len() {
        return None;
    }

    let result = if negate { !matched } else { matched };
    Some((result, i + 1))
}

/// Check whether a `Host` pattern matches a given alias.
/// OpenSSH `Host` keyword matches only against the target alias typed on the
/// command line, never against the resolved HostName.
pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
    let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
    if patterns.is_empty() {
        return false;
    }

    let mut any_positive_match = false;
    for pat in &patterns {
        if let Some(neg) = pat.strip_prefix('!') {
            if match_glob(neg, alias) {
                return false;
            }
        } else if ssh_pattern_match(pat, alias) {
            any_positive_match = true;
        }
    }

    any_positive_match
}

impl HostBlock {
    /// Index of the first trailing blank line (for inserting content before separators).
    fn content_end(&self) -> usize {
        let mut pos = self.directives.len();
        while pos > 0 {
            if self.directives[pos - 1].is_non_directive
                && self.directives[pos - 1].raw_line.trim().is_empty()
            {
                pos -= 1;
            } else {
                break;
            }
        }
        pos
    }

    /// Remove and return trailing blank lines.
    fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
        let end = self.content_end();
        self.directives.drain(end..).collect()
    }

    /// Ensure exactly one trailing blank line.
    fn ensure_trailing_blank(&mut self) {
        self.pop_trailing_blanks();
        self.directives.push(Directive {
            key: String::new(),
            value: String::new(),
            raw_line: String::new(),
            is_non_directive: true,
        });
    }

    /// Detect indentation used by existing directives (falls back to "  ").
    fn detect_indent(&self) -> String {
        for d in &self.directives {
            if !d.is_non_directive && !d.raw_line.is_empty() {
                let trimmed = d.raw_line.trim_start();
                let indent_len = d.raw_line.len() - trimmed.len();
                if indent_len > 0 {
                    return d.raw_line[..indent_len].to_string();
                }
            }
        }
        "  ".to_string()
    }

    /// Extract tags from purple:tags comment in directives.
    pub fn tags(&self) -> Vec<String> {
        for d in &self.directives {
            if d.is_non_directive {
                let trimmed = d.raw_line.trim();
                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
                    return rest
                        .split(',')
                        .map(|t| t.trim().to_string())
                        .filter(|t| !t.is_empty())
                        .collect();
                }
            }
        }
        Vec::new()
    }

    /// Extract provider-synced tags from purple:provider_tags comment.
    pub fn provider_tags(&self) -> Vec<String> {
        for d in &self.directives {
            if d.is_non_directive {
                let trimmed = d.raw_line.trim();
                if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
                    return rest
                        .split(',')
                        .map(|t| t.trim().to_string())
                        .filter(|t| !t.is_empty())
                        .collect();
                }
            }
        }
        Vec::new()
    }

    /// Check if a purple:provider_tags comment exists (even if empty).
    /// Used to distinguish "never migrated" from "migrated with no tags".
    pub fn has_provider_tags_comment(&self) -> bool {
        self.directives.iter().any(|d| {
            d.is_non_directive && {
                let t = d.raw_line.trim();
                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
            }
        })
    }

    /// Extract provider info from purple:provider comment in directives.
    /// Returns (provider_name, server_id), e.g. ("digitalocean", "412345678").
    pub fn provider(&self) -> Option<(String, String)> {
        for d in &self.directives {
            if d.is_non_directive {
                let trimmed = d.raw_line.trim();
                if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
                    if let Some((name, id)) = rest.split_once(':') {
                        return Some((name.trim().to_string(), id.trim().to_string()));
                    }
                }
            }
        }
        None
    }

    /// Set provider on a host block. Replaces existing purple:provider comment or adds one.
    pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
        let indent = self.detect_indent();
        self.directives.retain(|d| {
            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
        });
        let pos = self.content_end();
        self.directives.insert(
            pos,
            Directive {
                key: String::new(),
                value: String::new(),
                raw_line: format!(
                    "{}# purple:provider {}:{}",
                    indent, provider_name, server_id
                ),
                is_non_directive: true,
            },
        );
    }

    /// Extract askpass source from purple:askpass comment in directives.
    pub fn askpass(&self) -> Option<String> {
        for d in &self.directives {
            if d.is_non_directive {
                let trimmed = d.raw_line.trim();
                if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
                    let val = rest.trim();
                    if !val.is_empty() {
                        return Some(val.to_string());
                    }
                }
            }
        }
        None
    }

    /// Set askpass source on a host block. Replaces existing purple:askpass comment or adds one.
    /// Pass an empty string to remove the comment.
    pub fn set_askpass(&mut self, source: &str) {
        let indent = self.detect_indent();
        self.directives.retain(|d| {
            !(d.is_non_directive && {
                let t = d.raw_line.trim();
                t == "# purple:askpass" || t.starts_with("# purple:askpass ")
            })
        });
        if !source.is_empty() {
            let pos = self.content_end();
            self.directives.insert(
                pos,
                Directive {
                    key: String::new(),
                    value: String::new(),
                    raw_line: format!("{}# purple:askpass {}", indent, source),
                    is_non_directive: true,
                },
            );
        }
    }

    /// Extract provider metadata from purple:meta comment in directives.
    /// Format: `# purple:meta key=value,key=value`
    pub fn meta(&self) -> Vec<(String, String)> {
        for d in &self.directives {
            if d.is_non_directive {
                let trimmed = d.raw_line.trim();
                if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
                    return rest
                        .split(',')
                        .filter_map(|pair| {
                            let (k, v) = pair.split_once('=')?;
                            let k = k.trim();
                            let v = v.trim();
                            if k.is_empty() {
                                None
                            } else {
                                Some((k.to_string(), v.to_string()))
                            }
                        })
                        .collect();
                }
            }
        }
        Vec::new()
    }

    /// Set provider metadata on a host block. Replaces existing purple:meta comment or adds one.
    /// Pass an empty slice to remove the comment.
    pub fn set_meta(&mut self, meta: &[(String, String)]) {
        let indent = self.detect_indent();
        self.directives.retain(|d| {
            !(d.is_non_directive && {
                let t = d.raw_line.trim();
                t == "# purple:meta" || t.starts_with("# purple:meta ")
            })
        });
        if !meta.is_empty() {
            let encoded: Vec<String> = meta
                .iter()
                .map(|(k, v)| {
                    let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
                    let clean_v = Self::sanitize_tag(&v.replace(',', ""));
                    format!("{}={}", clean_k, clean_v)
                })
                .collect();
            let pos = self.content_end();
            self.directives.insert(
                pos,
                Directive {
                    key: String::new(),
                    value: String::new(),
                    raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
                    is_non_directive: true,
                },
            );
        }
    }

    /// Extract stale timestamp from purple:stale comment in directives.
    /// Returns `None` if absent or malformed.
    pub fn stale(&self) -> Option<u64> {
        for d in &self.directives {
            if d.is_non_directive {
                let trimmed = d.raw_line.trim();
                if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
                    return rest.trim().parse::<u64>().ok();
                }
            }
        }
        None
    }

    /// Mark a host block as stale with a unix timestamp.
    /// Replaces existing purple:stale comment or adds one.
    pub fn set_stale(&mut self, timestamp: u64) {
        let indent = self.detect_indent();
        self.clear_stale();
        let pos = self.content_end();
        self.directives.insert(
            pos,
            Directive {
                key: String::new(),
                value: String::new(),
                raw_line: format!("{}# purple:stale {}", indent, timestamp),
                is_non_directive: true,
            },
        );
    }

    /// Remove stale marking from a host block.
    pub fn clear_stale(&mut self) {
        self.directives.retain(|d| {
            !(d.is_non_directive && {
                let t = d.raw_line.trim();
                t == "# purple:stale" || t.starts_with("# purple:stale ")
            })
        });
    }

    /// Sanitize a tag value: strip control characters, commas (delimiter),
    /// and Unicode format/bidi override characters. Truncate to 128 chars.
    fn sanitize_tag(tag: &str) -> String {
        tag.chars()
            .filter(|c| {
                !c.is_control()
                    && *c != ','
                    && !('\u{200B}'..='\u{200F}').contains(c) // zero-width, bidi marks
                    && !('\u{202A}'..='\u{202E}').contains(c) // bidi embedding/override
                    && !('\u{2066}'..='\u{2069}').contains(c) // bidi isolate
                    && *c != '\u{FEFF}' // BOM/zero-width no-break space
            })
            .take(128)
            .collect()
    }

    /// Set user tags on a host block. Replaces existing purple:tags comment or adds one.
    pub fn set_tags(&mut self, tags: &[String]) {
        let indent = self.detect_indent();
        self.directives.retain(|d| {
            !(d.is_non_directive && {
                let t = d.raw_line.trim();
                t == "# purple:tags" || t.starts_with("# purple:tags ")
            })
        });
        let sanitized: Vec<String> = tags
            .iter()
            .map(|t| Self::sanitize_tag(t))
            .filter(|t| !t.is_empty())
            .collect();
        if !sanitized.is_empty() {
            let pos = self.content_end();
            self.directives.insert(
                pos,
                Directive {
                    key: String::new(),
                    value: String::new(),
                    raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
                    is_non_directive: true,
                },
            );
        }
    }

    /// Set provider-synced tags. Replaces existing purple:provider_tags comment.
    /// Always writes the comment (even when empty) as a migration sentinel.
    pub fn set_provider_tags(&mut self, tags: &[String]) {
        let indent = self.detect_indent();
        self.directives.retain(|d| {
            !(d.is_non_directive && {
                let t = d.raw_line.trim();
                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
            })
        });
        let sanitized: Vec<String> = tags
            .iter()
            .map(|t| Self::sanitize_tag(t))
            .filter(|t| !t.is_empty())
            .collect();
        let raw = if sanitized.is_empty() {
            format!("{}# purple:provider_tags", indent)
        } else {
            format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
        };
        let pos = self.content_end();
        self.directives.insert(
            pos,
            Directive {
                key: String::new(),
                value: String::new(),
                raw_line: raw,
                is_non_directive: true,
            },
        );
    }

    /// Extract a convenience HostEntry view from this block.
    pub fn to_host_entry(&self) -> HostEntry {
        let mut entry = HostEntry {
            alias: self.host_pattern.clone(),
            port: 22,
            ..Default::default()
        };
        for d in &self.directives {
            if d.is_non_directive {
                continue;
            }
            if d.key.eq_ignore_ascii_case("hostname") {
                entry.hostname = d.value.clone();
            } else if d.key.eq_ignore_ascii_case("user") {
                entry.user = d.value.clone();
            } else if d.key.eq_ignore_ascii_case("port") {
                entry.port = d.value.parse().unwrap_or(22);
            } else if d.key.eq_ignore_ascii_case("identityfile") {
                if entry.identity_file.is_empty() {
                    entry.identity_file = d.value.clone();
                }
            } else if d.key.eq_ignore_ascii_case("proxyjump") {
                entry.proxy_jump = d.value.clone();
            }
        }
        entry.tags = self.tags();
        entry.provider_tags = self.provider_tags();
        entry.has_provider_tags = self.has_provider_tags_comment();
        entry.provider = self.provider().map(|(name, _)| name);
        entry.tunnel_count = self.tunnel_count();
        entry.askpass = self.askpass();
        entry.provider_meta = self.meta();
        entry.stale = self.stale();
        entry
    }

    /// Extract a convenience PatternEntry view from this block.
    pub fn to_pattern_entry(&self) -> PatternEntry {
        let mut entry = PatternEntry {
            pattern: self.host_pattern.clone(),
            hostname: String::new(),
            user: String::new(),
            port: 22,
            identity_file: String::new(),
            proxy_jump: String::new(),
            tags: self.tags(),
            askpass: self.askpass(),
            source_file: None,
            directives: Vec::new(),
        };
        for d in &self.directives {
            if d.is_non_directive {
                continue;
            }
            match d.key.to_ascii_lowercase().as_str() {
                "hostname" => entry.hostname = d.value.clone(),
                "user" => entry.user = d.value.clone(),
                "port" => entry.port = d.value.parse().unwrap_or(22),
                "identityfile" => {
                    if entry.identity_file.is_empty() {
                        entry.identity_file = d.value.clone();
                    }
                }
                "proxyjump" => entry.proxy_jump = d.value.clone(),
                _ => {}
            }
            entry.directives.push((d.key.clone(), d.value.clone()));
        }
        entry
    }

    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
    pub fn tunnel_count(&self) -> u16 {
        let count = self
            .directives
            .iter()
            .filter(|d| {
                !d.is_non_directive
                    && (d.key.eq_ignore_ascii_case("localforward")
                        || d.key.eq_ignore_ascii_case("remoteforward")
                        || d.key.eq_ignore_ascii_case("dynamicforward"))
            })
            .count();
        count.min(u16::MAX as usize) as u16
    }

    /// Check if this block has any tunnel forwarding directives.
    #[allow(dead_code)]
    pub fn has_tunnels(&self) -> bool {
        self.directives.iter().any(|d| {
            !d.is_non_directive
                && (d.key.eq_ignore_ascii_case("localforward")
                    || d.key.eq_ignore_ascii_case("remoteforward")
                    || d.key.eq_ignore_ascii_case("dynamicforward"))
        })
    }

    /// Extract tunnel rules from forwarding directives.
    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
        self.directives
            .iter()
            .filter(|d| !d.is_non_directive)
            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
            .collect()
    }
}

impl SshConfigFile {
    /// Get all host entries as convenience views (including from Include files).
    pub fn host_entries(&self) -> Vec<HostEntry> {
        let mut entries = Vec::new();
        Self::collect_host_entries(&self.elements, &mut entries);
        entries
    }

    /// Get all pattern entries as convenience views (including from Include files).
    pub fn pattern_entries(&self) -> Vec<PatternEntry> {
        let mut entries = Vec::new();
        Self::collect_pattern_entries(&self.elements, &mut entries);
        entries
    }

    fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
        for e in elements {
            match e {
                ConfigElement::HostBlock(block) => {
                    if !is_host_pattern(&block.host_pattern) {
                        continue;
                    }
                    entries.push(block.to_pattern_entry());
                }
                ConfigElement::Include(include) => {
                    for file in &include.resolved_files {
                        let start = entries.len();
                        Self::collect_pattern_entries(&file.elements, entries);
                        for entry in &mut entries[start..] {
                            if entry.source_file.is_none() {
                                entry.source_file = Some(file.path.clone());
                            }
                        }
                    }
                }
                ConfigElement::GlobalLine(_) => {}
            }
        }
    }

    /// Find all pattern blocks that match a given host alias and hostname.
    /// Returns entries in config order (first match first).
    pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
        let mut matches = Vec::new();
        Self::collect_matching_patterns(&self.elements, alias, &mut matches);
        matches
    }

    fn collect_matching_patterns(
        elements: &[ConfigElement],
        alias: &str,
        matches: &mut Vec<PatternEntry>,
    ) {
        for e in elements {
            match e {
                ConfigElement::HostBlock(block) => {
                    if !is_host_pattern(&block.host_pattern) {
                        continue;
                    }
                    if host_pattern_matches(&block.host_pattern, alias) {
                        matches.push(block.to_pattern_entry());
                    }
                }
                ConfigElement::Include(include) => {
                    for file in &include.resolved_files {
                        let start = matches.len();
                        Self::collect_matching_patterns(&file.elements, alias, matches);
                        for entry in &mut matches[start..] {
                            if entry.source_file.is_none() {
                                entry.source_file = Some(file.path.clone());
                            }
                        }
                    }
                }
                ConfigElement::GlobalLine(_) => {}
            }
        }
    }

    /// Collect all resolved Include file paths (recursively).
    pub fn include_paths(&self) -> Vec<PathBuf> {
        let mut paths = Vec::new();
        Self::collect_include_paths(&self.elements, &mut paths);
        paths
    }

    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
        for e in elements {
            if let ConfigElement::Include(include) = e {
                for file in &include.resolved_files {
                    paths.push(file.path.clone());
                    Self::collect_include_paths(&file.elements, paths);
                }
            }
        }
    }

    /// Collect parent directories of Include glob patterns.
    /// When a file is added/removed under a glob dir, the directory's mtime changes.
    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
        let config_dir = self.path.parent();
        let mut seen = std::collections::HashSet::new();
        let mut dirs = Vec::new();
        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
        dirs
    }

    fn collect_include_glob_dirs(
        elements: &[ConfigElement],
        config_dir: Option<&std::path::Path>,
        seen: &mut std::collections::HashSet<PathBuf>,
        dirs: &mut Vec<PathBuf>,
    ) {
        for e in elements {
            if let ConfigElement::Include(include) = e {
                // Split respecting quoted paths (same as resolve_include does)
                for single in Self::split_include_patterns(&include.pattern) {
                    let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
                    let resolved = if expanded.starts_with('/') {
                        PathBuf::from(&expanded)
                    } else if let Some(dir) = config_dir {
                        dir.join(&expanded)
                    } else {
                        continue;
                    };
                    if let Some(parent) = resolved.parent() {
                        let parent = parent.to_path_buf();
                        if seen.insert(parent.clone()) {
                            dirs.push(parent);
                        }
                    }
                }
                // Recurse into resolved files
                for file in &include.resolved_files {
                    Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
                }
            }
        }
    }

    /// Remove `# purple:group <Name>` headers that have no corresponding
    /// provider hosts. Returns the number of headers removed.
    pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
        // Collect all provider display names that have at least one host.
        let active_providers: std::collections::HashSet<String> = self
            .elements
            .iter()
            .filter_map(|e| {
                if let ConfigElement::HostBlock(block) = e {
                    block
                        .provider()
                        .map(|(name, _)| provider_group_display_name(&name).to_string())
                } else {
                    None
                }
            })
            .collect();

        let mut removed = 0;
        self.elements.retain(|e| {
            if let ConfigElement::GlobalLine(line) = e {
                if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
                    if !active_providers.contains(rest.trim()) {
                        removed += 1;
                        return false;
                    }
                }
            }
            true
        });
        removed
    }

    /// Repair configs where `# purple:group` comments were absorbed into the
    /// preceding host block's directives instead of being stored as GlobalLines.
    /// Returns the number of blocks that were repaired.
    pub fn repair_absorbed_group_comments(&mut self) -> usize {
        let mut repaired = 0;
        let mut idx = 0;
        while idx < self.elements.len() {
            let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
                block
                    .directives
                    .iter()
                    .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
            } else {
                false
            };

            if !needs_repair {
                idx += 1;
                continue;
            }

            // Find the index of the first absorbed group comment in this block's directives.
            let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
                block
            } else {
                unreachable!()
            };

            let group_idx = block
                .directives
                .iter()
                .position(|d| {
                    d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
                })
                .unwrap();

            // Find where trailing blanks before the group comment start.
            let mut keep_end = group_idx;
            while keep_end > 0
                && block.directives[keep_end - 1].is_non_directive
                && block.directives[keep_end - 1].raw_line.trim().is_empty()
            {
                keep_end -= 1;
            }

            // Collect everything from keep_end onward as GlobalLines.
            let extracted: Vec<ConfigElement> = block
                .directives
                .drain(keep_end..)
                .map(|d| ConfigElement::GlobalLine(d.raw_line))
                .collect();

            // Insert extracted GlobalLines right after this HostBlock.
            let insert_at = idx + 1;
            for (i, elem) in extracted.into_iter().enumerate() {
                self.elements.insert(insert_at + i, elem);
            }

            repaired += 1;
            // Advance past the inserted elements.
            idx = insert_at;
            // Skip the inserted elements to continue scanning.
            while idx < self.elements.len() {
                if let ConfigElement::HostBlock(_) = &self.elements[idx] {
                    break;
                }
                idx += 1;
            }
        }
        repaired
    }

    /// Recursively collect host entries from a list of elements.
    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
        for e in elements {
            match e {
                ConfigElement::HostBlock(block) => {
                    if is_host_pattern(&block.host_pattern) {
                        continue;
                    }
                    entries.push(block.to_host_entry());
                }
                ConfigElement::Include(include) => {
                    for file in &include.resolved_files {
                        let start = entries.len();
                        Self::collect_host_entries(&file.elements, entries);
                        for entry in &mut entries[start..] {
                            if entry.source_file.is_none() {
                                entry.source_file = Some(file.path.clone());
                            }
                        }
                    }
                }
                ConfigElement::GlobalLine(_) => {}
            }
        }
    }

    /// Check if a host alias already exists (including in Include files).
    /// Walks the element tree directly without building HostEntry structs.
    pub fn has_host(&self, alias: &str) -> bool {
        Self::has_host_in_elements(&self.elements, alias)
    }

    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
        for e in elements {
            match e {
                ConfigElement::HostBlock(block) => {
                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
                        return true;
                    }
                }
                ConfigElement::Include(include) => {
                    for file in &include.resolved_files {
                        if Self::has_host_in_elements(&file.elements, alias) {
                            return true;
                        }
                    }
                }
                ConfigElement::GlobalLine(_) => {}
            }
        }
        false
    }

    /// Check if a host alias is from an included file (read-only).
    /// Handles multi-pattern Host lines by splitting on whitespace.
    pub fn is_included_host(&self, alias: &str) -> bool {
        // Not in top-level elements → must be in an Include
        for e in &self.elements {
            match e {
                ConfigElement::HostBlock(block) => {
                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
                        return false;
                    }
                }
                ConfigElement::Include(include) => {
                    for file in &include.resolved_files {
                        if Self::has_host_in_elements(&file.elements, alias) {
                            return true;
                        }
                    }
                }
                ConfigElement::GlobalLine(_) => {}
            }
        }
        false
    }

    /// Add a new host entry to the config.
    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
    /// so that SSH "first match wins" semantics are preserved. If wildcards are
    /// only at the top of the file (acting as global defaults), appends at end.
    pub fn add_host(&mut self, entry: &HostEntry) {
        let block = Self::entry_to_block(entry);
        let insert_pos = self.find_trailing_pattern_start();

        if let Some(pos) = insert_pos {
            // Insert before the trailing pattern group, with blank separators
            let needs_blank_before = pos > 0
                && !matches!(
                    self.elements.get(pos - 1),
                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
                );
            let mut idx = pos;
            if needs_blank_before {
                self.elements
                    .insert(idx, ConfigElement::GlobalLine(String::new()));
                idx += 1;
            }
            self.elements.insert(idx, ConfigElement::HostBlock(block));
            // Ensure a blank separator after the new block (before the wildcard group)
            let after = idx + 1;
            if after < self.elements.len()
                && !matches!(
                    self.elements.get(after),
                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
                )
            {
                self.elements
                    .insert(after, ConfigElement::GlobalLine(String::new()));
            }
        } else {
            // No trailing patterns: append at end
            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
                self.elements.push(ConfigElement::GlobalLine(String::new()));
            }
            self.elements.push(ConfigElement::HostBlock(block));
        }
    }

    /// Find the start of a trailing group of wildcard/pattern Host blocks.
    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
    /// i.e. patterns start at position 0 — in that case we append at end).
    fn find_trailing_pattern_start(&self) -> Option<usize> {
        let mut first_pattern_pos = None;
        for i in (0..self.elements.len()).rev() {
            match &self.elements[i] {
                ConfigElement::HostBlock(block) => {
                    if is_host_pattern(&block.host_pattern) {
                        first_pattern_pos = Some(i);
                    } else {
                        // Found a concrete host: the trailing group starts after this
                        break;
                    }
                }
                ConfigElement::GlobalLine(_) => {
                    // Blank lines, comments, Match blocks between patterns: keep scanning
                    if first_pattern_pos.is_some() {
                        first_pattern_pos = Some(i);
                    }
                }
                ConfigElement::Include(_) => break,
            }
        }
        // Don't return position 0 — that means everything is patterns (or patterns at top)
        first_pattern_pos.filter(|&pos| pos > 0)
    }

    /// Check if the last element already ends with a blank line.
    pub fn last_element_has_trailing_blank(&self) -> bool {
        match self.elements.last() {
            Some(ConfigElement::HostBlock(block)) => block
                .directives
                .last()
                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
            _ => false,
        }
    }

    /// Update an existing host entry by alias.
    /// Merges changes into the existing block, preserving unknown directives.
    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern == old_alias {
                    // Update host pattern (preserve raw_host_line when alias unchanged)
                    if entry.alias != block.host_pattern {
                        block.host_pattern = entry.alias.clone();
                        block.raw_host_line = format!("Host {}", entry.alias);
                    }

                    // Merge known directives (update existing, add missing, remove empty)
                    Self::upsert_directive(block, "HostName", &entry.hostname);
                    Self::upsert_directive(block, "User", &entry.user);
                    if entry.port != 22 {
                        Self::upsert_directive(block, "Port", &entry.port.to_string());
                    } else {
                        // Remove explicit Port 22 (it's the default)
                        block
                            .directives
                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
                    }
                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
                    return;
                }
            }
        }
    }

    /// Update a directive in-place, add it if missing, or remove it if value is empty.
    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
        if value.is_empty() {
            block
                .directives
                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
            return;
        }
        let indent = block.detect_indent();
        for d in &mut block.directives {
            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
                // Only rebuild raw_line when value actually changed (preserves inline comments)
                if d.value != value {
                    d.value = value.to_string();
                    // Detect separator style from original raw_line and preserve it.
                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
                    // Only considers '=' as separator if it appears before any
                    // non-whitespace content (avoids matching '=' inside values
                    // like "IdentityFile ~/.ssh/id=prod").
                    let trimmed = d.raw_line.trim_start();
                    let after_key = &trimmed[d.key.len()..];
                    let sep = if after_key.trim_start().starts_with('=') {
                        let eq_pos = after_key.find('=').unwrap();
                        let after_eq = &after_key[eq_pos + 1..];
                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
                        after_key[..eq_pos + 1 + trailing_ws].to_string()
                    } else {
                        " ".to_string()
                    };
                    // Preserve inline comment from original raw_line (e.g. "# production")
                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
                    d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
                }
                return;
            }
        }
        // Not found — insert before trailing blanks
        let pos = block.content_end();
        block.directives.insert(
            pos,
            Directive {
                key: key.to_string(),
                value: value.to_string(),
                raw_line: format!("{}{} {}", indent, key, value),
                is_non_directive: false,
            },
        );
    }

    /// Extract the inline comment suffix from a directive's raw line.
    /// Returns the trailing portion (e.g. " # production") or empty string.
    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
        let trimmed = raw_line.trim_start();
        if trimmed.len() <= key.len() {
            return String::new();
        }
        // Skip past key and separator to reach the value portion
        let after_key = &trimmed[key.len()..];
        let rest = after_key.trim_start();
        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
        // Scan for inline comment (# preceded by whitespace, outside quotes)
        let bytes = rest.as_bytes();
        let mut in_quote = false;
        for i in 0..bytes.len() {
            if bytes[i] == b'"' {
                in_quote = !in_quote;
            } else if !in_quote
                && bytes[i] == b'#'
                && i > 0
                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
            {
                // Found comment start. The clean value ends before the whitespace preceding #.
                let clean_end = rest[..i].trim_end().len();
                return rest[clean_end..].to_string();
            }
        }
        String::new()
    }

    /// Set provider on a host block by alias.
    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern == alias {
                    block.set_provider(provider_name, server_id);
                    return;
                }
            }
        }
    }

    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
    /// Searches both top-level elements and Include files so that provider hosts
    /// in included configs are recognized during sync (prevents duplicate additions).
    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
        let mut results = Vec::new();
        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
        results
    }

    fn collect_provider_hosts(
        elements: &[ConfigElement],
        provider_name: &str,
        results: &mut Vec<(String, String)>,
    ) {
        for element in elements {
            match element {
                ConfigElement::HostBlock(block) => {
                    if let Some((name, id)) = block.provider() {
                        if name == provider_name {
                            results.push((block.host_pattern.clone(), id));
                        }
                    }
                }
                ConfigElement::Include(include) => {
                    for file in &include.resolved_files {
                        Self::collect_provider_hosts(&file.elements, provider_name, results);
                    }
                }
                ConfigElement::GlobalLine(_) => {}
            }
        }
    }

    /// Compare two directive values with whitespace normalization.
    /// Handles hand-edited configs with tabs or multiple spaces.
    fn values_match(a: &str, b: &str) -> bool {
        a.split_whitespace().eq(b.split_whitespace())
    }

    /// Add a forwarding directive to a host block.
    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
    /// Uses split_whitespace matching for multi-pattern Host lines.
    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern.split_whitespace().any(|p| p == alias) {
                    let indent = block.detect_indent();
                    let pos = block.content_end();
                    block.directives.insert(
                        pos,
                        Directive {
                            key: directive_key.to_string(),
                            value: value.to_string(),
                            raw_line: format!("{}{} {}", indent, directive_key, value),
                            is_non_directive: false,
                        },
                    );
                    return;
                }
            }
        }
    }

    /// Remove a specific forwarding directive from a host block.
    /// Matches key (case-insensitive) and value (whitespace-normalized).
    /// Uses split_whitespace matching for multi-pattern Host lines.
    /// Returns true if a directive was actually removed.
    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern.split_whitespace().any(|p| p == alias) {
                    if let Some(pos) = block.directives.iter().position(|d| {
                        !d.is_non_directive
                            && d.key.eq_ignore_ascii_case(directive_key)
                            && Self::values_match(&d.value, value)
                    }) {
                        block.directives.remove(pos);
                        return true;
                    }
                    return false;
                }
            }
        }
        false
    }

    /// Check if a host block has a specific forwarding directive.
    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
        for element in &self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern.split_whitespace().any(|p| p == alias) {
                    return block.directives.iter().any(|d| {
                        !d.is_non_directive
                            && d.key.eq_ignore_ascii_case(directive_key)
                            && Self::values_match(&d.value, value)
                    });
                }
            }
        }
        false
    }

    /// Find tunnel directives for a host alias, searching all elements including
    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
    /// Host lines.
    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
        Self::find_tunnel_directives_in(&self.elements, alias)
    }

    fn find_tunnel_directives_in(
        elements: &[ConfigElement],
        alias: &str,
    ) -> Vec<crate::tunnel::TunnelRule> {
        for element in elements {
            match element {
                ConfigElement::HostBlock(block) => {
                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
                        return block.tunnel_directives();
                    }
                }
                ConfigElement::Include(include) => {
                    for file in &include.resolved_files {
                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
                        if !rules.is_empty() {
                            return rules;
                        }
                    }
                }
                ConfigElement::GlobalLine(_) => {}
            }
        }
        Vec::new()
    }

    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
    pub fn deduplicate_alias(&self, base: &str) -> String {
        self.deduplicate_alias_excluding(base, None)
    }

    /// Generate a unique alias, optionally excluding one alias from collision detection.
    /// Used during rename so the host being renamed doesn't collide with itself.
    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
        let is_taken = |alias: &str| {
            if exclude == Some(alias) {
                return false;
            }
            self.has_host(alias)
        };
        if !is_taken(base) {
            return base.to_string();
        }
        for n in 2..=9999 {
            let candidate = format!("{}-{}", base, n);
            if !is_taken(&candidate) {
                return candidate;
            }
        }
        // Practically unreachable: fall back to PID-based suffix
        format!("{}-{}", base, std::process::id())
    }

    /// Set tags on a host block by alias.
    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern == alias {
                    block.set_tags(tags);
                    return;
                }
            }
        }
    }

    /// Set provider-synced tags on a host block by alias.
    pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern == alias {
                    block.set_provider_tags(tags);
                    return;
                }
            }
        }
    }

    /// Set askpass source on a host block by alias.
    pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern == alias {
                    block.set_askpass(source);
                    return;
                }
            }
        }
    }

    /// Set provider metadata on a host block by alias.
    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern == alias {
                    block.set_meta(meta);
                    return;
                }
            }
        }
    }

    /// Mark a host as stale by alias.
    pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern == alias {
                    block.set_stale(timestamp);
                    return;
                }
            }
        }
    }

    /// Clear stale marking from a host by alias.
    pub fn clear_host_stale(&mut self, alias: &str) {
        for element in &mut self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if block.host_pattern == alias {
                    block.clear_stale();
                    return;
                }
            }
        }
    }

    /// Collect all stale hosts with their timestamps.
    pub fn stale_hosts(&self) -> Vec<(String, u64)> {
        let mut result = Vec::new();
        for element in &self.elements {
            if let ConfigElement::HostBlock(block) = element {
                if let Some(ts) = block.stale() {
                    result.push((block.host_pattern.clone(), ts));
                }
            }
        }
        result
    }

    /// Delete a host entry by alias.
    #[allow(dead_code)]
    pub fn delete_host(&mut self, alias: &str) {
        // Before deletion, check if this host belongs to a provider so we can
        // clean up an orphaned group header afterwards.
        let provider_name = self.elements.iter().find_map(|e| {
            if let ConfigElement::HostBlock(b) = e {
                if b.host_pattern == alias {
                    return b.provider().map(|(name, _)| name);
                }
            }
            None
        });

        self.elements.retain(|e| match e {
            ConfigElement::HostBlock(block) => block.host_pattern != alias,
            _ => true,
        });

        // Remove orphaned group header if no hosts remain for the provider.
        if let Some(name) = provider_name {
            self.remove_orphaned_group_header(&name);
        }

        // Collapse consecutive blank lines left by deletion
        self.elements.dedup_by(|a, b| {
            matches!(
                (&*a, &*b),
                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
                if x.trim().is_empty() && y.trim().is_empty()
            )
        });
    }

    /// Delete a host and return the removed element and its position for undo.
    /// Does NOT collapse blank lines or remove group headers so the position
    /// stays valid for re-insertion via `insert_host_at()`.
    /// Orphaned group headers (if any) are cleaned up at next startup.
    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
        let pos = self
            .elements
            .iter()
            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
        let element = self.elements.remove(pos);
        Some((element, pos))
    }

    /// Find the position of the `# purple:group <DisplayName>` GlobalLine for a provider.
    #[allow(dead_code)]
    fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
        let display = provider_group_display_name(provider_name);
        let header = format!("# purple:group {}", display);
        self.elements
            .iter()
            .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
    }

    /// Remove the `# purple:group <DisplayName>` GlobalLine for a provider
    /// if no remaining HostBlock has a `# purple:provider <name>:` directive.
    fn remove_orphaned_group_header(&mut self, provider_name: &str) {
        if self.find_hosts_by_provider(provider_name).is_empty() {
            let display = provider_group_display_name(provider_name);
            let header = format!("# purple:group {}", display);
            self.elements
                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
        }
    }

    /// Insert a host block at a specific position (for undo).
    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
        let pos = position.min(self.elements.len());
        self.elements.insert(pos, element);
    }

    /// Find the position after the last HostBlock that belongs to a provider.
    /// Returns `None` if no hosts for this provider exist in the config.
    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
        let mut last_pos = None;
        for (i, element) in self.elements.iter().enumerate() {
            if let ConfigElement::HostBlock(block) = element {
                if let Some((name, _)) = block.provider() {
                    if name == provider_name {
                        last_pos = Some(i);
                    }
                }
            }
        }
        // Return position after the last provider host
        last_pos.map(|p| p + 1)
    }

    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
    #[allow(dead_code)]
    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
        let pos_a = self
            .elements
            .iter()
            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
        let pos_b = self
            .elements
            .iter()
            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
        if let (Some(a), Some(b)) = (pos_a, pos_b) {
            if a == b {
                return false;
            }
            let (first, second) = (a.min(b), a.max(b));

            // Strip trailing blanks from both blocks before swap
            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
                block.pop_trailing_blanks();
            }
            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
                block.pop_trailing_blanks();
            }

            // Swap
            self.elements.swap(first, second);

            // Add trailing blank to first block (separator between the two)
            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
                block.ensure_trailing_blank();
            }

            // Add trailing blank to second only if not the last element
            if second < self.elements.len() - 1 {
                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
                    block.ensure_trailing_blank();
                }
            }

            return true;
        }
        false
    }

    /// Convert a HostEntry into a new HostBlock with clean formatting.
    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
        let mut directives = Vec::new();

        if !entry.hostname.is_empty() {
            directives.push(Directive {
                key: "HostName".to_string(),
                value: entry.hostname.clone(),
                raw_line: format!("  HostName {}", entry.hostname),
                is_non_directive: false,
            });
        }
        if !entry.user.is_empty() {
            directives.push(Directive {
                key: "User".to_string(),
                value: entry.user.clone(),
                raw_line: format!("  User {}", entry.user),
                is_non_directive: false,
            });
        }
        if entry.port != 22 {
            directives.push(Directive {
                key: "Port".to_string(),
                value: entry.port.to_string(),
                raw_line: format!("  Port {}", entry.port),
                is_non_directive: false,
            });
        }
        if !entry.identity_file.is_empty() {
            directives.push(Directive {
                key: "IdentityFile".to_string(),
                value: entry.identity_file.clone(),
                raw_line: format!("  IdentityFile {}", entry.identity_file),
                is_non_directive: false,
            });
        }
        if !entry.proxy_jump.is_empty() {
            directives.push(Directive {
                key: "ProxyJump".to_string(),
                value: entry.proxy_jump.clone(),
                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
                is_non_directive: false,
            });
        }

        HostBlock {
            host_pattern: entry.alias.clone(),
            raw_host_line: format!("Host {}", entry.alias),
            directives,
        }
    }
}

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

    fn parse_str(content: &str) -> SshConfigFile {
        SshConfigFile {
            elements: SshConfigFile::parse_content(content),
            path: PathBuf::from("/tmp/test_config"),
            crlf: false,
            bom: false,
        }
    }

    #[test]
    fn tunnel_directives_extracts_forwards() {
        let config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
        );
        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
            let rules = block.tunnel_directives();
            assert_eq!(rules.len(), 3);
            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
            assert_eq!(rules[0].bind_port, 8080);
            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
        } else {
            panic!("Expected HostBlock");
        }
    }

    #[test]
    fn tunnel_count_counts_forwards() {
        let config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n",
        );
        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
            assert_eq!(block.tunnel_count(), 2);
        } else {
            panic!("Expected HostBlock");
        }
    }

    #[test]
    fn tunnel_count_zero_for_no_forwards() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
            assert_eq!(block.tunnel_count(), 0);
            assert!(!block.has_tunnels());
        } else {
            panic!("Expected HostBlock");
        }
    }

    #[test]
    fn has_tunnels_true_with_forward() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  DynamicForward 1080\n");
        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
            assert!(block.has_tunnels());
        } else {
            panic!("Expected HostBlock");
        }
    }

    #[test]
    fn add_forward_inserts_directive() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
        let output = config.serialize();
        assert!(output.contains("LocalForward 8080 localhost:80"));
        // Existing directives preserved
        assert!(output.contains("HostName 10.0.0.1"));
        assert!(output.contains("User admin"));
    }

    #[test]
    fn add_forward_preserves_indentation() {
        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
        let output = config.serialize();
        assert!(output.contains("\tLocalForward 8080 localhost:80"));
    }

    #[test]
    fn add_multiple_forwards_same_type() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
        config.add_forward("myserver", "LocalForward", "9090 localhost:90");
        let output = config.serialize();
        assert!(output.contains("LocalForward 8080 localhost:80"));
        assert!(output.contains("LocalForward 9090 localhost:90"));
    }

    #[test]
    fn remove_forward_removes_exact_match() {
        let mut config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
        );
        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
        let output = config.serialize();
        assert!(!output.contains("8080 localhost:80"));
        assert!(output.contains("9090 localhost:90"));
    }

    #[test]
    fn remove_forward_leaves_other_directives() {
        let mut config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  User admin\n",
        );
        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
        let output = config.serialize();
        assert!(!output.contains("LocalForward"));
        assert!(output.contains("HostName 10.0.0.1"));
        assert!(output.contains("User admin"));
    }

    #[test]
    fn remove_forward_no_match_is_noop() {
        let original = "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n";
        let mut config = parse_str(original);
        config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
        assert_eq!(config.serialize(), original);
    }

    #[test]
    fn host_entry_tunnel_count_populated() {
        let config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  DynamicForward 1080\n",
        );
        let entries = config.host_entries();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].tunnel_count, 2);
    }

    #[test]
    fn remove_forward_returns_true_on_match() {
        let mut config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn remove_forward_returns_false_on_no_match() {
        let mut config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
        assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
    }

    #[test]
    fn remove_forward_returns_false_for_unknown_host() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn has_forward_finds_match() {
        let config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn has_forward_no_match() {
        let config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
        assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn has_forward_case_insensitive_key() {
        let config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n");
        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn add_forward_to_empty_block() {
        let mut config = parse_str("Host myserver\n");
        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
        let output = config.serialize();
        assert!(output.contains("LocalForward 8080 localhost:80"));
    }

    #[test]
    fn remove_forward_case_insensitive_key_match() {
        let mut config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n");
        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
        assert!(!config.serialize().contains("localforward"));
    }

    #[test]
    fn tunnel_count_case_insensitive() {
        let config = parse_str(
            "Host myserver\n  localforward 8080 localhost:80\n  REMOTEFORWARD 9090 localhost:90\n  dynamicforward 1080\n",
        );
        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
            assert_eq!(block.tunnel_count(), 3);
        } else {
            panic!("Expected HostBlock");
        }
    }

    #[test]
    fn tunnel_directives_extracts_all_types() {
        let config = parse_str(
            "Host myserver\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
        );
        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
            let rules = block.tunnel_directives();
            assert_eq!(rules.len(), 3);
            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
        } else {
            panic!("Expected HostBlock");
        }
    }

    #[test]
    fn tunnel_directives_skips_malformed() {
        let config = parse_str("Host myserver\n  LocalForward not_valid\n  DynamicForward 1080\n");
        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
            let rules = block.tunnel_directives();
            assert_eq!(rules.len(), 1);
            assert_eq!(rules[0].bind_port, 1080);
        } else {
            panic!("Expected HostBlock");
        }
    }

    #[test]
    fn find_tunnel_directives_multi_pattern_host() {
        let config =
            parse_str("Host prod staging\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
        let rules = config.find_tunnel_directives("prod");
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].bind_port, 8080);
        let rules2 = config.find_tunnel_directives("staging");
        assert_eq!(rules2.len(), 1);
    }

    #[test]
    fn find_tunnel_directives_no_match() {
        let config = parse_str("Host myserver\n  LocalForward 8080 localhost:80\n");
        let rules = config.find_tunnel_directives("nohost");
        assert!(rules.is_empty());
    }

    #[test]
    fn has_forward_exact_match() {
        let config = parse_str("Host myserver\n  LocalForward 8080 localhost:80\n");
        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
        assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
        assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn has_forward_whitespace_normalized() {
        let config = parse_str("Host myserver\n  LocalForward 8080  localhost:80\n");
        // Extra space in config value vs single space in query — should still match
        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn has_forward_multi_pattern_host() {
        let config = parse_str("Host prod staging\n  LocalForward 8080 localhost:80\n");
        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn add_forward_multi_pattern_host() {
        let mut config = parse_str("Host prod staging\n  HostName 10.0.0.1\n");
        config.add_forward("prod", "LocalForward", "8080 localhost:80");
        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn remove_forward_multi_pattern_host() {
        let mut config = parse_str(
            "Host prod staging\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
        );
        assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
        assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
        // Other forward should remain
        assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
    }

    #[test]
    fn edit_tunnel_detects_duplicate_after_remove() {
        // Simulates edit flow: remove old, then check if new value already exists
        let mut config = parse_str(
            "Host myserver\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
        );
        // Edit rule A (8080) toward rule B (9090): remove A first
        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
        // Now check if the target value already exists — should detect duplicate
        assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
    }

    #[test]
    fn has_forward_tab_whitespace_normalized() {
        let config = parse_str("Host myserver\n  LocalForward 8080\tlocalhost:80\n");
        // Tab in config value vs space in query — should match via values_match
        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn remove_forward_tab_whitespace_normalized() {
        let mut config = parse_str("Host myserver\n  LocalForward 8080\tlocalhost:80\n");
        // Remove with single space should match tab-separated value
        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
        assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
    }

    #[test]
    fn upsert_preserves_space_separator_when_value_contains_equals() {
        let mut config = parse_str("Host myserver\n  IdentityFile ~/.ssh/id=prod\n");
        let entry = HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.1".to_string(),
            identity_file: "~/.ssh/id=staging".to_string(),
            port: 22,
            ..Default::default()
        };
        config.update_host("myserver", &entry);
        let output = config.serialize();
        // Separator should remain a space, not pick up the = from the value
        assert!(
            output.contains("  IdentityFile ~/.ssh/id=staging"),
            "got: {}",
            output
        );
        assert!(!output.contains("IdentityFile="), "got: {}", output);
    }

    #[test]
    fn upsert_preserves_equals_separator() {
        let mut config = parse_str("Host myserver\n  IdentityFile=~/.ssh/id_rsa\n");
        let entry = HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.1".to_string(),
            identity_file: "~/.ssh/id_ed25519".to_string(),
            port: 22,
            ..Default::default()
        };
        config.update_host("myserver", &entry);
        let output = config.serialize();
        assert!(
            output.contains("IdentityFile=~/.ssh/id_ed25519"),
            "got: {}",
            output
        );
    }

    #[test]
    fn upsert_preserves_spaced_equals_separator() {
        let mut config = parse_str("Host myserver\n  IdentityFile = ~/.ssh/id_rsa\n");
        let entry = HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.1".to_string(),
            identity_file: "~/.ssh/id_ed25519".to_string(),
            port: 22,
            ..Default::default()
        };
        config.update_host("myserver", &entry);
        let output = config.serialize();
        assert!(
            output.contains("IdentityFile = ~/.ssh/id_ed25519"),
            "got: {}",
            output
        );
    }

    #[test]
    fn is_included_host_false_for_main_config() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        assert!(!config.is_included_host("myserver"));
    }

    #[test]
    fn is_included_host_false_for_nonexistent() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        assert!(!config.is_included_host("nohost"));
    }

    #[test]
    fn is_included_host_multi_pattern_main_config() {
        let config = parse_str("Host prod staging\n  HostName 10.0.0.1\n");
        assert!(!config.is_included_host("prod"));
        assert!(!config.is_included_host("staging"));
    }

    // =========================================================================
    // HostBlock::askpass() and set_askpass() tests
    // =========================================================================

    fn first_block(config: &SshConfigFile) -> &HostBlock {
        match config.elements.first().unwrap() {
            ConfigElement::HostBlock(b) => b,
            _ => panic!("Expected HostBlock"),
        }
    }

    fn first_block_mut(config: &mut SshConfigFile) -> &mut HostBlock {
        match config.elements.first_mut().unwrap() {
            ConfigElement::HostBlock(b) => b,
            _ => panic!("Expected HostBlock"),
        }
    }

    fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
        let mut count = 0;
        for el in &config.elements {
            if let ConfigElement::HostBlock(b) = el {
                if count == idx {
                    return b;
                }
                count += 1;
            }
        }
        panic!("No HostBlock at index {}", idx);
    }

    #[test]
    fn askpass_returns_none_when_absent() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        assert_eq!(first_block(&config).askpass(), None);
    }

    #[test]
    fn askpass_returns_keychain() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
    }

    #[test]
    fn askpass_returns_op_uri() {
        let config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass op://Vault/Item/field\n",
        );
        assert_eq!(
            first_block(&config).askpass(),
            Some("op://Vault/Item/field".to_string())
        );
    }

    #[test]
    fn askpass_returns_vault_with_field() {
        let config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:secret/ssh#password\n",
        );
        assert_eq!(
            first_block(&config).askpass(),
            Some("vault:secret/ssh#password".to_string())
        );
    }

    #[test]
    fn askpass_returns_bw_source() {
        let config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:my-item\n");
        assert_eq!(
            first_block(&config).askpass(),
            Some("bw:my-item".to_string())
        );
    }

    #[test]
    fn askpass_returns_pass_source() {
        let config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass pass:ssh/prod\n");
        assert_eq!(
            first_block(&config).askpass(),
            Some("pass:ssh/prod".to_string())
        );
    }

    #[test]
    fn askpass_returns_custom_command() {
        let config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass get-pass %a %h\n");
        assert_eq!(
            first_block(&config).askpass(),
            Some("get-pass %a %h".to_string())
        );
    }

    #[test]
    fn askpass_ignores_empty_value() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass \n");
        assert_eq!(first_block(&config).askpass(), None);
    }

    #[test]
    fn askpass_ignores_non_askpass_purple_comments() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod\n");
        assert_eq!(first_block(&config).askpass(), None);
    }

    #[test]
    fn set_askpass_adds_comment() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_askpass("myserver", "keychain");
        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
    }

    #[test]
    fn set_askpass_replaces_existing() {
        let mut config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
        config.set_host_askpass("myserver", "op://V/I/p");
        assert_eq!(
            first_block(&config).askpass(),
            Some("op://V/I/p".to_string())
        );
    }

    #[test]
    fn set_askpass_empty_removes_comment() {
        let mut config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
        config.set_host_askpass("myserver", "");
        assert_eq!(first_block(&config).askpass(), None);
    }

    #[test]
    fn set_askpass_preserves_other_directives() {
        let mut config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n  # purple:tags prod\n");
        config.set_host_askpass("myserver", "vault:secret/ssh");
        assert_eq!(
            first_block(&config).askpass(),
            Some("vault:secret/ssh".to_string())
        );
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.user, "admin");
        assert!(entry.tags.contains(&"prod".to_string()));
    }

    #[test]
    fn set_askpass_preserves_indent() {
        let mut config = parse_str("Host myserver\n    HostName 10.0.0.1\n");
        config.set_host_askpass("myserver", "keychain");
        let raw = first_block(&config)
            .directives
            .iter()
            .find(|d| d.raw_line.contains("purple:askpass"))
            .unwrap();
        assert!(
            raw.raw_line.starts_with("    "),
            "Expected 4-space indent, got: {:?}",
            raw.raw_line
        );
    }

    #[test]
    fn set_askpass_on_nonexistent_host() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_askpass("nohost", "keychain");
        assert_eq!(first_block(&config).askpass(), None);
    }

    #[test]
    fn to_entry_includes_askpass() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:item\n");
        let entries = config.host_entries();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
    }

    #[test]
    fn to_entry_askpass_none_when_absent() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        let entries = config.host_entries();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].askpass, None);
    }

    #[test]
    fn set_askpass_vault_with_hash_field() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
        assert_eq!(
            first_block(&config).askpass(),
            Some("vault:secret/data/team#api_key".to_string())
        );
    }

    #[test]
    fn set_askpass_custom_command_with_percent() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_askpass("myserver", "get-pass %a %h");
        assert_eq!(
            first_block(&config).askpass(),
            Some("get-pass %a %h".to_string())
        );
    }

    #[test]
    fn multiple_hosts_independent_askpass() {
        let mut config = parse_str("Host alpha\n  HostName a.com\n\nHost beta\n  HostName b.com\n");
        config.set_host_askpass("alpha", "keychain");
        config.set_host_askpass("beta", "vault:secret/ssh");
        assert_eq!(
            block_by_index(&config, 0).askpass(),
            Some("keychain".to_string())
        );
        assert_eq!(
            block_by_index(&config, 1).askpass(),
            Some("vault:secret/ssh".to_string())
        );
    }

    #[test]
    fn set_askpass_then_clear_then_set_again() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_askpass("myserver", "keychain");
        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
        config.set_host_askpass("myserver", "");
        assert_eq!(first_block(&config).askpass(), None);
        config.set_host_askpass("myserver", "op://V/I/p");
        assert_eq!(
            first_block(&config).askpass(),
            Some("op://V/I/p".to_string())
        );
    }

    #[test]
    fn askpass_tab_indent_preserved() {
        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
        config.set_host_askpass("myserver", "pass:ssh/prod");
        let raw = first_block(&config)
            .directives
            .iter()
            .find(|d| d.raw_line.contains("purple:askpass"))
            .unwrap();
        assert!(
            raw.raw_line.starts_with("\t"),
            "Expected tab indent, got: {:?}",
            raw.raw_line
        );
    }

    #[test]
    fn askpass_coexists_with_provider_comment() {
        let config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  # purple:provider do:123\n  # purple:askpass keychain\n",
        );
        let block = first_block(&config);
        assert_eq!(block.askpass(), Some("keychain".to_string()));
        assert!(block.provider().is_some());
    }

    #[test]
    fn set_askpass_does_not_remove_tags() {
        let mut config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod,staging\n");
        config.set_host_askpass("myserver", "keychain");
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.askpass, Some("keychain".to_string()));
        assert!(entry.tags.contains(&"prod".to_string()));
        assert!(entry.tags.contains(&"staging".to_string()));
    }

    #[test]
    fn askpass_idempotent_set_same_value() {
        let mut config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
        config.set_host_askpass("myserver", "keychain");
        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
        let serialized = config.serialize();
        assert_eq!(
            serialized.matches("purple:askpass").count(),
            1,
            "Should have exactly one askpass comment"
        );
    }

    #[test]
    fn askpass_with_value_containing_equals() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_askpass("myserver", "cmd --opt=val %h");
        assert_eq!(
            first_block(&config).askpass(),
            Some("cmd --opt=val %h".to_string())
        );
    }

    #[test]
    fn askpass_with_value_containing_hash() {
        let config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:a/b#c\n");
        assert_eq!(
            first_block(&config).askpass(),
            Some("vault:a/b#c".to_string())
        );
    }

    #[test]
    fn askpass_with_long_op_uri() {
        let uri = "op://My Personal Vault/SSH Production Server/password";
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_askpass("myserver", uri);
        assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
    }

    #[test]
    fn askpass_does_not_interfere_with_host_matching() {
        // askpass is stored as a non-directive comment; it shouldn't affect SSH matching
        let config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  User root\n  # purple:askpass keychain\n",
        );
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.user, "root");
        assert_eq!(entry.hostname, "10.0.0.1");
        assert_eq!(entry.askpass, Some("keychain".to_string()));
    }

    #[test]
    fn set_askpass_on_host_with_many_directives() {
        let config_str = "\
Host myserver
  HostName 10.0.0.1
  User admin
  Port 2222
  IdentityFile ~/.ssh/id_ed25519
  ProxyJump bastion
  # purple:tags prod,us-east
";
        let mut config = parse_str(config_str);
        config.set_host_askpass("myserver", "pass:ssh/prod");
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
        assert_eq!(entry.user, "admin");
        assert_eq!(entry.port, 2222);
        assert!(entry.tags.contains(&"prod".to_string()));
    }

    #[test]
    fn askpass_with_crlf_line_endings() {
        let config =
            parse_str("Host myserver\r\n  HostName 10.0.0.1\r\n  # purple:askpass keychain\r\n");
        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
    }

    #[test]
    fn askpass_only_on_first_matching_host() {
        // If two Host blocks have the same alias (unusual), askpass comes from first
        let config = parse_str(
            "Host dup\n  HostName a.com\n  # purple:askpass keychain\n\nHost dup\n  HostName b.com\n  # purple:askpass vault:x\n",
        );
        let entries = config.host_entries();
        // First match
        assert_eq!(entries[0].askpass, Some("keychain".to_string()));
    }

    #[test]
    fn set_askpass_preserves_other_non_directive_comments() {
        let config_str = "Host myserver\n  HostName 10.0.0.1\n  # This is a user comment\n  # purple:askpass old\n  # Another comment\n";
        let mut config = parse_str(config_str);
        config.set_host_askpass("myserver", "new-source");
        let serialized = config.serialize();
        assert!(serialized.contains("# This is a user comment"));
        assert!(serialized.contains("# Another comment"));
        assert!(serialized.contains("# purple:askpass new-source"));
        assert!(!serialized.contains("# purple:askpass old"));
    }

    #[test]
    fn askpass_mixed_with_tunnel_directives() {
        let config_str = "\
Host myserver
  HostName 10.0.0.1
  LocalForward 8080 localhost:80
  # purple:askpass bw:item
  RemoteForward 9090 localhost:9090
";
        let config = parse_str(config_str);
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.askpass, Some("bw:item".to_string()));
        assert_eq!(entry.tunnel_count, 2);
    }

    // =========================================================================
    // askpass: set_askpass idempotent (same value)
    // =========================================================================

    #[test]
    fn set_askpass_idempotent_same_value() {
        let config_str = "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n";
        let mut config = parse_str(config_str);
        config.set_host_askpass("myserver", "keychain");
        let output = config.serialize();
        // Should still have exactly one askpass comment
        assert_eq!(output.matches("purple:askpass").count(), 1);
        assert!(output.contains("# purple:askpass keychain"));
    }

    #[test]
    fn set_askpass_with_equals_in_value() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_askpass("myserver", "cmd --opt=val");
        let entries = config.host_entries();
        assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
    }

    #[test]
    fn set_askpass_with_hash_in_value() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_askpass("myserver", "vault:secret/data#field");
        let entries = config.host_entries();
        assert_eq!(
            entries[0].askpass,
            Some("vault:secret/data#field".to_string())
        );
    }

    #[test]
    fn set_askpass_long_op_uri() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
        config.set_host_askpass("myserver", long_uri);
        assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
    }

    #[test]
    fn askpass_host_with_multi_pattern_is_skipped() {
        // Multi-pattern host blocks ("Host prod staging") are treated as patterns
        // and are not included in host_entries(), so set_askpass is a no-op
        let config_str = "Host prod staging\n  HostName 10.0.0.1\n";
        let mut config = parse_str(config_str);
        config.set_host_askpass("prod", "keychain");
        // No entries because multi-pattern hosts are pattern hosts
        assert!(config.host_entries().is_empty());
    }

    #[test]
    fn askpass_survives_directive_reorder() {
        // askpass should survive even when directives are in unusual order
        let config_str = "\
Host myserver
  # purple:askpass op://V/I/p
  HostName 10.0.0.1
  User root
";
        let config = parse_str(config_str);
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
        assert_eq!(entry.hostname, "10.0.0.1");
    }

    #[test]
    fn askpass_among_many_purple_comments() {
        let config_str = "\
Host myserver
  HostName 10.0.0.1
  # purple:tags prod,us-east
  # purple:provider do:12345
  # purple:askpass pass:ssh/prod
";
        let config = parse_str(config_str);
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
        assert!(entry.tags.contains(&"prod".to_string()));
    }

    #[test]
    fn meta_empty_when_no_comment() {
        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
        let config = parse_str(config_str);
        let meta = first_block(&config).meta();
        assert!(meta.is_empty());
    }

    #[test]
    fn meta_parses_key_value_pairs() {
        let config_str = "\
Host myhost
  HostName 1.2.3.4
  # purple:meta region=nyc3,plan=s-1vcpu-1gb
";
        let config = parse_str(config_str);
        let meta = first_block(&config).meta();
        assert_eq!(meta.len(), 2);
        assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
        assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
    }

    #[test]
    fn meta_round_trip() {
        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
        let mut config = parse_str(config_str);
        let meta = vec![
            ("region".to_string(), "fra1".to_string()),
            ("plan".to_string(), "cx11".to_string()),
        ];
        config.set_host_meta("myhost", &meta);
        let output = config.serialize();
        assert!(output.contains("# purple:meta region=fra1,plan=cx11"));

        let config2 = parse_str(&output);
        let parsed = first_block(&config2).meta();
        assert_eq!(parsed, meta);
    }

    #[test]
    fn meta_replaces_existing() {
        let config_str = "\
Host myhost
  HostName 1.2.3.4
  # purple:meta region=old
";
        let mut config = parse_str(config_str);
        config.set_host_meta("myhost", &[("region".to_string(), "new".to_string())]);
        let output = config.serialize();
        assert!(!output.contains("region=old"));
        assert!(output.contains("region=new"));
    }

    #[test]
    fn meta_removed_when_empty() {
        let config_str = "\
Host myhost
  HostName 1.2.3.4
  # purple:meta region=nyc3
";
        let mut config = parse_str(config_str);
        config.set_host_meta("myhost", &[]);
        let output = config.serialize();
        assert!(!output.contains("purple:meta"));
    }

    #[test]
    fn meta_sanitizes_commas_in_values() {
        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
        let mut config = parse_str(config_str);
        let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
        config.set_host_meta("myhost", &meta);
        let output = config.serialize();
        // Comma stripped to prevent parse corruption
        assert!(output.contains("plan=s-1vcpu1gb"));

        let config2 = parse_str(&output);
        let parsed = first_block(&config2).meta();
        assert_eq!(parsed[0].1, "s-1vcpu1gb");
    }

    #[test]
    fn meta_in_host_entry() {
        let config_str = "\
Host myhost
  HostName 1.2.3.4
  # purple:meta region=nyc3,plan=s-1vcpu-1gb
";
        let config = parse_str(config_str);
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.provider_meta.len(), 2);
        assert_eq!(entry.provider_meta[0].0, "region");
        assert_eq!(entry.provider_meta[1].0, "plan");
    }

    #[test]
    fn repair_absorbed_group_comment() {
        // Simulate the bug: group comment absorbed into preceding block's directives.
        let mut config = SshConfigFile {
            elements: vec![ConfigElement::HostBlock(HostBlock {
                host_pattern: "myserver".to_string(),
                raw_host_line: "Host myserver".to_string(),
                directives: vec![
                    Directive {
                        key: "HostName".to_string(),
                        value: "10.0.0.1".to_string(),
                        raw_line: "  HostName 10.0.0.1".to_string(),
                        is_non_directive: false,
                    },
                    Directive {
                        key: String::new(),
                        value: String::new(),
                        raw_line: "# purple:group Production".to_string(),
                        is_non_directive: true,
                    },
                ],
            })],
            path: PathBuf::from("/tmp/test_config"),
            crlf: false,
            bom: false,
        };
        let count = config.repair_absorbed_group_comments();
        assert_eq!(count, 1);
        assert_eq!(config.elements.len(), 2);
        // Block should only have the HostName directive.
        if let ConfigElement::HostBlock(block) = &config.elements[0] {
            assert_eq!(block.directives.len(), 1);
            assert_eq!(block.directives[0].key, "HostName");
        } else {
            panic!("Expected HostBlock");
        }
        // Group comment should be a GlobalLine.
        if let ConfigElement::GlobalLine(line) = &config.elements[1] {
            assert_eq!(line, "# purple:group Production");
        } else {
            panic!("Expected GlobalLine for group comment");
        }
    }

    #[test]
    fn repair_strips_trailing_blanks_before_group() {
        let mut config = SshConfigFile {
            elements: vec![ConfigElement::HostBlock(HostBlock {
                host_pattern: "myserver".to_string(),
                raw_host_line: "Host myserver".to_string(),
                directives: vec![
                    Directive {
                        key: "HostName".to_string(),
                        value: "10.0.0.1".to_string(),
                        raw_line: "  HostName 10.0.0.1".to_string(),
                        is_non_directive: false,
                    },
                    Directive {
                        key: String::new(),
                        value: String::new(),
                        raw_line: "".to_string(),
                        is_non_directive: true,
                    },
                    Directive {
                        key: String::new(),
                        value: String::new(),
                        raw_line: "# purple:group Staging".to_string(),
                        is_non_directive: true,
                    },
                ],
            })],
            path: PathBuf::from("/tmp/test_config"),
            crlf: false,
            bom: false,
        };
        let count = config.repair_absorbed_group_comments();
        assert_eq!(count, 1);
        // Block keeps only HostName.
        if let ConfigElement::HostBlock(block) = &config.elements[0] {
            assert_eq!(block.directives.len(), 1);
        } else {
            panic!("Expected HostBlock");
        }
        // Blank line and group comment are now GlobalLines.
        assert_eq!(config.elements.len(), 3);
        if let ConfigElement::GlobalLine(line) = &config.elements[1] {
            assert!(line.trim().is_empty());
        } else {
            panic!("Expected blank GlobalLine");
        }
        if let ConfigElement::GlobalLine(line) = &config.elements[2] {
            assert!(line.starts_with("# purple:group"));
        } else {
            panic!("Expected group GlobalLine");
        }
    }

    #[test]
    fn repair_clean_config_returns_zero() {
        let mut config =
            parse_str("# purple:group Production\nHost myserver\n  HostName 10.0.0.1\n");
        let count = config.repair_absorbed_group_comments();
        assert_eq!(count, 0);
    }

    #[test]
    fn repair_roundtrip_serializes_correctly() {
        // Build a corrupted config manually.
        let mut config = SshConfigFile {
            elements: vec![
                ConfigElement::HostBlock(HostBlock {
                    host_pattern: "server1".to_string(),
                    raw_host_line: "Host server1".to_string(),
                    directives: vec![
                        Directive {
                            key: "HostName".to_string(),
                            value: "10.0.0.1".to_string(),
                            raw_line: "  HostName 10.0.0.1".to_string(),
                            is_non_directive: false,
                        },
                        Directive {
                            key: String::new(),
                            value: String::new(),
                            raw_line: "".to_string(),
                            is_non_directive: true,
                        },
                        Directive {
                            key: String::new(),
                            value: String::new(),
                            raw_line: "# purple:group Staging".to_string(),
                            is_non_directive: true,
                        },
                    ],
                }),
                ConfigElement::HostBlock(HostBlock {
                    host_pattern: "server2".to_string(),
                    raw_host_line: "Host server2".to_string(),
                    directives: vec![Directive {
                        key: "HostName".to_string(),
                        value: "10.0.0.2".to_string(),
                        raw_line: "  HostName 10.0.0.2".to_string(),
                        is_non_directive: false,
                    }],
                }),
            ],
            path: PathBuf::from("/tmp/test_config"),
            crlf: false,
            bom: false,
        };
        let count = config.repair_absorbed_group_comments();
        assert_eq!(count, 1);
        let output = config.serialize();
        // The group comment should appear between the two host blocks.
        let expected = "\
Host server1
  HostName 10.0.0.1

# purple:group Staging
Host server2
  HostName 10.0.0.2
";
        assert_eq!(output, expected);
    }

    // =========================================================================
    // delete_host: orphaned group header cleanup
    // =========================================================================

    #[test]
    fn delete_last_provider_host_removes_group_header() {
        let config_str = "\
# purple:group DigitalOcean
Host do-web
  HostName 1.2.3.4
  # purple:provider digitalocean:123
";
        let mut config = parse_str(config_str);
        config.delete_host("do-web");
        let has_header = config
            .elements
            .iter()
            .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
        assert!(
            !has_header,
            "Group header should be removed when last provider host is deleted"
        );
    }

    #[test]
    fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
        let config_str = "\
# purple:group DigitalOcean
Host do-web
  HostName 1.2.3.4
  # purple:provider digitalocean:123

Host do-db
  HostName 5.6.7.8
  # purple:provider digitalocean:456
";
        let mut config = parse_str(config_str);
        config.delete_host("do-web");
        let has_header = config.elements.iter().any(|e| {
            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
        });
        assert!(
            has_header,
            "Group header should be preserved when other provider hosts remain"
        );
        assert_eq!(config.host_entries().len(), 1);
    }

    #[test]
    fn delete_non_provider_host_leaves_group_headers() {
        let config_str = "\
Host personal
  HostName 10.0.0.1

# purple:group DigitalOcean
Host do-web
  HostName 1.2.3.4
  # purple:provider digitalocean:123
";
        let mut config = parse_str(config_str);
        config.delete_host("personal");
        let has_header = config.elements.iter().any(|e| {
            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
        });
        assert!(
            has_header,
            "Group header should not be affected by deleting a non-provider host"
        );
        assert_eq!(config.host_entries().len(), 1);
    }

    #[test]
    fn delete_host_undoable_keeps_group_header_for_undo() {
        // delete_host_undoable does NOT remove orphaned group headers so that
        // undo (insert_host_at) can restore the config to its original state.
        // Orphaned headers are cleaned up at startup instead.
        let config_str = "\
# purple:group Vultr
Host vultr-web
  HostName 2.3.4.5
  # purple:provider vultr:789
";
        let mut config = parse_str(config_str);
        let result = config.delete_host_undoable("vultr-web");
        assert!(result.is_some());
        let has_header = config
            .elements
            .iter()
            .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
        assert!(has_header, "Group header should be kept for undo");
    }

    #[test]
    fn delete_host_undoable_preserves_header_when_others_remain() {
        let config_str = "\
# purple:group AWS EC2
Host aws-web
  HostName 3.4.5.6
  # purple:provider aws:i-111

Host aws-db
  HostName 7.8.9.0
  # purple:provider aws:i-222
";
        let mut config = parse_str(config_str);
        let result = config.delete_host_undoable("aws-web");
        assert!(result.is_some());
        let has_header = config.elements.iter().any(
            |e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2")),
        );
        assert!(
            has_header,
            "Group header preserved when other provider hosts remain (undoable)"
        );
    }

    #[test]
    fn delete_host_undoable_returns_original_position_for_undo() {
        // Group header at index 0, host at index 1. Undo re-inserts at index 1
        // which correctly restores the host after the group header.
        let config_str = "\
# purple:group Vultr
Host vultr-web
  HostName 2.3.4.5
  # purple:provider vultr:789

Host manual
  HostName 10.0.0.1
";
        let mut config = parse_str(config_str);
        let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
        // Position is the original index (1), not adjusted, since no header was removed
        assert_eq!(pos, 1, "Position should be the original host index");
        // Undo: re-insert at the original position
        config.insert_host_at(element, pos);
        // The host should be back, group header intact, manual host accessible
        let output = config.serialize();
        assert!(
            output.contains("# purple:group Vultr"),
            "Group header should be present"
        );
        assert!(output.contains("Host vultr-web"), "Host should be restored");
        assert!(output.contains("Host manual"), "Manual host should survive");
        assert_eq!(config_str, output);
    }

    // =========================================================================
    // add_host: wildcard ordering
    // =========================================================================

    #[test]
    fn add_host_inserts_before_trailing_wildcard() {
        let config_str = "\
Host existing
  HostName 10.0.0.1

Host *
  ServerAliveInterval 60
";
        let mut config = parse_str(config_str);
        let entry = HostEntry {
            alias: "newhost".to_string(),
            hostname: "10.0.0.2".to_string(),
            port: 22,
            ..Default::default()
        };
        config.add_host(&entry);
        let output = config.serialize();
        let new_pos = output.find("Host newhost").unwrap();
        let wildcard_pos = output.find("Host *").unwrap();
        assert!(
            new_pos < wildcard_pos,
            "New host should appear before Host *: {}",
            output
        );
        let existing_pos = output.find("Host existing").unwrap();
        assert!(existing_pos < new_pos);
    }

    #[test]
    fn add_host_appends_when_no_wildcards() {
        let config_str = "\
Host existing
  HostName 10.0.0.1
";
        let mut config = parse_str(config_str);
        let entry = HostEntry {
            alias: "newhost".to_string(),
            hostname: "10.0.0.2".to_string(),
            port: 22,
            ..Default::default()
        };
        config.add_host(&entry);
        let output = config.serialize();
        let existing_pos = output.find("Host existing").unwrap();
        let new_pos = output.find("Host newhost").unwrap();
        assert!(existing_pos < new_pos, "New host should be appended at end");
    }

    #[test]
    fn add_host_appends_when_wildcard_at_beginning() {
        // Host * at the top acts as global defaults. New hosts go after it.
        let config_str = "\
Host *
  ServerAliveInterval 60

Host existing
  HostName 10.0.0.1
";
        let mut config = parse_str(config_str);
        let entry = HostEntry {
            alias: "newhost".to_string(),
            hostname: "10.0.0.2".to_string(),
            port: 22,
            ..Default::default()
        };
        config.add_host(&entry);
        let output = config.serialize();
        let existing_pos = output.find("Host existing").unwrap();
        let new_pos = output.find("Host newhost").unwrap();
        assert!(
            existing_pos < new_pos,
            "New host should be appended at end when wildcard is at top: {}",
            output
        );
    }

    #[test]
    fn add_host_inserts_before_trailing_pattern_host() {
        let config_str = "\
Host existing
  HostName 10.0.0.1

Host *.example.com
  ProxyJump bastion
";
        let mut config = parse_str(config_str);
        let entry = HostEntry {
            alias: "newhost".to_string(),
            hostname: "10.0.0.2".to_string(),
            port: 22,
            ..Default::default()
        };
        config.add_host(&entry);
        let output = config.serialize();
        let new_pos = output.find("Host newhost").unwrap();
        let pattern_pos = output.find("Host *.example.com").unwrap();
        assert!(
            new_pos < pattern_pos,
            "New host should appear before pattern host: {}",
            output
        );
    }

    #[test]
    fn add_host_no_triple_blank_lines() {
        let config_str = "\
Host existing
  HostName 10.0.0.1

Host *
  ServerAliveInterval 60
";
        let mut config = parse_str(config_str);
        let entry = HostEntry {
            alias: "newhost".to_string(),
            hostname: "10.0.0.2".to_string(),
            port: 22,
            ..Default::default()
        };
        config.add_host(&entry);
        let output = config.serialize();
        assert!(
            !output.contains("\n\n\n"),
            "Should not have triple blank lines: {}",
            output
        );
    }

    #[test]
    fn provider_group_display_name_matches_providers_mod() {
        // Ensure the duplicated display name function in model.rs stays in sync
        // with providers::provider_display_name(). If these diverge, group header
        // cleanup (remove_orphaned_group_header) will fail to match headers
        // written by the sync engine.
        let providers = [
            "digitalocean",
            "vultr",
            "linode",
            "hetzner",
            "upcloud",
            "proxmox",
            "aws",
            "scaleway",
            "gcp",
            "azure",
            "tailscale",
            "oracle",
        ];
        for name in &providers {
            assert_eq!(
                provider_group_display_name(name),
                crate::providers::provider_display_name(name),
                "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
                name,
                provider_group_display_name(name),
                crate::providers::provider_display_name(name),
            );
        }
    }

    #[test]
    fn test_sanitize_tag_strips_control_chars() {
        assert_eq!(HostBlock::sanitize_tag("prod"), "prod");
        assert_eq!(HostBlock::sanitize_tag("prod\n"), "prod");
        assert_eq!(HostBlock::sanitize_tag("pr\x00od"), "prod");
        assert_eq!(HostBlock::sanitize_tag("\t\r\n"), "");
    }

    #[test]
    fn test_sanitize_tag_strips_commas() {
        assert_eq!(HostBlock::sanitize_tag("prod,staging"), "prodstaging");
        assert_eq!(HostBlock::sanitize_tag(",,,"), "");
    }

    #[test]
    fn test_sanitize_tag_strips_bidi() {
        assert_eq!(HostBlock::sanitize_tag("prod\u{202E}tset"), "prodtset");
        assert_eq!(HostBlock::sanitize_tag("\u{200B}zero\u{FEFF}"), "zero");
    }

    #[test]
    fn test_sanitize_tag_truncates_long() {
        let long = "a".repeat(200);
        assert_eq!(HostBlock::sanitize_tag(&long).len(), 128);
    }

    #[test]
    fn test_sanitize_tag_preserves_unicode() {
        assert_eq!(HostBlock::sanitize_tag("日本語"), "日本語");
        assert_eq!(HostBlock::sanitize_tag("café"), "café");
    }

    // =========================================================================
    // provider_tags parsing and has_provider_tags_comment tests
    // =========================================================================

    #[test]
    fn test_provider_tags_parsing() {
        let config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider_tags a,b,c\n");
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.provider_tags, vec!["a", "b", "c"]);
    }

    #[test]
    fn test_provider_tags_empty() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        let entry = first_block(&config).to_host_entry();
        assert!(entry.provider_tags.is_empty());
    }

    #[test]
    fn test_has_provider_tags_comment_present() {
        let config =
            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider_tags prod\n");
        assert!(first_block(&config).has_provider_tags_comment());
        assert!(first_block(&config).to_host_entry().has_provider_tags);
    }

    #[test]
    fn test_has_provider_tags_comment_sentinel() {
        // Bare sentinel (no tags) still counts as "has provider_tags"
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider_tags\n");
        assert!(first_block(&config).has_provider_tags_comment());
        assert!(first_block(&config).to_host_entry().has_provider_tags);
        assert!(
            first_block(&config)
                .to_host_entry()
                .provider_tags
                .is_empty()
        );
    }

    #[test]
    fn test_has_provider_tags_comment_absent() {
        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        assert!(!first_block(&config).has_provider_tags_comment());
        assert!(!first_block(&config).to_host_entry().has_provider_tags);
    }

    #[test]
    fn test_set_tags_does_not_delete_provider_tags() {
        let mut config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  # purple:tags user1\n  # purple:provider_tags cloud1,cloud2\n",
        );
        config.set_host_tags("myserver", &["newuser".to_string()]);
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.tags, vec!["newuser"]);
        assert_eq!(entry.provider_tags, vec!["cloud1", "cloud2"]);
    }

    #[test]
    fn test_set_provider_tags_does_not_delete_user_tags() {
        let mut config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  # purple:tags user1,user2\n  # purple:provider_tags old\n",
        );
        config.set_host_provider_tags("myserver", &["new1".to_string(), "new2".to_string()]);
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.tags, vec!["user1", "user2"]);
        assert_eq!(entry.provider_tags, vec!["new1", "new2"]);
    }

    #[test]
    fn test_set_askpass_does_not_delete_similar_comments() {
        // A hypothetical "# purple:askpass_backup test" should NOT be deleted by set_askpass
        let mut config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n  # purple:askpass_backup test\n",
        );
        config.set_host_askpass("myserver", "op://vault/item/pass");
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.askpass, Some("op://vault/item/pass".to_string()));
        // The similar-but-different comment survives
        let serialized = config.serialize();
        assert!(serialized.contains("purple:askpass_backup test"));
    }

    #[test]
    fn test_set_meta_does_not_delete_similar_comments() {
        // A hypothetical "# purple:metadata foo" should NOT be deleted by set_meta
        let mut config = parse_str(
            "Host myserver\n  HostName 10.0.0.1\n  # purple:meta region=us-east\n  # purple:metadata foo\n",
        );
        config.set_host_meta("myserver", &[("region".to_string(), "eu-west".to_string())]);
        let serialized = config.serialize();
        assert!(serialized.contains("purple:meta region=eu-west"));
        assert!(serialized.contains("purple:metadata foo"));
    }

    #[test]
    fn test_set_meta_sanitizes_control_chars() {
        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
        config.set_host_meta(
            "myserver",
            &[
                ("region".to_string(), "us\x00east".to_string()),
                ("zone".to_string(), "a\u{202E}b".to_string()),
            ],
        );
        let serialized = config.serialize();
        // Control chars and bidi should be stripped from values
        assert!(serialized.contains("region=useast"));
        assert!(serialized.contains("zone=ab"));
        assert!(!serialized.contains('\x00'));
        assert!(!serialized.contains('\u{202E}'));
    }

    // ── stale tests ──────────────────────────────────────────────────────

    #[test]
    fn stale_returns_timestamp() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale 1711900000
";
        let config = parse_str(config_str);
        assert_eq!(first_block(&config).stale(), Some(1711900000));
    }

    #[test]
    fn stale_returns_none_when_absent() {
        let config_str = "Host web\n  HostName 1.2.3.4\n";
        let config = parse_str(config_str);
        assert_eq!(first_block(&config).stale(), None);
    }

    #[test]
    fn stale_returns_none_for_malformed() {
        for bad in &[
            "Host w\n  HostName 1.2.3.4\n  # purple:stale abc\n",
            "Host w\n  HostName 1.2.3.4\n  # purple:stale\n",
            "Host w\n  HostName 1.2.3.4\n  # purple:stale -1\n",
        ] {
            let config = parse_str(bad);
            assert_eq!(first_block(&config).stale(), None, "input: {bad}");
        }
    }

    #[test]
    fn set_stale_adds_comment() {
        let config_str = "Host web\n  HostName 1.2.3.4\n";
        let mut config = parse_str(config_str);
        first_block_mut(&mut config).set_stale(1711900000);
        assert_eq!(first_block(&config).stale(), Some(1711900000));
        assert!(config.serialize().contains("# purple:stale 1711900000"));
    }

    #[test]
    fn set_stale_replaces_existing() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale 1000
";
        let mut config = parse_str(config_str);
        first_block_mut(&mut config).set_stale(2000);
        assert_eq!(first_block(&config).stale(), Some(2000));
        let output = config.serialize();
        assert!(!output.contains("1000"));
        assert!(output.contains("# purple:stale 2000"));
    }

    #[test]
    fn clear_stale_removes_comment() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale 1711900000
";
        let mut config = parse_str(config_str);
        first_block_mut(&mut config).clear_stale();
        assert_eq!(first_block(&config).stale(), None);
        assert!(!config.serialize().contains("purple:stale"));
    }

    #[test]
    fn clear_stale_when_absent_is_noop() {
        let config_str = "Host web\n  HostName 1.2.3.4\n";
        let mut config = parse_str(config_str);
        let before = config.serialize();
        first_block_mut(&mut config).clear_stale();
        assert_eq!(config.serialize(), before);
    }

    #[test]
    fn stale_roundtrip() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale 1711900000
";
        let config = parse_str(config_str);
        let output = config.serialize();
        let config2 = parse_str(&output);
        assert_eq!(first_block(&config2).stale(), Some(1711900000));
    }

    #[test]
    fn stale_in_host_entry() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale 1711900000
";
        let config = parse_str(config_str);
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.stale, Some(1711900000));
    }

    #[test]
    fn stale_coexists_with_other_annotations() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:tags prod
  # purple:provider do:12345
  # purple:askpass keychain
  # purple:meta region=nyc3
  # purple:stale 1711900000
";
        let config = parse_str(config_str);
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.stale, Some(1711900000));
        assert!(entry.tags.contains(&"prod".to_string()));
        assert_eq!(entry.provider, Some("do".to_string()));
        assert_eq!(entry.askpass, Some("keychain".to_string()));
        assert_eq!(entry.provider_meta[0].0, "region");
    }

    #[test]
    fn set_host_stale_delegates() {
        let config_str = "\
Host web
  HostName 1.2.3.4

Host db
  HostName 5.6.7.8
";
        let mut config = parse_str(config_str);
        config.set_host_stale("db", 1234567890);
        assert_eq!(config.host_entries()[1].stale, Some(1234567890));
        assert_eq!(config.host_entries()[0].stale, None);
    }

    #[test]
    fn clear_host_stale_delegates() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale 1711900000
";
        let mut config = parse_str(config_str);
        config.clear_host_stale("web");
        assert_eq!(first_block(&config).stale(), None);
    }

    #[test]
    fn stale_hosts_collects_all() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale 1000

Host db
  HostName 5.6.7.8

Host app
  HostName 9.10.11.12
  # purple:stale 2000
";
        let config = parse_str(config_str);
        let stale = config.stale_hosts();
        assert_eq!(stale.len(), 2);
        assert_eq!(stale[0], ("web".to_string(), 1000));
        assert_eq!(stale[1], ("app".to_string(), 2000));
    }

    #[test]
    fn set_stale_preserves_indent() {
        let config_str = "Host web\n\tHostName 1.2.3.4\n";
        let mut config = parse_str(config_str);
        first_block_mut(&mut config).set_stale(1711900000);
        assert!(config.serialize().contains("\t# purple:stale 1711900000"));
    }

    #[test]
    fn stale_does_not_match_similar_comments() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale_backup 999
";
        let config = parse_str(config_str);
        assert_eq!(first_block(&config).stale(), None);
    }

    #[test]
    fn stale_with_whitespace_in_timestamp() {
        let config_str = "Host w\n  HostName 1.2.3.4\n  # purple:stale  1711900000 \n";
        let config = parse_str(config_str);
        assert_eq!(first_block(&config).stale(), Some(1711900000));
    }

    #[test]
    fn stale_with_u64_max() {
        let ts = u64::MAX;
        let config_str = format!("Host w\n  HostName 1.2.3.4\n  # purple:stale {}\n", ts);
        let config = parse_str(&config_str);
        assert_eq!(first_block(&config).stale(), Some(ts));
        // Round-trip
        let output = config.serialize();
        let config2 = parse_str(&output);
        assert_eq!(first_block(&config2).stale(), Some(ts));
    }

    #[test]
    fn stale_with_u64_overflow() {
        let config_str = "Host w\n  HostName 1.2.3.4\n  # purple:stale 18446744073709551616\n";
        let config = parse_str(config_str);
        assert_eq!(first_block(&config).stale(), None);
    }

    #[test]
    fn stale_timestamp_zero() {
        let config_str = "Host w\n  HostName 1.2.3.4\n  # purple:stale 0\n";
        let config = parse_str(config_str);
        assert_eq!(first_block(&config).stale(), Some(0));
    }

    #[test]
    fn set_host_stale_nonexistent_alias_is_noop() {
        let config_str = "Host web\n  HostName 1.2.3.4\n";
        let mut config = parse_str(config_str);
        let before = config.serialize();
        config.set_host_stale("nonexistent", 12345);
        assert_eq!(config.serialize(), before);
    }

    #[test]
    fn clear_host_stale_nonexistent_alias_is_noop() {
        let config_str = "Host web\n  HostName 1.2.3.4\n";
        let mut config = parse_str(config_str);
        let before = config.serialize();
        config.clear_host_stale("nonexistent");
        assert_eq!(config.serialize(), before);
    }

    #[test]
    fn stale_hosts_empty_config() {
        let config_str = "";
        let config = parse_str(config_str);
        assert!(config.stale_hosts().is_empty());
    }

    #[test]
    fn stale_hosts_no_stale() {
        let config_str = "Host web\n  HostName 1.2.3.4\n\nHost db\n  HostName 5.6.7.8\n";
        let config = parse_str(config_str);
        assert!(config.stale_hosts().is_empty());
    }

    #[test]
    fn clear_stale_preserves_other_purple_comments() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:tags prod
  # purple:provider do:123
  # purple:askpass keychain
  # purple:meta region=nyc3
  # purple:stale 1711900000
";
        let mut config = parse_str(config_str);
        config.clear_host_stale("web");
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.stale, None);
        assert!(entry.tags.contains(&"prod".to_string()));
        assert_eq!(entry.provider, Some("do".to_string()));
        assert_eq!(entry.askpass, Some("keychain".to_string()));
        assert_eq!(entry.provider_meta[0].0, "region");
    }

    #[test]
    fn set_stale_preserves_other_purple_comments() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:tags prod
  # purple:provider do:123
  # purple:askpass keychain
  # purple:meta region=nyc3
";
        let mut config = parse_str(config_str);
        config.set_host_stale("web", 1711900000);
        let entry = first_block(&config).to_host_entry();
        assert_eq!(entry.stale, Some(1711900000));
        assert!(entry.tags.contains(&"prod".to_string()));
        assert_eq!(entry.provider, Some("do".to_string()));
        assert_eq!(entry.askpass, Some("keychain".to_string()));
        assert_eq!(entry.provider_meta[0].0, "region");
    }

    #[test]
    fn stale_multiple_comments_first_wins() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale 1000
  # purple:stale 2000
";
        let config = parse_str(config_str);
        assert_eq!(first_block(&config).stale(), Some(1000));
    }

    #[test]
    fn set_stale_removes_multiple_stale_comments() {
        let config_str = "\
Host web
  HostName 1.2.3.4
  # purple:stale 1000
  # purple:stale 2000
";
        let mut config = parse_str(config_str);
        first_block_mut(&mut config).set_stale(3000);
        assert_eq!(first_block(&config).stale(), Some(3000));
        let output = config.serialize();
        assert_eq!(output.matches("purple:stale").count(), 1);
    }

    #[test]
    fn stale_absent_in_host_entry() {
        let config_str = "Host web\n  HostName 1.2.3.4\n";
        let config = parse_str(config_str);
        assert_eq!(first_block(&config).to_host_entry().stale, None);
    }

    #[test]
    fn set_stale_four_space_indent() {
        let config_str = "Host web\n    HostName 1.2.3.4\n";
        let mut config = parse_str(config_str);
        first_block_mut(&mut config).set_stale(1711900000);
        assert!(config.serialize().contains("    # purple:stale 1711900000"));
    }

    #[test]
    fn clear_stale_removes_bare_comment() {
        let config_str = "Host web\n  HostName 1.2.3.4\n  # purple:stale\n";
        let mut config = parse_str(config_str);
        first_block_mut(&mut config).clear_stale();
        assert!(!config.serialize().contains("purple:stale"));
    }

    // ── SSH config integrity tests for stale operations ──────────────

    #[test]
    fn stale_preserves_blank_line_between_hosts() {
        let config_str = "\
Host web
  HostName 1.2.3.4

Host db
  HostName 5.6.7.8
";
        let mut config = parse_str(config_str);
        config.set_host_stale("web", 1711900000);
        let output = config.serialize();
        // There must still be a blank line between hosts
        assert!(
            output.contains("# purple:stale 1711900000\n\nHost db"),
            "blank line between hosts lost after set_stale:\n{}",
            output
        );
    }

    #[test]
    fn stale_preserves_blank_line_before_group_header() {
        let config_str = "\
Host do-web
  HostName 1.2.3.4
  # purple:provider digitalocean:111

# purple:group Hetzner

Host hz-cache
  HostName 9.10.11.12
  # purple:provider hetzner:333
";
        let mut config = parse_str(config_str);
        config.set_host_stale("do-web", 1711900000);
        let output = config.serialize();
        // There must still be a blank line before the Hetzner group header
        assert!(
            output.contains("\n\n# purple:group Hetzner"),
            "blank line before group header lost after set_stale:\n{}",
            output
        );
    }

    #[test]
    fn stale_set_and_clear_is_byte_identical() {
        let config_str = "\
Host manual
  HostName 10.0.0.1
  User admin

# purple:group DigitalOcean

Host do-web
  HostName 1.2.3.4
  User root
  # purple:provider digitalocean:111
  # purple:tags prod

Host do-db
  HostName 5.6.7.8
  User root
  # purple:provider digitalocean:222
  # purple:meta region=nyc3

# purple:group Hetzner

Host hz-cache
  HostName 9.10.11.12
  User root
  # purple:provider hetzner:333
";
        let original = config_str.to_string();
        let mut config = parse_str(config_str);

        // Mark stale
        config.set_host_stale("do-db", 1711900000);
        let after_stale = config.serialize();
        assert_ne!(after_stale, original, "stale should change the config");

        // Clear stale
        config.clear_host_stale("do-db");
        let after_clear = config.serialize();
        assert_eq!(
            after_clear, original,
            "clearing stale must restore byte-identical config"
        );
    }

    #[test]
    fn stale_does_not_accumulate_blank_lines() {
        let config_str = "Host web\n  HostName 1.2.3.4\n\nHost db\n  HostName 5.6.7.8\n";
        let mut config = parse_str(config_str);

        // Set and clear stale 10 times
        for _ in 0..10 {
            config.set_host_stale("web", 1711900000);
            config.clear_host_stale("web");
        }

        let output = config.serialize();
        assert_eq!(
            output, config_str,
            "repeated set/clear must not accumulate blank lines"
        );
    }

    #[test]
    fn stale_preserves_all_directives_and_comments() {
        let config_str = "\
Host complex
  HostName 1.2.3.4
  User deploy
  Port 2222
  IdentityFile ~/.ssh/id_ed25519
  ProxyJump bastion
  LocalForward 8080 localhost:80
  # purple:provider digitalocean:999
  # purple:tags prod,us-east
  # purple:provider_tags web-tier
  # purple:askpass keychain
  # purple:meta region=nyc3,plan=s-1vcpu-1gb
  # This is a user comment
";
        let mut config = parse_str(config_str);
        let entry_before = first_block(&config).to_host_entry();

        config.set_host_stale("complex", 1711900000);
        let entry_after = first_block(&config).to_host_entry();

        // Every field must survive stale marking
        assert_eq!(entry_after.hostname, entry_before.hostname);
        assert_eq!(entry_after.user, entry_before.user);
        assert_eq!(entry_after.port, entry_before.port);
        assert_eq!(entry_after.identity_file, entry_before.identity_file);
        assert_eq!(entry_after.proxy_jump, entry_before.proxy_jump);
        assert_eq!(entry_after.tags, entry_before.tags);
        assert_eq!(entry_after.provider_tags, entry_before.provider_tags);
        assert_eq!(entry_after.provider, entry_before.provider);
        assert_eq!(entry_after.askpass, entry_before.askpass);
        assert_eq!(entry_after.provider_meta, entry_before.provider_meta);
        assert_eq!(entry_after.tunnel_count, entry_before.tunnel_count);
        assert_eq!(entry_after.stale, Some(1711900000));

        // Clear stale and verify everything still intact
        config.clear_host_stale("complex");
        let entry_cleared = first_block(&config).to_host_entry();
        assert_eq!(entry_cleared.stale, None);
        assert_eq!(entry_cleared.hostname, entry_before.hostname);
        assert_eq!(entry_cleared.tags, entry_before.tags);
        assert_eq!(entry_cleared.provider, entry_before.provider);
        assert_eq!(entry_cleared.askpass, entry_before.askpass);
        assert_eq!(entry_cleared.provider_meta, entry_before.provider_meta);

        // User comment must survive
        assert!(config.serialize().contains("# This is a user comment"));
    }

    #[test]
    fn stale_on_last_host_preserves_trailing_newline() {
        let config_str = "Host web\n  HostName 1.2.3.4\n";
        let mut config = parse_str(config_str);
        config.set_host_stale("web", 1711900000);
        let output = config.serialize();
        assert!(output.ends_with('\n'), "config must end with newline");

        config.clear_host_stale("web");
        let output2 = config.serialize();
        assert_eq!(output2, config_str);
    }

    #[test]
    fn stale_with_crlf_preserves_line_endings() {
        let config_str = "Host web\r\n  HostName 1.2.3.4\r\n";
        let config = SshConfigFile {
            elements: SshConfigFile::parse_content(config_str),
            path: std::path::PathBuf::from("/tmp/test"),
            crlf: true,
            bom: false,
        };
        let mut config = config;
        config.set_host_stale("web", 1711900000);
        let output = config.serialize();
        // All lines must use CRLF
        for line in output.split('\n') {
            if !line.is_empty() {
                assert!(
                    line.ends_with('\r'),
                    "CRLF lost after set_stale. Line: {:?}",
                    line
                );
            }
        }

        config.clear_host_stale("web");
        assert_eq!(config.serialize(), config_str);
    }

    #[test]
    fn pattern_match_star_wildcard() {
        assert!(ssh_pattern_match("*", "anything"));
        assert!(ssh_pattern_match("10.30.0.*", "10.30.0.5"));
        assert!(ssh_pattern_match("10.30.0.*", "10.30.0.100"));
        assert!(!ssh_pattern_match("10.30.0.*", "10.30.1.5"));
        assert!(ssh_pattern_match("*.example.com", "web.example.com"));
        assert!(!ssh_pattern_match("*.example.com", "example.com"));
        assert!(ssh_pattern_match("prod-*-web", "prod-us-web"));
        assert!(!ssh_pattern_match("prod-*-web", "prod-us-api"));
    }

    #[test]
    fn pattern_match_question_mark() {
        assert!(ssh_pattern_match("server-?", "server-1"));
        assert!(ssh_pattern_match("server-?", "server-a"));
        assert!(!ssh_pattern_match("server-?", "server-10"));
        assert!(!ssh_pattern_match("server-?", "server-"));
    }

    #[test]
    fn pattern_match_character_class() {
        assert!(ssh_pattern_match("server-[abc]", "server-a"));
        assert!(ssh_pattern_match("server-[abc]", "server-c"));
        assert!(!ssh_pattern_match("server-[abc]", "server-d"));
        assert!(ssh_pattern_match("server-[0-9]", "server-5"));
        assert!(!ssh_pattern_match("server-[0-9]", "server-a"));
        assert!(ssh_pattern_match("server-[!abc]", "server-d"));
        assert!(!ssh_pattern_match("server-[!abc]", "server-a"));
        assert!(ssh_pattern_match("server-[^abc]", "server-d"));
        assert!(!ssh_pattern_match("server-[^abc]", "server-a"));
    }

    #[test]
    fn pattern_match_negation() {
        assert!(!ssh_pattern_match("!prod-*", "prod-web"));
        assert!(ssh_pattern_match("!prod-*", "staging-web"));
    }

    #[test]
    fn pattern_match_exact() {
        assert!(ssh_pattern_match("myserver", "myserver"));
        assert!(!ssh_pattern_match("myserver", "myserver2"));
        assert!(!ssh_pattern_match("myserver", "other"));
    }

    #[test]
    fn pattern_match_empty() {
        assert!(!ssh_pattern_match("", "anything"));
        assert!(!ssh_pattern_match("*", ""));
        assert!(ssh_pattern_match("", ""));
    }

    #[test]
    fn host_pattern_matches_multi_pattern() {
        assert!(host_pattern_matches("prod staging", "prod"));
        assert!(host_pattern_matches("prod staging", "staging"));
        assert!(!host_pattern_matches("prod staging", "dev"));
    }

    #[test]
    fn host_pattern_matches_with_negation() {
        assert!(host_pattern_matches(
            "*.example.com !internal.example.com",
            "web.example.com",
        ));
        assert!(!host_pattern_matches(
            "*.example.com !internal.example.com",
            "internal.example.com",
        ));
    }

    #[test]
    fn host_pattern_matches_alias_only() {
        // OpenSSH Host keyword matches only against alias, not HostName
        assert!(!host_pattern_matches("10.30.0.*", "production"));
        assert!(host_pattern_matches("prod*", "production"));
        assert!(!host_pattern_matches("staging*", "production"));
    }

    #[test]
    fn pattern_entries_collects_wildcards() {
        let config = parse_str(
            "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n  ProxyJump bastion\n\nHost *\n  ServerAliveInterval 60\n",
        );
        let patterns = config.pattern_entries();
        assert_eq!(patterns.len(), 2);
        assert_eq!(patterns[0].pattern, "10.30.0.*");
        assert_eq!(patterns[0].user, "debian");
        assert_eq!(patterns[0].proxy_jump, "bastion");
        assert_eq!(patterns[1].pattern, "*");
        assert!(
            patterns[1]
                .directives
                .iter()
                .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
        );
    }

    #[test]
    fn pattern_entries_empty_when_no_patterns() {
        let config = parse_str("Host myserver\n  Hostname 10.0.0.1\n");
        let patterns = config.pattern_entries();
        assert!(patterns.is_empty());
    }

    #[test]
    fn matching_patterns_returns_in_config_order() {
        let config = parse_str(
            "Host 10.30.0.*\n  User debian\n\nHost myserver\n  Hostname 10.30.0.5\n\nHost *\n  ServerAliveInterval 60\n",
        );
        // "myserver" matches "*" but not "10.30.0.*" (alias-only matching)
        let matches = config.matching_patterns("myserver");
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].pattern, "*");
    }

    #[test]
    fn matching_patterns_negation_excludes() {
        let config = parse_str(
            "Host * !bastion\n  ServerAliveInterval 60\n\nHost bastion\n  Hostname 10.0.0.1\n",
        );
        let matches = config.matching_patterns("bastion");
        assert!(matches.is_empty());
    }

    #[test]
    fn pattern_entries_and_host_entries_are_disjoint() {
        let config = parse_str(
            "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n\nHost *\n  ServerAliveInterval 60\n",
        );
        let hosts = config.host_entries();
        let patterns = config.pattern_entries();
        assert_eq!(hosts.len(), 1);
        assert_eq!(hosts[0].alias, "myserver");
        assert_eq!(patterns.len(), 2);
        assert_eq!(patterns[0].pattern, "10.30.0.*");
        assert_eq!(patterns[1].pattern, "*");
    }

    #[test]
    fn pattern_crud_round_trip() {
        let mut config = parse_str("Host myserver\n  Hostname 10.0.0.1\n");
        // Add a pattern via HostEntry (the form uses HostEntry for submission)
        let entry = HostEntry {
            alias: "10.30.0.*".to_string(),
            user: "debian".to_string(),
            ..Default::default()
        };
        config.add_host(&entry);
        let output = config.serialize();
        assert!(output.contains("Host 10.30.0.*"));
        assert!(output.contains("User debian"));
        // Verify it appears in pattern_entries, not host_entries
        let reparsed = parse_str(&output);
        assert_eq!(reparsed.host_entries().len(), 1);
        assert_eq!(reparsed.pattern_entries().len(), 1);
        assert_eq!(reparsed.pattern_entries()[0].pattern, "10.30.0.*");
    }

    #[test]
    fn matching_patterns_full_ssh_semantics() {
        let config = parse_str(
            "Host 10.30.0.*\n  User debian\n  IdentityFile ~/.ssh/id_bootstrap\n  ProxyJump bastion\n\n\
             Host *.internal !secret.internal\n  ForwardAgent yes\n\n\
             Host myserver\n  Hostname 10.30.0.5\n\n\
             Host *\n  ServerAliveInterval 60\n",
        );
        // "myserver" only matches "*" (alias-only, not hostname)
        let matches = config.matching_patterns("myserver");
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].pattern, "*");
        assert!(
            matches[0]
                .directives
                .iter()
                .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
        );
    }

    #[test]
    fn pattern_entries_preserve_all_directives() {
        let config = parse_str(
            "Host *.example.com\n  User admin\n  Port 2222\n  IdentityFile ~/.ssh/id_example\n  ProxyJump gateway\n  ServerAliveInterval 30\n  ForwardAgent yes\n",
        );
        let patterns = config.pattern_entries();
        assert_eq!(patterns.len(), 1);
        let p = &patterns[0];
        assert_eq!(p.pattern, "*.example.com");
        assert_eq!(p.user, "admin");
        assert_eq!(p.port, 2222);
        assert_eq!(p.identity_file, "~/.ssh/id_example");
        assert_eq!(p.proxy_jump, "gateway");
        // All directives should be in the directives vec
        assert_eq!(p.directives.len(), 6);
        assert!(
            p.directives
                .iter()
                .any(|(k, v)| k == "ForwardAgent" && v == "yes")
        );
        assert!(
            p.directives
                .iter()
                .any(|(k, v)| k == "ServerAliveInterval" && v == "30")
        );
    }

    // --- Pattern visibility tests ---

    #[test]
    fn roundtrip_pattern_blocks_preserved() {
        let input = "Host myserver\n  Hostname 10.0.0.1\n  User root\n\nHost 10.30.0.*\n  User debian\n  IdentityFile ~/.ssh/id_bootstrap\n  ProxyJump bastion\n\nHost *\n  ServerAliveInterval 60\n  AddKeysToAgent yes\n";
        let config = parse_str(input);
        let output = config.serialize();
        assert_eq!(
            input, output,
            "Pattern blocks must survive round-trip exactly"
        );
    }

    #[test]
    fn add_pattern_preserves_existing_config() {
        let input = "Host myserver\n  Hostname 10.0.0.1\n\nHost otherserver\n  Hostname 10.0.0.2\n\nHost *\n  ServerAliveInterval 60\n";
        let mut config = parse_str(input);
        let entry = HostEntry {
            alias: "10.30.0.*".to_string(),
            user: "debian".to_string(),
            ..Default::default()
        };
        config.add_host(&entry);
        let output = config.serialize();
        // Original hosts must still be there
        assert!(output.contains("Host myserver"));
        assert!(output.contains("Hostname 10.0.0.1"));
        assert!(output.contains("Host otherserver"));
        assert!(output.contains("Hostname 10.0.0.2"));
        // New pattern must be present
        assert!(output.contains("Host 10.30.0.*"));
        assert!(output.contains("User debian"));
        // Host * must still be at the end
        assert!(output.contains("Host *"));
        // New pattern must be BEFORE Host * (SSH first-match-wins)
        let new_pos = output.find("Host 10.30.0.*").unwrap();
        let star_pos = output.find("Host *").unwrap();
        assert!(new_pos < star_pos, "New pattern must be before Host *");
        // Reparse and verify counts
        let reparsed = parse_str(&output);
        assert_eq!(reparsed.host_entries().len(), 2);
        assert_eq!(reparsed.pattern_entries().len(), 2); // 10.30.0.* and *
    }

    #[test]
    fn update_pattern_preserves_other_blocks() {
        let input = "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n\nHost *\n  ServerAliveInterval 60\n";
        let mut config = parse_str(input);
        let updated = HostEntry {
            alias: "10.30.0.*".to_string(),
            user: "admin".to_string(),
            ..Default::default()
        };
        config.update_host("10.30.0.*", &updated);
        let output = config.serialize();
        // Pattern updated
        assert!(output.contains("User admin"));
        assert!(!output.contains("User debian"));
        // Other blocks unchanged
        assert!(output.contains("Host myserver"));
        assert!(output.contains("Hostname 10.0.0.1"));
        assert!(output.contains("Host *"));
        assert!(output.contains("ServerAliveInterval 60"));
    }

    #[test]
    fn delete_pattern_preserves_other_blocks() {
        let input = "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n\nHost *\n  ServerAliveInterval 60\n";
        let mut config = parse_str(input);
        config.delete_host("10.30.0.*");
        let output = config.serialize();
        assert!(!output.contains("Host 10.30.0.*"));
        assert!(!output.contains("User debian"));
        assert!(output.contains("Host myserver"));
        assert!(output.contains("Hostname 10.0.0.1"));
        assert!(output.contains("Host *"));
        assert!(output.contains("ServerAliveInterval 60"));
        let reparsed = parse_str(&output);
        assert_eq!(reparsed.host_entries().len(), 1);
        assert_eq!(reparsed.pattern_entries().len(), 1); // only Host *
    }

    #[test]
    fn update_pattern_rename() {
        let input = "Host *.example.com\n  User admin\n\nHost myserver\n  Hostname 10.0.0.1\n";
        let mut config = parse_str(input);
        let renamed = HostEntry {
            alias: "*.prod.example.com".to_string(),
            user: "admin".to_string(),
            ..Default::default()
        };
        config.update_host("*.example.com", &renamed);
        let output = config.serialize();
        assert!(
            !output.contains("Host *.example.com\n"),
            "Old pattern removed"
        );
        assert!(
            output.contains("Host *.prod.example.com"),
            "New pattern present"
        );
        assert!(output.contains("Host myserver"), "Other host preserved");
    }

    #[test]
    fn config_with_only_patterns() {
        let input = "Host *.example.com\n  User admin\n\nHost *\n  ServerAliveInterval 60\n";
        let config = parse_str(input);
        assert!(config.host_entries().is_empty());
        assert_eq!(config.pattern_entries().len(), 2);
        // Round-trip
        let output = config.serialize();
        assert_eq!(input, output);
    }

    #[test]
    fn host_pattern_matches_all_negative_returns_false() {
        assert!(!host_pattern_matches("!prod !staging", "anything"));
        assert!(!host_pattern_matches("!prod !staging", "dev"));
    }

    #[test]
    fn host_pattern_matches_negation_only_checks_alias() {
        // Negation matches against alias only
        assert!(host_pattern_matches("* !10.0.0.1", "myserver"));
        assert!(!host_pattern_matches("* !myserver", "myserver"));
    }

    #[test]
    fn pattern_match_malformed_char_class() {
        // Unmatched bracket: should not panic, treat as no-match
        assert!(!ssh_pattern_match("[abc", "a"));
        assert!(!ssh_pattern_match("[", "a"));
        // Empty class body before ]
        assert!(!ssh_pattern_match("[]", "a"));
    }

    #[test]
    fn host_pattern_matches_whitespace_edge_cases() {
        assert!(host_pattern_matches("prod  staging", "prod"));
        assert!(host_pattern_matches("  prod  ", "prod"));
        assert!(host_pattern_matches("prod\tstaging", "prod"));
        assert!(!host_pattern_matches("   ", "anything"));
        assert!(!host_pattern_matches("", "anything"));
    }

    #[test]
    fn pattern_with_metadata_roundtrip() {
        let input = "Host 10.30.0.*\n  User debian\n  # purple:tags internal,vpn\n  # purple:askpass keychain\n\nHost myserver\n  Hostname 10.0.0.1\n";
        let config = parse_str(input);
        let patterns = config.pattern_entries();
        assert_eq!(patterns.len(), 1);
        assert_eq!(patterns[0].tags, vec!["internal", "vpn"]);
        assert_eq!(patterns[0].askpass.as_deref(), Some("keychain"));
        // Round-trip
        let output = config.serialize();
        assert_eq!(input, output);
    }

    #[test]
    fn matching_patterns_multiple_in_config_order() {
        // Use alias-based patterns that match the alias "my-10-server"
        let input = "Host my-*\n  User fallback\n\nHost my-10*\n  User team\n\nHost my-10-*\n  User specific\n\nHost other\n  Hostname 10.30.0.5\n\nHost *\n  ServerAliveInterval 60\n";
        let config = parse_str(input);
        let matches = config.matching_patterns("my-10-server");
        assert_eq!(matches.len(), 4);
        assert_eq!(matches[0].pattern, "my-*");
        assert_eq!(matches[1].pattern, "my-10*");
        assert_eq!(matches[2].pattern, "my-10-*");
        assert_eq!(matches[3].pattern, "*");
    }

    #[test]
    fn add_pattern_to_empty_config() {
        let mut config = parse_str("");
        let entry = HostEntry {
            alias: "*.example.com".to_string(),
            user: "admin".to_string(),
            ..Default::default()
        };
        config.add_host(&entry);
        let output = config.serialize();
        assert!(output.contains("Host *.example.com"));
        assert!(output.contains("User admin"));
        let reparsed = parse_str(&output);
        assert!(reparsed.host_entries().is_empty());
        assert_eq!(reparsed.pattern_entries().len(), 1);
    }
}
