// SPDX-License-Identifier: AGPL-3.0-only OR Commercial

//! Protocol code generation — TypeScript types and protocol descriptors.
//!
//! Reads a [`ProtocolManifest`] and emits:
//!
//! - TypeScript interfaces for commands, queries, events, and DTOs
//! - Protocol descriptors for adapter generation
//!
//! This module is the bridge between Rust protocol definitions and the
//! generated artifacts that TypeScript consumers and runtime adapters use.

use crate::manifest::{ManifestEntry, ManifestField, ProtocolManifest};
use crate::protocol::ProtocolKind;
use serde::{Deserialize, Serialize};

// =============================================================================
// Rust → TypeScript type mapping
// =============================================================================

/// Map a Rust type string (from manifest `ty` field) to a TypeScript type.
///
/// Handles common primitive and collection types. Unknown types pass through
/// as-is (assumed to be protocol DTOs that will also be generated).
pub fn rust_type_to_ts(rust_ty: &str) -> String {
    match rust_ty {
        // Numeric types → number
        "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" | "i128"
        | "isize" | "f32" | "f64" => "number".to_string(),

        // Boolean
        "bool" => "boolean".to_string(),

        // String types → string
        "String" | "&str" | "str" => "string".to_string(),

        // Unit / empty
        "()" => "void".to_string(),

        other => {
            // Vec<T> → T[]
            if let Some(inner) = other.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) {
                return format!("{}[]", rust_type_to_ts(inner));
            }

            // Option<T> → T | null
            if let Some(inner) = other
                .strip_prefix("Option<")
                .and_then(|s| s.strip_suffix('>'))
            {
                return format!("{} | null", rust_type_to_ts(inner));
            }

            // HashMap<K, V> → Record<K, V>
            if let Some(inner) = other
                .strip_prefix("HashMap<")
                .and_then(|s| s.strip_suffix('>'))
                && let Some((k, v)) = inner.split_once(',')
            {
                return format!(
                    "Record<{}, {}>",
                    rust_type_to_ts(k.trim()),
                    rust_type_to_ts(v.trim())
                );
            }

            // Unknown type → pass through (assumed to be another protocol type)
            other.to_string()
        }
    }
}

// =============================================================================
// TypeScript code generation
// =============================================================================

/// Generate a TypeScript interface from a manifest entry.
fn emit_ts_interface(entry: &ManifestEntry) -> String {
    let mut out = String::new();

    // Doc comment
    if !entry.doc.is_empty() {
        out.push_str(&format!("/** {} */\n", entry.doc));
    }

    if entry.fields.is_empty() {
        // Unit struct → type alias to empty object
        out.push_str(&format!(
            "export type {} = Record<string, never>;\n",
            entry.name
        ));
    } else {
        out.push_str(&format!("export interface {} {{\n", entry.name));
        for field in &entry.fields {
            let ts_type = rust_type_to_ts(&field.ty);
            out.push_str(&format!("  {}: {};\n", field.name, ts_type));
        }
        out.push_str("}\n");
    }

    out
}

/// Generate a complete TypeScript module from a protocol manifest.
///
/// Emits all protocol types grouped by kind with section comments.
pub fn generate_typescript(manifest: &ProtocolManifest) -> String {
    let mut out = String::new();

    out.push_str("// Auto-generated by Galeon Engine — do not edit.\n");
    out.push_str(&format!(
        "// Protocol: {} | Manifest v{}\n\n",
        manifest.protocol_version, manifest.manifest_version
    ));

    let sections: &[(&str, &[ManifestEntry])] = &[
        ("Commands", &manifest.commands),
        ("Queries", &manifest.queries),
        ("Events", &manifest.events),
        ("DTOs", &manifest.dtos),
    ];

    for (label, entries) in sections {
        if entries.is_empty() {
            continue;
        }
        out.push_str(&format!("// — {} —\n\n", label));
        for entry in *entries {
            out.push_str(&emit_ts_interface(entry));
            out.push('\n');
        }
    }

    out
}

/// Generate a TypeScript module containing only the items that belong to `surface`.
///
/// Items with an explicit `surfaces` list are included when `surface` appears
/// in that list. Items with an empty `surfaces` list are included when
/// `surface` matches `manifest.default_surface`.
pub fn generate_typescript_for_surface(manifest: &ProtocolManifest, surface: &str) -> String {
    let mut out = String::new();

    out.push_str("// Auto-generated by Galeon Engine — do not edit.\n");
    out.push_str(&format!(
        "// Protocol: {} | Surface: {} | Manifest v{}\n\n",
        manifest.protocol_version, surface, manifest.manifest_version
    ));

    let belongs = |entry: &ManifestEntry| {
        ProtocolManifest::entry_belongs_to_surface(entry, surface, &manifest.default_surface)
    };

    let sections: &[(&str, &Vec<ManifestEntry>)] = &[
        ("Commands", &manifest.commands),
        ("Queries", &manifest.queries),
        ("Events", &manifest.events),
        ("DTOs", &manifest.dtos),
    ];

    for (label, entries) in sections {
        let filtered: Vec<&ManifestEntry> = entries.iter().filter(|e| belongs(e)).collect();
        if filtered.is_empty() {
            continue;
        }
        out.push_str(&format!("// — {} —\n\n", label));
        for entry in filtered {
            out.push_str(&emit_ts_interface(entry));
            out.push('\n');
        }
    }

    out
}

/// Generate one TypeScript module per surface in the manifest.
///
/// Returns `(surface_name, typescript_source)` pairs sorted by surface name.
/// Single-surface manifests delegate to [`generate_typescript`] so that
/// existing consumers see identical output (no `Surface:` header drift).
pub fn generate_all_surface_typescripts(manifest: &ProtocolManifest) -> Vec<(String, String)> {
    let mut names = manifest.resolved_surface_names();
    names.sort();
    names.dedup();

    if names.len() == 1 {
        return vec![(names[0].clone(), generate_typescript(manifest))];
    }

    names
        .into_iter()
        .map(|name| {
            let ts = generate_typescript_for_surface(manifest, &name);
            (name, ts)
        })
        .collect()
}

// =============================================================================
// Protocol descriptors
// =============================================================================

/// HTTP method for remote adapter routing.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HttpMethod {
    Get,
    Post,
}

/// A protocol descriptor for a command, query, or event.
///
/// Descriptors carry the identity and routing metadata that adapters need
/// to dispatch protocol items. They are generated artifacts — not handwritten.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolDescriptor {
    /// Stable protocol name (e.g., `"SpawnUnit"`).
    pub name: String,
    /// Protocol kind.
    pub kind: ProtocolKind,
    /// Route path for remote adapter (e.g., `"/commands/spawn-unit"`).
    pub route: String,
    /// HTTP method for remote adapter.
    pub method: HttpMethod,
    /// Field names (for schema documentation).
    pub fields: Vec<ManifestField>,
}

/// A per-surface descriptor group.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SurfaceDescriptorSet {
    /// Surface name these descriptors belong to.
    pub name: String,
    /// Commands, queries, and events reachable from this surface.
    pub descriptors: Vec<ProtocolDescriptor>,
}

/// A complete set of protocol descriptors for a project.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolDescriptorSet {
    pub protocol_version: String,
    pub surfaces: Vec<SurfaceDescriptorSet>,
}

impl ProtocolDescriptorSet {
    /// Iterate all descriptors across every surface group.
    ///
    /// Items annotated with multiple surfaces will appear once per surface
    /// they belong to. Callers that need unique items should deduplicate
    /// by name.
    pub fn iter_descriptors(&self) -> impl Iterator<Item = &ProtocolDescriptor> {
        self.surfaces
            .iter()
            .flat_map(|surface| surface.descriptors.iter())
    }
}

/// Convert a PascalCase name to a kebab-case route segment.
fn to_kebab_case(name: &str) -> String {
    let mut result = String::new();
    for (i, ch) in name.chars().enumerate() {
        if ch.is_uppercase() && i > 0 {
            result.push('-');
        }
        result.push(ch.to_ascii_lowercase());
    }
    result
}

/// Generate protocol descriptors from a manifest.
///
/// Default routing conventions:
/// - Commands → `POST /commands/<kebab-name>`
/// - Queries → `GET /queries/<kebab-name>` (POST if has fields)
/// - Events → `GET /events/<kebab-name>` (stream subscription)
pub fn generate_descriptors(manifest: &ProtocolManifest) -> ProtocolDescriptorSet {
    let mut surfaces = Vec::new();
    let surface_names = manifest.resolved_surface_names();

    for surface_name in &surface_names {
        let mut descriptors = Vec::new();

        for entry in manifest.commands.iter().filter(|entry| {
            ProtocolManifest::entry_belongs_to_surface(
                entry,
                surface_name,
                &manifest.default_surface,
            )
        }) {
            let slug = to_kebab_case(&entry.name);
            descriptors.push(ProtocolDescriptor {
                name: entry.name.clone(),
                kind: ProtocolKind::Command,
                route: format!("/commands/{}", slug),
                method: HttpMethod::Post,
                fields: entry.fields.clone(),
            });
        }

        for entry in manifest.queries.iter().filter(|entry| {
            ProtocolManifest::entry_belongs_to_surface(
                entry,
                surface_name,
                &manifest.default_surface,
            )
        }) {
            let slug = to_kebab_case(&entry.name);
            let method = if entry.fields.is_empty() {
                HttpMethod::Get
            } else {
                HttpMethod::Post
            };
            descriptors.push(ProtocolDescriptor {
                name: entry.name.clone(),
                kind: ProtocolKind::Query,
                route: format!("/queries/{}", slug),
                method,
                fields: entry.fields.clone(),
            });
        }

        for entry in manifest.events.iter().filter(|entry| {
            ProtocolManifest::entry_belongs_to_surface(
                entry,
                surface_name,
                &manifest.default_surface,
            )
        }) {
            let slug = to_kebab_case(&entry.name);
            descriptors.push(ProtocolDescriptor {
                name: entry.name.clone(),
                kind: ProtocolKind::Event,
                route: format!("/events/{}", slug),
                method: HttpMethod::Get,
                fields: entry.fields.clone(),
            });
        }

        surfaces.push(SurfaceDescriptorSet {
            name: surface_name.clone(),
            descriptors,
        });
    }

    ProtocolDescriptorSet {
        protocol_version: manifest.protocol_version.clone(),
        surfaces,
    }
}

// =============================================================================
// Tests
// =============================================================================

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

    // -- Type mapping --

    #[test]
    fn primitives_map_to_ts() {
        assert_eq!(rust_type_to_ts("u64"), "number");
        assert_eq!(rust_type_to_ts("f32"), "number");
        assert_eq!(rust_type_to_ts("bool"), "boolean");
        assert_eq!(rust_type_to_ts("String"), "string");
        assert_eq!(rust_type_to_ts("()"), "void");
    }

    #[test]
    fn vec_maps_to_array() {
        assert_eq!(rust_type_to_ts("Vec<u64>"), "number[]");
        assert_eq!(rust_type_to_ts("Vec<String>"), "string[]");
    }

    #[test]
    fn option_maps_to_nullable() {
        assert_eq!(rust_type_to_ts("Option<u32>"), "number | null");
        assert_eq!(rust_type_to_ts("Option<String>"), "string | null");
    }

    #[test]
    fn hashmap_maps_to_record() {
        assert_eq!(
            rust_type_to_ts("HashMap<String, u64>"),
            "Record<string, number>"
        );
    }

    #[test]
    fn unknown_type_passes_through() {
        assert_eq!(rust_type_to_ts("UnitView"), "UnitView");
        assert_eq!(rust_type_to_ts("Vec<UnitView>"), "UnitView[]");
    }

    // -- TS generation --

    fn sample_manifest() -> ProtocolManifest {
        ProtocolManifest {
            manifest_version: "2".into(),
            protocol_version: "test@0.1".into(),
            default_surface: "default".into(),
            surfaces: vec!["default".into()],
            commands: vec![ManifestEntry {
                name: "SpawnUnit".into(),
                kind: ProtocolKind::Command,
                fields: vec![
                    ManifestField {
                        name: "unit_id".into(),
                        ty: "u64".into(),
                    },
                    ManifestField {
                        name: "location_id".into(),
                        ty: "u64".into(),
                    },
                ],
                doc: "Spawn a unit at a location.".into(),
                surfaces: vec![],
            }],
            queries: vec![ManifestEntry {
                name: "GetWorldSnapshot".into(),
                kind: ProtocolKind::Query,
                fields: vec![],
                doc: "Get world status.".into(),
                surfaces: vec![],
            }],
            events: vec![ManifestEntry {
                name: "UnitDestroyed".into(),
                kind: ProtocolKind::Event,
                fields: vec![
                    ManifestField {
                        name: "unit_id".into(),
                        ty: "u64".into(),
                    },
                    ManifestField {
                        name: "destination".into(),
                        ty: "String".into(),
                    },
                ],
                doc: "".into(),
                surfaces: vec![],
            }],
            dtos: vec![ManifestEntry {
                name: "WorldSnapshot".into(),
                kind: ProtocolKind::Dto,
                fields: vec![
                    ManifestField {
                        name: "units_active".into(),
                        ty: "u32".into(),
                    },
                    ManifestField {
                        name: "units_idle".into(),
                        ty: "u32".into(),
                    },
                ],
                doc: "World overview.".into(),
                surfaces: vec![],
            }],
        }
    }

    fn sample_multi_surface_manifest() -> ProtocolManifest {
        ProtocolManifest {
            manifest_version: "2".into(),
            protocol_version: "test@0.1".into(),
            default_surface: "gameplay".into(),
            surfaces: vec!["authority".into(), "gameplay".into()],
            commands: vec![
                ManifestEntry {
                    name: "SpawnUnit".into(),
                    kind: ProtocolKind::Command,
                    fields: vec![ManifestField {
                        name: "unit_id".into(),
                        ty: "u64".into(),
                    }],
                    doc: "".into(),
                    surfaces: vec![],
                },
                ManifestEntry {
                    name: "AdminReset".into(),
                    kind: ProtocolKind::Command,
                    fields: vec![ManifestField {
                        name: "zone_id".into(),
                        ty: "u64".into(),
                    }],
                    doc: "".into(),
                    surfaces: vec!["authority".into()],
                },
            ],
            queries: vec![],
            events: vec![ManifestEntry {
                name: "UnitDestroyed".into(),
                kind: ProtocolKind::Event,
                fields: vec![ManifestField {
                    name: "unit_id".into(),
                    ty: "u64".into(),
                }],
                doc: "".into(),
                surfaces: vec!["authority".into(), "gameplay".into()],
            }],
            dtos: vec![],
        }
    }

    #[test]
    fn generate_ts_produces_valid_output() {
        let ts = generate_typescript(&sample_manifest());

        // Header
        assert!(ts.contains("Auto-generated by Galeon Engine"));
        assert!(ts.contains("test@0.1"));

        // Command interface
        assert!(ts.contains("export interface SpawnUnit {"));
        assert!(ts.contains("unit_id: number;"));
        assert!(ts.contains("location_id: number;"));

        // Query unit struct
        assert!(ts.contains("export type GetWorldSnapshot = Record<string, never>;"));

        // Event interface
        assert!(ts.contains("export interface UnitDestroyed {"));
        assert!(ts.contains("destination: string;"));

        // DTO interface
        assert!(ts.contains("export interface WorldSnapshot {"));
        assert!(ts.contains("units_active: number;"));

        // Doc comment
        assert!(ts.contains("/** Spawn a unit at a location. */"));
    }

    #[test]
    fn generate_ts_empty_manifest() {
        let manifest = ProtocolManifest {
            manifest_version: "2".into(),
            protocol_version: "empty@0.0".into(),
            default_surface: "default".into(),
            surfaces: vec![],
            commands: vec![],
            queries: vec![],
            events: vec![],
            dtos: vec![],
        };
        let ts = generate_typescript(&manifest);
        assert!(ts.contains("Auto-generated"));
        // No section headers for empty categories
        assert!(!ts.contains("Commands"));
    }

    // -- Descriptors --

    #[test]
    fn to_kebab_case_converts_pascal() {
        assert_eq!(to_kebab_case("SpawnUnit"), "spawn-unit");
        assert_eq!(to_kebab_case("GetWorldSnapshot"), "get-world-snapshot");
        assert_eq!(to_kebab_case("A"), "a");
    }

    #[test]
    fn generate_descriptors_correct_routes() {
        let descs = generate_descriptors(&sample_manifest());

        assert_eq!(descs.protocol_version, "test@0.1");
        assert_eq!(descs.surfaces.len(), 1);
        assert_eq!(descs.surfaces[0].name, "default");
        // DTOs are type-only — no descriptor (3 = command + query + event)
        assert_eq!(descs.surfaces[0].descriptors.len(), 3);

        let cmd = descs.surfaces[0]
            .descriptors
            .iter()
            .find(|d| d.name == "SpawnUnit")
            .unwrap();
        assert_eq!(cmd.route, "/commands/spawn-unit");
        assert_eq!(cmd.method, HttpMethod::Post);
        assert_eq!(cmd.kind, ProtocolKind::Command);

        let query = descs.surfaces[0]
            .descriptors
            .iter()
            .find(|d| d.name == "GetWorldSnapshot")
            .unwrap();
        assert_eq!(query.route, "/queries/get-world-snapshot");
        assert_eq!(query.method, HttpMethod::Get);

        let event = descs.surfaces[0]
            .descriptors
            .iter()
            .find(|d| d.name == "UnitDestroyed")
            .unwrap();
        assert_eq!(event.route, "/events/unit-destroyed");
        assert_eq!(event.method, HttpMethod::Get);
    }

    #[test]
    fn generate_descriptors_groups_entries_by_surface() {
        let descs = generate_descriptors(&sample_multi_surface_manifest());

        assert_eq!(descs.surfaces.len(), 2);
        let authority = descs
            .surfaces
            .iter()
            .find(|surface| surface.name == "authority")
            .unwrap();
        let gameplay = descs
            .surfaces
            .iter()
            .find(|surface| surface.name == "gameplay")
            .unwrap();

        assert_eq!(authority.descriptors.len(), 2);
        assert!(
            authority
                .descriptors
                .iter()
                .any(|desc| desc.name == "AdminReset")
        );
        assert!(
            authority
                .descriptors
                .iter()
                .any(|desc| desc.name == "UnitDestroyed")
        );

        assert_eq!(gameplay.descriptors.len(), 2);
        assert!(
            gameplay
                .descriptors
                .iter()
                .any(|desc| desc.name == "SpawnUnit")
        );
        assert!(
            gameplay
                .descriptors
                .iter()
                .any(|desc| desc.name == "UnitDestroyed")
        );
    }

    #[test]
    fn query_with_fields_uses_post() {
        let manifest = ProtocolManifest {
            manifest_version: "2".into(),
            protocol_version: "test@0.1".into(),
            default_surface: "default".into(),
            surfaces: vec!["default".into()],
            commands: vec![],
            queries: vec![ManifestEntry {
                name: "SearchUnits".into(),
                kind: ProtocolKind::Query,
                fields: vec![ManifestField {
                    name: "name_filter".into(),
                    ty: "String".into(),
                }],
                doc: "".into(),
                surfaces: vec![],
            }],
            events: vec![],
            dtos: vec![],
        };
        let descs = generate_descriptors(&manifest);
        let q = &descs.surfaces[0].descriptors[0];
        assert_eq!(q.method, HttpMethod::Post);
    }

    #[test]
    fn descriptors_serialize_to_json() {
        let descs = generate_descriptors(&sample_manifest());
        let json = serde_json::to_string_pretty(&descs).unwrap();
        assert!(json.contains("/commands/spawn-unit"));
        assert!(json.contains("\"Post\""));
        assert!(json.contains("\"surfaces\""));

        // Round-trip
        let back: ProtocolDescriptorSet = serde_json::from_str(&json).unwrap();
        assert_eq!(back.surfaces.len(), 1);
        assert_eq!(back.iter_descriptors().count(), 3);
    }

    #[test]
    fn generate_descriptors_derives_surfaces_for_legacy_manifests() {
        let manifest: ProtocolManifest = serde_json::from_str(
            r#"{
                "manifest_version": "1",
                "protocol_version": "legacy@0.1",
                "commands": [
                    {
                        "name": "SpawnUnit",
                        "kind": "Command",
                        "fields": [],
                        "doc": ""
                    }
                ],
                "queries": [],
                "events": [],
                "dtos": []
            }"#,
        )
        .unwrap();

        let descs = generate_descriptors(&manifest);
        assert_eq!(descs.surfaces.len(), 1);
        assert_eq!(descs.surfaces[0].name, "default");
        assert_eq!(descs.surfaces[0].descriptors.len(), 1);
        assert_eq!(descs.surfaces[0].descriptors[0].name, "SpawnUnit");
    }

    #[test]
    fn generate_descriptors_empty_manifest_has_default_surface() {
        let manifest = ProtocolManifest {
            manifest_version: "2".into(),
            protocol_version: "empty@0.0".into(),
            default_surface: "default".into(),
            surfaces: vec![],
            commands: vec![],
            queries: vec![],
            events: vec![],
            dtos: vec![],
        };
        let descs = generate_descriptors(&manifest);
        assert_eq!(descs.surfaces.len(), 1);
        assert_eq!(descs.surfaces[0].name, "default");
        assert!(descs.surfaces[0].descriptors.is_empty());
    }

    // -- Per-surface TypeScript generation --

    #[test]
    fn generate_ts_for_surface_filters_entries() {
        let manifest = sample_multi_surface_manifest();
        let gameplay_ts = generate_typescript_for_surface(&manifest, "gameplay");
        let authority_ts = generate_typescript_for_surface(&manifest, "authority");

        // SpawnUnit has no explicit surfaces → belongs to default ("gameplay")
        assert!(gameplay_ts.contains("export interface SpawnUnit"));
        assert!(!authority_ts.contains("SpawnUnit"));

        // AdminReset is authority-only
        assert!(authority_ts.contains("export interface AdminReset"));
        assert!(!gameplay_ts.contains("AdminReset"));

        // UnitDestroyed belongs to both surfaces
        assert!(gameplay_ts.contains("export interface UnitDestroyed"));
        assert!(authority_ts.contains("export interface UnitDestroyed"));

        // Headers include surface name
        assert!(gameplay_ts.contains("Surface: gameplay"));
        assert!(authority_ts.contains("Surface: authority"));
    }

    #[test]
    fn generate_ts_for_nonexistent_surface_is_empty() {
        let manifest = sample_multi_surface_manifest();
        let ts = generate_typescript_for_surface(&manifest, "nonexistent");
        assert!(ts.contains("Surface: nonexistent"));
        // No section headers
        assert!(!ts.contains("Commands"));
        assert!(!ts.contains("Events"));
    }

    #[test]
    fn generate_all_surface_typescripts_returns_one_per_surface() {
        let manifest = sample_multi_surface_manifest();
        let all = generate_all_surface_typescripts(&manifest);

        assert_eq!(all.len(), 2);
        assert_eq!(all[0].0, "authority");
        assert_eq!(all[1].0, "gameplay");

        // Each module is self-contained
        assert!(all[0].1.contains("AdminReset"));
        assert!(all[1].1.contains("SpawnUnit"));
    }

    #[test]
    fn single_surface_generate_all_matches_generate_typescript() {
        let manifest = sample_manifest();
        let all = generate_all_surface_typescripts(&manifest);
        assert_eq!(all.len(), 1);
        assert_eq!(all[0].0, "default");
        // Single-surface output must be byte-identical to generate_typescript()
        assert_eq!(all[0].1, generate_typescript(&manifest));
    }

    #[test]
    fn generate_all_surface_typescripts_sorts_and_dedups_surface_names() {
        let mut manifest = sample_multi_surface_manifest();
        // Intentionally out-of-order with a duplicate
        manifest.surfaces = vec!["gameplay".into(), "authority".into(), "authority".into()];

        let all = generate_all_surface_typescripts(&manifest);

        assert_eq!(all.len(), 2);
        assert_eq!(all[0].0, "authority");
        assert_eq!(all[1].0, "gameplay");
    }

    // -- T6: End-to-end multi-surface proof --

    #[test]
    fn multi_surface_end_to_end_isolation() {
        let manifest = sample_multi_surface_manifest();

        // TS codegen: each surface gets its own module
        let ts_modules = generate_all_surface_typescripts(&manifest);
        assert_eq!(ts_modules.len(), 2);

        // Descriptor codegen: each surface gets its own descriptor set
        let descs = generate_descriptors(&manifest);
        assert_eq!(descs.surfaces.len(), 2);

        let authority_ts = &ts_modules.iter().find(|(n, _)| n == "authority").unwrap().1;
        let gameplay_ts = &ts_modules.iter().find(|(n, _)| n == "gameplay").unwrap().1;
        let authority_descs = descs
            .surfaces
            .iter()
            .find(|s| s.name == "authority")
            .unwrap();
        let gameplay_descs = descs
            .surfaces
            .iter()
            .find(|s| s.name == "gameplay")
            .unwrap();

        // Authority surface: AdminReset + UnitDestroyed
        assert!(authority_ts.contains("AdminReset"));
        assert!(authority_ts.contains("UnitDestroyed"));
        assert!(!authority_ts.contains("SpawnUnit"));
        assert_eq!(authority_descs.descriptors.len(), 2);

        // Gameplay surface: SpawnUnit + UnitDestroyed
        assert!(gameplay_ts.contains("SpawnUnit"));
        assert!(gameplay_ts.contains("UnitDestroyed"));
        assert!(!gameplay_ts.contains("AdminReset"));
        assert_eq!(gameplay_descs.descriptors.len(), 2);

        // Descriptor names match TS interface names
        for surface_descs in &descs.surfaces {
            let ts = if surface_descs.name == "authority" {
                authority_ts
            } else {
                gameplay_ts
            };
            for desc in &surface_descs.descriptors {
                assert!(
                    ts.contains(&desc.name),
                    "surface {:?} descriptor {:?} missing from TS output",
                    surface_descs.name,
                    desc.name
                );
            }
        }
    }
}
