mod detect;

use anyhow::Result;
use inquire::ui::{Color, RenderConfig, StyleSheet, Styled};
use inquire::{Confirm, CustomType, Select, Text};
use serde_yml::{Mapping, Value};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};

use crate::yml::{ConfigSection, FileRef, Loader, ModEntry, Runtime, Server, Snap};
use detect::Detected;

pub use detect::detect as detect_path;

const TEMPLATE: &str = r#"schema: 1
eula: false

server:
  name: my-server
  description: a mc-snap server
  minecraft: 26.1.2
  loader:
    type: fabric

runtime:
  java: 26
  memory: 4G
  flags:
    - -XX:+UseG1GC

mods:
  - id: fabric-api
    provider: modrinth
    version: latest

config:
  server.properties:
    motd: my-server
    max-players: 20
"#;

pub async fn run(
    non_interactive: bool,
    detect_path: Option<String>,
    force: bool,
    no_mod_resolve: bool,
) -> Result<()> {
    let cwd = std::env::current_dir()?;
    let target = cwd.join("mc-snap.yml");
    let interactive = !non_interactive && std::io::stdin().is_terminal();

    if target.exists() {
        if force {
            // overwrite silently
        } else if interactive {
            inquire::set_global_render_config(render_config());
            let ok = Confirm::new(&format!(
                "mc-snap.yml exists at {} - overwrite?",
                target.display()
            ))
            .with_default(false)
            .prompt()?;
            if !ok {
                anyhow::bail!("aborted: mc-snap.yml already exists");
            }
        } else {
            anyhow::bail!(
                "mc-snap.yml already exists at {} (use --force to overwrite)",
                target.display()
            );
        }
    }

    let detected = if let Some(p) = detect_path {
        let root = PathBuf::from(&p);
        if !root.is_dir() {
            anyhow::bail!("--detect path is not a directory: {}", root.display());
        }
        println!();
        println!(
            "\x1b[1;36m  mc-snap\x1b[0m \x1b[2m- detecting in {}\x1b[0m",
            root.display()
        );
        let d = detect::detect(&root, !no_mod_resolve).await?;
        print_detection_summary(&d, no_mod_resolve);
        Some(d)
    } else {
        None
    };

    let body = match (&detected, interactive) {
        (Some(d), true) => wizard(&cwd, Some(d))?,
        (Some(d), false) => render_from_detected(&cwd, d),
        (None, true) => wizard(&cwd, None)?,
        (None, false) => TEMPLATE.to_string(),
    };

    std::fs::write(&target, &body)?;
    ensure_gitignore(&cwd)?;

    let written_location = crate::yml::Snap::from_str(&body)
        .ok()
        .and_then(|s| s.server.location);
    print_gitignore_hint(written_location.as_deref());

    println!();
    println!("\x1b[1;32m✓\x1b[0m created \x1b[1m{}\x1b[0m", target.display());
    println!(
        "  next: review the file, set \x1b[1meula: true\x1b[0m after reading \x1b[36mhttps://www.minecraft.net/en-us/eula\x1b[0m,"
    );
    println!("        then run \x1b[1mmc-snap install\x1b[0m");
    Ok(())
}

fn print_gitignore_hint(location: Option<&str>) {
    let prefix = match location {
        Some(sub) if !sub.trim().is_empty() && sub.trim() != "." => {
            format!("{}/", sub.trim().trim_end_matches('/'))
        }
        _ => String::new(),
    };
    println!();
    println!(
        "\x1b[2m  hint: server artifacts will land at {}\x1b[0m",
        if prefix.is_empty() {
            "the project root".to_string()
        } else {
            format!("./{prefix}")
        }
    );
    println!("\x1b[2m        consider adding to .gitignore:\x1b[0m");
    for entry in [
        "world/",
        "world_nether/",
        "world_the_end/",
        "logs/",
        "crash-reports/",
        "mods/",
        "*.jar",
        "server.properties",
        "eula.txt",
        "usercache.json",
        "ops.json",
        "banned-players.json",
        "banned-ips.json",
        "whitelist.json",
    ] {
        println!("\x1b[2m          {prefix}{entry}\x1b[0m");
    }
}

fn print_detection_summary(d: &Detected, no_mod_resolve: bool) {
    let bullet = "\x1b[1;32m✓\x1b[0m";
    let warn = "\x1b[1;33m!\x1b[0m";
    if let Some(unsup) = d.unsupported_loader {
        println!(
            "  {warn} detected {} server - mc-snap currently supports only vanilla and fabric",
            unsup
        );
    }
    if let Some(loader) = d.loader {
        match (loader, d.fabric_loader_version.as_deref()) {
            ("fabric", Some(v)) => println!("  {bullet} loader: fabric {v}"),
            (l, _) => println!("  {bullet} loader: {l}"),
        }
    }
    if let Some(mc) = &d.minecraft {
        println!("  {bullet} minecraft: {mc}");
    }
    if let Some(j) = d.java_major {
        println!("  {bullet} java: {j}");
    }
    if let Some(mem) = &d.memory {
        println!("  {bullet} memory: {mem}");
    }
    if d.eula {
        println!("  {bullet} eula: accepted");
    }
    if !d.server_properties.is_empty() {
        println!(
            "  {bullet} server.properties: {} entries",
            d.server_properties.len()
        );
    }
    if !d.mods.is_empty() || !d.unresolved_mods.is_empty() {
        if no_mod_resolve {
            println!(
                "  {bullet} mods: {} jar(s) listed (resolve skipped)",
                d.unresolved_mods.len()
            );
        } else {
            println!(
                "  {bullet} mods: {} resolved via Modrinth, {} unresolved",
                d.mods.len(),
                d.unresolved_mods.len()
            );
            for name in &d.unresolved_mods {
                println!("    \x1b[2m-\x1b[0m {name}");
            }
        }
    }
    println!();
}

fn wizard(cwd: &Path, detected: Option<&Detected>) -> Result<String> {
    inquire::set_global_render_config(render_config());

    let dir_default = detected
        .and_then(|d| d.name.clone())
        .or_else(|| {
            cwd.file_name()
                .and_then(|s| s.to_str())
                .map(|s| s.to_string())
        })
        .unwrap_or_else(|| "my-server".to_string());

    println!();
    println!("\x1b[1;36m  mc-snap\x1b[0m \x1b[2m- new server config\x1b[0m");
    println!("\x1b[2m  arrow keys to navigate, enter to confirm, ctrl-c to cancel\x1b[0m");
    println!();

    let name = Text::new("Server name")
        .with_default(&dir_default)
        .with_validator(inquire::required!("server name is required"))
        .prompt()?;

    let description = Text::new("Description")
        .with_default("a mc-snap server")
        .prompt()?;

    let mc_default = detected
        .and_then(|d| d.minecraft.clone())
        .unwrap_or_else(|| "26.1.2".to_string());
    let minecraft = Text::new("Minecraft version")
        .with_default(&mc_default)
        .with_help_message("e.g. 26.1.2")
        .with_validator(inquire::required!("minecraft version is required"))
        .prompt()?;

    let loader_default_idx = match detected.and_then(|d| d.loader) {
        Some("vanilla") => 1,
        _ => 0,
    };
    let loader = Select::new("Loader", vec!["fabric", "vanilla"])
        .with_starting_cursor(loader_default_idx)
        .with_help_message("fabric supports mods; vanilla is mod-free")
        .prompt()?;

    let loc_default = detected
        .and_then(|d| d.detected_location.clone())
        .unwrap_or_else(|| ".".to_string());
    let location_input = Text::new("Server directory")
        .with_default(&loc_default)
        .with_help_message("'.' for current directory, or a subdir like 'server'")
        .prompt()?;
    let location = normalize_location(&location_input);

    let java_default = detected.and_then(|d| d.java_major).unwrap_or(26);
    let java = CustomType::<u32>::new("Java version")
        .with_default(java_default)
        .with_help_message("major version only, e.g. 21 or 26")
        .with_error_message("enter a positive integer")
        .prompt()?;

    let mem_default = detected
        .and_then(|d| d.memory.clone())
        .unwrap_or_else(|| "4G".to_string());
    let memory = Text::new("Memory")
        .with_default(&mem_default)
        .with_help_message("e.g. 2G, 4G, 8G")
        .with_validator(inquire::required!("memory is required"))
        .prompt()?;

    let motd_detected = detected.and_then(|d| {
        d.server_properties
            .get(Value::String("motd".to_string()))
            .and_then(|v| v.as_str().map(|s| s.to_string()))
    });
    let motd_default = motd_detected.unwrap_or_else(|| name.clone());
    let motd = Text::new("MOTD").with_default(&motd_default).prompt()?;

    let max_players_detected = detected.and_then(|d| {
        d.server_properties
            .get(Value::String("max-players".to_string()))
            .and_then(|v| v.as_i64())
            .and_then(|n| u32::try_from(n).ok())
    });
    let max_players = CustomType::<u32>::new("Max players")
        .with_default(max_players_detected.unwrap_or(20))
        .with_error_message("enter a positive integer")
        .prompt()?;

    // If detection already found a mod list, don't ask about fabric-api again - keep what we found.
    let detected_has_mods = detected.is_some_and(|d| !d.mods.is_empty());
    let include_fabric_api = if loader == "fabric" && !detected_has_mods {
        Confirm::new("Include fabric-api?")
            .with_default(true)
            .with_help_message("the core mod API most fabric mods depend on")
            .prompt()?
    } else {
        false
    };

    let eula_default = detected.is_some_and(|d| d.eula);
    let eula = Confirm::new("Accept the Minecraft EULA?")
        .with_default(eula_default)
        .with_help_message(
            "https://www.minecraft.net/en-us/eula - required before starting the server",
        )
        .prompt()?;

    let mods: Vec<ModEntry> = if detected_has_mods {
        detected.unwrap().mods.clone()
    } else if include_fabric_api {
        vec![ModEntry::Registry {
            id: "fabric-api".to_string(),
            provider: "modrinth".to_string(),
            version: "latest".to_string(),
        }]
    } else {
        Vec::new()
    };

    let mut props = detected
        .map(|d| d.server_properties.clone())
        .unwrap_or_default();
    set_prop(&mut props, "motd", Value::String(motd));
    set_prop(
        &mut props,
        "max-players",
        Value::Number((max_players as i64).into()),
    );

    let mut snap = build_snap(
        &name,
        &description,
        &minecraft,
        loader,
        location.as_deref(),
        java,
        &memory,
        &mods,
        &props,
        eula,
    );

    if let Some(d) = detected {
        snap.config.files = scan_and_select_configs(cwd, &snap, d)?;
    }

    Ok(render_yml(&snap))
}

fn scan_and_select_configs(
    cwd: &Path,
    snap: &Snap,
    detected: &Detected,
) -> Result<Vec<FileRef>> {
    use crate::commands::config::{scan_config_dir, track_candidate};
    use inquire::MultiSelect;

    let server_dir = detected_server_dir(cwd, snap, detected);
    let candidates = scan_config_dir(&server_dir).unwrap_or_default();
    if candidates.is_empty() {
        return Ok(Vec::new());
    }

    println!();
    println!(
        "\x1b[1;36m  config\x1b[0m \x1b[2m- found {} file(s) under {}/\x1b[0m",
        candidates.len(),
        server_dir.join("config").display()
    );

    let labels: Vec<String> = candidates.iter().map(|c| c.server_rel.clone()).collect();
    let defaults: Vec<usize> = (0..labels.len()).collect();
    let chosen = MultiSelect::new("Track which configs?", labels.clone())
        .with_default(&defaults)
        .with_help_message("space toggles, enter confirms - default tracks everything")
        .prompt()?;

    let chosen_set: std::collections::HashSet<&str> = chosen.iter().map(|s| s.as_str()).collect();
    let mut out = Vec::with_capacity(chosen.len());
    for c in &candidates {
        if chosen_set.contains(c.server_rel.as_str()) {
            out.push(track_candidate(cwd, c)?);
        }
    }
    Ok(out)
}

fn scan_configs_all(cwd: &Path, snap: &Snap, detected: &Detected) -> Result<Vec<FileRef>> {
    use crate::commands::config::{scan_config_dir, track_candidate};
    let server_dir = detected_server_dir(cwd, snap, detected);
    let candidates = scan_config_dir(&server_dir).unwrap_or_default();
    let mut out = Vec::with_capacity(candidates.len());
    for c in &candidates {
        out.push(track_candidate(cwd, c)?);
    }
    Ok(out)
}

fn detected_server_dir(cwd: &Path, snap: &Snap, detected: &Detected) -> PathBuf {
    if let Some(loc) = detected.detected_location.as_deref() {
        return cwd.join(loc);
    }
    match snap.server.location.as_deref() {
        Some(sub) if !sub.trim().is_empty() && sub.trim() != "." => cwd.join(sub),
        _ => cwd.to_path_buf(),
    }
}

fn normalize_location(s: &str) -> Option<String> {
    let t = s.trim();
    if t.is_empty() || t == "." {
        None
    } else {
        Some(t.to_string())
    }
}

fn render_from_detected(cwd: &Path, d: &Detected) -> String {
    let name = d
        .name
        .clone()
        .or_else(|| {
            cwd.file_name()
                .and_then(|s| s.to_str())
                .map(|s| s.to_string())
        })
        .unwrap_or_else(|| "my-server".to_string());
    let description = "a mc-snap server".to_string();
    let minecraft = d.minecraft.clone().unwrap_or_else(|| "26.1.2".to_string());
    let loader = d.loader.unwrap_or("fabric");
    let java = d.java_major.unwrap_or(26);
    let memory = d.memory.clone().unwrap_or_else(|| "4G".to_string());

    let mut props = d.server_properties.clone();
    if !props.contains_key(Value::String("motd".to_string())) {
        set_prop(&mut props, "motd", Value::String(name.clone()));
    }
    if !props.contains_key(Value::String("max-players".to_string())) {
        set_prop(&mut props, "max-players", Value::Number(20i64.into()));
    }

    let mut snap = build_snap(
        &name,
        &description,
        &minecraft,
        loader,
        d.detected_location.as_deref(),
        java,
        &memory,
        &d.mods,
        &props,
        d.eula,
    );
    snap.config.files = scan_configs_all(cwd, &snap, d).unwrap_or_default();
    render_yml(&snap)
}

fn set_prop(m: &mut Mapping, key: &str, value: Value) {
    m.insert(Value::String(key.to_string()), value);
}

pub fn render_yml(snap: &Snap) -> String {
    let mut out = String::new();
    out.push_str(&format!("schema: {}\n", snap.schema));
    out.push_str(&format!("eula: {}\n\n", snap.eula));

    out.push_str("server:\n");
    out.push_str(&format!("  name: {}\n", yaml_scalar(&snap.server.name)));
    if let Some(desc) = snap.server.description.as_deref() {
        if !desc.trim().is_empty() {
            out.push_str(&format!("  description: {}\n", yaml_scalar(desc)));
        }
    }
    out.push_str(&format!(
        "  minecraft: {}\n",
        yaml_scalar(&snap.server.minecraft)
    ));
    if let Some(loc) = snap.server.location.as_deref() {
        out.push_str(&format!("  location: {}\n", yaml_scalar(loc)));
    }
    out.push_str("  loader:\n");
    out.push_str(&format!(
        "    type: {}\n",
        yaml_scalar(&snap.server.loader.kind)
    ));
    if let Some(v) = snap.server.loader.version.as_deref() {
        out.push_str(&format!("    version: {}\n", yaml_scalar(v)));
    }
    if let Some(v) = snap.server.loader.installer.as_deref() {
        out.push_str(&format!("    installer: {}\n", yaml_scalar(v)));
    }
    out.push('\n');

    out.push_str("runtime:\n");
    if let Some(j) = snap.runtime.java {
        out.push_str(&format!("  java: {j}\n"));
    }
    if let Some(mem) = snap.runtime.memory.as_deref() {
        out.push_str(&format!("  memory: {}\n", yaml_scalar(mem)));
    }
    if snap.runtime.flags.is_empty() {
        out.push_str("  flags:\n    - -XX:+UseG1GC\n");
    } else {
        out.push_str("  flags:\n");
        for f in &snap.runtime.flags {
            out.push_str(&format!("    - {}\n", yaml_scalar(f)));
        }
    }
    out.push('\n');

    if snap.mods.is_empty() {
        out.push_str("mods: []\n\n");
    } else {
        out.push_str("mods:\n");
        for entry in &snap.mods {
            match entry {
                ModEntry::Registry { id, provider, version } => {
                    out.push_str(&format!("  - id: {}\n", yaml_scalar(id)));
                    out.push_str(&format!("    provider: {}\n", yaml_scalar(provider)));
                    out.push_str(&format!("    version: {}\n", yaml_scalar(version)));
                }
                ModEntry::Url { url, provider, sha256, filename } => {
                    out.push_str(&format!("  - url: {}\n", yaml_scalar(url)));
                    out.push_str(&format!("    provider: {}\n", yaml_scalar(provider)));
                    out.push_str(&format!("    sha256: {}\n", yaml_scalar(sha256)));
                    if let Some(f) = filename {
                        out.push_str(&format!("    filename: {}\n", yaml_scalar(f)));
                    }
                }
            }
        }
        out.push('\n');
    }

    out.push_str("config:\n");
    out.push_str("  server.properties:\n");
    for (k, v) in snap.config.server_properties.iter() {
        let key = k.as_str().unwrap_or_default();
        out.push_str(&format!("    {}: {}\n", yaml_scalar(key), render_value(v)));
    }
    if !snap.config.files.is_empty() {
        out.push_str("  files:\n");
        for f in &snap.config.files {
            out.push_str(&format!(
                "    - {{ src: {}, dst: {} }}\n",
                yaml_scalar(&f.src),
                yaml_scalar(&f.dst)
            ));
        }
    }

    out
}

#[allow(clippy::too_many_arguments)]
fn build_snap(
    name: &str,
    description: &str,
    minecraft: &str,
    loader: &str,
    location: Option<&str>,
    java: u32,
    memory: &str,
    mods: &[ModEntry],
    server_properties: &Mapping,
    eula: bool,
) -> Snap {
    Snap {
        schema: 1,
        eula,
        server: Server {
            name: name.to_string(),
            description: if description.trim().is_empty() {
                None
            } else {
                Some(description.to_string())
            },
            minecraft: minecraft.to_string(),
            loader: Loader {
                kind: loader.to_string(),
                version: None,
                installer: None,
            },
            location: location.map(|s| s.to_string()),
        },
        runtime: Runtime {
            java: Some(java),
            memory: Some(memory.to_string()),
            flags: vec![],
        },
        mods: mods.to_vec(),
        config: ConfigSection {
            server_properties: server_properties.clone(),
            files: vec![],
        },
    }
}

fn render_value(v: &Value) -> String {
    match v {
        Value::Bool(b) => b.to_string(),
        Value::Number(n) => n.to_string(),
        Value::String(s) => yaml_scalar(s),
        Value::Null => "null".to_string(),
        other => serde_yml::to_string(other)
            .unwrap_or_default()
            .trim_end()
            .to_string(),
    }
}

fn yaml_scalar(s: &str) -> String {
    let needs_quote = s.is_empty()
        || s.contains(':')
        || s.contains('#')
        || s.contains('"')
        || s.contains('\\')
        || s.starts_with(' ')
        || s.ends_with(' ')
        || s.starts_with('-')
        || s.starts_with(|c: char| {
            matches!(
                c,
                '!' | '&' | '*' | '[' | '{' | '|' | '>' | '\'' | '%' | '@' | '`'
            )
        });
    if needs_quote {
        let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
        format!("\"{escaped}\"")
    } else {
        s.to_string()
    }
}

fn render_config() -> RenderConfig<'static> {
    RenderConfig::default()
        .with_prompt_prefix(Styled::new("›").with_fg(Color::LightCyan))
        .with_answered_prompt_prefix(Styled::new("✓").with_fg(Color::LightGreen))
        .with_help_message(StyleSheet::new().with_fg(Color::DarkGrey))
        .with_answer(StyleSheet::new().with_fg(Color::LightCyan))
        .with_default_value(StyleSheet::new().with_fg(Color::DarkGrey))
}

fn ensure_gitignore(dir: &Path) -> Result<()> {
    let path = dir.join(".gitignore");
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    if existing.lines().any(|l| l.trim() == ".mc-snap") || existing.lines().any(|l| l.trim() == ".mc-snap/") {
        return Ok(());
    }
    let mut new = existing;
    if !new.ends_with('\n') && !new.is_empty() {
        new.push('\n');
    }
    new.push_str(".mc-snap/\n");
    std::fs::write(path, new)?;
    Ok(())
}

#[cfg(test)]
fn props_with(motd: &str, max_players: u32) -> Mapping {
    let mut m = Mapping::new();
    m.insert(Value::String("motd".into()), Value::String(motd.into()));
    m.insert(
        Value::String("max-players".into()),
        Value::Number((max_players as i64).into()),
    );
    m
}

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

    fn r(
        name: &str,
        desc: &str,
        mc: &str,
        loader: &str,
        location: Option<&str>,
        java: u32,
        mem: &str,
        mods: &[ModEntry],
        props: &Mapping,
        eula: bool,
    ) -> String {
        let snap = build_snap(
            name, desc, mc, loader, location, java, mem, mods, props, eula,
        );
        render_yml(&snap)
    }

    #[test]
    fn rendered_yml_parses_and_round_trips() {
        let mods = vec![ModEntry::Registry {
            id: "fabric-api".into(),
            provider: "modrinth".into(),
            version: "latest".into(),
        }];
        let props = props_with("welcome!", 20);
        let body = r("grimwald", "the grimwald smp", "26.1.2", "fabric", None, 26, "4G", &mods, &props, true);
        let snap = Snap::from_str(&body).unwrap();
        assert_eq!(snap.server.name, "grimwald");
        assert_eq!(snap.server.minecraft, "26.1.2");
        assert_eq!(snap.server.loader.kind, "fabric");
        assert_eq!(snap.runtime.java, Some(26));
        assert_eq!(snap.mods.len(), 1);
        assert!(snap.eula);
        assert!(snap.server.location.is_none());
    }

    #[test]
    fn vanilla_without_mods_has_empty_mods() {
        let props = props_with("hi", 10);
        let body = r("vanilla-svr", "", "26.1.2", "vanilla", None, 26, "2G", &[], &props, false);
        let snap = Snap::from_str(&body).unwrap();
        assert_eq!(snap.server.loader.kind, "vanilla");
        assert!(snap.mods.is_empty());
        assert!(!snap.eula);
    }

    #[test]
    fn quotes_values_with_special_chars() {
        let mut props = Mapping::new();
        props.insert(
            Value::String("motd".into()),
            Value::String("say \"hi\"".into()),
        );
        props.insert(
            Value::String("max-players".into()),
            Value::Number(20i64.into()),
        );
        let body = r("weird:name", "has # hash", "26.1.2", "fabric", None, 26, "4G", &[], &props, false);
        let snap = Snap::from_str(&body).unwrap();
        assert_eq!(snap.server.name, "weird:name");
    }

    #[test]
    fn renders_location_when_present() {
        let props = props_with("hi", 20);
        let body = r("s", "", "26.1.2", "vanilla", Some("server"), 26, "2G", &[], &props, true);
        let snap = Snap::from_str(&body).unwrap();
        assert_eq!(snap.server.location.as_deref(), Some("server"));
    }

    #[test]
    fn omits_location_when_none() {
        let props = props_with("hi", 20);
        let body = r("s", "", "26.1.2", "vanilla", None, 26, "2G", &[], &props, true);
        assert!(!body.contains("location:"));
        let snap = Snap::from_str(&body).unwrap();
        assert!(snap.server.location.is_none());
    }

    #[test]
    fn yaml_scalar_passes_simple_strings_through() {
        assert_eq!(yaml_scalar("hello"), "hello");
        assert_eq!(yaml_scalar("my-server"), "my-server");
        assert_eq!(yaml_scalar("-leading-dash"), "\"-leading-dash\"");
        assert_eq!(yaml_scalar(""), "\"\"");
        assert_eq!(yaml_scalar("a:b"), "\"a:b\"");
        assert_eq!(yaml_scalar("has # hash"), "\"has # hash\"");
    }

    #[test]
    fn renders_url_mod_entry() {
        let mods = vec![ModEntry::Url {
            url: "https://example.com/x.jar".into(),
            provider: "url".into(),
            sha256: "0".repeat(64),
            filename: Some("x.jar".into()),
        }];
        let props = props_with("hi", 20);
        let body = r("s", "", "26.1.2", "vanilla", None, 26, "2G", &mods, &props, false);
        let snap = Snap::from_str(&body).unwrap();
        assert_eq!(snap.mods.len(), 1);
    }

    #[test]
    fn server_properties_preserves_numbers_as_ints() {
        let mut props = Mapping::new();
        props.insert(
            Value::String("max-players".into()),
            Value::Number(20i64.into()),
        );
        props.insert(Value::String("online-mode".into()), Value::Bool(true));
        props.insert(
            Value::String("motd".into()),
            Value::String("hello".into()),
        );
        let body = r("s", "", "26.1.2", "vanilla", None, 26, "2G", &[], &props, false);
        let snap = Snap::from_str(&body).unwrap();
        let saved = &snap.config.server_properties;
        assert_eq!(
            saved[Value::String("max-players".into())],
            Value::Number(20i64.into())
        );
        assert_eq!(saved[Value::String("online-mode".into())], Value::Bool(true));
    }

    #[test]
    fn renders_config_files_block() {
        let props = props_with("hi", 20);
        let mut snap = build_snap("s", "", "26.1.2", "vanilla", None, 26, "2G", &[], &props, false);
        snap.config.files = vec![
            crate::yml::FileRef {
                src: "configs/chunky.toml".into(),
                dst: "config/chunky.toml".into(),
            },
        ];
        let body = render_yml(&snap);
        assert!(body.contains("files:"));
        let parsed = Snap::from_str(&body).unwrap();
        assert_eq!(parsed.config.files.len(), 1);
        assert_eq!(parsed.config.files[0].src, "configs/chunky.toml");
        assert_eq!(parsed.config.files[0].dst, "config/chunky.toml");
    }

    #[test]
    fn round_trips_loader_version_and_flags() {
        let props = props_with("hi", 20);
        let mut snap = build_snap("s", "", "26.1.2", "fabric", None, 26, "2G", &[], &props, true);
        snap.server.loader.version = Some("0.16.9".into());
        snap.runtime.flags = vec!["-XX:+UseG1GC".into(), "-XX:+ParallelRefProcEnabled".into()];
        let body = render_yml(&snap);
        let parsed = Snap::from_str(&body).unwrap();
        assert_eq!(parsed.server.loader.version.as_deref(), Some("0.16.9"));
        assert_eq!(parsed.runtime.flags.len(), 2);
    }
}
