//! Property-based tests for SSH config round-trip fidelity.
//!
//! Uses proptest to generate random SSH configs and verify that:
//! 1. parse -> serialize -> reparse produces identical output (idempotent)
//! 2. Mutations (add/delete/update) preserve structural integrity
//! 3. No panics on arbitrary input

use std::path::PathBuf;

use proptest::prelude::*;
use purple_ssh::ssh_config::model::{HostEntry, SshConfigFile};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn parse_str(content: &str) -> SshConfigFile {
    SshConfigFile {
        elements: SshConfigFile::parse_content(content),
        path: PathBuf::from("/tmp/proptest_config"),
        crlf: purple_ssh::ssh_config::parser::detect_crlf_majority(content),
        bom: content.starts_with('\u{FEFF}'),
    }
}

// ---------------------------------------------------------------------------
// Strategies: building blocks
// ---------------------------------------------------------------------------

/// Safe characters for SSH config aliases (alphanumeric, dash, underscore, dot).
fn alias_strategy() -> impl Strategy<Value = String> {
    "[a-zA-Z][a-zA-Z0-9._-]{0,30}"
}

/// Hostname: IP or domain-like.
fn hostname_strategy() -> impl Strategy<Value = String> {
    prop_oneof![
        // IPv4
        (1u8..=254, 0u8..=255, 0u8..=255, 1u8..=254)
            .prop_map(|(a, b, c, d)| format!("{a}.{b}.{c}.{d}")),
        // Simple domain
        "[a-z]{3,12}\\.(com|org|net|io|dev)",
        // Subdomain
        "[a-z]{2,8}\\.[a-z]{3,10}\\.(com|org|net)",
    ]
}

/// User names.
fn user_strategy() -> impl Strategy<Value = String> {
    prop_oneof![
        Just("root".to_string()),
        Just("admin".to_string()),
        Just("deploy".to_string()),
        Just("ec2-user".to_string()),
        "[a-z]{3,12}",
    ]
}

/// Port numbers (valid SSH range).
fn port_strategy() -> impl Strategy<Value = u16> {
    prop_oneof![
        Just(22u16),
        Just(22u16), // weight toward default
        Just(22u16),
        2222u16..=65535,
    ]
}

/// Indentation style.
fn indent_strategy() -> impl Strategy<Value = String> {
    prop_oneof![
        Just("  ".to_string()),
        Just("    ".to_string()),
        Just("\t".to_string()),
    ]
}

/// An identity file path.
fn identity_file_strategy() -> impl Strategy<Value = String> {
    prop_oneof![
        Just("~/.ssh/id_ed25519".to_string()),
        Just("~/.ssh/id_rsa".to_string()),
        "[a-z_]{3,12}".prop_map(|name| format!("~/.ssh/{name}")),
    ]
}

/// A comment line (including the # prefix).
fn comment_strategy() -> impl Strategy<Value = String> {
    "[a-zA-Z0-9 .,;:!?_-]{0,60}".prop_map(|text| format!("# {text}"))
}

// ---------------------------------------------------------------------------
// Strategy: a complete Host block
// ---------------------------------------------------------------------------

fn host_block_strategy() -> impl Strategy<Value = String> {
    (
        alias_strategy(),
        hostname_strategy(),
        user_strategy(),
        port_strategy(),
        indent_strategy(),
        prop::option::of(identity_file_strategy()),
        prop::option::of(comment_strategy()),
        prop::bool::ANY, // use equals syntax
    )
        .prop_map(
            |(alias, hostname, user, port, indent, identity, comment, use_equals)| {
                let mut lines = Vec::new();

                // Host line
                if use_equals {
                    lines.push(format!("Host={alias}"));
                } else {
                    lines.push(format!("Host {alias}"));
                }

                // HostName
                if use_equals {
                    lines.push(format!("{indent}HostName={hostname}"));
                } else {
                    lines.push(format!("{indent}HostName {hostname}"));
                }

                // User
                lines.push(format!("{indent}User {user}"));

                // Port (skip default 22)
                if port != 22 {
                    lines.push(format!("{indent}Port {port}"));
                }

                // Optional identity file
                if let Some(ref id) = identity {
                    lines.push(format!("{indent}IdentityFile {id}"));
                }

                // Optional inline comment in block
                if let Some(ref c) = comment {
                    lines.push(format!("{indent}{c}"));
                }

                lines.join("\n")
            },
        )
}

// ---------------------------------------------------------------------------
// Strategy: a complete SSH config file
// ---------------------------------------------------------------------------

fn ssh_config_strategy() -> impl Strategy<Value = String> {
    (
        prop::option::of(comment_strategy()), // optional header comment
        prop::collection::vec(host_block_strategy(), 1..=20),
        prop::bool::ANY, // optional trailing Host *
        prop::bool::ANY, // use CRLF
    )
        .prop_map(|(header, blocks, trailing_wildcard, crlf)| {
            let mut parts = Vec::new();

            if let Some(h) = header {
                parts.push(h);
                parts.push(String::new()); // blank line after header
            }

            for (i, block) in blocks.iter().enumerate() {
                if i > 0 {
                    parts.push(String::new()); // blank line between blocks
                }
                parts.push(block.clone());
            }

            if trailing_wildcard {
                parts.push(String::new());
                parts.push("Host *".to_string());
                parts.push("  ServerAliveInterval 60".to_string());
            }

            let le = if crlf { "\r\n" } else { "\n" };
            let mut content = parts.join(le);
            content.push_str(le);
            content
        })
}

/// Strategy for Match blocks.
fn match_block_strategy() -> impl Strategy<Value = String> {
    prop_oneof![
        Just("Match host *.example.com\n  User deploy\n".to_string()),
        Just("Match originalhost web*\n  ProxyJump bastion\n".to_string()),
        Just("Match all\n  ServerAliveInterval 30\n".to_string()),
        Just("Match exec \"test -f /tmp/flag\"\n  HostName override.local\n".to_string()),
    ]
}

/// Strategy for configs with Match blocks interspersed.
fn config_with_match_strategy() -> impl Strategy<Value = String> {
    (
        prop::collection::vec(host_block_strategy(), 1..=5),
        prop::collection::vec(match_block_strategy(), 1..=3),
    )
        .prop_map(|(blocks, matches)| {
            let mut parts = Vec::new();
            for (i, block) in blocks.iter().enumerate() {
                if i > 0 {
                    parts.push(String::new());
                }
                parts.push(block.clone());
                // Insert Match block after every other host
                if i < matches.len() && i % 2 == 0 {
                    parts.push(String::new());
                    parts.push(matches[i].clone());
                }
            }
            let mut content = parts.join("\n");
            content.push('\n');
            content
        })
}

/// Purple-specific comment annotations.
fn purple_tags_strategy() -> impl Strategy<Value = String> {
    prop::collection::vec("[a-z]{2,8}", 1..=5)
        .prop_map(|tags| format!("  # purple:tags {}", tags.join(",")))
}

fn purple_provider_tags_strategy() -> impl Strategy<Value = String> {
    prop::collection::vec("[a-z]{2,8}", 1..=5)
        .prop_map(|tags| format!("  # purple:provider_tags {}", tags.join(",")))
}

fn purple_provider_strategy() -> impl Strategy<Value = String> {
    (
        prop_oneof![
            Just("aws"),
            Just("digitalocean"),
            Just("hetzner"),
            Just("gcp"),
            Just("vultr"),
            Just("tailscale"),
        ],
        "[a-z0-9]{8,20}",
    )
        .prop_map(|(provider, id)| format!("  # purple:provider {provider}:{id}"))
}

fn purple_meta_strategy() -> impl Strategy<Value = String> {
    prop_oneof![
        Just("  # purple:meta region=us-east-1,status=running".to_string()),
        Just("  # purple:meta zone=eu-west-1a,type=t3.micro".to_string()),
        Just("  # purple:meta node=pve1,type=qemu,specs=4c/8g".to_string()),
    ]
}

fn purple_stale_strategy() -> impl Strategy<Value = String> {
    (1_600_000_000u64..=1_900_000_000).prop_map(|ts| format!("  # purple:stale {}", ts))
}

/// Host block with purple annotations.
fn annotated_host_block_strategy() -> impl Strategy<Value = String> {
    (
        host_block_strategy(),
        prop::option::of(purple_tags_strategy()),
        prop::option::of(purple_provider_tags_strategy()),
        prop::option::of(purple_provider_strategy()),
        prop::option::of(purple_meta_strategy()),
        prop::option::of(purple_stale_strategy()),
    )
        .prop_map(|(block, tags, ptags, provider, meta, stale)| {
            let mut lines: Vec<&str> = block.lines().collect();
            // Insert purple comments before any trailing blank
            if let Some(t) = &tags {
                lines.push(t);
            }
            if let Some(pt) = &ptags {
                lines.push(pt);
            }
            if let Some(p) = &provider {
                lines.push(p);
            }
            if let Some(m) = &meta {
                lines.push(m);
            }
            if let Some(s) = &stale {
                lines.push(s);
            }
            lines.join("\n")
        })
}

/// A full config with purple annotations.
fn annotated_config_strategy() -> impl Strategy<Value = String> {
    prop::collection::vec(annotated_host_block_strategy(), 1..=10).prop_map(|blocks| {
        let mut content = blocks.join("\n\n");
        content.push('\n');
        content
    })
}

// ---------------------------------------------------------------------------
// Property: parse -> serialize is idempotent
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(500))]

    #[test]
    fn roundtrip_idempotent(content in ssh_config_strategy()) {
        let config = parse_str(&content);
        let serialized = config.serialize();
        let reparsed = parse_str(&serialized);
        let reserialized = reparsed.serialize();
        prop_assert!(
            serialized == reserialized,
            "Second serialization differs from first (len {} vs {})",
            serialized.len(),
            reserialized.len(),
        );
    }

    #[test]
    fn roundtrip_with_match_blocks(content in config_with_match_strategy()) {
        let config = parse_str(&content);
        let serialized = config.serialize();
        let reparsed = parse_str(&serialized);
        let reserialized = reparsed.serialize();
        prop_assert!(serialized == reserialized);
    }

    #[test]
    fn roundtrip_annotated_configs(content in annotated_config_strategy()) {
        let config = parse_str(&content);
        let serialized = config.serialize();
        let reparsed = parse_str(&serialized);
        let reserialized = reparsed.serialize();
        prop_assert!(serialized == reserialized);
    }
}

// ---------------------------------------------------------------------------
// Property: host count preserved after parse -> serialize -> reparse
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(200))]

    #[test]
    fn host_count_preserved(content in ssh_config_strategy()) {
        let config = parse_str(&content);
        let count1 = config.host_entries().len();
        let serialized = config.serialize();
        let reparsed = parse_str(&serialized);
        let count2 = reparsed.host_entries().len();
        prop_assert_eq!(
            count1, count2,
            "Host count changed: {} -> {}",
            count1, count2,
        );
    }
}

// ---------------------------------------------------------------------------
// Property: add_host then serialize preserves existing hosts
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(200))]

    #[test]
    fn add_host_preserves_existing(
        content in ssh_config_strategy(),
        new_alias in alias_strategy(),
        new_hostname in hostname_strategy(),
        new_user in user_strategy(),
    ) {
        let mut config = parse_str(&content);
        let original_hosts: Vec<String> = config
            .host_entries()
            .iter()
            .map(|e| e.alias.clone())
            .collect();

        // Skip if alias already exists (add_host doesn't overwrite)
        if config.has_host(&new_alias) {
            return Ok(());
        }

        config.add_host(&HostEntry {
            alias: new_alias.clone(),
            hostname: new_hostname,
            user: new_user,
            port: 22,
            ..Default::default()
        });

        let entries = config.host_entries();
        // All original hosts still present
        for alias in &original_hosts {
            prop_assert!(
                entries.iter().any(|e| e.alias == *alias),
                "Lost host '{}' after add",
                alias,
            );
        }
        // New host is present
        prop_assert!(
            entries.iter().any(|e| e.alias == new_alias),
            "New host '{}' not found after add",
            new_alias,
        );

        // Round-trip stable after add
        let serialized = config.serialize();
        let reparsed = parse_str(&serialized);
        prop_assert!(serialized == reparsed.serialize());
    }
}

// ---------------------------------------------------------------------------
// Property: delete_host removes all hosts with matching alias
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(200))]

    #[test]
    fn delete_host_removes_all_with_alias(content in ssh_config_strategy()) {
        let mut config = parse_str(&content);
        let entries = config.host_entries();
        if entries.is_empty() {
            return Ok(());
        }

        // Pick the first host
        let alias = entries[0].alias.clone();
        let count_before = entries.len();
        let alias_count = entries.iter().filter(|e| e.alias == alias).count();

        config.delete_host(&alias);

        let count_after = config.host_entries().len();
        prop_assert_eq!(
            count_before - alias_count,
            count_after,
            "Expected {} hosts after deleting {} copies of '{}', got {}",
            count_before - alias_count,
            alias_count,
            alias,
            count_after,
        );

        // Round-trip stable after delete
        let serialized = config.serialize();
        let reparsed = parse_str(&serialized);
        prop_assert!(serialized == reparsed.serialize());
    }
}

// ---------------------------------------------------------------------------
// Property: update_host preserves host count
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(200))]

    #[test]
    fn update_host_preserves_count(
        content in ssh_config_strategy(),
        new_hostname in hostname_strategy(),
        new_user in user_strategy(),
    ) {
        let mut config = parse_str(&content);
        let entries = config.host_entries();
        if entries.is_empty() {
            return Ok(());
        }

        let alias = entries[0].alias.clone();
        let count_before = entries.len();

        config.update_host(
            &alias,
            &HostEntry {
                alias: alias.clone(),
                hostname: new_hostname,
                user: new_user,
                port: 22,
                ..Default::default()
            },
        );

        let count_after = config.host_entries().len();
        prop_assert_eq!(count_before, count_after);

        // Round-trip stable
        let serialized = config.serialize();
        let reparsed = parse_str(&serialized);
        prop_assert!(serialized == reparsed.serialize());
    }
}

// ---------------------------------------------------------------------------
// Property: delete_host_undoable + insert_host_at restores config
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(200))]

    #[test]
    fn undo_restores_host(content in ssh_config_strategy()) {
        let mut config = parse_str(&content);
        let entries = config.host_entries();
        if entries.is_empty() {
            return Ok(());
        }

        let alias = entries[0].alias.clone();
        // Delete undoable
        if let Some((element, position)) = config.delete_host_undoable(&alias) {
            // Undo
            config.insert_host_at(element, position);

            // Serialization should match (modulo blank line collapse which
            // only happens in serialize(), and delete_host_undoable skips collapse)
            let reparsed = parse_str(&config.serialize());
            let final_entries = reparsed.host_entries();
            prop_assert!(
                final_entries.iter().any(|e| e.alias == alias),
                "Host '{}' not restored after undo",
                alias,
            );
        }
    }
}

// ---------------------------------------------------------------------------
// Property: no panic on arbitrary bytes
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(2000))]

    #[test]
    fn no_panic_on_arbitrary_input(content in "\\PC{0,500}") {
        // Just parse and serialize. Should never panic.
        let config = parse_str(&content);
        let _ = config.serialize();
        let _ = config.host_entries();
    }

    #[test]
    fn no_panic_on_binary_input(bytes in prop::collection::vec(any::<u8>(), 0..1000)) {
        if let Ok(content) = String::from_utf8(bytes) {
            let config = parse_str(&content);
            let _ = config.serialize();
            let _ = config.host_entries();
        }
    }
}

// ---------------------------------------------------------------------------
// Property: arbitrary input — full fuzz-equivalent (idempotency + mutations)
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(2000))]

    /// Mirrors the cargo-fuzz harness: parse, serialize, verify idempotency,
    /// then exercise mutations (delete, undo, update, swap, add) on arbitrary
    /// Unicode input. Runs in CI unlike cargo-fuzz.
    #[test]
    fn arbitrary_input_idempotent_with_mutations(content in "\\PC{0,500}") {
        let config = parse_str(&content);
        let serialized = config.serialize();

        // Idempotency: serialize(reparse(serialized)) == serialized
        let reparsed = parse_str(&serialized);
        let reserialized = reparsed.serialize();
        prop_assert!(
            serialized == reserialized,
            "Idempotency broken on arbitrary input (len {} vs {})",
            serialized.len(),
            reserialized.len(),
        );

        // Mutation smoke tests (must not panic)
        let entries = config.host_entries();
        if !entries.is_empty() {
            let alias = entries[0].alias.clone();

            // Delete
            let mut config_del = config.clone();
            config_del.delete_host(&alias);
            let _ = config_del.serialize();

            // Delete undoable + undo
            let mut config_undo = config.clone();
            if let Some((element, position)) = config_undo.delete_host_undoable(&alias) {
                config_undo.insert_host_at(element, position);
                let _ = config_undo.serialize();
            }

            // Update
            let mut config_upd = config.clone();
            config_upd.update_host(
                &alias,
                &HostEntry {
                    alias: alias.clone(),
                    hostname: "10.0.0.1".to_string(),
                    user: "fuzz".to_string(),
                    port: 22,
                    ..Default::default()
                },
            );
            let s = config_upd.serialize();
            let r = parse_str(&s);
            prop_assert!(s == r.serialize(), "Idempotency broken after update");

            // Swap (if 2+ hosts)
            if entries.len() >= 2 {
                let mut config_swap = config.clone();
                config_swap.swap_hosts(&entries[0].alias, &entries[1].alias);
                let s = config_swap.serialize();
                let r = parse_str(&s);
                prop_assert!(s == r.serialize(), "Idempotency broken after swap");
            }
        }

        // Add host
        let mut config_add = config.clone();
        config_add.add_host(&HostEntry {
            alias: "proptest-new-host".to_string(),
            hostname: "10.0.0.99".to_string(),
            user: "tester".to_string(),
            port: 22,
            ..Default::default()
        });
        let s = config_add.serialize();
        let r = parse_str(&s);
        prop_assert!(s == r.serialize(), "Idempotency broken after add");
    }

    /// Same as above but starting from raw bytes (catches encoding edge cases).
    #[test]
    fn arbitrary_bytes_idempotent_with_mutations(bytes in prop::collection::vec(any::<u8>(), 0..1000)) {
        let Ok(content) = String::from_utf8(bytes) else {
            return Ok(());
        };

        let config = parse_str(&content);
        let serialized = config.serialize();

        // Idempotency
        let reparsed = parse_str(&serialized);
        let reserialized = reparsed.serialize();
        prop_assert!(
            serialized == reserialized,
            "Idempotency broken on raw bytes input (len {} vs {})",
            serialized.len(),
            reserialized.len(),
        );

        // Mutations
        let entries = config.host_entries();
        if !entries.is_empty() {
            let alias = entries[0].alias.clone();

            let mut config_del = config.clone();
            config_del.delete_host(&alias);
            let s = config_del.serialize();
            prop_assert!(s == parse_str(&s).serialize());

            let mut config_upd = config.clone();
            config_upd.update_host(
                &alias,
                &HostEntry {
                    alias: alias.clone(),
                    hostname: "10.0.0.1".to_string(),
                    user: "fuzz".to_string(),
                    port: 22,
                    ..Default::default()
                },
            );
            let s = config_upd.serialize();
            prop_assert!(s == parse_str(&s).serialize());
        }

        let mut config_add = config.clone();
        config_add.add_host(&HostEntry {
            alias: "proptest-new-host".to_string(),
            hostname: "10.0.0.99".to_string(),
            user: "tester".to_string(),
            port: 22,
            ..Default::default()
        });
        let s = config_add.serialize();
        prop_assert!(s == parse_str(&s).serialize());
    }
}

// ---------------------------------------------------------------------------
// Property: swap_hosts is reversible
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(200))]

    #[test]
    fn swap_hosts_reversible(content in ssh_config_strategy()) {
        let mut config = parse_str(&content);
        let entries = config.host_entries();
        if entries.len() < 2 {
            return Ok(());
        }

        let a = entries[0].alias.clone();
        let b = entries[1].alias.clone();
        let before = config.serialize();

        // Swap twice = identity
        config.swap_hosts(&a, &b);
        config.swap_hosts(&a, &b);
        let after = config.serialize();

        prop_assert!(before == after, "Double swap should be identity");
    }
}

// ---------------------------------------------------------------------------
// Property: CRLF and BOM flags preserved
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(200))]

    #[test]
    fn crlf_detected_by_majority(content in ssh_config_strategy()) {
        // Per-line CRLF preservation (M8): a single stray CRLF in an
        // otherwise LF file must not flip the entire output to CRLF.
        // The detector picks majority — only when more lines end with
        // \r\n than with bare \n does the file count as CRLF.
        let config = parse_str(&content);
        let expected = purple_ssh::ssh_config::parser::detect_crlf_majority(&content);
        prop_assert_eq!(
            config.crlf,
            expected,
            "CRLF detection should follow majority rule",
        );
    }
}

// ---------------------------------------------------------------------------
// Property: tags survive mutations
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(200))]

    #[test]
    fn tags_survive_update(content in annotated_config_strategy()) {
        let mut config = parse_str(&content);
        let entries = config.host_entries();
        if entries.is_empty() {
            return Ok(());
        }

        let alias = entries[0].alias.clone();
        let original_tags = entries[0].tags.clone();
        let original_user = entries[0].user.clone();
        let original_port = entries[0].port;
        let original_identity = entries[0].identity_file.clone();
        let original_proxy = entries[0].proxy_jump.clone();

        // Update hostname only
        config.update_host(
            &alias,
            &HostEntry {
                alias: alias.clone(),
                hostname: "10.99.99.99".to_string(),
                user: original_user,
                port: original_port,
                identity_file: original_identity,
                proxy_jump: original_proxy,
                ..Default::default()
            },
        );

        let updated_entries = config.host_entries();
        let updated = updated_entries.iter().find(|e| e.alias == alias);
        if let Some(entry) = updated {
            prop_assert!(
                entry.tags == original_tags,
                "Tags changed after update for host '{}'",
                alias,
            );
        }
    }
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(200))]

    #[test]
    fn stale_survives_update(content in annotated_config_strategy()) {
        let mut config = parse_str(&content);
        let entries = config.host_entries();
        if entries.is_empty() {
            return Ok(());
        }

        let alias = entries[0].alias.clone();
        let original_stale = entries[0].stale;
        let original_user = entries[0].user.clone();
        let original_port = entries[0].port;
        let original_identity = entries[0].identity_file.clone();
        let original_proxy = entries[0].proxy_jump.clone();

        // Update hostname only
        config.update_host(
            &alias,
            &HostEntry {
                alias: alias.clone(),
                hostname: "10.99.99.99".to_string(),
                user: original_user,
                port: original_port,
                identity_file: original_identity,
                proxy_jump: original_proxy,
                ..Default::default()
            },
        );

        let updated_entries = config.host_entries();
        let updated = updated_entries.iter().find(|e| e.alias == alias);
        if let Some(entry) = updated {
            prop_assert!(
                entry.stale == original_stale,
                "Stale changed after update for host '{}'",
                alias,
            );
        }
    }
}

// ---------------------------------------------------------------------------
// Property: grouping is display-only — serialize() output is never altered
//
// GroupBy changes only `display_list` (the UI ordering), never `config.elements`
// (the data). Serialization reads exclusively from `elements`, so the written
// output must be byte-identical before and after any grouping operation.
//
// We test this invariant two ways:
//   1. Proptest: configs with purple:tags annotations produce stable output
//      across two independent serialize() calls on the same parsed data.
//   2. Deterministic: an explicit multi-host config with tags survives a
//      parse -> serialize -> parse -> serialize cycle unchanged.
// ---------------------------------------------------------------------------

proptest! {
    #![proptest_config(ProptestConfig::with_cases(300))]

    /// Grouping is display-only: calling serialize() twice on the same
    /// parsed config (with purple:tags annotations) must return the same bytes.
    /// This simulates the invariant that applying GroupBy to `display_list`
    /// never mutates `elements` and therefore never alters the written output.
    #[test]
    fn groupby_does_not_alter_serialized_output(content in annotated_config_strategy()) {
        let config = parse_str(&content);

        // First serialize — baseline (analogous to "before any GroupBy applied")
        let first = config.serialize();

        // Second serialize on the same config — must be byte-identical.
        // GroupBy only ever touches display_list (an App-level Vec of display
        // indices), never config.elements.  Serialize reads only elements, so
        // no matter what ordering the UI uses, the on-disk representation must
        // be stable.
        let second = config.serialize();

        prop_assert!(
            first == second,
            "serialize() is non-deterministic on annotated config \
             (len {} vs {})",
            first.len(),
            second.len(),
        );

        // Also verify the output is idempotent under a full parse round-trip,
        // confirming tags survive intact when re-read.
        let reparsed = parse_str(&first);
        let third = reparsed.serialize();
        prop_assert!(
            first == third,
            "Round-trip changed serialized output after parse (len {} vs {})",
            first.len(),
            third.len(),
        );
    }
}

// Deterministic: explicit config with purple:tags on multiple hosts.
// Validates that a concrete, human-readable scenario passes the same invariant.
#[test]
fn groupby_roundtrip_deterministic() {
    let content = "\
Host web-prod
  HostName 10.0.1.10
  User deploy
  # purple:tags prod,web
  # purple:provider aws:i-0abc123

Host db-prod
  HostName 10.0.1.20
  User deploy
  # purple:tags prod,db
  # purple:provider aws:i-0def456

Host staging
  HostName 10.0.2.10
  User deploy
  # purple:tags staging,web

Host bastion
  HostName 203.0.113.5
  User admin
";

    let config = parse_str(content);
    let first = config.serialize();

    // Parse -> serialize again — must be byte-identical regardless of
    // any GroupBy ordering that the UI layer might apply.
    let reparsed = parse_str(&first);
    let second = reparsed.serialize();

    assert_eq!(
        first, second,
        "Deterministic round-trip changed output for config with tags"
    );

    // Confirm all tags survived the round-trip.
    let entries = reparsed.host_entries();
    let web = entries.iter().find(|e| e.alias == "web-prod").unwrap();
    assert!(
        web.tags.contains(&"prod".to_string()) && web.tags.contains(&"web".to_string()),
        "web-prod tags not preserved: {:?}",
        web.tags
    );
    let db = entries.iter().find(|e| e.alias == "db-prod").unwrap();
    assert!(
        db.tags.contains(&"prod".to_string()) && db.tags.contains(&"db".to_string()),
        "db-prod tags not preserved: {:?}",
        db.tags
    );
    let staging = entries.iter().find(|e| e.alias == "staging").unwrap();
    assert!(
        staging.tags.contains(&"staging".to_string()),
        "staging tags not preserved: {:?}",
        staging.tags
    );
}

// ---------------------------------------------------------------------------
// Multi-alias Host block mutation invariants (T-002 audit gap)
// ---------------------------------------------------------------------------
//
// Until 2026-05 the proptest strategies generated only single-alias blocks
// (`Host alpha`), leaving `Host alpha beta gamma` mutation paths covered
// only by hand-written deterministic tests. The strategy below produces
// real multi-alias blocks so update / delete / set_host_* invariants are
// fuzzed against shared-token blocks too.

/// Strategy: a Host block whose pattern is two or three whitespace-separated
/// alias tokens. Each token is a safe `[a-z][a-z0-9-]{1,15}` identifier.
fn multi_alias_host_block_strategy() -> impl Strategy<Value = (Vec<String>, String)> {
    (
        prop::collection::vec("[a-z][a-z0-9-]{0,15}", 2..=3),
        "(10|192)\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
    )
        .prop_filter("aliases must be unique", |(aliases, _)| {
            let mut seen = std::collections::HashSet::new();
            aliases.iter().all(|a| seen.insert(a.clone()))
        })
        .prop_map(|(aliases, hostname)| {
            let block = format!(
                "Host {}\n  HostName {}\n  User deploy\n",
                aliases.join(" "),
                hostname
            );
            (aliases, block)
        })
}

fn parse_str_local(s: &str) -> SshConfigFile {
    SshConfigFile::from_content(s, PathBuf::from("/tmp/test"))
}

proptest! {
    #![proptest_config(ProptestConfig {
        cases: 200,
        max_shrink_iters: 1000,
        ..Default::default()
    })]

    /// Deleting one alias from a multi-alias block must keep every sibling
    /// alias addressable (parse → has_host). Token-strip must not collapse
    /// the block silently. Property pinned after the 3.x regression where
    /// `delete_host("web-01.prod")` on `Host web-01 web-01.prod` wiped both.
    #[test]
    fn multi_alias_delete_preserves_siblings(
        (aliases, content) in multi_alias_host_block_strategy()
    ) {
        let mut config = parse_str_local(&content);
        let target = aliases[0].clone();
        config.delete_host(&target);

        let serialized = config.serialize();
        let reparsed = parse_str_local(&serialized);
        for sibling in &aliases[1..] {
            prop_assert!(
                reparsed.has_host(sibling),
                "sibling alias {} was wiped after delete_host({}): {}",
                sibling,
                target,
                serialized
            );
        }
    }

    /// Renaming one alias on a multi-alias block must NOT rename the others.
    /// Token-replace must replace only the matching token in the Host line.
    #[test]
    fn multi_alias_rename_preserves_other_tokens(
        (aliases, content) in multi_alias_host_block_strategy()
    ) {
        let mut config = parse_str_local(&content);
        let old_name = aliases[0].clone();
        let new_name = format!("renamed-{}", old_name);

        let entry = HostEntry {
            alias: new_name.clone(),
            hostname: "10.0.0.42".to_string(),
            ..Default::default()
        };
        config.update_host(&old_name, &entry);

        let serialized = config.serialize();
        let reparsed = parse_str_local(&serialized);
        prop_assert!(
            reparsed.has_host(&new_name),
            "renamed alias not found: {}",
            serialized
        );
        for sibling in &aliases[1..] {
            prop_assert!(
                reparsed.has_host(sibling),
                "sibling alias {} disappeared after rename of {}: {}",
                sibling,
                old_name,
                serialized
            );
        }
    }

    /// set_host_vault_ssh must REFUSE multi-alias blocks (policy guard
    /// added 2026-05). Verify the refuse path returns false and leaves the
    /// config byte-identical so no sibling alias is silently affected.
    /// (T-001 / T-008)
    #[test]
    fn set_host_vault_ssh_refuses_multi_alias_block_prop(
        (aliases, content) in multi_alias_host_block_strategy()
    ) {
        let mut config = parse_str_local(&content);
        let before = config.serialize();
        for alias in &aliases {
            let mutated = config.set_host_vault_ssh(alias, "ssh/sign/role");
            prop_assert!(
                !mutated,
                "set_host_vault_ssh on multi-alias block must return false"
            );
        }
        let after = config.serialize();
        prop_assert_eq!(
            before, after,
            "refused mutation must leave config byte-identical"
        );
    }
}
