//! Exhaustive round-trip fidelity tests for Purple's SSH config parser/writer.
//!
//! Tests every code path that modifies the config: serialize(), add_host(),
//! delete_host(), delete_host_undoable(), update_host(), swap_hosts(),
//! set_host_tags(), insert_host_at().
//!
//! Tests marked with "BUG:" in their name demonstrate actual formatting issues.

use std::path::PathBuf;

use purple_ssh::ssh_config::model::{ConfigElement, HostBlock, HostEntry, SshConfigFile};

/// Helper: parse a string into an SshConfigFile without touching disk.
fn parse_str(content: &str) -> SshConfigFile {
    SshConfigFile {
        elements: SshConfigFile::parse_content(content),
        path: PathBuf::from("/tmp/test_config"),
        crlf: content.contains("\r\n"),
    }
}

/// Helper: make visible whitespace for assertion messages.
fn visible(s: &str) -> String {
    s.replace(' ', "\u{00B7}")
        .replace('\t', "\\t")
        .replace('\n', "\\n\n")
        .replace('\r', "\\r")
}

/// Assert two strings are equal, with helpful visible-whitespace diff on failure.
fn assert_eq_visible(expected: &str, actual: &str) {
    if expected != actual {
        panic!(
            "\n=== EXPECTED ===\n{}\n=== ACTUAL ===\n{}\n=== END ===",
            visible(expected),
            visible(actual)
        );
    }
}

// ============================================================================
// 1. ROUND-TRIP: Parse -> Serialize with no changes
// ============================================================================

#[test]
fn roundtrip_basic_single_host() {
    let input = "\
Host myserver
  HostName 192.168.1.10
  User admin
  Port 2222
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_multiple_hosts_with_blank_separator() {
    let input = "\
Host alpha
  HostName alpha.example.com
  User deploy

Host beta
  HostName beta.example.com
  User root
  Port 22022
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_global_comments_before_hosts() {
    let input = "\
# My SSH config
# Generated by hand

Host alpha
  HostName alpha.example.com
  # Deploy user
  User deploy

Host beta
  HostName beta.example.com
  User root
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_wildcard_plus_regular() {
    let input = "\
# Global settings
Host *
  ServerAliveInterval 60
  ServerAliveCountMax 3

Host production
  HostName prod.example.com
  User deployer
  IdentityFile ~/.ssh/prod_key
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_include_directive_before_hosts() {
    let input = "\
Include ~/.ssh/config.d/*

Host myserver
  HostName 10.0.0.1
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_tab_indentation() {
    let input = "\
Host myserver
\tHostName 192.168.1.10
\tUser admin
\tPort 2222
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_mixed_indentation() {
    let input = "\
Host tabhost
\tHostName 10.0.0.1
\tUser admin

Host spacehost
  HostName 10.0.0.2
  User root
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_four_space_indentation() {
    let input = "\
Host myserver
    HostName 192.168.1.10
    User admin
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_comments_inside_host_block() {
    let input = "\
Host myserver
  # Connection settings
  HostName 10.0.0.1
  # Authentication
  User admin
  IdentityFile ~/.ssh/id_rsa
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_blank_lines_inside_host_block() {
    let input = "\
Host myserver
  HostName 10.0.0.1

  User admin
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_unknown_directives() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  ForwardAgent yes
  LocalForward 8080 localhost:80
  Compression yes
  RequestTTY yes
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_empty_config() {
    let input = "";
    let config = parse_str(input);
    let output = config.serialize();
    assert_eq_visible("\n", &output);
}

#[test]
fn roundtrip_only_comments() {
    let input = "\
# This is a comment
# Another comment
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_only_wildcard() {
    let input = "\
Host *
  ServerAliveInterval 60
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_host_with_no_directives() {
    let input = "\
Host emptyhost
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_multiple_blank_lines_between_blocks() {
    let input = "\
Host alpha
  HostName a.com


Host beta
  HostName b.com
";
    let config = parse_str(input);
    // Serialize collapses consecutive blank lines to one
    let expected = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn roundtrip_config_starting_with_blank_lines() {
    let input = "\n\
Host myserver\n\
  HostName 10.0.0.1\n";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_config_no_trailing_newline() {
    let input = "Host myserver\n  HostName 10.0.0.1";
    let config = parse_str(input);
    let expected = "Host myserver\n  HostName 10.0.0.1\n";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn roundtrip_group_header_comment_above_host() {
    let input = "\
# Production
Host prod
  HostName prod.example.com

# Staging
Host staging
  HostName staging.example.com
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_consecutive_blank_lines_in_original() {
    let input = "\
Host alpha
  HostName a.com



Host beta
  HostName b.com
";
    let config = parse_str(input);
    // Serialize collapses consecutive blank lines to one
    let expected = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn roundtrip_purple_tags_comment() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
  # purple:tags prod,web
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_very_long_directive_value() {
    let long_value = "a".repeat(500);
    let input = format!("Host myserver\n  HostName {}\n", long_value);
    let config = parse_str(&input);
    assert_eq_visible(&input, &config.serialize());
}

#[test]
fn roundtrip_equals_syntax() {
    let input = "\
Host myserver
  HostName=10.0.0.1
  User=admin
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn roundtrip_config_with_trailing_blank_lines_after_host() {
    // Trailing blank lines after the last host round-trip correctly.
    let input = "Host myserver\n  HostName 10.0.0.1\n\n";
    let config = parse_str(input);
    let output = config.serialize();
    assert_eq_visible(input, &output);
}

#[test]
fn roundtrip_two_trailing_blank_lines_after_host() {
    // Two trailing blank lines: one is preserved, one is lost
    let input = "Host myserver\n  HostName 10.0.0.1\n\n\n";
    let config = parse_str(input);
    let output = config.serialize();
    // .lines() on this gives: ["Host myserver", "  HostName 10.0.0.1", "", ""]
    // Two blank lines stored as directives.
    // Serialize lines: ["Host myserver", "  HostName 10.0.0.1", "", ""]
    // join("\n") = "Host myserver\n  HostName 10.0.0.1\n\n"
    // That ends with \n, so no extra added.
    // Output = "Host myserver\n  HostName 10.0.0.1\n\n"
    // Original was "Host myserver\n  HostName 10.0.0.1\n\n\n"
    // So ONE blank line is lost.
    let expected = "Host myserver\n  HostName 10.0.0.1\n\n";
    assert_eq_visible(expected, &output);
}

// ============================================================================
// 2. ADD HOST
// ============================================================================

#[test]
fn add_host_to_empty_config() {
    let mut config = parse_str("");
    config.add_host(&HostEntry {
        alias: "newhost".to_string(),
        hostname: "10.0.0.1".to_string(),
        user: "admin".to_string(),
        port: 22,
        ..Default::default()
    });
    let expected = "\
Host newhost
  HostName 10.0.0.1
  User admin
";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn add_host_to_existing_single_host() {
    let mut config = parse_str("Host existing\n  HostName 10.0.0.1\n");
    config.add_host(&HostEntry {
        alias: "newhost".to_string(),
        hostname: "10.0.0.2".to_string(),
        user: "admin".to_string(),
        port: 22,
        ..Default::default()
    });
    let expected = "\
Host existing
  HostName 10.0.0.1

Host newhost
  HostName 10.0.0.2
  User admin
";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn add_host_to_config_with_multiple_hosts() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.add_host(&HostEntry {
        alias: "gamma".to_string(),
        hostname: "g.com".to_string(),
        port: 22,
        ..Default::default()
    });
    let expected = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com

Host gamma
  HostName g.com
";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn add_host_with_non_default_port() {
    let mut config = parse_str("");
    config.add_host(&HostEntry {
        alias: "newhost".to_string(),
        hostname: "10.0.0.1".to_string(),
        user: "admin".to_string(),
        port: 2222,
        identity_file: "~/.ssh/id_rsa".to_string(),
        proxy_jump: "bastion".to_string(),
        ..Default::default()
    });
    let expected = "\
Host newhost
  HostName 10.0.0.1
  User admin
  Port 2222
  IdentityFile ~/.ssh/id_rsa
  ProxyJump bastion
";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn add_host_default_port_not_written() {
    let mut config = parse_str("");
    config.add_host(&HostEntry {
        alias: "newhost".to_string(),
        hostname: "10.0.0.1".to_string(),
        port: 22,
        ..Default::default()
    });
    let output = config.serialize();
    assert!(!output.contains("Port"), "Port 22 should not be written explicitly");
}

/// FIXED: add_host to config that already ends with a blank line no longer creates double blank lines.
///
/// add_host() now checks if the last element already has a trailing blank before adding a separator.
#[test]
fn add_host_to_config_ending_with_blank_line_no_double_blank() {
    let input = "Host existing\n  HostName 10.0.0.1\n\n";
    let mut config = parse_str(input);
    config.add_host(&HostEntry {
        alias: "newhost".to_string(),
        hostname: "10.0.0.2".to_string(),
        port: 22,
        ..Default::default()
    });
    let output = config.serialize();
    assert!(
        !output.contains("\n\n\n"),
        "Should not have double blank lines. Output:\n{}",
        visible(&output)
    );
    let expected = "\
Host existing
  HostName 10.0.0.1

Host newhost
  HostName 10.0.0.2
";
    assert_eq_visible(expected, &output);
}

#[test]
fn add_host_to_config_with_wildcard() {
    let input = "\
Host *
  ServerAliveInterval 60
";
    let mut config = parse_str(input);
    config.add_host(&HostEntry {
        alias: "newhost".to_string(),
        hostname: "10.0.0.1".to_string(),
        port: 22,
        ..Default::default()
    });
    let expected = "\
Host *
  ServerAliveInterval 60

Host newhost
  HostName 10.0.0.1
";
    assert_eq_visible(expected, &config.serialize());
}

// ============================================================================
// 3. DELETE HOST
// ============================================================================

#[test]
fn delete_only_host() {
    let input = "\
Host myserver
  HostName 10.0.0.1
";
    let mut config = parse_str(input);
    config.delete_host("myserver");
    let output = config.serialize();
    assert!(
        !output.contains("Host myserver"),
        "Deleted host should not appear"
    );
}

#[test]
fn delete_first_host_of_two() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.delete_host("alpha");
    let output = config.serialize();
    assert!(!output.contains("Host alpha"));
    assert!(output.contains("Host beta"));
    // The blank line between alpha and beta was INSIDE alpha's directives,
    // so it gets deleted with alpha. Output starts cleanly with "Host beta".
    assert!(
        output.starts_with("Host beta"),
        "After deleting first host, output should start with second host. Got:\n{}",
        visible(&output)
    );
}

#[test]
fn delete_last_host_of_two() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.delete_host("beta");
    let output = config.serialize();
    assert!(output.contains("Host alpha"));
    assert!(!output.contains("Host beta"));
    // The blank line between alpha and beta is INSIDE alpha's directives.
    // After deleting beta, alpha still has that trailing blank directive.
    // So the output will be "Host alpha\n  HostName a.com\n\n"
    // (the trailing blank from alpha's directives is preserved)
}

#[test]
fn delete_middle_host_of_three() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com

Host gamma
  HostName g.com
";
    let mut config = parse_str(input);
    config.delete_host("beta");
    let output = config.serialize();
    assert!(!output.contains("Host beta"));
    assert!(output.contains("Host alpha"));
    assert!(output.contains("Host gamma"));
    // The blank line between alpha and beta is inside alpha's directives.
    // The blank line between beta and gamma is inside beta's directives.
    // Deleting beta removes beta + its trailing blank.
    // Alpha still has its trailing blank, so there's one blank line between alpha and gamma.
    let expected = "\
Host alpha
  HostName a.com

Host gamma
  HostName g.com
";
    assert_eq_visible(expected, &output);
}

#[test]
fn delete_host_preserves_global_lines() {
    let input = "\
# Global comment

Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.delete_host("alpha");
    let output = config.serialize();
    assert!(output.contains("# Global comment"));
    assert!(!output.contains("Host alpha"));
    assert!(output.contains("Host beta"));
}

#[test]
fn delete_host_with_preceding_group_comment() {
    // "# Production" is a GlobalLine before the first host.
    // The blank line + "# Staging" are INSIDE prod's directives.
    let input = "\
# Production
Host prod
  HostName prod.example.com

# Staging
Host staging
  HostName staging.example.com
";
    let mut config = parse_str(input);
    config.delete_host("prod");
    let output = config.serialize();
    assert!(!output.contains("Host prod"));
    assert!(output.contains("Host staging"));
    // "# Production" is a GlobalLine, so it survives
    assert!(
        output.contains("# Production"),
        "GlobalLine comment should remain after host deletion. Got:\n{}",
        visible(&output)
    );
    // "# Staging" was inside prod's directives, so it was DELETED with prod
    assert!(
        !output.contains("# Staging"),
        "Comment inside deleted host's block should be removed. Got:\n{}",
        visible(&output)
    );
}

#[test]
fn delete_all_hosts_one_by_one() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com

Host gamma
  HostName g.com
";
    let mut config = parse_str(input);
    config.delete_host("alpha");
    config.delete_host("beta");
    config.delete_host("gamma");
    let output = config.serialize();
    assert!(!output.contains("Host alpha"));
    assert!(!output.contains("Host beta"));
    assert!(!output.contains("Host gamma"));
}

// ============================================================================
// 4. DELETE HOST UNDOABLE
// ============================================================================

#[test]
fn delete_undoable_returns_correct_position() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com

Host gamma
  HostName g.com
";
    let mut config = parse_str(input);
    // Elements are: [HostBlock(alpha), HostBlock(beta), HostBlock(gamma)]
    // because blank lines between hosts are stored INSIDE the preceding HostBlock's directives.
    assert_eq!(config.elements.len(), 3);

    let result = config.delete_host_undoable("beta");
    assert!(result.is_some());
    let (element, position) = result.unwrap();
    // beta is at element index 1 (alpha=0, beta=1, gamma=2)
    assert_eq!(position, 1, "beta should be at element index 1");
    assert!(matches!(element, ConfigElement::HostBlock(ref b) if b.host_pattern == "beta"));
}

#[test]
fn delete_undoable_nonexistent_returns_none() {
    let input = "Host alpha\n  HostName a.com\n";
    let mut config = parse_str(input);
    let result = config.delete_host_undoable("nonexistent");
    assert!(result.is_none());
}

// ============================================================================
// 5. UNDO DELETE (insert_host_at)
// ============================================================================

#[test]
fn undo_delete_restores_exact_position() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com

Host gamma
  HostName g.com
";
    let mut config = parse_str(input);
    let (element, position) = config.delete_host_undoable("beta").unwrap();
    config.insert_host_at(element, position);
    let output = config.serialize();
    assert_eq_visible(input, &output);
}

#[test]
fn undo_delete_first_host() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    let (element, position) = config.delete_host_undoable("alpha").unwrap();
    config.insert_host_at(element, position);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn undo_delete_last_host() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    let (element, position) = config.delete_host_undoable("beta").unwrap();
    config.insert_host_at(element, position);
    assert_eq_visible(input, &config.serialize());
}

// ============================================================================
// 6. UPDATE/EDIT HOST
// ============================================================================

#[test]
fn update_host_changes_hostname() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.2".to_string(),
            user: "admin".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(output.contains("HostName 10.0.0.2"));
    assert!(!output.contains("10.0.0.1"));
}

#[test]
fn update_host_preserves_unknown_directives() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
  ForwardAgent yes
  LocalForward 8080 localhost:80
  Compression yes
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.2".to_string(),
            user: "admin".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(output.contains("ForwardAgent yes"));
    assert!(output.contains("LocalForward 8080 localhost:80"));
    assert!(output.contains("Compression yes"));
}

#[test]
fn update_host_preserves_comments_inside_block() {
    let input = "\
Host myserver
  # Connection info
  HostName 10.0.0.1
  # Auth settings
  User admin
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.2".to_string(),
            user: "newuser".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(output.contains("# Connection info"));
    assert!(output.contains("# Auth settings"));
}

#[test]
fn update_host_preserves_host_line_when_alias_unchanged() {
    let input = "\
Host myserver\t# production
  HostName 10.0.0.1
  User admin
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.2".to_string(),
            user: "admin".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    // Host line should be preserved exactly (including tab + inline comment)
    assert!(output.contains("Host myserver\t# production"));
    assert!(output.contains("HostName 10.0.0.2"));
}

#[test]
fn update_host_changes_alias() {
    let input = "\
Host oldname
  HostName 10.0.0.1
  User admin
";
    let mut config = parse_str(input);
    config.update_host(
        "oldname",
        &HostEntry {
            alias: "newname".to_string(),
            hostname: "10.0.0.1".to_string(),
            user: "admin".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(output.contains("Host newname"));
    assert!(!output.contains("Host oldname"));
}

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

#[test]
fn update_host_removes_empty_directives() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
  IdentityFile ~/.ssh/id_rsa
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.1".to_string(),
            user: "admin".to_string(),
            port: 22,
            identity_file: "".to_string(),
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(!output.contains("IdentityFile"), "IdentityFile should be removed when empty");
}

#[test]
fn update_host_removes_port_22() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  Port 2222
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.1".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(!output.contains("Port"), "Port 22 should be removed (it's the default)");
}

/// FIXED: update_host now detects and preserves the original indentation of the block.
///
/// Tab-indented files stay tab-indented, 4-space files stay 4-space, etc.
#[test]
fn update_host_preserves_original_indentation() {
    let input = "\
Host myserver
\tHostName 10.0.0.1
\tUser admin
\tForwardAgent yes
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.2".to_string(),
            user: "admin".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();

    // All directives should use tab indentation (matching original)
    assert!(
        output.contains("\tHostName 10.0.0.2"),
        "Updated directive should keep tab indent. Output:\n{}",
        visible(&output)
    );
    assert!(
        output.contains("\tUser admin"),
        "User directive should keep tab indent"
    );
    assert!(
        output.contains("\tForwardAgent yes"),
        "Unknown directive keeps tab indent"
    );
    // No mixed indentation
    assert!(
        !output.contains("  HostName") && !output.contains("  User"),
        "Should not have 2-space indented directives in a tab-indented block"
    );
}

#[test]
fn update_host_preserves_surrounding_blocks() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com

Host gamma
  HostName g.com
";
    let mut config = parse_str(input);
    config.update_host(
        "beta",
        &HostEntry {
            alias: "beta".to_string(),
            hostname: "b-new.com".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(output.contains("Host alpha\n  HostName a.com"));
    assert!(output.contains("Host gamma\n  HostName g.com"));
    assert!(output.contains("HostName b-new.com"));
}

// ============================================================================
// 7. TAGS
// ============================================================================

#[test]
fn set_tags_on_host_without_tags() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
";
    let mut config = parse_str(input);
    config.set_host_tags("myserver", &["prod".to_string(), "web".to_string()]);
    let output = config.serialize();
    assert!(
        output.contains("# purple:tags prod,web"),
        "Tags comment should be added. Got:\n{}",
        visible(&output)
    );
}

#[test]
fn set_tags_replaces_existing_tags() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
  # purple:tags old,tags
";
    let mut config = parse_str(input);
    config.set_host_tags("myserver", &["new".to_string(), "tags".to_string()]);
    let output = config.serialize();
    assert!(output.contains("# purple:tags new,tags"));
    assert!(!output.contains("# purple:tags old,tags"));
}

#[test]
fn set_empty_tags_removes_tags_comment() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
  # purple:tags prod,web
";
    let mut config = parse_str(input);
    config.set_host_tags("myserver", &[]);
    let output = config.serialize();
    assert!(
        !output.contains("purple:tags"),
        "Tags comment should be removed. Got:\n{}",
        visible(&output)
    );
}

#[test]
fn host_entries_skips_bracket_and_negation_patterns() {
    let input = "\
Host web[12]
  HostName 10.0.0.1

Host !prod
  HostName 10.0.0.2

Host real-host
  HostName 10.0.0.3
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].alias, "real-host");
}

#[test]
fn tags_roundtrip_parse_and_read() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:tags prod,web,critical
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].tags, vec!["prod", "web", "critical"]);
}

/// set_host_tags inserts the tags comment before trailing blank separators
/// (via content_end()), so the tag stays visually attached to its host.
#[test]
fn set_tags_placed_before_blank_separator() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.set_host_tags("alpha", &["prod".to_string()]);
    let output = config.serialize();

    let lines: Vec<&str> = output.lines().collect();
    let tags_pos = lines.iter().position(|l| l.contains("purple:tags")).unwrap();
    let blank_pos = lines.iter().position(|l| l.is_empty()).unwrap();

    assert!(
        tags_pos < blank_pos,
        "Tags should be placed before the blank separator line.\n{}",
        visible(&output)
    );
}

// ============================================================================
// 7b. ASKPASS COMMENT
// ============================================================================

#[test]
fn askpass_roundtrip_parse_and_read() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass keychain
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("keychain".to_string()));
}

#[test]
fn askpass_op_uri_parse() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass op://Vault/Item/password
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("op://Vault/Item/password".to_string()));
}

#[test]
fn askpass_pass_source_parse() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass pass:ssh/myserver
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("pass:ssh/myserver".to_string()));
}

#[test]
fn askpass_custom_command_parse() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass my-script %a %h
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("my-script %a %h".to_string()));
}

#[test]
fn askpass_bitwarden_source_parse() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass bw:my-server-id
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("bw:my-server-id".to_string()));
}

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

#[test]
fn set_host_askpass_writes_comment() {
    let input = "\
Host myserver
  HostName 10.0.0.1
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "keychain");
    let output = config.serialize();
    assert!(output.contains("# purple:askpass keychain"), "output: {}", output);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("keychain".to_string()));
}

#[test]
fn set_host_askpass_replaces_existing() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass keychain
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "op://Vault/Item/pw");
    let output = config.serialize();
    assert!(!output.contains("keychain"), "old source should be removed");
    assert!(output.contains("# purple:askpass op://Vault/Item/pw"));
}

#[test]
fn set_host_askpass_empty_removes() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass keychain
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "");
    let output = config.serialize();
    assert!(!output.contains("purple:askpass"), "askpass comment should be removed");
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, None);
}

#[test]
fn askpass_placed_before_blank_separator() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.set_host_askpass("alpha", "keychain");
    let output = config.serialize();

    let lines: Vec<&str> = output.lines().collect();
    let askpass_pos = lines.iter().position(|l| l.contains("purple:askpass")).unwrap();
    let blank_pos = lines.iter().position(|l| l.is_empty()).unwrap();

    assert!(
        askpass_pos < blank_pos,
        "Askpass should be placed before the blank separator line.\n{}",
        visible(&output)
    );
}

// -- Vault source parsing and round-trip --

#[test]
fn askpass_vault_source_parse() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass vault:secret/data/myapp#password
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("vault:secret/data/myapp#password".to_string()));
}

#[test]
fn askpass_vault_no_field_parse() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass vault:secret/data/myapp
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("vault:secret/data/myapp".to_string()));
}

#[test]
fn set_host_askpass_vault_writes_comment() {
    let input = "\
Host myserver
  HostName 10.0.0.1
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "vault:secret/ssh/prod#api_key");
    let output = config.serialize();
    assert!(output.contains("# purple:askpass vault:secret/ssh/prod#api_key"), "output: {}", output);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("vault:secret/ssh/prod#api_key".to_string()));
}

#[test]
fn askpass_vault_replace_with_other_source() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass vault:secret/data/myapp
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "keychain");
    let output = config.serialize();
    assert!(!output.contains("vault:"), "old vault source should be removed");
    assert!(output.contains("# purple:askpass keychain"));
}

#[test]
fn askpass_replace_other_with_vault() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass keychain
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "vault:kv/ssh/server#pass");
    let output = config.serialize();
    assert!(!output.contains("keychain"), "old source should be removed");
    assert!(output.contains("# purple:askpass vault:kv/ssh/server#pass"));
}

#[test]
fn askpass_preserves_indent_four_spaces() {
    let input = "\
Host myserver
    HostName 10.0.0.1
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "keychain");
    let output = config.serialize();
    // Should use 4-space indent to match existing directives
    assert!(
        output.contains("    # purple:askpass keychain"),
        "Should preserve 4-space indent. output:\n{}",
        visible(&output)
    );
}

#[test]
fn askpass_preserves_indent_tab() {
    let input = "\
Host myserver
\tHostName 10.0.0.1
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "pass:ssh/prod");
    let output = config.serialize();
    assert!(
        output.contains("\t# purple:askpass pass:ssh/prod"),
        "Should preserve tab indent. output:\n{}",
        visible(&output)
    );
}

#[test]
fn askpass_coexists_with_tags_and_provider() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:tags prod,us-east
  # purple:provider digitalocean:12345
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "op://Vault/SSH/password");
    let output = config.serialize();
    assert!(output.contains("# purple:tags prod,us-east"), "tags preserved");
    assert!(output.contains("# purple:provider digitalocean:12345"), "provider preserved");
    assert!(output.contains("# purple:askpass op://Vault/SSH/password"), "askpass added");
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("op://Vault/SSH/password".to_string()));
    assert_eq!(entries[0].tags, vec!["prod", "us-east"]);
}

#[test]
fn askpass_with_custom_command_containing_percent() {
    let input = "\
Host myserver
  HostName 10.0.0.1
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "get-pass %a %h");
    let output = config.serialize();
    assert!(output.contains("# purple:askpass get-pass %a %h"));
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("get-pass %a %h".to_string()));
}

#[test]
fn askpass_set_then_clear_then_set() {
    let input = "\
Host myserver
  HostName 10.0.0.1
";
    let mut config = parse_str(input);
    // Set
    config.set_host_askpass("myserver", "keychain");
    assert_eq!(config.host_entries()[0].askpass, Some("keychain".to_string()));
    // Clear
    config.set_host_askpass("myserver", "");
    assert_eq!(config.host_entries()[0].askpass, None);
    // Set again with different source
    config.set_host_askpass("myserver", "vault:secret/ssh#pass");
    assert_eq!(config.host_entries()[0].askpass, Some("vault:secret/ssh#pass".to_string()));
    let output = config.serialize();
    // Should have exactly one askpass comment
    assert_eq!(output.matches("purple:askpass").count(), 1);
}

#[test]
fn askpass_multiple_hosts_independent() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.set_host_askpass("alpha", "keychain");
    config.set_host_askpass("beta", "vault:secret/ssh#pw");
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("keychain".to_string()));
    assert_eq!(entries[1].askpass, Some("vault:secret/ssh#pw".to_string()));
}

#[test]
fn askpass_does_not_affect_other_hosts() {
    let input = "\
Host alpha
  HostName a.com
  # purple:askpass keychain

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.set_host_askpass("alpha", "");
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, None);
    assert_eq!(entries[1].askpass, None); // Beta still has no askpass
}

#[test]
fn askpass_all_source_types_roundtrip() {
    let sources = [
        "keychain",
        "op://Vault/Item/password",
        "bw:my-server-id",
        "pass:ssh/myserver",
        "vault:secret/data/myapp#password",
        "vault:secret/data/myapp",
        "my-script %a %h",
        "echo hunter2",
    ];
    for source in &sources {
        let input = "\
Host myserver
  HostName 10.0.0.1
";
        let mut config = parse_str(input);
        config.set_host_askpass("myserver", source);
        let entries = config.host_entries();
        assert_eq!(
            entries[0].askpass.as_deref(),
            Some(*source),
            "Round-trip failed for source: {}",
            source
        );
    }
}

// -- Edge cases in askpass parsing and model behavior --

#[test]
fn askpass_empty_comment_value_is_none() {
    // "# purple:askpass " with trailing space but no value
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, None, "Empty askpass value should be None");
}

#[test]
fn askpass_only_spaces_after_prefix_is_none() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, None, "Whitespace-only askpass value should be None");
}

#[test]
fn askpass_with_leading_trailing_spaces_in_value() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass  keychain
";
    let config = parse_str(input);
    let entries = config.host_entries();
    // The parser trims the value
    assert_eq!(entries[0].askpass, Some("keychain".to_string()));
}

#[test]
fn askpass_value_with_spaces_preserved() {
    // Custom command with spaces: "my-script %a %h"
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpass my-script %a %h
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("my-script %a %h".to_string()));
}

#[test]
fn askpass_not_confused_with_other_purple_comments() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:tags prod
  # purple:provider digitalocean:123
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, None, "Tags/provider should not be parsed as askpass");
}

#[test]
fn askpass_case_sensitive_prefix() {
    // "# Purple:askpass" should NOT be recognized (wrong case)
    let input = "\
Host myserver
  HostName 10.0.0.1
  # Purple:askpass keychain
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, None, "Wrong case prefix should not be parsed");
}

#[test]
fn askpass_requires_space_after_prefix() {
    // "# purple:askpasskeychain" should NOT match
    let input = "\
Host myserver
  HostName 10.0.0.1
  # purple:askpasskeychain
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, None, "No space after prefix should not match");
}

#[test]
fn set_askpass_on_nonexistent_host_is_noop() {
    let input = "\
Host myserver
  HostName 10.0.0.1
";
    let mut config = parse_str(input);
    config.set_host_askpass("nonexistent", "keychain");
    let output = config.serialize();
    assert!(!output.contains("purple:askpass"), "Should not add askpass to nonexistent host");
}

#[test]
fn askpass_survives_update_host() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
  # purple:askpass pass:ssh/myserver
";
    let mut config = parse_str(input);
    // Update host with new hostname
    let mut entry = config.host_entries()[0].clone();
    entry.hostname = "10.0.0.2".to_string();
    config.update_host("myserver", &entry);
    // Askpass should still be there
    let output = config.serialize();
    assert!(output.contains("10.0.0.2"), "Hostname should be updated");
    assert!(output.contains("# purple:askpass pass:ssh/myserver"), "Askpass should survive update");
}

#[test]
fn askpass_crlf_roundtrip() {
    let input = "Host myserver\r\n  HostName 10.0.0.1\r\n  # purple:askpass keychain\r\n";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].askpass, Some("keychain".to_string()), "CRLF askpass should parse");
    let output = config.serialize();
    assert!(output.contains("purple:askpass keychain"), "CRLF askpass should survive serialize");
}

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

#[test]
fn askpass_survives_add_host_then_update() {
    let input = "";
    let mut config = parse_str(input);
    let entry = purple_ssh::ssh_config::model::HostEntry {
        alias: "newhost".to_string(),
        hostname: "10.0.0.1".to_string(),
        askpass: Some("bw:my-item".to_string()),
        ..Default::default()
    };
    config.add_host(&entry);
    config.set_host_askpass("newhost", "bw:my-item");
    assert_eq!(config.host_entries()[0].askpass, Some("bw:my-item".to_string()));
    // Update hostname
    let mut updated = config.host_entries()[0].clone();
    updated.hostname = "10.0.0.2".to_string();
    config.update_host("newhost", &updated);
    assert_eq!(config.host_entries()[0].askpass, Some("bw:my-item".to_string()));
    assert_eq!(config.host_entries()[0].hostname, "10.0.0.2");
}

#[test]
fn askpass_survives_provider_style_update() {
    // Provider sync calls update_host + set_host_tags but NOT set_host_askpass.
    // Askpass should survive this pattern.
    let input = "\
Host do-myserver
  HostName 10.0.0.1
  # purple:provider digitalocean:12345
  # purple:tags web
  # purple:askpass keychain
";
    let mut config = parse_str(input);
    // Simulate provider sync: update hostname and merge tags
    let mut entry = config.host_entries()[0].clone();
    entry.hostname = "10.0.0.2".to_string();
    config.update_host("do-myserver", &entry);
    config.set_host_tags("do-myserver", &["web".to_string(), "prod".to_string()]);
    // Askpass should be preserved (not touched by sync)
    let updated = config.host_entries()[0].clone();
    assert_eq!(updated.askpass, Some("keychain".to_string()));
    assert_eq!(updated.hostname, "10.0.0.2");
    assert!(updated.tags.contains(&"prod".to_string()));
}

#[test]
fn askpass_survives_swap_hosts() {
    let input = "\
Host alpha
  HostName a.com
  # purple:askpass keychain

Host beta
  HostName b.com
  # purple:askpass vault:secret/ssh#pw
";
    let mut config = parse_str(input);
    let swapped = config.swap_hosts("alpha", "beta");
    assert!(swapped);
    let entries = config.host_entries();
    // After swap, the entries are reordered but each keeps its askpass
    let alpha = entries.iter().find(|e| e.alias == "alpha").unwrap();
    let beta = entries.iter().find(|e| e.alias == "beta").unwrap();
    assert_eq!(alpha.askpass, Some("keychain".to_string()));
    assert_eq!(beta.askpass, Some("vault:secret/ssh#pw".to_string()));
}

#[test]
fn askpass_unicode_custom_command_roundtrip() {
    let input = "\
Host myserver
  HostName 10.0.0.1
";
    let mut config = parse_str(input);
    config.set_host_askpass("myserver", "get-p\u{00E4}ss %h");
    assert_eq!(config.host_entries()[0].askpass, Some("get-p\u{00E4}ss %h".to_string()));
    let output = config.serialize();
    assert!(output.contains("get-p\u{00E4}ss %h"));
}

// ============================================================================
// 8. SWAP HOSTS
// ============================================================================

#[test]
fn swap_two_hosts() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    let swapped = config.swap_hosts("alpha", "beta");
    assert!(swapped);
    let output = config.serialize();
    assert!(output.contains("Host beta"));
    assert!(output.contains("Host alpha"));
    let beta_pos = output.find("Host beta").unwrap();
    let alpha_pos = output.find("Host alpha").unwrap();
    assert!(
        beta_pos < alpha_pos,
        "After swap, beta should come before alpha"
    );
}

/// swap_hosts normalizes trailing blanks: strips both, swaps, then
/// ensure_trailing_blank on the first block to maintain a separator.
#[test]
fn swap_hosts_preserves_blank_separator() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.swap_hosts("alpha", "beta");
    let output = config.serialize();
    assert!(
        output.contains("b.com\n\nHost alpha"),
        "Blank line separator must be preserved between swapped hosts.\n{}",
        visible(&output)
    );
}

#[test]
fn swap_nonexistent_host_returns_false() {
    let input = "Host alpha\n  HostName a.com\n";
    let mut config = parse_str(input);
    assert!(!config.swap_hosts("alpha", "nonexistent"));
}

// ============================================================================
// 9. MULTIPLE OPERATIONS IN SEQUENCE
// ============================================================================

#[test]
fn add_then_delete_produces_clean_output() {
    let input = "\
Host alpha
  HostName a.com
";
    let mut config = parse_str(input);
    config.add_host(&HostEntry {
        alias: "beta".to_string(),
        hostname: "b.com".to_string(),
        port: 22,
        ..Default::default()
    });
    config.delete_host("beta");
    let output = config.serialize();
    assert!(output.contains("Host alpha"));
    assert!(!output.contains("Host beta"));
}

#[test]
fn add_delete_undo_sequence() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.add_host(&HostEntry {
        alias: "gamma".to_string(),
        hostname: "g.com".to_string(),
        port: 22,
        ..Default::default()
    });
    let (element, position) = config.delete_host_undoable("beta").unwrap();
    config.insert_host_at(element, position);
    let output = config.serialize();
    assert!(output.contains("Host alpha"));
    assert!(output.contains("Host beta"));
    assert!(output.contains("Host gamma"));
}

#[test]
fn multiple_adds_check_spacing() {
    let mut config = parse_str("");
    for i in 0..5 {
        config.add_host(&HostEntry {
            alias: format!("host{}", i),
            hostname: format!("{}.example.com", i),
            port: 22,
            ..Default::default()
        });
    }
    let output = config.serialize();
    let lines: Vec<&str> = output.lines().collect();
    let mut blank_count = 0;
    let mut prev_blank = false;
    for line in &lines {
        if line.is_empty() {
            blank_count += 1;
            assert!(
                !prev_blank,
                "Should not have consecutive blank lines. Got:\n{}",
                visible(&output)
            );
            prev_blank = true;
        } else {
            prev_blank = false;
        }
    }
    assert_eq!(blank_count, 4, "Should have exactly 4 blank line separators");
}

#[test]
fn repeated_add_delete_cycle_no_blank_line_accumulation() {
    let mut config = parse_str("Host base\n  HostName base.com\n");
    for i in 0..10 {
        config.add_host(&HostEntry {
            alias: format!("temp{}", i),
            hostname: format!("{}.com", i),
            port: 22,
            ..Default::default()
        });
        config.delete_host(&format!("temp{}", i));
    }
    let output = config.serialize();
    let consecutive_blanks = output.matches("\n\n\n").count();
    assert_eq!(
        consecutive_blanks, 0,
        "No triple newlines should accumulate. Got:\n{}",
        visible(&output)
    );
}

/// Verify add, edit, delete, add sequence produces clean spacing.
/// serialize() collapses consecutive blank lines to prevent accumulation.
#[test]
fn add_edit_delete_add_sequence_no_double_blank() {
    let input = "\
Host alpha
  HostName a.com
";
    let mut config = parse_str(input);

    // Add beta
    config.add_host(&HostEntry {
        alias: "beta".to_string(),
        hostname: "b.com".to_string(),
        port: 22,
        ..Default::default()
    });

    // Edit alpha
    config.update_host(
        "alpha",
        &HostEntry {
            alias: "alpha".to_string(),
            hostname: "a-new.com".to_string(),
            port: 22,
            ..Default::default()
        },
    );

    // Delete beta
    config.delete_host("beta");

    // Add gamma
    config.add_host(&HostEntry {
        alias: "gamma".to_string(),
        hostname: "g.com".to_string(),
        port: 22,
        ..Default::default()
    });

    let output = config.serialize();
    assert!(output.contains("Host alpha"));
    assert!(output.contains("HostName a-new.com"));
    assert!(!output.contains("Host beta"));
    assert!(output.contains("Host gamma"));

    assert!(
        !output.contains("\n\n\n"),
        "Should not have double blank lines. Output:\n{}",
        visible(&output)
    );
}

// ============================================================================
// 10. EDGE CASES - FORMATTING
// ============================================================================

#[test]
fn windows_line_endings_crlf() {
    let input = "Host myserver\r\n  HostName 10.0.0.1\r\n  User admin\r\n";
    let config = parse_str(input);
    assert!(config.crlf, "CRLF should be detected");
    let output = config.serialize();
    let entries = config.host_entries();
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].alias, "myserver");
    // CRLF is preserved in serialized output
    assert!(
        output.contains("\r\n"),
        "Serialized output should preserve CRLF line endings"
    );
}

#[test]
fn config_with_only_blank_lines() {
    let input = "\n\n\n";
    let config = parse_str(input);
    assert_eq!(config.elements.len(), 3);
}

#[test]
fn global_directives_before_any_host() {
    let input = "\
ServerAliveInterval 60
ServerAliveCountMax 3

Host myserver
  HostName 10.0.0.1
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn host_block_with_inline_comment_after_value() {
    let input = "\
Host myserver
  HostName 10.0.0.1 # production server
  User admin
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn delete_host_between_group_comments() {
    // "# Group A" -> GlobalLine
    // "Host ahost" -> HostBlock start
    // "  HostName a.com" -> directive
    // "" -> blank non-directive (inside ahost)
    // "# Group B" -> comment non-directive (inside ahost)
    // "Host bhost" -> new HostBlock (flushes ahost)
    // So "# Group B" lives inside ahost's block
    let input = "\
# Group A
Host ahost
  HostName a.com

# Group B
Host bhost
  HostName b.com
";
    let mut config = parse_str(input);
    config.delete_host("ahost");
    let output = config.serialize();
    assert!(output.contains("Host bhost"));
    // "# Group B" was inside ahost's block directives, so it's gone
    assert!(
        !output.contains("# Group B"),
        "Comment inside deleted block should be removed"
    );
    // "# Group A" was a GlobalLine, so it survives
    assert!(output.contains("# Group A"));
}

#[test]
fn delete_undoable_then_add_new_then_undo() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    let (element, position) = config.delete_host_undoable("alpha").unwrap();
    config.add_host(&HostEntry {
        alias: "gamma".to_string(),
        hostname: "g.com".to_string(),
        port: 22,
        ..Default::default()
    });
    config.insert_host_at(element, position);
    let output = config.serialize();
    assert!(output.contains("Host alpha"));
    assert!(output.contains("Host beta"));
    assert!(output.contains("Host gamma"));
}

// ============================================================================
// 11. DETAILED BLANK LINE BEHAVIOR
// ============================================================================

#[test]
fn add_host_blank_line_separator_is_exactly_one() {
    let mut config = parse_str("Host a\n  HostName a.com\n");
    config.add_host(&HostEntry {
        alias: "b".to_string(),
        hostname: "b.com".to_string(),
        port: 22,
        ..Default::default()
    });
    let expected = "\
Host a
  HostName a.com

Host b
  HostName b.com
";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn delete_host_dedup_blank_lines() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com

Host gamma
  HostName g.com
";
    let mut config = parse_str(input);
    config.delete_host("beta");
    let output = config.serialize();
    // delete_host removes HostBlock(beta). The blank between alpha and beta is inside alpha's
    // directives. The blank between beta and gamma is inside beta's directives.
    // After removing beta (and its trailing blank), alpha's trailing blank remains.
    let expected = "\
Host alpha
  HostName a.com

Host gamma
  HostName g.com
";
    assert_eq_visible(expected, &output);
}

#[test]
fn delete_host_undoable_does_not_collapse_blank_lines() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com

Host gamma
  HostName g.com
";
    let mut config = parse_str(input);
    config.delete_host_undoable("beta");
    let output = config.serialize();
    // Since blank lines are inside HostBlock directives (not GlobalLines),
    // and delete_host_undoable just removes the HostBlock, the result should
    // be the same as delete_host for this case.
    // alpha has trailing blank, gamma has no trailing blank.
    let expected = "\
Host alpha
  HostName a.com

Host gamma
  HostName g.com
";
    assert_eq_visible(expected, &output);
}

// ============================================================================
// 12. SERIALIZE EDGE CASES
// ============================================================================

#[test]
fn serialize_always_ends_with_newline() {
    let configs = vec![
        "",
        "Host a\n  HostName a.com",
        "Host a\n  HostName a.com\n",
        "# comment",
        "\n\n",
    ];
    for input in configs {
        let config = parse_str(input);
        let output = config.serialize();
        assert!(
            output.ends_with('\n'),
            "serialize() should always end with newline. Input: {:?}, Output: {:?}",
            input,
            output
        );
    }
}

#[test]
fn serialize_empty_elements_produces_just_newline() {
    let config = SshConfigFile {
        elements: Vec::new(),
        path: PathBuf::from("/tmp/test"),
        crlf: false,
    };
    assert_eq!(config.serialize(), "\n");
}

/// The trailing blank line after the last host is preserved as a directive inside
/// the HostBlock, but serialize's join() + trailing-newline logic causes it to
/// be silently dropped. This is a very minor fidelity loss.
#[test]
fn trailing_blank_line_after_last_host_is_preserved() {
    let input = "Host myserver\n  HostName 10.0.0.1\n\n";
    let config = parse_str(input);
    let output = config.serialize();
    assert_eq!(
        output, input,
        "Trailing blank line after last host should be preserved"
    );
}

// ============================================================================
// 13. CLONE HOST (simulated)
// ============================================================================

#[test]
fn clone_host_simulation() {
    let input = "\
Host original
  HostName original.com
  User admin
  Port 2222
  IdentityFile ~/.ssh/id_rsa
  ForwardAgent yes
";
    let mut config = parse_str(input);
    let entries = config.host_entries();
    let mut cloned_entry = entries[0].clone();
    cloned_entry.alias = "clone".to_string();
    config.add_host(&cloned_entry);
    let output = config.serialize();
    assert!(output.contains("Host original"));
    assert!(output.contains("Host clone"));
    // Unknown directives like ForwardAgent are NOT cloned (HostEntry limitation)
    let clone_section = output.split("Host clone").nth(1).unwrap();
    assert!(
        !clone_section.contains("ForwardAgent"),
        "Unknown directives should NOT be in clone"
    );
}

// ============================================================================
// 14. PARSER BEHAVIOR: BLANK LINES AND COMMENTS ARE INSIDE HOST BLOCKS
// ============================================================================

#[test]
fn parser_blank_line_between_hosts_is_directive() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let config = parse_str(input);
    // Only 2 elements (both HostBlocks), no GlobalLines
    assert_eq!(config.elements.len(), 2);
    let alpha = match &config.elements[0] {
        ConfigElement::HostBlock(b) => b,
        _ => panic!("Expected HostBlock"),
    };
    assert_eq!(alpha.directives.len(), 2); // HostName + blank
    assert!(!alpha.directives[0].is_non_directive);
    assert!(alpha.directives[1].is_non_directive);
    assert!(alpha.directives[1].raw_line.is_empty());
}

#[test]
fn parser_comment_between_hosts_is_directive_of_preceding() {
    let input = "\
# Global header

# Group A
Host alpha
  HostName a.com

# Group B
Host beta
  HostName b.com
";
    let config = parse_str(input);
    // Elements: GlobalLine("# Global header"), GlobalLine(""), GlobalLine("# Group A"),
    //           HostBlock(alpha), HostBlock(beta)
    assert!(matches!(&config.elements[0], ConfigElement::GlobalLine(s) if s == "# Global header"));
    assert!(matches!(&config.elements[1], ConfigElement::GlobalLine(s) if s.is_empty()));
    assert!(matches!(&config.elements[2], ConfigElement::GlobalLine(s) if s == "# Group A"));

    let alpha = match &config.elements[3] {
        ConfigElement::HostBlock(b) => b,
        _ => panic!("Expected alpha HostBlock"),
    };
    assert_eq!(alpha.host_pattern, "alpha");
    // alpha's directives should include: HostName, blank, "# Group B"
    assert_eq!(alpha.directives.len(), 3);
    assert!(!alpha.directives[0].is_non_directive); // HostName
    assert!(alpha.directives[1].is_non_directive);  // blank
    assert!(alpha.directives[2].is_non_directive);  // # Group B
    assert_eq!(alpha.directives[2].raw_line, "# Group B");
}

#[test]
fn delete_first_host_leaves_no_leading_blank() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.delete_host("alpha");
    let output = config.serialize();
    assert!(output.starts_with("Host beta"), "Should start with beta. Got:\n{}", visible(&output));
}

/// FIXED: Include directives between host blocks are now parsed as top-level elements.
///
/// The parser flushes the current HostBlock when it encounters an Include line,
/// so Include is always a top-level ConfigElement, not absorbed into a HostBlock.
#[test]
fn include_between_hosts_is_top_level_element() {
    let input = "\
Host alpha
  HostName a.com

Include ~/.ssh/config.d/*

Host beta
  HostName b.com
";
    let config = parse_str(input);

    let include_count = config.elements.iter().filter(|e| matches!(e, ConfigElement::Include(_))).count();
    let host_count = config.elements.iter().filter(|e| matches!(e, ConfigElement::HostBlock(_))).count();

    assert_eq!(host_count, 2, "Should have 2 host blocks");
    assert_eq!(include_count, 1, "Include between hosts should be a top-level element");

    // Include should NOT be inside alpha's directives
    let alpha = match &config.elements[0] {
        ConfigElement::HostBlock(b) => b,
        _ => panic!("Expected HostBlock"),
    };
    let has_include_in_directives = alpha.directives.iter().any(|d| d.raw_line.contains("Include"));
    assert!(
        !has_include_in_directives,
        "Include line should NOT be inside alpha's directives"
    );

    // Deleting alpha should preserve the Include
    let mut config2 = parse_str(input);
    config2.delete_host("alpha");
    let output = config2.serialize();
    assert!(
        output.contains("Include"),
        "Deleting host before Include should preserve the Include directive.\n\
         Output:\n{}",
        visible(&output)
    );
}

/// The Include-before-hosts case works correctly because Include is parsed
/// at global level (before any Host block).
#[test]
fn include_before_hosts_is_top_level_element() {
    let input = "\
Include ~/.ssh/config.d/*

Host alpha
  HostName a.com
";
    let config = parse_str(input);
    assert!(
        matches!(&config.elements[0], ConfigElement::Include(inc) if inc.raw_line == "Include ~/.ssh/config.d/*"),
        "Include before any Host block is correctly a top-level element"
    );
}

// ============================================================================
// 15. COMPLEX REAL-WORLD SCENARIOS
// ============================================================================

#[test]
fn realistic_config_roundtrip() {
    let input = "\
# Purple SSH Config
# Last updated: 2024-01-15

Include ~/.ssh/config.d/*

# Global defaults
Host *
  ServerAliveInterval 60
  ServerAliveCountMax 3
  AddKeysToAgent yes

# Production
Host prod-web
  HostName 10.1.0.10
  User deploy
  IdentityFile ~/.ssh/prod_key
  ForwardAgent no
  # purple:tags prod,web

Host prod-db
  HostName 10.1.0.20
  User dba
  IdentityFile ~/.ssh/prod_key
  Port 2222
  # purple:tags prod,db

# Staging
Host staging
  HostName staging.example.com
  User developer
  # purple:tags staging

# Development
Host dev
  HostName 192.168.1.100
  User dev
  ForwardAgent yes
  LocalForward 8080 localhost:80
  LocalForward 3000 localhost:3000
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

/// Realistic workflow with add+edit+delete+tags produces clean spacing.
/// serialize() collapses consecutive blank lines from orphaned separators.
#[test]
fn realistic_workflow_no_double_blank_lines() {
    let input = "\
Host web
  HostName web.example.com
  User admin

Host db
  HostName db.example.com
  User dba
  Port 5432

Host cache
  HostName cache.example.com
  User admin
";
    let mut config = parse_str(input);

    // 1. Add monitoring
    config.add_host(&HostEntry {
        alias: "monitoring".to_string(),
        hostname: "mon.example.com".to_string(),
        user: "admin".to_string(),
        port: 22,
        ..Default::default()
    });

    // 2. Edit db
    config.update_host(
        "db",
        &HostEntry {
            alias: "db".to_string(),
            hostname: "db-new.example.com".to_string(),
            user: "dba".to_string(),
            port: 5432,
            ..Default::default()
        },
    );

    // 3. Delete cache
    config.delete_host("cache");

    // 4. Add tags to web
    config.set_host_tags("web", &["prod".to_string(), "frontend".to_string()]);

    let output = config.serialize();

    assert!(output.contains("Host web"));
    assert!(output.contains("Host db"));
    assert!(!output.contains("Host cache"));
    assert!(output.contains("Host monitoring"));

    // No double blank lines after collapse fix
    let has_triple_newline = output.contains("\n\n\n");
    assert!(
        !has_triple_newline,
        "Serialize should collapse consecutive blank lines. Output:\n{}",
        visible(&output)
    );
}

// ============================================================================
// 16. WINDOWS LINE ENDINGS
// ============================================================================

#[test]
fn crlf_preserved_in_round_trip() {
    let input = "Host myserver\r\n  HostName 10.0.0.1\r\n";
    let config = parse_str(input);
    assert!(config.crlf, "CRLF should be detected");
    // raw_line values are stored without \r (lines() strips it)
    if let ConfigElement::HostBlock(block) = &config.elements[0] {
        assert!(
            !block.raw_host_line.contains('\r'),
            "raw_host_line should not contain \\r"
        );
        for d in &block.directives {
            assert!(!d.raw_line.contains('\r'), "raw_line should not contain \\r");
        }
    }
    // But serialize re-adds CRLF
    let output = config.serialize();
    assert!(
        output.contains("\r\n"),
        "Serialize should preserve CRLF line endings"
    );
}

// ============================================================================
// 17. INSERT_HOST_AT EDGE CASES
// ============================================================================

#[test]
fn insert_host_at_beginning() {
    let input = "Host beta\n  HostName b.com\n";
    let mut config = parse_str(input);
    let new_block = HostBlock {
        host_pattern: "alpha".to_string(),
        raw_host_line: "Host alpha".to_string(),
        directives: vec![purple_ssh::ssh_config::model::Directive {
            key: "HostName".to_string(),
            value: "a.com".to_string(),
            raw_line: "  HostName a.com".to_string(),
            is_non_directive: false,
        }],
    };
    config.insert_host_at(ConfigElement::HostBlock(new_block), 0);
    let output = config.serialize();
    let alpha_pos = output.find("Host alpha").unwrap();
    let beta_pos = output.find("Host beta").unwrap();
    assert!(alpha_pos < beta_pos);
}

#[test]
fn insert_host_at_beyond_length() {
    let input = "Host alpha\n  HostName a.com\n";
    let mut config = parse_str(input);
    let new_block = HostBlock {
        host_pattern: "beta".to_string(),
        raw_host_line: "Host beta".to_string(),
        directives: vec![purple_ssh::ssh_config::model::Directive {
            key: "HostName".to_string(),
            value: "b.com".to_string(),
            raw_line: "  HostName b.com".to_string(),
            is_non_directive: false,
        }],
    };
    config.insert_host_at(ConfigElement::HostBlock(new_block), 999);
    let output = config.serialize();
    let alpha_pos = output.find("Host alpha").unwrap();
    let beta_pos = output.find("Host beta").unwrap();
    assert!(alpha_pos < beta_pos);
}

/// insert_host_at with no blank line separator creates hosts without spacing.
/// This is expected since insert_host_at is a low-level undo primitive,
/// but it means undo can produce outputs that differ from add_host.
#[test]
fn insert_host_at_no_automatic_blank_separator() {
    let input = "Host alpha\n  HostName a.com\n";
    let mut config = parse_str(input);
    let new_block = HostBlock {
        host_pattern: "beta".to_string(),
        raw_host_line: "Host beta".to_string(),
        directives: vec![purple_ssh::ssh_config::model::Directive {
            key: "HostName".to_string(),
            value: "b.com".to_string(),
            raw_line: "  HostName b.com".to_string(),
            is_non_directive: false,
        }],
    };
    config.insert_host_at(ConfigElement::HostBlock(new_block), 1);
    let output = config.serialize();
    // No blank line between alpha and beta because insert_host_at doesn't add one
    // and alpha has no trailing blank directive
    assert!(
        output.contains("a.com\nHost beta"),
        "insert_host_at does not add blank separator. Got:\n{}",
        visible(&output)
    );
}

// ============================================================================
// 18. STRESS: CUMULATIVE FORMATTING DRIFT
// ============================================================================

#[test]
fn many_add_operations_consistent_spacing() {
    let mut config = parse_str("");
    for i in 0..20 {
        config.add_host(&HostEntry {
            alias: format!("host{:02}", i),
            hostname: format!("{}.example.com", i),
            port: 22,
            ..Default::default()
        });
    }
    let output = config.serialize();
    let lines: Vec<&str> = output.lines().collect();
    let mut prev_was_blank = false;
    let mut blank_count = 0;
    for line in &lines {
        if line.is_empty() {
            blank_count += 1;
            assert!(
                !prev_was_blank,
                "Found consecutive blank lines:\n{}",
                visible(&output)
            );
            prev_was_blank = true;
        } else {
            prev_was_blank = false;
        }
    }
    assert_eq!(blank_count, 19, "19 separators between 20 hosts");
}

#[test]
fn many_delete_operations_no_blank_accumulation() {
    let mut input = String::new();
    for i in 0..10 {
        if i > 0 {
            input.push('\n');
        }
        input.push_str(&format!("Host host{}\n  HostName {}.com\n", i, i));
    }
    let mut config = parse_str(&input);
    for i in [4, 5, 3, 6, 2, 7, 1, 8] {
        config.delete_host(&format!("host{}", i));
    }
    let output = config.serialize();
    assert!(output.contains("Host host0"));
    assert!(output.contains("Host host9"));
    assert!(
        !output.contains("\n\n\n"),
        "No triple newlines after many deletions. Got:\n{}",
        visible(&output)
    );
}

#[test]
fn alternating_add_delete_no_drift() {
    let input = "Host base\n  HostName base.com\n";
    let mut config = parse_str(input);
    for i in 0..20 {
        config.add_host(&HostEntry {
            alias: format!("temp{}", i),
            hostname: format!("{}.com", i),
            port: 22,
            ..Default::default()
        });
        config.delete_host(&format!("temp{}", i));
    }
    let output = config.serialize();
    assert!(output.contains("Host base"));
    let blank_count = output.lines().filter(|l| l.is_empty()).count();
    assert!(
        blank_count <= 1,
        "Should have at most 1 blank line. Got {} blank lines:\n{}",
        blank_count,
        visible(&output)
    );
}

// ============================================================================
// 19. INTERACTION BETWEEN TAGS AND OTHER OPERATIONS
// ============================================================================

#[test]
fn add_tags_then_update_host_preserves_tags() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
";
    let mut config = parse_str(input);
    config.set_host_tags("myserver", &["prod".to_string()]);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.2".to_string(),
            user: "admin".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(
        output.contains("# purple:tags prod"),
        "Tags should survive update_host. Got:\n{}",
        visible(&output)
    );
}

#[test]
fn update_host_then_add_tags() {
    let input = "\
Host myserver
  HostName 10.0.0.1
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.2".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    config.set_host_tags("myserver", &["staging".to_string()]);
    let output = config.serialize();
    assert!(output.contains("HostName 10.0.0.2"));
    assert!(output.contains("# purple:tags staging"));
}

// ============================================================================
// 20. INDENTATION FIDELITY IN UPDATE
// ============================================================================

#[test]
fn update_host_with_four_space_indent_preserves_indent() {
    let input = "\
Host myserver
    HostName 10.0.0.1
    User admin
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.2".to_string(),
            user: "admin".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    let lines: Vec<&str> = output.lines().collect();
    for line in &lines {
        if line.contains("HostName") || line.contains("User") {
            assert!(
                line.starts_with("    "),
                "Updated directives should preserve 4-space indent. Line: {:?}",
                line
            );
        }
    }
}

// ============================================================================
// 21. HAS_HOST
// ============================================================================

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

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

#[test]
fn has_host_matches_wildcards() {
    let config = parse_str("Host *\n  ServerAliveInterval 60\n");
    assert!(config.has_host("*"));
}

#[test]
fn has_host_detects_multi_pattern() {
    let config = parse_str("Host prod staging\n  HostName 10.0.0.1\n");
    assert!(config.has_host("prod"));
    assert!(config.has_host("staging"));
    assert!(!config.has_host("dev"));
    // Exact full pattern still works
    assert!(!config.has_host("prod staging"));
}

#[test]
fn ssh_command_quotes_alias() {
    let entry = HostEntry {
        alias: "myserver".to_string(),
        ..Default::default()
    };
    assert_eq!(entry.ssh_command(), "ssh -- 'myserver'");
}

#[test]
fn ssh_command_escapes_single_quotes() {
    let entry = HostEntry {
        alias: "it's-a-host".to_string(),
        ..Default::default()
    };
    assert_eq!(entry.ssh_command(), "ssh -- 'it'\\''s-a-host'");
}

#[test]
fn ssh_command_escapes_shell_metacharacters() {
    let entry = HostEntry {
        alias: "prod;rm -rf /".to_string(),
        ..Default::default()
    };
    assert_eq!(entry.ssh_command(), "ssh -- 'prod;rm -rf /'");
}

// ============================================================================
// 22. INCLUDE DIRECTIVES PRESERVATION
// ============================================================================

#[test]
fn include_directive_preserved_through_add() {
    let input = "\
Include ~/.ssh/config.d/*

Host alpha
  HostName a.com
";
    let mut config = parse_str(input);
    config.add_host(&HostEntry {
        alias: "beta".to_string(),
        hostname: "b.com".to_string(),
        port: 22,
        ..Default::default()
    });
    let output = config.serialize();
    assert!(
        output.contains("Include ~/.ssh/config.d/*"),
        "Include before hosts should be preserved through add"
    );
}

// ============================================================================
// 23. EXACT OUTPUT VERIFICATION
// ============================================================================

#[test]
fn exact_output_add_to_single_host() {
    let input = "Host alpha\n  HostName a.com\n  User deploy\n";
    let mut config = parse_str(input);
    config.add_host(&HostEntry {
        alias: "beta".to_string(),
        hostname: "b.com".to_string(),
        user: "root".to_string(),
        port: 2222,
        ..Default::default()
    });
    let expected = "Host alpha\n  HostName a.com\n  User deploy\n\nHost beta\n  HostName b.com\n  User root\n  Port 2222\n";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn exact_output_delete_middle() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com

Host gamma
  HostName g.com
";
    let mut config = parse_str(input);
    config.delete_host("beta");
    let expected = "\
Host alpha
  HostName a.com

Host gamma
  HostName g.com
";
    assert_eq_visible(expected, &config.serialize());
}

#[test]
fn exact_output_update() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
  ForwardAgent yes
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver-renamed".to_string(),
            hostname: "10.0.0.2".to_string(),
            user: "newuser".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let expected = "\
Host myserver-renamed
  HostName 10.0.0.2
  User newuser
  ForwardAgent yes
";
    assert_eq_visible(expected, &config.serialize());
}

// ============================================================================
// 24. ADDITIONAL EDGE CASES
// ============================================================================

/// Test that update_host adding new directives to a block with a trailing blank
/// separator adds them BEFORE the blank, not after.
#[test]
fn update_host_adds_directive_position_with_trailing_blank() {
    let input = "\
Host alpha
  HostName a.com

Host beta
  HostName b.com
";
    let mut config = parse_str(input);
    config.update_host(
        "alpha",
        &HostEntry {
            alias: "alpha".to_string(),
            hostname: "a.com".to_string(),
            user: "newuser".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    let lines: Vec<&str> = output.lines().collect();
    let user_pos = lines
        .iter()
        .position(|l| l.contains("User newuser"))
        .expect("Output must contain User newuser");
    let blank_pos = lines
        .iter()
        .position(|l| l.is_empty())
        .expect("Output must contain a blank separator line");
    assert!(
        user_pos < blank_pos,
        "New directive should be placed before the blank separator line.\n{}",
        visible(&output)
    );
}

/// Test that deleting a host with a group comment before it works correctly
/// when that group comment is a GlobalLine (i.e., before the first host).
#[test]
fn delete_first_host_with_global_group_comment() {
    let input = "\
# Production
Host prod
  HostName prod.com

Host staging
  HostName staging.com
";
    let mut config = parse_str(input);
    config.delete_host("prod");
    let output = config.serialize();
    // "# Production" is a GlobalLine (before first host), it survives
    // The blank line + "# Staging" (if present) would be inside prod's block
    assert!(output.contains("# Production"));
    assert!(output.contains("Host staging"));
    assert!(!output.contains("Host prod"));
}

/// Edge case: empty alias in has_host
#[test]
fn has_host_empty_alias() {
    let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
    assert!(!config.has_host(""));
}

/// Verify that swap_hosts with same alias is a no-op
#[test]
fn swap_same_host_is_noop() {
    let input = "Host alpha\n  HostName a.com\n";
    let mut config = parse_str(input);
    let before = config.serialize();
    config.swap_hosts("alpha", "alpha");
    assert_eq_visible(&before, &config.serialize());
}

// ============================================================================
// 25. MULTIPLE IDENTITYFILE PRESERVATION
// ============================================================================

/// Editing an unrelated field on a host with multiple IdentityFile directives
/// must preserve all of them. Previously, to_host_entry() captured the LAST
/// IdentityFile while upsert_directive() updated the FIRST, causing corruption.
#[test]
fn update_host_preserves_multiple_identity_files() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  User admin
  IdentityFile ~/.ssh/id_ed25519
  IdentityFile ~/.ssh/id_rsa
  IdentityFile ~/.ssh/company_key
";
    let config = parse_str(input);
    let entries = config.host_entries();
    assert_eq!(entries[0].identity_file, "~/.ssh/id_ed25519",
        "to_host_entry should capture the FIRST IdentityFile");

    // Edit hostname only (identity_file unchanged from to_host_entry)
    let mut config = parse_str(input);
    let mut entry = config.host_entries().remove(0);
    entry.hostname = "10.0.0.2".to_string();
    config.update_host("myserver", &entry);
    let output = config.serialize();

    // All three IdentityFile directives must survive
    assert!(output.contains("IdentityFile ~/.ssh/id_ed25519"),
        "First IdentityFile should be preserved");
    assert!(output.contains("IdentityFile ~/.ssh/id_rsa"),
        "Second IdentityFile should be preserved");
    assert!(output.contains("IdentityFile ~/.ssh/company_key"),
        "Third IdentityFile should be preserved");
    assert!(output.contains("HostName 10.0.0.2"),
        "Hostname should be updated");

    // Count IdentityFile lines — must be exactly 3
    let id_count = output.lines().filter(|l| l.trim().starts_with("IdentityFile")).count();
    assert_eq!(id_count, 3,
        "Should have exactly 3 IdentityFile directives. Got:\n{}",
        output);
}

/// Changing the primary IdentityFile updates only the first directive.
#[test]
fn update_host_changes_first_identity_file_only() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  IdentityFile ~/.ssh/id_ed25519
  IdentityFile ~/.ssh/id_rsa
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.1".to_string(),
            identity_file: "~/.ssh/new_key".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    let id_lines: Vec<&str> = output.lines()
        .filter(|l| l.trim().starts_with("IdentityFile"))
        .collect();
    assert_eq!(id_lines.len(), 2, "Should still have 2 IdentityFile directives");
    assert!(id_lines[0].contains("~/.ssh/new_key"),
        "First IdentityFile should be updated");
    assert!(id_lines[1].contains("~/.ssh/id_rsa"),
        "Second IdentityFile should be untouched");
}

// ============================================================================
// 26. MATCH BLOCKS
// ============================================================================

/// Match blocks are stored as GlobalLines and survive host deletion.
#[test]
fn match_block_preserved_through_host_deletion() {
    let input = "\
Host myserver
  HostName 10.0.0.1

Match host *.example.com
  ForwardAgent yes

Host other
  HostName 10.0.0.2
";
    let mut config = parse_str(input);
    config.delete_host("myserver");
    let output = config.serialize();
    assert!(output.contains("Match host *.example.com"), "Match block should survive host deletion");
    assert!(output.contains("ForwardAgent yes"), "Match directives should survive host deletion");
    assert!(output.contains("Host other"));
    assert!(!output.contains("Host myserver"));
}

#[test]
fn match_block_round_trip() {
    let input = "\
Host myserver
  HostName 10.0.0.1

Match host *.example.com
  ForwardAgent yes
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

#[test]
fn match_block_between_hosts_round_trip() {
    let input = "\
Host alpha
  HostName a.com

Match all
  ServerAliveInterval 60

Host beta
  HostName b.com
";
    let config = parse_str(input);
    assert_eq_visible(input, &config.serialize());
}

/// Clearing IdentityFile removes all IdentityFile directives.
#[test]
fn update_host_clear_identity_file_removes_all() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  IdentityFile ~/.ssh/id_ed25519
  IdentityFile ~/.ssh/id_rsa
";
    let mut config = parse_str(input);
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.1".to_string(),
            identity_file: "".to_string(),
            port: 22,
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(!output.contains("IdentityFile"),
        "All IdentityFile directives should be removed when cleared");
}

// ============================================================================
// 22. EQUALS-SYNTAX PRESERVATION THROUGH MUTATIONS
// ============================================================================

#[test]
fn equals_syntax_preserved_after_hostname_change() {
    let input = "\
Host myserver
  HostName=10.0.0.1
  User=admin
  Port=2222
";
    let mut config = parse_str(input);
    let mut entry = config.host_entries().remove(0);
    entry.hostname = "10.0.0.2".to_string();
    config.update_host("myserver", &entry);
    let output = config.serialize();
    assert!(output.contains("HostName=10.0.0.2"), "Equals-syntax should be preserved after value change: {}", output);
    assert!(output.contains("User=admin"), "Unchanged equals-syntax directive should survive: {}", output);
    assert!(output.contains("Port=2222"), "Unchanged equals-syntax Port should survive: {}", output);
}

#[test]
fn equals_syntax_preserved_after_user_change() {
    let input = "\
Host myserver
  HostName=10.0.0.1
  User=admin
";
    let mut config = parse_str(input);
    let mut entry = config.host_entries().remove(0);
    entry.user = "deploy".to_string();
    config.update_host("myserver", &entry);
    let output = config.serialize();
    assert!(output.contains("HostName=10.0.0.1"), "Unchanged equals-syntax HostName should survive");
    assert!(output.contains("User=deploy"), "Equals-syntax should be preserved after User change: {}", output);
}

#[test]
fn mixed_equals_and_space_syntax_preserved() {
    let input = "\
Host myserver
  HostName=10.0.0.1
  User admin
  Port=2222
  IdentityFile ~/.ssh/id_rsa
";
    let mut config = parse_str(input);
    let mut entry = config.host_entries().remove(0);
    entry.hostname = "10.0.0.2".to_string();
    entry.user = "deploy".to_string();
    config.update_host("myserver", &entry);
    let output = config.serialize();
    assert!(output.contains("HostName=10.0.0.2"), "Equals-syntax should be preserved for HostName");
    assert!(output.contains("User deploy"), "Space-syntax should be preserved for User: {}", output);
    assert!(output.contains("Port=2222"), "Unchanged equals-syntax Port should survive");
    assert!(output.contains("IdentityFile ~/.ssh/id_rsa"), "Space-syntax IdentityFile should survive");
}

#[test]
fn spaced_equals_syntax_preserved_after_value_change() {
    let input = "\
Host myserver
  HostName = 10.0.0.1
  User = admin
  Port = 2222
";
    let mut config = parse_str(input);
    let mut entry = config.host_entries().remove(0);
    entry.hostname = "10.0.0.2".to_string();
    entry.user = "deploy".to_string();
    config.update_host("myserver", &entry);
    let output = config.serialize();
    assert!(output.contains("HostName = 10.0.0.2"), "Spaced equals-syntax should be preserved for HostName: {}", output);
    assert!(output.contains("User = deploy"), "Spaced equals-syntax should be preserved for User: {}", output);
    assert!(output.contains("Port = 2222"), "Unchanged spaced equals-syntax Port should survive: {}", output);
}

// ============================================================================
// 23. CRLF PRESERVATION THROUGH MUTATIONS
// ============================================================================

#[test]
fn crlf_preserved_after_hostname_change() {
    let input = "Host myserver\r\n  HostName 10.0.0.1\r\n  User admin\r\n";
    let mut config = parse_str(input);
    assert!(config.crlf, "CRLF should be detected");
    let mut entry = config.host_entries().remove(0);
    entry.hostname = "10.0.0.2".to_string();
    config.update_host("myserver", &entry);
    let output = config.serialize();
    assert!(output.contains("\r\n"), "CRLF should be preserved after mutation");
    assert!(output.contains("HostName 10.0.0.2"), "Hostname should be updated");
    // Verify no bare \n (every \n should be preceded by \r)
    for (i, b) in output.bytes().enumerate() {
        if b == b'\n' && i > 0 {
            assert_eq!(output.as_bytes()[i - 1], b'\r', "All line endings should be CRLF, found bare LF at byte {}", i);
        }
    }
}

#[test]
fn crlf_preserved_after_add_host() {
    let input = "Host existing\r\n  HostName 10.0.0.1\r\n";
    let mut config = parse_str(input);
    assert!(config.crlf, "CRLF should be detected");
    config.add_host(&HostEntry {
        alias: "newhost".to_string(),
        hostname: "10.0.0.2".to_string(),
        user: "root".to_string(),
        port: 22,
        ..Default::default()
    });
    let output = config.serialize();
    assert!(output.contains("\r\n"), "CRLF should be preserved after add");
    // Verify no bare \n
    for (i, b) in output.bytes().enumerate() {
        if b == b'\n' && i > 0 {
            assert_eq!(output.as_bytes()[i - 1], b'\r', "All line endings should be CRLF after add, found bare LF at byte {}", i);
        }
    }
}

#[test]
fn crlf_preserved_after_delete_host() {
    let input = "Host first\r\n  HostName 10.0.0.1\r\n\r\nHost second\r\n  HostName 10.0.0.2\r\n";
    let mut config = parse_str(input);
    assert!(config.crlf, "CRLF should be detected");
    config.delete_host("first");
    let output = config.serialize();
    assert!(output.contains("\r\n"), "CRLF should be preserved after delete");
    assert!(output.contains("Host second"), "Second host should survive");
    for (i, b) in output.bytes().enumerate() {
        if b == b'\n' && i > 0 {
            assert_eq!(output.as_bytes()[i - 1], b'\r', "All line endings should be CRLF after delete, found bare LF at byte {}", i);
        }
    }
}

// --- New tests from review findings ---

#[test]
fn update_host_nonexistent_is_noop() {
    let input = "Host alpha\n  HostName a.com\n";
    let mut config = parse_str(input);
    let entry = HostEntry {
        alias: "nonexistent".to_string(),
        hostname: "b.com".to_string(),
        ..Default::default()
    };
    config.update_host("nonexistent", &entry);
    assert_eq!(config.serialize(), input);
}

#[test]
fn set_tags_nonexistent_alias_is_noop() {
    let input = "Host alpha\n  HostName a.com\n";
    let mut config = parse_str(input);
    config.set_host_tags("nonexistent", &["prod".to_string()]);
    assert_eq!(config.serialize(), input);
}

#[test]
fn set_provider_nonexistent_alias_is_noop() {
    let input = "Host alpha\n  HostName a.com\n";
    let mut config = parse_str(input);
    config.set_host_provider("nonexistent", "digitalocean", "123");
    assert_eq!(config.serialize(), input);
}

#[test]
fn deduplicate_alias_basic() {
    let config = parse_str("Host alpha\n  HostName a.com\n");
    assert_eq!(config.deduplicate_alias("alpha"), "alpha-2");
    assert_eq!(config.deduplicate_alias("beta"), "beta");
}

#[test]
fn deduplicate_alias_multiple_collisions() {
    let input = "Host web\n  HostName a.com\n\nHost web-2\n  HostName b.com\n\nHost web-3\n  HostName c.com\n";
    let config = parse_str(input);
    assert_eq!(config.deduplicate_alias("web"), "web-4");
}

#[test]
fn delete_host_multi_pattern_not_deleted_by_single() {
    let input = "Host prod staging\n  HostName shared.com\n\nHost other\n  HostName other.com\n";
    let mut config = parse_str(input);
    config.delete_host("prod");
    let output = config.serialize();
    assert!(output.contains("Host prod staging"), "Multi-pattern host should survive single-pattern delete");
    assert!(output.contains("Host other"), "Other host should survive");
}

// ============================================================================
// Tunnel forwarding round-trip tests
// ============================================================================

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

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

#[test]
fn add_forward_does_not_break_update_host() {
    let input = "Host myserver\n  HostName 10.0.0.1\n  User admin\n";
    let mut config = parse_str(input);
    config.add_forward("myserver", "LocalForward", "8080 localhost:80");

    // Now update the host — forward should be preserved
    config.update_host(
        "myserver",
        &HostEntry {
            alias: "myserver".to_string(),
            hostname: "10.0.0.2".to_string(),
            user: "deploy".to_string(),
            ..Default::default()
        },
    );
    let output = config.serialize();
    assert!(output.contains("HostName 10.0.0.2"));
    assert!(output.contains("User deploy"));
    assert!(output.contains("LocalForward 8080 localhost:80"));
}

#[test]
fn multiple_forward_types_in_one_host() {
    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", "RemoteForward", "9090 localhost:3000");
    config.add_forward("myserver", "DynamicForward", "1080");
    let output = config.serialize();
    assert!(output.contains("LocalForward 8080 localhost:80"));
    assert!(output.contains("RemoteForward 9090 localhost:3000"));
    assert!(output.contains("DynamicForward 1080"));
}

#[test]
fn add_forward_ipv6_roundtrip() {
    let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
    config.add_forward("myserver", "LocalForward", "[::1]:8080 [fe80::1]:80");
    let output = config.serialize();
    assert!(output.contains("LocalForward [::1]:8080 [fe80::1]:80"));
    // Verify re-parse preserves the same value
    let config2 = parse_str(&output);
    let output2 = config2.serialize();
    assert_eq!(output, output2);
}

#[test]
fn add_forward_crlf_preservation() {
    let input = "Host myserver\r\n  HostName 10.0.0.1\r\n";
    let mut config = parse_str(input);
    assert!(config.crlf);
    config.add_forward("myserver", "LocalForward", "8080 localhost:80");
    let output = config.serialize();
    // All lines should use CRLF
    assert!(output.contains("\r\n"));
    assert!(output.contains("LocalForward 8080 localhost:80\r\n"));
    // No bare LF
    let bare_lf_count = output.chars().filter(|c| *c == '\n').count();
    let crlf_count = output.matches("\r\n").count();
    assert_eq!(bare_lf_count, crlf_count);
}

#[test]
fn remove_forward_crlf_preservation() {
    let input = "Host myserver\r\n  HostName 10.0.0.1\r\n  LocalForward 8080 localhost:80\r\n";
    let mut config = parse_str(input);
    assert!(config.crlf);
    config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
    let output = config.serialize();
    assert!(!output.contains("LocalForward"));
    // All remaining lines should use CRLF
    let bare_lf_count = output.chars().filter(|c| *c == '\n').count();
    let crlf_count = output.matches("\r\n").count();
    assert_eq!(bare_lf_count, crlf_count);
}

#[test]
fn add_forward_to_empty_host_block() {
    let mut config = parse_str("Host myserver\n");
    config.add_forward("myserver", "DynamicForward", "1080");
    let output = config.serialize();
    assert!(output.contains("DynamicForward 1080"));
    // Should use default indent (2 spaces)
    assert!(output.contains("  DynamicForward 1080"));
}

#[test]
fn add_forward_preserves_provider_marker() {
    let input = "Host myserver\n  HostName 10.0.0.1\n  # purple:provider do:12345\n  # purple:tags web,prod\n";
    let mut config = parse_str(input);
    config.add_forward("myserver", "LocalForward", "8080 localhost:80");
    let output = config.serialize();
    assert!(output.contains("# purple:provider do:12345"));
    assert!(output.contains("# purple:tags web,prod"));
    assert!(output.contains("LocalForward 8080 localhost:80"));
}

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

#[test]
fn remove_forward_then_has_forward_false() {
    let mut 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"));
    config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
    assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
}

#[test]
fn edit_forward_remove_then_add() {
    let input = "\
Host myserver
  HostName 10.0.0.1
  LocalForward 8080 localhost:80
  User admin
";
    let mut config = parse_str(input);
    // Edit: remove old, add new (same pattern as TUI edit flow)
    config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
    config.add_forward("myserver", "RemoteForward", "9090 localhost:3000");
    let output = config.serialize();
    assert!(!output.contains("LocalForward 8080 localhost:80"));
    assert!(output.contains("RemoteForward 9090 localhost:3000"));
    assert!(output.contains("HostName 10.0.0.1"));
    assert!(output.contains("User admin"));
}

#[test]
fn find_tunnel_directives_multi_pattern_host() {
    let input = "\
Host prod staging
  HostName 10.0.0.1
  LocalForward 8080 localhost:80
  RemoteForward 9090 localhost:3000
";
    let config = parse_str(input);
    let rules = config.find_tunnel_directives("staging");
    assert_eq!(rules.len(), 2);
    let rules2 = config.find_tunnel_directives("prod");
    assert_eq!(rules2.len(), 2);
    let rules3 = config.find_tunnel_directives("nohost");
    assert!(rules3.is_empty());
}

#[test]
fn edit_forward_type_change_local_to_dynamic() {
    // Simulates edit: remove LocalForward, add DynamicForward
    let input = "\
Host myserver
  HostName 10.0.0.1
  LocalForward 8080 localhost:80
";
    let mut config = parse_str(input);
    // Remove old Local forward
    assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
    // Add new Dynamic forward
    config.add_forward("myserver", "DynamicForward", "1080");

    let output = config.serialize();
    assert!(!output.contains("LocalForward"));
    assert!(output.contains("DynamicForward 1080"));
    // HostName preserved
    assert!(output.contains("HostName 10.0.0.1"));

    // Round-trip: re-parse and verify
    let reparsed = parse_str(&output);
    let rules = reparsed.find_tunnel_directives("myserver");
    assert_eq!(rules.len(), 1);
    assert_eq!(rules[0].tunnel_type, purple_ssh::tunnel::TunnelType::Dynamic);
    assert_eq!(rules[0].bind_port, 1080);
}

// ── Equals-in-value separator preservation ──────────────────────────────

#[test]
fn update_host_equals_in_value_preserves_space_separator() {
    // IdentityFile value contains '=' — separator must remain a space
    let input = "Host myserver\n  HostName 10.0.0.1\n  IdentityFile ~/.ssh/id=prod\n";
    let mut config = parse_str(input);

    let entry = HostEntry {
        alias: "myserver".to_string(),
        hostname: "10.0.0.2".to_string(),
        identity_file: "~/.ssh/id=staging".to_string(),
        port: 22,
        ..Default::default()
    };
    config.update_host("myserver", &entry);
    let output = config.serialize();

    assert_eq_visible("Host myserver\n  HostName 10.0.0.2\n  IdentityFile ~/.ssh/id=staging\n", &output);
}

#[test]
fn update_host_equals_separator_with_equals_in_value() {
    // Equals separator + value containing '=' — both must coexist
    let input = "Host myserver\n  HostName=10.0.0.1\n  IdentityFile=~/.ssh/id=prod\n";
    let mut config = parse_str(input);

    let entry = HostEntry {
        alias: "myserver".to_string(),
        hostname: "10.0.0.2".to_string(),
        identity_file: "~/.ssh/id=staging".to_string(),
        port: 22,
        ..Default::default()
    };
    config.update_host("myserver", &entry);
    let output = config.serialize();

    assert_eq_visible("Host myserver\n  HostName=10.0.0.2\n  IdentityFile=~/.ssh/id=staging\n", &output);
}

// ============================================================================
// Provider sync integration tests
// ============================================================================

use purple_ssh::providers::config::ProviderSection;
use purple_ssh::providers::sync::{sync_provider, sync_provider_with_options};
use purple_ssh::providers::{Provider, ProviderError, ProviderHost};

struct TestProvider {
    name: &'static str,
    label: &'static str,
}

impl Provider for TestProvider {
    fn name(&self) -> &str {
        self.name
    }
    fn short_label(&self) -> &str {
        self.label
    }
    fn fetch_hosts_cancellable(
        &self,
        _token: &str,
        _cancel: &std::sync::atomic::AtomicBool,
    ) -> Result<Vec<ProviderHost>, ProviderError> {
        Ok(Vec::new())
    }
}

fn test_section(provider: &str, prefix: &str) -> ProviderSection {
    ProviderSection {
        provider: provider.to_string(),
        token: "test".to_string(),
        alias_prefix: prefix.to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        url: String::new(),
        verify_tls: true,
        auto_sync: true,
    }
}

#[test]
fn sync_adds_host_to_empty_config() {
    let mut config = parse_str("");
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: vec!["prod".to_string()],
    }];
    let result = sync_provider(&mut config, &provider, &remote, &section, false, false);
    assert_eq!(result.added, 1);

    let output = config.serialize();
    assert!(output.contains("Host do-web-1"), "alias should be do-web-1");
    assert!(output.contains("HostName 1.2.3.4"), "hostname should be 1.2.3.4");
    assert!(output.contains("User root"), "user should be root");
    assert!(output.contains("# purple:provider digitalocean:123"), "provider marker");
    assert!(output.contains("# purple:tags prod"), "tags");
}

#[test]
fn sync_preserves_existing_hosts() {
    let input = "Host manual-host\n  HostName 10.0.0.1\n  User admin\n";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "456".to_string(),
        name: "db-1".to_string(),
        ip: "5.6.7.8".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);

    let output = config.serialize();
    assert!(output.contains("Host manual-host"), "manual host preserved");
    assert!(output.contains("HostName 10.0.0.1"), "manual host IP preserved");
    assert!(output.contains("Host do-db-1"), "synced host added");
}

#[test]
fn sync_updates_ip_preserves_formatting() {
    let input = "Host do-web-1\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:123\n";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "9.8.7.6".to_string(),
        tags: Vec::new(),
    }];
    let result = sync_provider(&mut config, &provider, &remote, &section, false, false);
    assert_eq!(result.updated, 1);

    let output = config.serialize();
    assert!(output.contains("HostName 9.8.7.6"), "IP should be updated");
    assert!(output.contains("# purple:provider digitalocean:123"), "provider marker preserved");
}

#[test]
fn sync_removes_deleted_host() {
    let input = "\
Host do-web-1
  HostName 1.2.3.4
  User root
  # purple:provider digitalocean:123

Host do-db-1
  HostName 5.6.7.8
  User root
  # purple:provider digitalocean:456
";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    // Only web-1 in remote, db-1 should be removed
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: Vec::new(),
    }];
    let result = sync_provider(&mut config, &provider, &remote, &section, true, false);
    assert_eq!(result.removed, 1);
    assert_eq!(result.unchanged, 1);

    let output = config.serialize();
    assert!(output.contains("Host do-web-1"), "web-1 preserved");
    assert!(!output.contains("Host do-db-1"), "db-1 removed");
}

#[test]
fn sync_tag_merge_preserves_existing_tags() {
    let input = "\
Host do-web-1
  HostName 1.2.3.4
  User root
  # purple:provider digitalocean:123
  # purple:tags prod,my-custom
";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: vec!["prod".to_string(), "us-east".to_string()],
    }];
    let result = sync_provider(&mut config, &provider, &remote, &section, false, false);
    assert_eq!(result.updated, 1); // new tag "us-east" added

    let output = config.serialize();
    assert!(output.contains("my-custom"), "local tag preserved");
    assert!(output.contains("us-east"), "new remote tag added");
    assert!(output.contains("prod"), "shared tag preserved");
}

#[test]
fn sync_reset_tags_replaces_all() {
    let input = "\
Host do-web-1
  HostName 1.2.3.4
  User root
  # purple:provider digitalocean:123
  # purple:tags prod,my-custom
";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: vec!["staging".to_string()],
    }];
    let result = sync_provider_with_options(&mut config, &provider, &remote, &section, false, false, true);
    assert_eq!(result.updated, 1);

    let output = config.serialize();
    assert!(output.contains("staging"), "new tag set");
    assert!(!output.contains("my-custom"), "local tag replaced");
}

#[test]
fn sync_rename_updates_alias_and_provider_marker() {
    let input = "\
Host do-old-name
  HostName 1.2.3.4
  User root
  # purple:provider digitalocean:123
";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "new-name".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: Vec::new(),
    }];
    let result = sync_provider(&mut config, &provider, &remote, &section, false, false);
    assert_eq!(result.renames.len(), 1);
    assert_eq!(result.renames[0], ("do-old-name".to_string(), "do-new-name".to_string()));

    let output = config.serialize();
    assert!(output.contains("Host do-new-name"), "alias renamed");
    assert!(!output.contains("Host do-old-name"), "old alias gone");
    assert!(output.contains("# purple:provider digitalocean:123"), "provider marker updated");
}

#[test]
fn sync_with_identity_file() {
    let mut config = parse_str("");
    let mut section = test_section("digitalocean", "do");
    section.identity_file = "~/.ssh/do_key".to_string();
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "1".to_string(),
        name: "web".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);

    let output = config.serialize();
    assert!(output.contains("IdentityFile ~/.ssh/do_key"), "identity file set");
}

#[test]
fn sync_two_providers_coexist() {
    let mut config = parse_str("");
    let do_section = test_section("digitalocean", "do");
    let do_provider = TestProvider { name: "digitalocean", label: "do" };
    let hetzner_section = test_section("hetzner", "hz");
    let hetzner_provider = TestProvider { name: "hetzner", label: "hetzner" };

    let do_remote = vec![ProviderHost {
        server_id: "1".to_string(),
        name: "do-web".to_string(),
        ip: "1.1.1.1".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &do_provider, &do_remote, &do_section, false, false);

    let hz_remote = vec![ProviderHost {
        server_id: "2".to_string(),
        name: "hz-db".to_string(),
        ip: "2.2.2.2".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &hetzner_provider, &hz_remote, &hetzner_section, false, false);

    let output = config.serialize();
    assert!(output.contains("Host do-do-web"), "DO host present");
    assert!(output.contains("Host hz-hz-db"), "Hetzner host present");
    assert!(output.contains("# purple:provider digitalocean:1"), "DO marker");
    assert!(output.contains("# purple:provider hetzner:2"), "Hetzner marker");

    // Remove DO hosts, Hetzner should survive
    let result = sync_provider(&mut config, &do_provider, &[], &do_section, true, false);
    assert_eq!(result.removed, 1);
    let output = config.serialize();
    assert!(!output.contains("Host do-do-web"), "DO host removed");
    assert!(output.contains("Host hz-hz-db"), "Hetzner host survived");
}

#[test]
fn sync_empty_ip_skipped_but_not_removed() {
    let input = "\
Host do-web-1
  HostName 1.2.3.4
  User root
  # purple:provider digitalocean:123
";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    // Server exists but has empty IP (stopped VM)
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "".to_string(), // empty = no IP
        tags: Vec::new(),
    }];
    let result = sync_provider(&mut config, &provider, &remote, &section, true, false);
    assert_eq!(result.removed, 0, "should not remove stopped VMs");
    assert_eq!(result.unchanged, 1);

    let output = config.serialize();
    assert!(output.contains("Host do-web-1"), "host preserved despite empty IP");
}

#[test]
fn sync_preserves_indentation_style() {
    // Existing host uses 4-space indent. Sync update should preserve it.
    let input = "\
Host do-web-1
    HostName 1.2.3.4
    User root
    # purple:provider digitalocean:123
";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "9.8.7.6".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);

    let output = config.serialize();
    assert!(output.contains("    HostName 9.8.7.6"), "4-space indent preserved on update");
    assert!(output.contains("    User root"), "other directives unchanged");
}

#[test]
fn sync_preserves_comments_inside_host_block() {
    let input = "\
Host do-web-1
  HostName 1.2.3.4
  # This is a custom comment
  User root
  # purple:provider digitalocean:123
";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "9.8.7.6".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);

    let output = config.serialize();
    assert!(output.contains("# This is a custom comment"), "custom comment preserved");
}

#[test]
fn sync_alias_dedup_in_serialized_output() {
    // Existing manual host conflicts with synced alias
    let input = "Host do-web\n  HostName 10.0.0.1\n";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "1".to_string(),
        name: "web".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);

    let output = config.serialize();
    assert!(output.contains("Host do-web\n"), "manual host preserved");
    assert!(output.contains("Host do-web-2"), "synced host deduped with -2 suffix");
}

#[test]
fn sync_group_header_in_output() {
    let mut config = parse_str("");
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "1".to_string(),
        name: "web".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);

    let output = config.serialize();
    assert!(output.contains("# purple:group DigitalOcean"), "group header present");
}

#[test]
fn sync_group_header_removed_after_all_deleted() {
    let mut config = parse_str("");
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "1".to_string(),
        name: "web".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);
    let output = config.serialize();
    assert!(output.contains("# purple:group DigitalOcean"));

    // Remove all
    sync_provider(&mut config, &provider, &[], &section, true, false);
    let output = config.serialize();
    assert!(!output.contains("# purple:group DigitalOcean"), "header cleaned up");
}

#[test]
fn sync_multiple_tags_serialized_comma_separated() {
    let mut config = parse_str("");
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "1".to_string(),
        name: "web".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: vec!["prod".to_string(), "us-east".to_string(), "web".to_string()],
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);

    let output = config.serialize();
    assert!(output.contains("# purple:tags prod,us-east,web"), "tags comma-separated");
}

#[test]
fn sync_rename_prefix_change_serialized() {
    let input = "\
Host do-web-1
  HostName 1.2.3.4
  User root
  # purple:provider digitalocean:123
";
    let mut config = parse_str(input);
    let mut section = test_section("digitalocean", "ocean");
    section.user = "root".to_string();
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "1.2.3.4".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);

    let output = config.serialize();
    assert!(output.contains("Host ocean-web-1"), "alias uses new prefix");
    assert!(!output.contains("Host do-web-1"), "old alias gone");
}

#[test]
fn sync_unknown_directives_preserved_on_ip_update() {
    let input = "\
Host do-web-1
  HostName 1.2.3.4
  User root
  ProxyJump bastion
  ForwardAgent yes
  # purple:provider digitalocean:123
";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "9.8.7.6".to_string(),
        tags: Vec::new(),
    }];
    sync_provider(&mut config, &provider, &remote, &section, false, false);

    let output = config.serialize();
    assert!(output.contains("HostName 9.8.7.6"), "IP updated");
    assert!(output.contains("ProxyJump bastion"), "unknown directive preserved");
    assert!(output.contains("ForwardAgent yes"), "unknown directive preserved");
}

#[test]
fn sync_dry_run_serialized_unchanged() {
    let input = "Host do-web-1\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:123\n";
    let mut config = parse_str(input);
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote = vec![ProviderHost {
        server_id: "123".to_string(),
        name: "web-1".to_string(),
        ip: "9.9.9.9".to_string(),
        tags: Vec::new(),
    }];
    let result = sync_provider(&mut config, &provider, &remote, &section, false, true);
    assert_eq!(result.updated, 1);

    let output = config.serialize();
    assert!(output.contains("HostName 1.2.3.4"), "IP unchanged in dry-run");
    assert!(!output.contains("9.9.9.9"), "new IP not applied in dry-run");
}

#[test]
fn sync_large_batch_serialized() {
    let mut config = parse_str("");
    let section = test_section("digitalocean", "do");
    let provider = TestProvider { name: "digitalocean", label: "do" };
    let remote: Vec<ProviderHost> = (0..20)
        .map(|i| ProviderHost {
            server_id: format!("{}", i),
            name: format!("server-{}", i),
            ip: format!("10.0.0.{}", i),
            tags: Vec::new(),
        })
        .collect();
    let result = sync_provider(&mut config, &provider, &remote, &section, false, false);
    assert_eq!(result.added, 20);

    let output = config.serialize();
    for i in 0..20 {
        assert!(
            output.contains(&format!("Host do-server-{}", i)),
            "host {} present",
            i
        );
    }
}
