//! Emitters: render the canonical [`Gamedata`] model into each framework's on-disk format. Adding a
//! new consumer (SourceMod, a diff, a language SDK) is a new `impl GamedataEmitter` here — the
//! derivation never changes. This module depends only on `model` + serde, NOT on the deriver, so a
//! standalone `source2rosetta-gen` binary can link just this to turn a published model JSON into files.

use crate::model::{Entry, Gamedata, MonoEntry, Monolith, Schema, Tier, TierSelect};
use serde_json::{Map, Value, json};

/// Render the monolith as the CounterStrikeSharp combined `gamedata.json`: the guaranteed `core` section, a
/// `//` banner, then the extrapolated section (the tiers `select` includes beyond core), both key-sorted with
/// compact locators. At `TierSelect::HighConfidence` the output is exactly core + high_confidence; other
/// selections extend the extrapolated section.
///
/// Drops `validated == Some(false)` entries, matching [`Monolith::select`]: an entry live validation
/// confidently rejected must not reach a consumer under a banner claiming it resolves on a running server.
/// (Pre-validation every `validated` is `None`, so there this is a no-op.)
pub fn render_monolith_cssharp(mono: &Monolith, select: TierSelect) -> String {
    // the extrapolated section: the tiers beyond core that `select` includes, merged key-sorted. The tiers are
    // documented disjoint and are so for anything this crate derives, but an externally-supplied monolith need
    // not be — so keep the more-confident tier on collision (`select`'s rule) and subtract `core`, which would
    // otherwise emit the same JSON key in both sections.
    let mut extra: std::collections::BTreeMap<&String, &MonoEntry> =
        std::collections::BTreeMap::new();
    let mut sources: Vec<&std::collections::BTreeMap<String, MonoEntry>> = Vec::new();
    if select != TierSelect::Core {
        sources.push(&mono.high_confidence);
    }
    if select == TierSelect::Experimental {
        sources.push(&mono.experimental);
    }
    for m in sources {
        for (name, e) in m {
            if e.validated == Some(false) || mono.core.contains_key(name) {
                continue;
            }
            extra.entry(name).or_insert(e);
        }
    }
    let n_self = extra
        .values()
        .filter(|e| e.provenance.tier == Tier::SelfNamed)
        .count();
    // BOTH labels count: dictionary hits are tagged `DictExact` in the high-confidence tier and
    // `Corroborated` in the experimental tier, so counting only one variant misses genuinely corroborated
    // entries when the experimental tier is selected.
    let n_dict = extra
        .values()
        .filter(|e| e.provenance.tier.is_dict_corroborated())
        .count();
    let n_byval = extra
        .values()
        .filter(|e| e.provenance.by_value == Some(true))
        .count();

    let line = |name: &str, e: &MonoEntry| {
        format!(
            "  {}: {}",
            serde_json::to_string(name).unwrap_or_default(),
            serde_json::to_string(&locator_value(&e.locator)).unwrap_or_default()
        )
    };
    let core: Vec<(&String, &MonoEntry)> = mono
        .core
        .iter()
        .filter(|(_, e)| e.validated != Some(false))
        .collect();
    let core_lines: Vec<String> = core.iter().map(|(n, e)| line(n, e)).collect();
    let extra_lines: Vec<String> = extra.iter().map(|(n, e)| line(n, e)).collect();
    let total = core.len() + extra.len();
    let header = format!(
        "// ============================================================================\n\
         // source2rosetta combined gamedata  ({version})\n\
         // {} guaranteed + {} extrapolated = {total} functions.\n\
         // Auto-derived from the stripped {} server libraries, live-validated against a running server.\n\
         // (JSON with // comments — CS#'s loader and source2rosetta's own reader both tolerate them.)\n\
         // ============================================================================",
        core.len(),
        extra.len(),
        mono.meta.game,
        version = mono.meta.version,
    );
    let divider = [
        "  // ==========================================================================".to_string(),
        format!("  // EXTRAPOLATED NAMES  ({} total: {n_self} self-named, {n_dict} dict-corroborated; {n_byval} by-value)", extra.len()),
        "  // ---------------------------------------------------------------------------".to_string(),
        "  // Everything BELOW is AI-extrapolated from the stripped binary. Each LOCATOR".to_string(),
        "  // (sig / vtable offset) is live-validated — it resolves to real executable code".to_string(),
        "  // on a running server. The NAME is a best-effort label, not a symbol Valve".to_string(),
        "  // shipped (the binary is stripped); the exact C++ prototype (arg/return types)".to_string(),
        "  // is unrecoverable, so confirm the call signature yourself. Trust tiers + a".to_string(),
        "  // by-value (unsafe-to-naive-call) flag live in the provenance sidecar. Above = guaranteed.".to_string(),
        "  // ==========================================================================".to_string(),
    ]
    .join("\n");
    let mut s = header;
    s.push_str("\n{\n");
    s.push_str(&core_lines.join(",\n"));
    if !core_lines.is_empty() && !extra_lines.is_empty() {
        s.push(','); // the last core entry needs a comma — the extrapolated section follows the banner
    }
    s.push('\n');
    s.push_str(&divider);
    s.push('\n');
    s.push_str(&extra_lines.join(",\n"));
    s.push_str("\n}\n");
    s
}

/// Render a derived gamedata model to one output format's text.
pub trait GamedataEmitter {
    fn id(&self) -> &'static str;
    fn render(&self, gd: &Gamedata) -> String;
}

/// The canonical CS#-gamedata locator for one entry: an object carrying `"signatures":{library,linux}`
/// when it has a signature and/or `"offsets":{linux}` when it has a vtable slot. This is the on-disk shape
/// the cssharp `gamedata.json` and the deriver's combined/experimental files all share, so every producer
/// builds it through this ONE function instead of an ad-hoc `json!`. Keys serialize sorted (serde_json's
/// default `Map`), so an entry carrying both a signature and an offset is deterministic.
pub fn locator_value(e: &Entry) -> Value {
    let mut obj = Map::new();
    if let Some(s) = &e.signature {
        obj.insert(
            "signatures".into(),
            json!({ "library": s.library, "linux": s.linux }),
        );
    }
    if let Some(o) = e.offset {
        obj.insert("offsets".into(), json!({ "linux": o }));
    }
    Value::Object(obj)
}

/// The inverse of [`locator_value`]: parse the on-disk cssharp locator shape
/// (`{"signatures":{library,linux}}` and/or `{"offsets":{linux}}`) back into a typed [`Entry`]. Lives here,
/// beside the forward writer, so the round-trip is single-sourced in `core` instead of hand-rolled in the
/// deriver. Tolerant: a missing `signatures.library` defaults to `server`, a
/// missing `signatures.linux` to empty, and an entry may carry a signature and/or an offset (or neither).
pub fn entry_from_value(v: &Value) -> Entry {
    let signature = v.get("signatures").map(|s| crate::model::Sig {
        library: s
            .get("library")
            .and_then(Value::as_str)
            .unwrap_or("server")
            .to_string(),
        linux: s
            .get("linux")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string(),
    });
    let offset = v
        .get("offsets")
        .and_then(|o| o.get("linux"))
        .and_then(Value::as_i64);
    Entry { signature, offset }
}

/// Look up an emitter by its `--format` id.
pub fn by_id(id: &str) -> Option<Box<dyn GamedataEmitter>> {
    match id {
        "cssharp" => Some(Box::new(CsSharp)),
        "metamod" => Some(Box::new(Metamod)),
        "modsharp" => Some(Box::new(ModSharp)),
        "swiftly" => Some(Box::new(Swiftly)),
        "plugify" => Some(Box::new(Plugify)),
        "model" => Some(Box::new(ModelJson)),
        _ => None,
    }
}

/// Every `--format` id `by_id` accepts — for help text and error messages (keep in sync with `by_id`).
pub const FORMAT_IDS: &[&str] = &[
    "cssharp", "metamod", "modsharp", "swiftly", "plugify", "model",
];

/// The canonical model itself, serialized — the format-neutral artifact `source2rosetta-gen` reads back.
pub struct ModelJson;

impl GamedataEmitter for ModelJson {
    fn id(&self) -> &'static str {
        "model"
    }

    fn render(&self, gd: &Gamedata) -> String {
        serde_json::to_string_pretty(gd).unwrap_or_default()
    }
}

/// CounterStrikeSharp `gamedata.json`: `{ name: { "signatures": {library,linux} | "offsets": {linux} } }`.
/// Built through serde_json's key-sorted Map for deterministic output.
pub struct CsSharp;

impl GamedataEmitter for CsSharp {
    fn id(&self) -> &'static str {
        "cssharp"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let mut doc = Map::new();
        for (name, e) in &gd.entries {
            doc.insert(name.clone(), locator_value(e));
        }
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

/// Metamod/SourceMod `*.games.txt` (Valve KeyValues): `Games { csgo { Signatures{..} Offsets{..} } }`.
/// Signature bytes become `\xAB` escapes with `\x2A` for wildcards.
pub struct Metamod;

/// "55 48 ? E5" -> "\x55\x48\x2A\xE5". Handles every wildcard token `sig::Pattern` accepts (`?`/`??`/`*`).
///
/// LIMITATION: SourceMod/Metamod's wildcard byte IS 0x2A (`*`), so a signature with a *fixed* 0x2A
/// byte is inherently ambiguous in this format — it renders as `\x2A` and Metamod reads it as a
/// wildcard, widening the pattern. That's a constraint of the VDF signature format itself (real
/// SourceMod gamedata shares it), not something the emitter can encode away; a fully Metamod-safe
/// pattern would need make_sig to avoid depending on a fixed 0x2A byte for uniqueness.
fn vdf_pattern(spacehex: &str) -> String {
    spacehex
        .split_whitespace()
        .map(|t| match t {
            "?" | "??" | "*" => "\\x2A".to_string(),
            b => format!("\\x{}", b.to_uppercase()),
        })
        .collect()
}

impl GamedataEmitter for Metamod {
    fn id(&self) -> &'static str {
        "metamod"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let mut sigs = String::new();
        let mut offs = String::new();
        for (name, e) in &gd.entries {
            if let Some(s) = &e.signature {
                sigs.push_str(&format!(
                    "\t\t\t\"{name}\"\n\t\t\t{{\n\t\t\t\t\"library\"\t\"{}\"\n\t\t\t\t\"linux\"\t\"{}\"\n\t\t\t}}\n",
                    s.library,
                    vdf_pattern(&s.linux),
                ));
            }
            if let Some(o) = e.offset {
                offs.push_str(&format!(
                    "\t\t\t\"{name}\"\n\t\t\t{{\n\t\t\t\t\"linux\"\t\"{o}\"\n\t\t\t}}\n"
                ));
            }
        }
        let key = &gd.game_key;
        format!(
            "\"Games\"\n{{\n\t\"{key}\"\n\t{{\n\t\t\"Signatures\"\n\t\t{{\n{sigs}\t\t}}\n\t\t\"Offsets\"\n\t\t{{\n{offs}\t\t}}\n\t}}\n}}\n"
        )
    }
}

/// ModSharp `*.games.jsonc`: `{ "Addresses": {sig}, "VFuncs": {offset} }`. Signatures carry a `library`;
/// VFuncs are a bare `linux` slot index. Linux-only (windows isn't derived from a `.so`).
pub struct ModSharp;

impl GamedataEmitter for ModSharp {
    fn id(&self) -> &'static str {
        "modsharp"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let (mut addresses, mut vfuncs) = (Map::new(), Map::new());
        for (name, e) in &gd.entries {
            if let Some(s) = &e.signature {
                addresses.insert(
                    name.clone(),
                    json!({ "library": s.library, "linux": s.linux }),
                );
            }
            if let Some(o) = e.offset {
                vfuncs.insert(name.clone(), json!({ "linux": o }));
            }
        }
        let doc = json!({ "Addresses": addresses, "VFuncs": vfuncs });
        serde_json::to_string_pretty(&doc).unwrap_or_default()
    }
}

/// SwiftlyS2 `signatures.jsonc`: `{ name: {"lib": lib, "linux": sig} }`. Swiftly keeps offsets in a
/// separate `offsets.jsonc`; this emits the signatures file (the bulk of a gamedata).
pub struct Swiftly;

impl GamedataEmitter for Swiftly {
    fn id(&self) -> &'static str {
        "swiftly"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let mut doc = Map::new();
        for (name, e) in &gd.entries {
            if let Some(s) = &e.signature {
                doc.insert(name.clone(), json!({ "lib": s.library, "linux": s.linux }));
            }
        }
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

/// Plugify (s2sdk) `gamedata.jsonc`: `{ "csgo": { "Signatures": {..}, "Offsets": {..} } }`. Uses the
/// `linuxsteamrt64` platform key; signatures carry a `library`.
pub struct Plugify;

impl GamedataEmitter for Plugify {
    fn id(&self) -> &'static str {
        "plugify"
    }

    fn render(&self, gd: &Gamedata) -> String {
        let (mut sigs, mut offs) = (Map::new(), Map::new());
        for (name, e) in &gd.entries {
            if let Some(s) = &e.signature {
                sigs.insert(
                    name.clone(),
                    json!({ "library": s.library, "linuxsteamrt64": s.linux }),
                );
            }
            if let Some(o) = e.offset {
                offs.insert(name.clone(), json!({ "linuxsteamrt64": o }));
            }
        }
        let mut doc = Map::new();
        doc.insert(
            gd.game_key.clone(),
            json!({ "Signatures": sigs, "Offsets": offs }),
        );
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

// ============================ SCHEMA / NETVAR emitters ============================
// The typed schema (`netvars-<game>.json`, `model::Schema`) has its own emitter family, parallel to
// `GamedataEmitter`: turn the class->field->offset/type surface into a consumable SDK or netvar file.
// Adding a language SDK or a framework netvar format is a new `impl SchemaEmitter` here.

/// Render `model::Schema` (the typed netvars) into a consumer format (a language SDK, a netvar map).
pub trait SchemaEmitter {
    fn id(&self) -> &'static str;
    fn render(&self, schema: &Schema) -> String;
}

/// A Source-2 schema type as its C# spelling: the primitive scalars map to C# built-ins; everything else
/// (Source-2 math types, handles, `CUtl*`, enums, templates) is kept verbatim as the ground-truth type name
/// — a fully-typed SDK for all 1,400+ custom types isn't ours to define, and the raw name is the honest hint.
fn cs_type(ty: &str) -> &str {
    match ty {
        "int8" | "char8" => "sbyte",
        "uint8" => "byte",
        "int16" => "short",
        "uint16" => "ushort",
        "int32" => "int",
        "uint32" => "uint",
        "int64" => "long",
        "uint64" => "ulong",
        "float32" => "float",
        "float64" => "double",
        "bool" => "bool",
        other => other,
    }
}

/// A schema name (class or nested-type) as a valid C# identifier: nested `Outer::Inner` and any other
/// non-identifier char collapse to `_`. The original name rides along in an XML-doc comment when it changed.
fn cs_ident(name: &str) -> String {
    let id: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect();
    if id.chars().next().is_some_and(|c| c.is_ascii_digit()) {
        format!("_{id}")
    } else {
        id
    }
}

/// A typed C# SDK: one `static class` per schema class, each field a `const int <name> = 0x<offset>;` tagged
/// with its C# / Source-2 type. Framework-neutral (offsets + types, no runtime-read assumption), complete
/// (every field, every type), deterministic (the schema's BTreeMaps sort classes then fields).
pub struct CsSdk;

impl SchemaEmitter for CsSdk {
    fn id(&self) -> &'static str {
        "cs-sdk"
    }

    fn render(&self, s: &Schema) -> String {
        let m = &s.meta;
        let mut out = String::new();
        out.push_str(&format!(
            "// <auto-generated> source2rosetta — {} build {}. Source-2 SchemaSystem field offsets.\n\
             // {} classes, {} typed fields. Regenerate: source2rosetta-gen --netvars netvars-{}.json --format cs-sdk\n\
             namespace Source2.Schema;\n",
            m.game_key, m.source_build, s.classes.len(), m.typed, m.game_key
        ));
        for (cls, fields) in &s.classes {
            let ident = cs_ident(cls);
            out.push('\n');
            if ident != *cls {
                out.push_str(&format!(
                    "/// <summary><c>{cls}</c> — {} fields</summary>\n",
                    fields.len()
                ));
            } else {
                out.push_str(&format!(
                    "/// <summary>{cls} — {} fields</summary>\n",
                    fields.len()
                ));
            }
            out.push_str(&format!("public static class {ident}\n{{\n"));
            for (fname, f) in fields {
                out.push_str(&format!(
                    "    public const int {fname} = 0x{:X}; // {}\n",
                    f.offset,
                    cs_type(&f.ty)
                ));
            }
            out.push_str("}\n");
        }
        out
    }
}

/// Framework-neutral netvar map: `{ class: { field: offset } }` — the raw offset table any tool/framework
/// can consume without the SDK's C# packaging.
pub struct NetvarsJson;

impl SchemaEmitter for NetvarsJson {
    fn id(&self) -> &'static str {
        "netvars"
    }

    fn render(&self, s: &Schema) -> String {
        let mut doc = Map::new();
        for (cls, fields) in &s.classes {
            let mut fm = Map::new();
            for (fname, f) in fields {
                fm.insert(fname.clone(), json!(f.offset));
            }
            doc.insert(cls.clone(), Value::Object(fm));
        }
        serde_json::to_string_pretty(&Value::Object(doc)).unwrap_or_default()
    }
}

/// Look up a schema emitter by its `--format` id.
pub fn schema_by_id(id: &str) -> Option<Box<dyn SchemaEmitter>> {
    match id {
        "cs-sdk" => Some(Box::new(CsSdk)),
        "netvars" => Some(Box::new(NetvarsJson)),
        _ => None,
    }
}

/// Every schema `--format` id `schema_by_id` accepts (keep in sync with it).
pub const SCHEMA_FORMAT_IDS: &[&str] = &["cs-sdk", "netvars"];

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

    fn sample() -> Gamedata {
        let mut gd = Gamedata::default();
        gd.set_signature("Host_Say", "server", "55 48 89 ? E5");
        gd.set_offset("GameEntitySystem", 80);
        gd
    }

    #[test]
    fn cssharp_shape_is_sorted_and_correct() {
        let out = CsSharp.render(&sample());
        // key-sorted: GameEntitySystem before Host_Say; offsets/signatures shapes intact.
        let g = out.find("GameEntitySystem").unwrap();
        let h = out.find("Host_Say").unwrap();
        assert!(g < h, "entries must be key-sorted");
        assert!(out.contains("\"offsets\""));
        assert!(out.contains("\"library\": \"server\""));
        assert!(out.contains("\"linux\": \"55 48 89 ? E5\""));
    }

    #[test]
    fn locator_value_and_entry_from_value_round_trip() {
        use crate::model::{Entry, Sig};
        // the canonical write/read pair is a true inverse for both locator kinds
        let sig = Entry {
            signature: Some(Sig {
                library: "engine2".into(),
                linux: "55 48 ? E5".into(),
            }),
            offset: None,
        };
        let off = Entry {
            signature: None,
            offset: Some(158),
        };
        assert_eq!(entry_from_value(&locator_value(&sig)), sig);
        assert_eq!(entry_from_value(&locator_value(&off)), off);
        // a signature missing its library reads back as "server" — the reader's tolerance
        let v = json!({ "signatures": { "linux": "90" } });
        assert_eq!(entry_from_value(&v).signature.unwrap().library, "server");
    }

    #[test]
    fn metamod_escapes_bytes_and_wildcards() {
        let out = Metamod.render(&sample());
        assert!(out.contains("\"Games\""));
        assert!(out.contains(r"\x55\x48\x89\x2A\xE5")); // ? -> \x2A
        assert!(out.contains("\"linux\"\t\"80\""));
    }

    #[test]
    fn modsharp_splits_addresses_and_vfuncs() {
        let v: serde_json::Value = serde_json::from_str(&ModSharp.render(&sample())).unwrap();
        assert_eq!(v["Addresses"]["Host_Say"]["library"], "server");
        assert_eq!(v["Addresses"]["Host_Say"]["linux"], "55 48 89 ? E5");
        assert_eq!(v["VFuncs"]["GameEntitySystem"]["linux"], 80);
        assert!(v["Addresses"].get("GameEntitySystem").is_none()); // offset isn't an address
    }

    #[test]
    fn swiftly_signatures_use_lib_key() {
        let v: serde_json::Value = serde_json::from_str(&Swiftly.render(&sample())).unwrap();
        assert_eq!(v["Host_Say"]["lib"], "server");
        assert_eq!(v["Host_Say"]["linux"], "55 48 89 ? E5");
        assert!(v.get("GameEntitySystem").is_none()); // offsets are a separate file
    }

    #[test]
    fn plugify_is_game_keyed_with_linuxsteamrt64() {
        let v: serde_json::Value = serde_json::from_str(&Plugify.render(&sample())).unwrap();
        assert_eq!(v["csgo"]["Signatures"]["Host_Say"]["library"], "server");
        assert_eq!(
            v["csgo"]["Signatures"]["Host_Say"]["linuxsteamrt64"],
            "55 48 89 ? E5"
        );
        assert_eq!(
            v["csgo"]["Offsets"]["GameEntitySystem"]["linuxsteamrt64"],
            80
        );
    }

    #[test]
    fn game_keyed_emitters_honor_a_non_csgo_game_key() {
        // The game-keyed formats (Metamod, Plugify) must wrap output in the model's game_key, not a
        // hardcoded "csgo" — the multi-game seam. A dota-keyed model renders under "dota".
        let mut gd = sample();
        gd.game_key = "dota".to_string();
        let mm = Metamod.render(&gd);
        assert!(
            mm.contains("\t\"dota\"\n") && !mm.contains("\"csgo\""),
            "metamod: {mm}"
        );
        let pl: Value = serde_json::from_str(&Plugify.render(&gd)).unwrap();
        assert_eq!(
            pl["dota"]["Offsets"]["GameEntitySystem"]["linuxsteamrt64"],
            80
        );
        assert!(pl.get("csgo").is_none());
    }

    #[test]
    fn every_format_id_resolves_and_renders() {
        for id in FORMAT_IDS {
            let em = by_id(id).unwrap_or_else(|| panic!("by_id({id}) is None"));
            assert!(!em.render(&sample()).is_empty(), "{id} rendered empty");
        }
    }

    #[test]
    fn metamod_handles_all_wildcard_token_forms() {
        // sig::Pattern accepts ?, ?? and * as wildcards — all must render as the VDF wildcard \x2A,
        // never as a corrupt \x?? / \x* escape.
        let mut gd = Gamedata::default();
        gd.set_signature("F", "server", "48 ?? 89 * E5 ?");
        let out = Metamod.render(&gd);
        assert!(out.contains(r"\x48\x2A\x89\x2A\xE5\x2A"), "got: {out}");
        assert!(!out.contains(r"\x??") && !out.contains(r"\x*"));
    }

    fn sample_schema() -> Schema {
        use crate::model::{Field, SchemaMeta};
        use std::collections::BTreeMap;
        let f = |offset, ty: &str| Field {
            offset,
            ty: ty.into(),
            kind: crate::model::FieldKind::Ref,
            size: 4,
            name_hash: 0,
        };
        let mut base = BTreeMap::new();
        base.insert("m_iHealth".to_string(), f(0x5B0, "int32"));
        let mut nested = BTreeMap::new();
        nested.insert("m_x".to_string(), f(0, "float32"));
        let mut classes = BTreeMap::new();
        classes.insert("CBaseEntity".to_string(), base);
        classes.insert("Outer_t::Inner_t".to_string(), nested); // must sanitize to a valid C# identifier
        Schema {
            meta: SchemaMeta {
                game_key: "csgo".into(),
                source_build: "b".into(),
                typed: 2,
                untyped: 0,
            },
            classes,
        }
    }

    #[test]
    fn every_schema_format_renders_nonempty() {
        let s = sample_schema();
        for id in SCHEMA_FORMAT_IDS {
            let em = schema_by_id(id).unwrap_or_else(|| panic!("schema_by_id({id}) is None"));
            assert!(!em.render(&s).is_empty(), "{id} rendered empty");
        }
    }

    #[test]
    fn cs_sdk_sanitizes_idents_and_maps_primitives() {
        let cs = CsSdk.render(&sample_schema());
        // primitive types map to C# built-ins; the offset is hex
        assert!(
            cs.contains("public const int m_iHealth = 0x5B0; // int"),
            "{cs}"
        );
        // a `::` nested class name becomes a valid C# identifier, original kept in the doc comment
        assert!(cs.contains("public static class Outer_t__Inner_t"), "{cs}");
        assert!(cs.contains("<c>Outer_t::Inner_t</c>"), "{cs}");
        // no raw `::` ever leaks into an emitted identifier
        for line in cs.lines().filter(|l| l.starts_with("public static class ")) {
            assert!(!line.contains("::"), "invalid C# class ident: {line}");
        }
    }
}
