Skip to main content

alef_backend_csharp/
gen_bindings.rs

1use crate::type_map::csharp_type;
2use alef_codegen::naming::to_csharp_name;
3use alef_core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile};
4use alef_core::config::{AdapterPattern, AlefConfig, Language, resolve_output_dir};
5use alef_core::hash::{self, CommentStyle};
6use alef_core::ir::{ApiSurface, EnumDef, FieldDef, FunctionDef, MethodDef, PrimitiveType, TypeDef, TypeRef};
7use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase};
8use std::collections::HashSet;
9use std::path::PathBuf;
10
11pub struct CsharpBackend;
12
13impl CsharpBackend {
14    // lib_name comes from config.ffi_lib_name()
15}
16
17impl Backend for CsharpBackend {
18    fn name(&self) -> &str {
19        "csharp"
20    }
21
22    fn language(&self) -> Language {
23        Language::Csharp
24    }
25
26    fn capabilities(&self) -> Capabilities {
27        Capabilities {
28            supports_async: true,
29            supports_classes: true,
30            supports_enums: true,
31            supports_option: true,
32            supports_result: true,
33            ..Capabilities::default()
34        }
35    }
36
37    fn generate_bindings(&self, api: &ApiSurface, config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
38        let namespace = config.csharp_namespace();
39        let prefix = config.ffi_prefix();
40        let lib_name = config.ffi_lib_name();
41
42        // Collect bridge param names and type aliases from trait_bridges config so we can strip
43        // them from generated function signatures and emit ConvertWithVisitor instead.
44        let bridge_param_names: HashSet<String> = config
45            .trait_bridges
46            .iter()
47            .filter_map(|b| b.param_name.clone())
48            .collect();
49        let bridge_type_aliases: HashSet<String> = config
50            .trait_bridges
51            .iter()
52            .filter_map(|b| b.type_alias.clone())
53            .collect();
54        let has_visitor_bridge = !config.trait_bridges.is_empty();
55
56        // Streaming adapter methods use a callback-based C signature that P/Invoke can't call
57        // directly. Skip them in all generated method loops.
58        let streaming_methods: HashSet<String> = config
59            .adapters
60            .iter()
61            .filter(|a| matches!(a.pattern, AdapterPattern::Streaming))
62            .map(|a| a.name.clone())
63            .collect();
64
65        let output_dir = resolve_output_dir(
66            config.output.csharp.as_ref(),
67            &config.crate_config.name,
68            "packages/csharp/",
69        );
70
71        let base_path = PathBuf::from(&output_dir).join(namespace.replace('.', "/"));
72
73        let mut files = Vec::new();
74
75        // 1. Generate NativeMethods.cs
76        files.push(GeneratedFile {
77            path: base_path.join("NativeMethods.cs"),
78            content: strip_trailing_whitespace(&gen_native_methods(
79                api,
80                &namespace,
81                &lib_name,
82                &prefix,
83                &bridge_param_names,
84                &bridge_type_aliases,
85                has_visitor_bridge,
86                &config.trait_bridges,
87                &streaming_methods,
88            )),
89            generated_header: true,
90        });
91
92        // 2. Generate error types from thiserror enums (if any), otherwise generic exception
93        if !api.errors.is_empty() {
94            for error in &api.errors {
95                let error_files = alef_codegen::error_gen::gen_csharp_error_types(error, &namespace);
96                for (class_name, content) in error_files {
97                    files.push(GeneratedFile {
98                        path: base_path.join(format!("{}.cs", class_name)),
99                        content: strip_trailing_whitespace(&content),
100                        generated_header: false, // already has header
101                    });
102                }
103            }
104        }
105
106        // Fallback generic exception class (always generated for GetLastError)
107        let exception_class_name = format!("{}Exception", api.crate_name.to_pascal_case());
108        if api.errors.is_empty()
109            || !api
110                .errors
111                .iter()
112                .any(|e| format!("{}Exception", e.name) == exception_class_name)
113        {
114            files.push(GeneratedFile {
115                path: base_path.join(format!("{}.cs", exception_class_name)),
116                content: strip_trailing_whitespace(&gen_exception_class(&namespace, &exception_class_name)),
117                generated_header: true,
118            });
119        }
120
121        // 3. Generate main wrapper class
122        let base_class_name = api.crate_name.to_pascal_case();
123        let wrapper_class_name = if namespace == base_class_name {
124            format!("{}Lib", base_class_name)
125        } else {
126            base_class_name
127        };
128        files.push(GeneratedFile {
129            path: base_path.join(format!("{}.cs", wrapper_class_name)),
130            content: strip_trailing_whitespace(&gen_wrapper_class(
131                api,
132                &namespace,
133                &wrapper_class_name,
134                &exception_class_name,
135                &prefix,
136                &bridge_param_names,
137                &bridge_type_aliases,
138                has_visitor_bridge,
139                &streaming_methods,
140            )),
141            generated_header: true,
142        });
143
144        // 3b. Generate visitor support files when a bridge is configured.
145        if has_visitor_bridge {
146            for (filename, content) in crate::gen_visitor::gen_visitor_files(&namespace) {
147                files.push(GeneratedFile {
148                    path: base_path.join(filename),
149                    content: strip_trailing_whitespace(&content),
150                    generated_header: true,
151                });
152            }
153        }
154
155        // 4. Generate opaque handle classes
156        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
157            if typ.is_opaque {
158                let type_filename = typ.name.to_pascal_case();
159                files.push(GeneratedFile {
160                    path: base_path.join(format!("{}.cs", type_filename)),
161                    content: strip_trailing_whitespace(&gen_opaque_handle(typ, &namespace)),
162                    generated_header: true,
163                });
164            }
165        }
166
167        // Collect enum names so record generation can distinguish enum fields from class fields.
168        let enum_names: HashSet<String> = api.enums.iter().map(|e| e.name.to_pascal_case()).collect();
169
170        // Collect complex enums (enums with data variants and no serde tag) — these can't be
171        // simple C# enums and should be represented as JsonElement for flexible deserialization.
172        // Tagged unions (serde_tag is set) are now generated as proper abstract records
173        // and can be deserialized as their concrete types, so they are NOT complex_enums.
174        let complex_enums: HashSet<String> = api
175            .enums
176            .iter()
177            .filter(|e| e.serde_tag.is_none() && e.variants.iter().any(|v| !v.fields.is_empty()))
178            .map(|e| e.name.to_pascal_case())
179            .collect();
180
181        // Collect enums that require a custom JsonConverter (non-standard serialized names or
182        // tagged unions). When a property has this enum as its type, we must emit a property-level
183        // [JsonConverter] attribute so the custom converter wins over the global JsonStringEnumConverter.
184        let custom_converter_enums: HashSet<String> = api
185            .enums
186            .iter()
187            .filter(|e| {
188                // Tagged unions always use a custom converter
189                (e.serde_tag.is_some() && e.variants.iter().any(|v| !v.fields.is_empty()))
190                // Enums with non-standard variant names need a custom converter
191                || e.variants.iter().any(|v| {
192                    if let Some(ref rename) = v.serde_rename {
193                        let snake = apply_rename_all(&v.name, e.serde_rename_all.as_deref());
194                        rename != &snake
195                    } else {
196                        false
197                    }
198                })
199            })
200            .map(|e| e.name.to_pascal_case())
201            .collect();
202
203        // Resolve the language-level serde rename_all strategy (always wins over IR type-level).
204        let lang_rename_all = config.serde_rename_all_for_language(Language::Csharp);
205
206        // 5. Generate record types (structs)
207        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
208            if !typ.is_opaque {
209                // Skip types where all fields are unnamed tuple positions — they have no
210                // meaningful properties to expose in C#.
211                let has_named_fields = typ.fields.iter().any(|f| !is_tuple_field(f));
212                if !typ.fields.is_empty() && !has_named_fields {
213                    continue;
214                }
215                // Skip types that gen_visitor handles with richer visitor-specific versions
216                if has_visitor_bridge && (typ.name == "NodeContext" || typ.name == "VisitResult") {
217                    continue;
218                }
219
220                let type_filename = typ.name.to_pascal_case();
221                files.push(GeneratedFile {
222                    path: base_path.join(format!("{}.cs", type_filename)),
223                    content: strip_trailing_whitespace(&gen_record_type(
224                        typ,
225                        &namespace,
226                        &enum_names,
227                        &complex_enums,
228                        &custom_converter_enums,
229                        &lang_rename_all,
230                    )),
231                    generated_header: true,
232                });
233            }
234        }
235
236        // 6. Generate enums
237        for enum_def in &api.enums {
238            // Skip enums that gen_visitor handles with richer visitor-specific versions
239            if has_visitor_bridge && (enum_def.name == "VisitResult" || enum_def.name == "NodeContext") {
240                continue;
241            }
242            let enum_filename = enum_def.name.to_pascal_case();
243            files.push(GeneratedFile {
244                path: base_path.join(format!("{}.cs", enum_filename)),
245                content: strip_trailing_whitespace(&gen_enum(enum_def, &namespace)),
246                generated_header: true,
247            });
248        }
249
250        // Build adapter body map (consumed by generators via body substitution)
251        let _adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Csharp)?;
252
253        Ok(files)
254    }
255
256    /// C# wrapper class is already the public API.
257    /// The `gen_wrapper_class` (generated in `generate_bindings`) provides high-level public methods
258    /// that wrap NativeMethods (P/Invoke), marshal types, and handle errors.
259    /// No additional facade is needed.
260    fn generate_public_api(&self, _api: &ApiSurface, _config: &AlefConfig) -> anyhow::Result<Vec<GeneratedFile>> {
261        // C#'s wrapper class IS the public API — no additional wrapper needed.
262        Ok(vec![])
263    }
264
265    fn build_config(&self) -> Option<BuildConfig> {
266        Some(BuildConfig {
267            tool: "dotnet",
268            crate_suffix: "",
269            build_dep: BuildDependency::Ffi,
270            post_build: vec![],
271        })
272    }
273}
274
275/// Returns true if a field is a tuple struct positional field (e.g., `_0`, `_1`, `0`, `1`).
276fn is_tuple_field(field: &FieldDef) -> bool {
277    (field.name.starts_with('_') && field.name[1..].chars().all(|c| c.is_ascii_digit()))
278        || field.name.chars().next().is_none_or(|c| c.is_ascii_digit())
279}
280
281/// Strip trailing whitespace from every line and ensure the file ends with a single newline.
282fn strip_trailing_whitespace(content: &str) -> String {
283    let mut result: String = content
284        .lines()
285        .map(|line| line.trim_end())
286        .collect::<Vec<_>>()
287        .join("\n");
288    if !result.ends_with('\n') {
289        result.push('\n');
290    }
291    result
292}
293
294// ---------------------------------------------------------------------------
295// Helpers: P/Invoke return type mapping
296// ---------------------------------------------------------------------------
297
298/// Returns the C# type to use in a `[DllImport]` declaration for the given return type.
299///
300/// Key differences from the high-level `csharp_type`:
301/// - Bool is marshalled as `int` (C FFI convention) — the wrapper compares != 0.
302/// - String / Named / Vec / Map / Path / Json / Bytes all come back as `IntPtr`.
303/// - Numeric primitives use their natural C# types (`nuint`, `int`, etc.).
304fn pinvoke_return_type(ty: &TypeRef) -> &'static str {
305    match ty {
306        TypeRef::Unit => "void",
307        // Bool over FFI is a C int (0/1).
308        TypeRef::Primitive(PrimitiveType::Bool) => "int",
309        // Numeric primitives — use their real C# types.
310        TypeRef::Primitive(PrimitiveType::U8) => "byte",
311        TypeRef::Primitive(PrimitiveType::U16) => "ushort",
312        TypeRef::Primitive(PrimitiveType::U32) => "uint",
313        TypeRef::Primitive(PrimitiveType::U64) => "ulong",
314        TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
315        TypeRef::Primitive(PrimitiveType::I16) => "short",
316        TypeRef::Primitive(PrimitiveType::I32) => "int",
317        TypeRef::Primitive(PrimitiveType::I64) => "long",
318        TypeRef::Primitive(PrimitiveType::F32) => "float",
319        TypeRef::Primitive(PrimitiveType::F64) => "double",
320        TypeRef::Primitive(PrimitiveType::Usize) => "ulong",
321        TypeRef::Primitive(PrimitiveType::Isize) => "long",
322        // Duration as u64
323        TypeRef::Duration => "ulong",
324        // Everything else is a pointer that needs manual marshalling.
325        TypeRef::String
326        | TypeRef::Char
327        | TypeRef::Bytes
328        | TypeRef::Optional(_)
329        | TypeRef::Vec(_)
330        | TypeRef::Map(_, _)
331        | TypeRef::Named(_)
332        | TypeRef::Path
333        | TypeRef::Json => "IntPtr",
334    }
335}
336
337/// Does the return type need IntPtr→string marshalling in the wrapper?
338fn returns_string(ty: &TypeRef) -> bool {
339    matches!(ty, TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json)
340}
341
342/// Does the return type come back as a C int that should be converted to bool?
343fn returns_bool_via_int(ty: &TypeRef) -> bool {
344    matches!(ty, TypeRef::Primitive(PrimitiveType::Bool))
345}
346
347/// Does the return type need JSON deserialization from an IntPtr string?
348fn returns_json_object(ty: &TypeRef) -> bool {
349    matches!(
350        ty,
351        TypeRef::Vec(_) | TypeRef::Map(_, _) | TypeRef::Named(_) | TypeRef::Bytes | TypeRef::Optional(_)
352    )
353}
354
355/// Does this return type represent an opaque handle (Named struct type) that needs special marshalling?
356///
357/// Opaque handles are returned as `IntPtr` from P/Invoke.  The wrapper must call
358/// `{prefix}_{type_snake}_to_json(ptr)` to obtain a JSON string, then deserialise it,
359/// Returns the C# type to use for a parameter in a `[DllImport]` declaration.
360///
361/// Managed reference types (Named structs, Vec, Map, Bytes, Optional of Named, etc.)
362/// cannot be directly marshalled by P/Invoke.  They must be passed as `IntPtr` (opaque
363/// handle or JSON-string pointer).  Primitive types and plain strings use their natural
364/// types.
365fn pinvoke_param_type(ty: &TypeRef) -> &'static str {
366    match ty {
367        TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json => "string",
368        // Managed objects — pass as opaque IntPtr (serialised to handle before call)
369        TypeRef::Named(_) | TypeRef::Vec(_) | TypeRef::Map(_, _) | TypeRef::Bytes | TypeRef::Optional(_) => "IntPtr",
370        TypeRef::Unit => "void",
371        TypeRef::Primitive(PrimitiveType::Bool) => "int",
372        TypeRef::Primitive(PrimitiveType::U8) => "byte",
373        TypeRef::Primitive(PrimitiveType::U16) => "ushort",
374        TypeRef::Primitive(PrimitiveType::U32) => "uint",
375        TypeRef::Primitive(PrimitiveType::U64) => "ulong",
376        TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
377        TypeRef::Primitive(PrimitiveType::I16) => "short",
378        TypeRef::Primitive(PrimitiveType::I32) => "int",
379        TypeRef::Primitive(PrimitiveType::I64) => "long",
380        TypeRef::Primitive(PrimitiveType::F32) => "float",
381        TypeRef::Primitive(PrimitiveType::F64) => "double",
382        TypeRef::Primitive(PrimitiveType::Usize) => "ulong",
383        TypeRef::Primitive(PrimitiveType::Isize) => "long",
384        TypeRef::Duration => "ulong",
385    }
386}
387
388// ---------------------------------------------------------------------------
389// Code generation functions
390// ---------------------------------------------------------------------------
391
392/// Returns true if a parameter should be hidden from the public API because it is a
393/// trait-bridge param (e.g. the FFI visitor handle).
394fn is_bridge_param(
395    param: &alef_core::ir::ParamDef,
396    bridge_param_names: &HashSet<String>,
397    bridge_type_aliases: &HashSet<String>,
398) -> bool {
399    bridge_param_names.contains(&param.name)
400        || matches!(&param.ty, alef_core::ir::TypeRef::Named(n) if bridge_type_aliases.contains(n))
401}
402
403#[allow(clippy::too_many_arguments)]
404fn gen_native_methods(
405    api: &ApiSurface,
406    namespace: &str,
407    lib_name: &str,
408    prefix: &str,
409    bridge_param_names: &HashSet<String>,
410    bridge_type_aliases: &HashSet<String>,
411    has_visitor_bridge: bool,
412    trait_bridges: &[alef_core::config::TraitBridgeConfig],
413    streaming_methods: &HashSet<String>,
414) -> String {
415    let mut out = hash::header(CommentStyle::DoubleSlash);
416    out.push_str("using System;\n");
417    out.push_str("using System.Runtime.InteropServices;\n\n");
418
419    out.push_str(&format!("namespace {};\n\n", namespace));
420
421    out.push_str("internal static partial class NativeMethods\n{\n");
422    out.push_str(&format!("    private const string LibName = \"{}\";\n\n", lib_name));
423
424    // Track emitted C entry-point names to avoid duplicates when the same FFI
425    // function appears both as a free function and as a type method.
426    let mut emitted: HashSet<String> = HashSet::new();
427
428    // Enum type names — these are NOT opaque handles and must not have from_json / to_json / free
429    // helpers emitted for them.
430    let enum_names: HashSet<String> = api.enums.iter().map(|e| e.name.clone()).collect();
431
432    // Collect opaque struct type names that appear as parameters or return types so we can
433    // emit their from_json / to_json / free P/Invoke helpers.
434    // Enum types are excluded.
435    let mut opaque_param_types: HashSet<String> = HashSet::new();
436    let mut opaque_return_types: HashSet<String> = HashSet::new();
437
438    for func in &api.functions {
439        for param in &func.params {
440            if let TypeRef::Named(name) = &param.ty {
441                if !enum_names.contains(name) {
442                    opaque_param_types.insert(name.clone());
443                }
444            }
445        }
446        if let TypeRef::Named(name) = &func.return_type {
447            if !enum_names.contains(name) {
448                opaque_return_types.insert(name.clone());
449            }
450        }
451    }
452    for typ in api.types.iter().filter(|typ| !typ.is_trait) {
453        for method in &typ.methods {
454            if streaming_methods.contains(&method.name) {
455                continue;
456            }
457            for param in &method.params {
458                if let TypeRef::Named(name) = &param.ty {
459                    if !enum_names.contains(name) {
460                        opaque_param_types.insert(name.clone());
461                    }
462                }
463            }
464            if let TypeRef::Named(name) = &method.return_type {
465                if !enum_names.contains(name) {
466                    opaque_return_types.insert(name.clone());
467                }
468            }
469        }
470    }
471
472    // Collect truly opaque types (is_opaque = true in IR) — these have no to_json/from_json FFI.
473    let true_opaque_types: HashSet<String> = api
474        .types
475        .iter()
476        .filter(|t| t.is_opaque)
477        .map(|t| t.name.clone())
478        .collect();
479
480    // Emit from_json + free helpers for opaque types used as parameters.
481    // Truly opaque handles (is_opaque = true) have no from_json — only free.
482    // E.g. `htm_conversion_options_from_json(const char *json) -> HTMConversionOptions*`
483    let mut sorted_param_types: Vec<&String> = opaque_param_types.iter().collect();
484    sorted_param_types.sort();
485    for type_name in sorted_param_types {
486        let snake = type_name.to_snake_case();
487        if !true_opaque_types.contains(type_name) {
488            let from_json_entry = format!("{prefix}_{snake}_from_json");
489            let from_json_cs = format!("{}FromJson", type_name.to_pascal_case());
490            if emitted.insert(from_json_entry.clone()) {
491                out.push_str(&format!(
492                    "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{from_json_entry}\")]\n"
493                ));
494                out.push_str(&format!(
495                    "    internal static extern IntPtr {from_json_cs}([MarshalAs(UnmanagedType.LPStr)] string json);\n\n"
496                ));
497            }
498        }
499        let free_entry = format!("{prefix}_{snake}_free");
500        let free_cs = format!("{}Free", type_name.to_pascal_case());
501        if emitted.insert(free_entry.clone()) {
502            out.push_str(&format!(
503                "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{free_entry}\")]\n"
504            ));
505            out.push_str(&format!("    internal static extern void {free_cs}(IntPtr ptr);\n\n"));
506        }
507    }
508
509    // Emit to_json + free helpers for opaque types returned from functions.
510    // Truly opaque handles (is_opaque = true) have no to_json — only free.
511    let mut sorted_return_types: Vec<&String> = opaque_return_types.iter().collect();
512    sorted_return_types.sort();
513    for type_name in sorted_return_types {
514        let snake = type_name.to_snake_case();
515        if !true_opaque_types.contains(type_name) {
516            let to_json_entry = format!("{prefix}_{snake}_to_json");
517            let to_json_cs = format!("{}ToJson", type_name.to_pascal_case());
518            if emitted.insert(to_json_entry.clone()) {
519                out.push_str(&format!(
520                    "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{to_json_entry}\")]\n"
521                ));
522                out.push_str(&format!(
523                    "    internal static extern IntPtr {to_json_cs}(IntPtr ptr);\n\n"
524                ));
525            }
526        }
527        let free_entry = format!("{prefix}_{snake}_free");
528        let free_cs = format!("{}Free", type_name.to_pascal_case());
529        if emitted.insert(free_entry.clone()) {
530            out.push_str(&format!(
531                "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{free_entry}\")]\n"
532            ));
533            out.push_str(&format!("    internal static extern void {free_cs}(IntPtr ptr);\n\n"));
534        }
535    }
536
537    // Generate P/Invoke declarations for functions
538    for func in &api.functions {
539        let c_func_name = format!("{}_{}", prefix, func.name.to_lowercase());
540        if emitted.insert(c_func_name.clone()) {
541            out.push_str(&gen_pinvoke_for_func(
542                &c_func_name,
543                func,
544                bridge_param_names,
545                bridge_type_aliases,
546            ));
547        }
548    }
549
550    // Generate P/Invoke declarations for type methods.
551    // Skip streaming adapter methods — their FFI signature uses callbacks that P/Invoke can't call.
552    for typ in api.types.iter().filter(|typ| !typ.is_trait) {
553        let type_snake = typ.name.to_snake_case();
554        for method in &typ.methods {
555            if streaming_methods.contains(&method.name) {
556                continue;
557            }
558            let c_method_name = format!("{}_{}_{}", prefix, type_snake, method.name.to_lowercase());
559            // Use a type-prefixed C# method name to avoid collisions when different types
560            // share a method with the same name (e.g. BrowserConfig::default and CrawlConfig::default
561            // would both produce "Default" without the prefix, but have different FFI entry points).
562            let cs_method_name = format!("{}{}", typ.name.to_pascal_case(), to_csharp_name(&method.name));
563            if emitted.insert(c_method_name.clone()) {
564                out.push_str(&gen_pinvoke_for_method(&c_method_name, &cs_method_name, method));
565            }
566        }
567    }
568
569    // Add error handling functions with PascalCase names
570    out.push_str(&format!(
571        "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{prefix}_last_error_code\")]\n"
572    ));
573    out.push_str("    internal static extern int LastErrorCode();\n\n");
574
575    out.push_str(&format!(
576        "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{prefix}_last_error_context\")]\n"
577    ));
578    out.push_str("    internal static extern IntPtr LastErrorContext();\n\n");
579
580    out.push_str(&format!(
581        "    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{prefix}_free_string\")]\n"
582    ));
583    out.push_str("    internal static extern void FreeString(IntPtr ptr);\n");
584
585    // Inject visitor create/free/convert P/Invoke declarations when a bridge is configured.
586    if has_visitor_bridge {
587        out.push('\n');
588        out.push_str(&crate::gen_visitor::gen_native_methods_visitor(
589            namespace, lib_name, prefix,
590        ));
591    }
592
593    // Inject trait bridge registration/unregistration P/Invoke declarations.
594    if !trait_bridges.is_empty() {
595        // Collect trait definitions from api.types (by name) to match with trait_bridges config
596        let trait_defs: Vec<_> = api.types.iter().filter(|t| t.is_trait).collect();
597
598        // Build a list of (trait_name, bridge_config, trait_def) tuples for trait bridges
599        let bridges: Vec<_> = trait_bridges
600            .iter()
601            .filter_map(|config| {
602                let trait_name = config.trait_name.clone();
603                trait_defs
604                    .iter()
605                    .find(|t| t.name == trait_name)
606                    .map(|trait_def| (trait_name, config, *trait_def))
607            })
608            .collect();
609
610        if !bridges.is_empty() {
611            out.push('\n');
612            out.push_str(&crate::trait_bridge::gen_native_methods_trait_bridges(
613                namespace, prefix, &bridges,
614            ));
615        }
616    }
617
618    out.push_str("}\n");
619
620    out
621}
622
623fn gen_pinvoke_for_func(
624    c_name: &str,
625    func: &FunctionDef,
626    bridge_param_names: &HashSet<String>,
627    bridge_type_aliases: &HashSet<String>,
628) -> String {
629    let cs_name = to_csharp_name(&func.name);
630    let mut out =
631        format!("    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{c_name}\")]\n");
632    out.push_str("    internal static extern ");
633
634    // Return type — use the correct P/Invoke type for each kind.
635    out.push_str(pinvoke_return_type(&func.return_type));
636
637    out.push_str(&format!(" {}(", cs_name));
638
639    // Filter bridge params — they are not visible in P/Invoke declarations; the wrapper
640    // passes IntPtr.Zero directly when calling the visitor-less FFI entry point.
641    let visible_params: Vec<_> = func
642        .params
643        .iter()
644        .filter(|p| !is_bridge_param(p, bridge_param_names, bridge_type_aliases))
645        .collect();
646
647    if visible_params.is_empty() {
648        out.push_str(");\n\n");
649    } else {
650        out.push('\n');
651        for (i, param) in visible_params.iter().enumerate() {
652            out.push_str("        ");
653            let pinvoke_ty = pinvoke_param_type(&param.ty);
654            if pinvoke_ty == "string" {
655                out.push_str("[MarshalAs(UnmanagedType.LPStr)] ");
656            }
657            let param_name = param.name.to_lower_camel_case();
658            out.push_str(&format!("{pinvoke_ty} {param_name}"));
659
660            if i < visible_params.len() - 1 {
661                out.push(',');
662            }
663            out.push('\n');
664        }
665        out.push_str("    );\n\n");
666    }
667
668    out
669}
670
671fn gen_pinvoke_for_method(c_name: &str, cs_name: &str, method: &MethodDef) -> String {
672    let mut out =
673        format!("    [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, EntryPoint = \"{c_name}\")]\n");
674    out.push_str("    internal static extern ");
675
676    // Return type — use the correct P/Invoke type for each kind.
677    out.push_str(pinvoke_return_type(&method.return_type));
678
679    out.push_str(&format!(" {}(", cs_name));
680
681    if method.params.is_empty() {
682        out.push_str(");\n\n");
683    } else {
684        out.push('\n');
685        for (i, param) in method.params.iter().enumerate() {
686            out.push_str("        ");
687            let pinvoke_ty = pinvoke_param_type(&param.ty);
688            if pinvoke_ty == "string" {
689                out.push_str("[MarshalAs(UnmanagedType.LPStr)] ");
690            }
691            let param_name = param.name.to_lower_camel_case();
692            out.push_str(&format!("{pinvoke_ty} {param_name}"));
693
694            if i < method.params.len() - 1 {
695                out.push(',');
696            }
697            out.push('\n');
698        }
699        out.push_str("    );\n\n");
700    }
701
702    out
703}
704
705fn gen_exception_class(namespace: &str, class_name: &str) -> String {
706    let mut out = hash::header(CommentStyle::DoubleSlash);
707    out.push_str("using System;\n\n");
708
709    out.push_str(&format!("namespace {};\n\n", namespace));
710
711    out.push_str(&format!("public class {} : Exception\n", class_name));
712    out.push_str("{\n");
713    out.push_str("    public int Code { get; }\n\n");
714    out.push_str(&format!(
715        "    public {}(int code, string message) : base(message)\n",
716        class_name
717    ));
718    out.push_str("    {\n");
719    out.push_str("        Code = code;\n");
720    out.push_str("    }\n");
721    out.push_str("}\n");
722
723    out
724}
725
726#[allow(clippy::too_many_arguments)]
727fn gen_wrapper_class(
728    api: &ApiSurface,
729    namespace: &str,
730    class_name: &str,
731    exception_name: &str,
732    prefix: &str,
733    bridge_param_names: &HashSet<String>,
734    bridge_type_aliases: &HashSet<String>,
735    has_visitor_bridge: bool,
736    streaming_methods: &HashSet<String>,
737) -> String {
738    let mut out = hash::header(CommentStyle::DoubleSlash);
739    out.push_str("using System;\n");
740    out.push_str("using System.Collections.Generic;\n");
741    out.push_str("using System.Runtime.InteropServices;\n");
742    out.push_str("using System.Text.Json;\n");
743    out.push_str("using System.Text.Json.Serialization;\n");
744    out.push_str("using System.Threading.Tasks;\n\n");
745
746    out.push_str(&format!("namespace {};\n\n", namespace));
747
748    out.push_str(&format!("public static class {}\n", class_name));
749    out.push_str("{\n");
750    out.push_str("    private static readonly JsonSerializerOptions JsonOptions = new()\n");
751    out.push_str("    {\n");
752    out.push_str("        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },\n");
753    out.push_str("        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault\n");
754    out.push_str("    };\n\n");
755
756    // Enum names: used to distinguish opaque struct handles from enum return types.
757    let enum_names: HashSet<String> = api.enums.iter().map(|e| e.name.to_pascal_case()).collect();
758
759    // Truly opaque types (is_opaque = true) — returned/passed as handles, no JSON serialization.
760    let true_opaque_types: HashSet<String> = api
761        .types
762        .iter()
763        .filter(|t| t.is_opaque)
764        .map(|t| t.name.clone())
765        .collect();
766
767    // Generate wrapper methods for functions
768    for func in &api.functions {
769        out.push_str(&gen_wrapper_function(
770            func,
771            exception_name,
772            prefix,
773            &enum_names,
774            &true_opaque_types,
775            bridge_param_names,
776            bridge_type_aliases,
777        ));
778    }
779
780    // Generate wrapper methods for type methods (prefixed with type name to avoid collisions).
781    // Skip streaming adapter methods — their FFI signature uses callbacks that P/Invoke can't call.
782    for typ in api.types.iter().filter(|typ| !typ.is_trait) {
783        // Skip opaque types — their methods belong on the opaque handle class, not the static wrapper
784        if typ.is_opaque {
785            continue;
786        }
787        for method in &typ.methods {
788            if streaming_methods.contains(&method.name) {
789                continue;
790            }
791            out.push_str(&gen_wrapper_method(
792                method,
793                exception_name,
794                prefix,
795                &typ.name,
796                &enum_names,
797                &true_opaque_types,
798                bridge_param_names,
799                bridge_type_aliases,
800            ));
801        }
802    }
803
804    // Inject ConvertWithVisitor when a visitor bridge is configured.
805    if has_visitor_bridge {
806        out.push_str(&crate::gen_visitor::gen_convert_with_visitor_method(
807            exception_name,
808            prefix,
809        ));
810    }
811
812    // Add error handling helper
813    out.push_str("    private static ");
814    out.push_str(&format!("{} GetLastError()\n", exception_name));
815    out.push_str("    {\n");
816    out.push_str("        var code = NativeMethods.LastErrorCode();\n");
817    out.push_str("        var ctxPtr = NativeMethods.LastErrorContext();\n");
818    out.push_str("        var message = Marshal.PtrToStringAnsi(ctxPtr) ?? \"Unknown error\";\n");
819    out.push_str(&format!("        return new {}(code, message);\n", exception_name));
820    out.push_str("    }\n");
821
822    out.push_str("}\n");
823
824    out
825}
826
827// ---------------------------------------------------------------------------
828// Helpers: Named-param setup/teardown for opaque handle marshalling
829// ---------------------------------------------------------------------------
830
831/// For each `Named` parameter, emit code to serialise it to JSON and obtain a native handle.
832///
833/// For truly opaque types (is_opaque = true), the C# class already wraps the native handle, so
834/// we pass `param.Handle` directly without any JSON serialisation.
835///
836/// ```text
837/// // Data struct (has from_json):
838/// var optionsJson = JsonSerializer.Serialize(options);
839/// var optionsHandle = NativeMethods.ConversionOptionsFromJson(optionsJson);
840///
841/// // Truly opaque handle: passed as engineHandle.Handle directly — no setup needed.
842/// ```
843fn emit_named_param_setup(
844    out: &mut String,
845    params: &[alef_core::ir::ParamDef],
846    indent: &str,
847    true_opaque_types: &HashSet<String>,
848) {
849    for param in params {
850        let param_name = param.name.to_lower_camel_case();
851        let json_var = format!("{param_name}Json");
852        let handle_var = format!("{param_name}Handle");
853
854        match &param.ty {
855            TypeRef::Named(type_name) => {
856                // Truly opaque handles: the C# wrapper class holds the IntPtr directly.
857                // No from_json round-trip needed — pass .Handle directly in native_call_arg.
858                if true_opaque_types.contains(type_name) {
859                    continue;
860                }
861                let from_json_method = format!("{}FromJson", type_name.to_pascal_case());
862                if param.optional {
863                    out.push_str(&format!(
864                        "{indent}var {json_var} = {param_name} != null ? JsonSerializer.Serialize({param_name}, JsonOptions) : \"null\";\n"
865                    ));
866                } else {
867                    out.push_str(&format!(
868                        "{indent}var {json_var} = JsonSerializer.Serialize({param_name}, JsonOptions);\n"
869                    ));
870                }
871                out.push_str(&format!(
872                    "{indent}var {handle_var} = NativeMethods.{from_json_method}({json_var});\n"
873                ));
874            }
875            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
876                // Vec/Map: serialize to JSON string, marshal to native pointer
877                out.push_str(&format!(
878                    "{indent}var {json_var} = JsonSerializer.Serialize({param_name}, JsonOptions);\n"
879                ));
880                out.push_str(&format!(
881                    "{indent}var {handle_var} = Marshal.StringToHGlobalAnsi({json_var});\n"
882                ));
883            }
884            TypeRef::Bytes => {
885                // byte[]: pin the managed array and pass pointer to native
886                out.push_str(&format!(
887                    "{indent}var {handle_var} = GCHandle.Alloc({param_name}, GCHandleType.Pinned);\n"
888                ));
889            }
890            _ => {}
891        }
892    }
893}
894
895/// Returns true if the FFI return type is a pointer (IntPtr), as opposed to a numeric value.
896/// Only pointer-returning functions use `IntPtr.Zero` as an error sentinel.
897fn returns_ptr(ty: &TypeRef) -> bool {
898    matches!(
899        ty,
900        TypeRef::String
901            | TypeRef::Char
902            | TypeRef::Path
903            | TypeRef::Json
904            | TypeRef::Named(_)
905            | TypeRef::Vec(_)
906            | TypeRef::Map(_, _)
907            | TypeRef::Bytes
908            | TypeRef::Optional(_)
909    )
910}
911
912/// Returns the argument expression to pass to the native method for a given parameter.
913///
914/// For truly opaque types (is_opaque = true), the C# class wraps an IntPtr; pass `.Handle`.
915/// For data-struct `Named` types this is the handle variable (e.g. `optionsHandle`).
916/// For everything else it is the parameter name (with `!` for optional).
917fn native_call_arg(ty: &TypeRef, param_name: &str, optional: bool, true_opaque_types: &HashSet<String>) -> String {
918    match ty {
919        TypeRef::Named(type_name) if true_opaque_types.contains(type_name) => {
920            // Truly opaque: unwrap the IntPtr from the C# handle class.
921            let bang = if optional { "!" } else { "" };
922            format!("{param_name}{bang}.Handle")
923        }
924        TypeRef::Named(_) | TypeRef::Vec(_) | TypeRef::Map(_, _) => {
925            format!("{param_name}Handle")
926        }
927        TypeRef::Bytes => {
928            format!("{param_name}Handle.AddrOfPinnedObject()")
929        }
930        ty => {
931            if optional {
932                // For optional primitive types (e.g. ulong?, uint?), the `!` null-forgiving
933                // operator does not coerce a nullable to non-nullable at the type level —
934                // only `.Value` does. String/Char/Path/Json are reference types so `!` is
935                // correct for those; all value-type primitives need `.Value`.
936                let needs_value_unwrap = matches!(ty, TypeRef::Primitive(_) | TypeRef::Duration);
937                if needs_value_unwrap {
938                    format!("{param_name}.Value")
939                } else {
940                    format!("{param_name}!")
941                }
942            } else {
943                param_name.to_string()
944            }
945        }
946    }
947}
948
949/// Emit cleanup code to free native handles allocated for `Named` parameters.
950///
951/// Truly opaque handles (is_opaque = true) are NOT freed here — their lifetime is managed by
952/// the C# wrapper class (IDisposable). Only data-struct handles (from_json-allocated) are freed.
953fn emit_named_param_teardown(
954    out: &mut String,
955    params: &[alef_core::ir::ParamDef],
956    true_opaque_types: &HashSet<String>,
957) {
958    for param in params {
959        let param_name = param.name.to_lower_camel_case();
960        let handle_var = format!("{param_name}Handle");
961        match &param.ty {
962            TypeRef::Named(type_name) => {
963                if true_opaque_types.contains(type_name) {
964                    // Caller owns the opaque handle — do not free it here.
965                    continue;
966                }
967                let free_method = format!("{}Free", type_name.to_pascal_case());
968                out.push_str(&format!("        NativeMethods.{free_method}({handle_var});\n"));
969            }
970            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
971                out.push_str(&format!("        Marshal.FreeHGlobal({handle_var});\n"));
972            }
973            TypeRef::Bytes => {
974                out.push_str(&format!("        {handle_var}.Free();\n"));
975            }
976            _ => {}
977        }
978    }
979}
980
981/// Emit cleanup code with configurable indentation (used inside `Task.Run` lambdas).
982fn emit_named_param_teardown_indented(
983    out: &mut String,
984    params: &[alef_core::ir::ParamDef],
985    indent: &str,
986    true_opaque_types: &HashSet<String>,
987) {
988    for param in params {
989        let param_name = param.name.to_lower_camel_case();
990        let handle_var = format!("{param_name}Handle");
991        match &param.ty {
992            TypeRef::Named(type_name) => {
993                if true_opaque_types.contains(type_name) {
994                    // Caller owns the opaque handle — do not free it here.
995                    continue;
996                }
997                let free_method = format!("{}Free", type_name.to_pascal_case());
998                out.push_str(&format!("{indent}NativeMethods.{free_method}({handle_var});\n"));
999            }
1000            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
1001                out.push_str(&format!("{indent}Marshal.FreeHGlobal({handle_var});\n"));
1002            }
1003            TypeRef::Bytes => {
1004                out.push_str(&format!("{indent}{handle_var}.Free();\n"));
1005            }
1006            _ => {}
1007        }
1008    }
1009}
1010
1011fn gen_wrapper_function(
1012    func: &FunctionDef,
1013    _exception_name: &str,
1014    _prefix: &str,
1015    enum_names: &HashSet<String>,
1016    true_opaque_types: &HashSet<String>,
1017    bridge_param_names: &HashSet<String>,
1018    bridge_type_aliases: &HashSet<String>,
1019) -> String {
1020    let mut out = String::with_capacity(1024);
1021
1022    // Collect visible params (non-bridge) for the public C# signature.
1023    let visible_params: Vec<alef_core::ir::ParamDef> = func
1024        .params
1025        .iter()
1026        .filter(|p| !is_bridge_param(p, bridge_param_names, bridge_type_aliases))
1027        .cloned()
1028        .collect();
1029
1030    // XML doc comment
1031    if !func.doc.is_empty() {
1032        out.push_str("    /// <summary>\n");
1033        for line in func.doc.lines() {
1034            out.push_str(&format!("    /// {}\n", line));
1035        }
1036        out.push_str("    /// </summary>\n");
1037        for param in &visible_params {
1038            out.push_str(&format!(
1039                "    /// <param name=\"{}\">{}</param>\n",
1040                param.name.to_lower_camel_case(),
1041                if param.optional { "Optional." } else { "" }
1042            ));
1043        }
1044    }
1045
1046    out.push_str("    public static ");
1047
1048    // Return type — use async Task<T> for async methods
1049    if func.is_async {
1050        if func.return_type == TypeRef::Unit {
1051            out.push_str("async Task");
1052        } else {
1053            out.push_str(&format!("async Task<{}>", csharp_type(&func.return_type)));
1054        }
1055    } else if func.return_type == TypeRef::Unit {
1056        out.push_str("void");
1057    } else {
1058        out.push_str(&csharp_type(&func.return_type));
1059    }
1060
1061    out.push_str(&format!(" {}", to_csharp_name(&func.name)));
1062    out.push('(');
1063
1064    // Parameters (bridge params stripped from public signature)
1065    for (i, param) in visible_params.iter().enumerate() {
1066        let param_name = param.name.to_lower_camel_case();
1067        let mapped = csharp_type(&param.ty);
1068        if param.optional && !mapped.ends_with('?') {
1069            out.push_str(&format!("{mapped}? {param_name}"));
1070        } else {
1071            out.push_str(&format!("{mapped} {param_name}"));
1072        }
1073
1074        if i < visible_params.len() - 1 {
1075            out.push_str(", ");
1076        }
1077    }
1078
1079    out.push_str(")\n    {\n");
1080
1081    // Null checks for required string/object parameters
1082    for param in &visible_params {
1083        if !param.optional && matches!(param.ty, TypeRef::String | TypeRef::Named(_) | TypeRef::Bytes) {
1084            let param_name = param.name.to_lower_camel_case();
1085            out.push_str(&format!("        ArgumentNullException.ThrowIfNull({param_name});\n"));
1086        }
1087    }
1088
1089    // Serialize Named (opaque handle) params to JSON and obtain native handles.
1090    emit_named_param_setup(&mut out, &visible_params, "        ", true_opaque_types);
1091
1092    // Method body - delegation to native method with proper marshalling
1093    let cs_native_name = to_csharp_name(&func.name);
1094
1095    if func.is_async {
1096        // Async: wrap in Task.Run for non-blocking execution
1097        out.push_str("        return await Task.Run(() =>\n        {\n");
1098
1099        if func.return_type != TypeRef::Unit {
1100            out.push_str("            var result = ");
1101        } else {
1102            out.push_str("            ");
1103        }
1104
1105        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1106
1107        if visible_params.is_empty() {
1108            out.push_str(");\n");
1109        } else {
1110            out.push('\n');
1111            for (i, param) in visible_params.iter().enumerate() {
1112                let param_name = param.name.to_lower_camel_case();
1113                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1114                out.push_str(&format!("                {arg}"));
1115                if i < visible_params.len() - 1 {
1116                    out.push(',');
1117                }
1118                out.push('\n');
1119            }
1120            out.push_str("            );\n");
1121        }
1122
1123        // Check for FFI error (null result means the call failed).
1124        if func.return_type != TypeRef::Unit {
1125            out.push_str(
1126                "            if (result == IntPtr.Zero)\n            {\n                var err = GetLastError();\n                if (err.Code != 0)\n                {\n                    throw err;\n                }\n            }\n",
1127            );
1128        }
1129
1130        emit_return_marshalling_indented(
1131            &mut out,
1132            &func.return_type,
1133            "            ",
1134            enum_names,
1135            true_opaque_types,
1136        );
1137        emit_named_param_teardown_indented(&mut out, &visible_params, "            ", true_opaque_types);
1138        emit_return_statement_indented(&mut out, &func.return_type, "            ");
1139        out.push_str("        });\n");
1140    } else {
1141        if func.return_type != TypeRef::Unit {
1142            out.push_str("        var result = ");
1143        } else {
1144            out.push_str("        ");
1145        }
1146
1147        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1148
1149        if visible_params.is_empty() {
1150            out.push_str(");\n");
1151        } else {
1152            out.push('\n');
1153            for (i, param) in visible_params.iter().enumerate() {
1154                let param_name = param.name.to_lower_camel_case();
1155                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1156                out.push_str(&format!("            {arg}"));
1157                if i < visible_params.len() - 1 {
1158                    out.push(',');
1159                }
1160                out.push('\n');
1161            }
1162            out.push_str("        );\n");
1163        }
1164
1165        // Check for FFI error (null result means the call failed).
1166        // Only emit for pointer-returning functions — numeric returns (ulong, uint, bool)
1167        // don't use IntPtr.Zero as an error sentinel.
1168        if func.return_type != TypeRef::Unit && returns_ptr(&func.return_type) {
1169            out.push_str(
1170                "        if (result == IntPtr.Zero)\n        {\n            var err = GetLastError();\n            if (err.Code != 0)\n            {\n                throw err;\n            }\n        }\n",
1171            );
1172        }
1173
1174        emit_return_marshalling(&mut out, &func.return_type, enum_names, true_opaque_types);
1175        emit_named_param_teardown(&mut out, &visible_params, true_opaque_types);
1176        emit_return_statement(&mut out, &func.return_type);
1177    }
1178
1179    out.push_str("    }\n\n");
1180
1181    out
1182}
1183
1184#[allow(clippy::too_many_arguments)]
1185fn gen_wrapper_method(
1186    method: &MethodDef,
1187    _exception_name: &str,
1188    _prefix: &str,
1189    type_name: &str,
1190    enum_names: &HashSet<String>,
1191    true_opaque_types: &HashSet<String>,
1192    bridge_param_names: &HashSet<String>,
1193    bridge_type_aliases: &HashSet<String>,
1194) -> String {
1195    let mut out = String::with_capacity(1024);
1196
1197    // Collect visible params (non-bridge) for the public C# signature.
1198    let visible_params: Vec<alef_core::ir::ParamDef> = method
1199        .params
1200        .iter()
1201        .filter(|p| !is_bridge_param(p, bridge_param_names, bridge_type_aliases))
1202        .cloned()
1203        .collect();
1204
1205    // XML doc comment
1206    if !method.doc.is_empty() {
1207        out.push_str("    /// <summary>\n");
1208        for line in method.doc.lines() {
1209            out.push_str(&format!("    /// {}\n", line));
1210        }
1211        out.push_str("    /// </summary>\n");
1212        for param in &visible_params {
1213            out.push_str(&format!(
1214                "    /// <param name=\"{}\">{}</param>\n",
1215                param.name.to_lower_camel_case(),
1216                if param.optional { "Optional." } else { "" }
1217            ));
1218        }
1219    }
1220
1221    // The wrapper class is always `static class`, so all methods must be static.
1222    out.push_str("    public static ");
1223
1224    // Return type — use async Task<T> for async methods
1225    if method.is_async {
1226        if method.return_type == TypeRef::Unit {
1227            out.push_str("async Task");
1228        } else {
1229            out.push_str(&format!("async Task<{}>", csharp_type(&method.return_type)));
1230        }
1231    } else if method.return_type == TypeRef::Unit {
1232        out.push_str("void");
1233    } else {
1234        out.push_str(&csharp_type(&method.return_type));
1235    }
1236
1237    // Prefix method name with type name to avoid collisions (e.g., MetadataConfigDefault)
1238    let method_cs_name = format!("{}{}", type_name, to_csharp_name(&method.name));
1239    out.push_str(&format!(" {method_cs_name}"));
1240    out.push('(');
1241
1242    // Parameters (bridge params stripped from public signature)
1243    for (i, param) in visible_params.iter().enumerate() {
1244        let param_name = param.name.to_lower_camel_case();
1245        let mapped = csharp_type(&param.ty);
1246        if param.optional && !mapped.ends_with('?') {
1247            out.push_str(&format!("{mapped}? {param_name}"));
1248        } else {
1249            out.push_str(&format!("{mapped} {param_name}"));
1250        }
1251
1252        if i < visible_params.len() - 1 {
1253            out.push_str(", ");
1254        }
1255    }
1256
1257    out.push_str(")\n    {\n");
1258
1259    // Null checks for required string/object parameters
1260    for param in &visible_params {
1261        if !param.optional && matches!(param.ty, TypeRef::String | TypeRef::Named(_) | TypeRef::Bytes) {
1262            let param_name = param.name.to_lower_camel_case();
1263            out.push_str(&format!("        ArgumentNullException.ThrowIfNull({param_name});\n"));
1264        }
1265    }
1266
1267    // Serialize Named (opaque handle) params to JSON and obtain native handles.
1268    emit_named_param_setup(&mut out, &visible_params, "        ", true_opaque_types);
1269
1270    // Method body - delegation to native method with proper marshalling.
1271    // Use the type-prefixed name to match the P/Invoke declaration, which includes the type
1272    // name to avoid collisions between different types with identically-named methods
1273    // (e.g. BrowserConfig::default and CrawlConfig::default).
1274    let cs_native_name = format!("{}{}", type_name.to_pascal_case(), to_csharp_name(&method.name));
1275
1276    if method.is_async {
1277        // Async: wrap in Task.Run for non-blocking execution
1278        out.push_str("        return await Task.Run(() =>\n        {\n");
1279
1280        if method.return_type != TypeRef::Unit {
1281            out.push_str("            var result = ");
1282        } else {
1283            out.push_str("            ");
1284        }
1285
1286        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1287
1288        if visible_params.is_empty() {
1289            out.push_str(");\n");
1290        } else {
1291            out.push('\n');
1292            for (i, param) in visible_params.iter().enumerate() {
1293                let param_name = param.name.to_lower_camel_case();
1294                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1295                out.push_str(&format!("                {arg}"));
1296                if i < visible_params.len() - 1 {
1297                    out.push(',');
1298                }
1299                out.push('\n');
1300            }
1301            out.push_str("            );\n");
1302        }
1303
1304        emit_return_marshalling_indented(
1305            &mut out,
1306            &method.return_type,
1307            "            ",
1308            enum_names,
1309            true_opaque_types,
1310        );
1311        emit_named_param_teardown_indented(&mut out, &visible_params, "            ", true_opaque_types);
1312        emit_return_statement_indented(&mut out, &method.return_type, "            ");
1313        out.push_str("        });\n");
1314    } else {
1315        if method.return_type != TypeRef::Unit {
1316            out.push_str("        var result = ");
1317        } else {
1318            out.push_str("        ");
1319        }
1320
1321        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1322
1323        if visible_params.is_empty() {
1324            out.push_str(");\n");
1325        } else {
1326            out.push('\n');
1327            for (i, param) in visible_params.iter().enumerate() {
1328                let param_name = param.name.to_lower_camel_case();
1329                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1330                out.push_str(&format!("            {arg}"));
1331                if i < visible_params.len() - 1 {
1332                    out.push(',');
1333                }
1334                out.push('\n');
1335            }
1336            out.push_str("        );\n");
1337        }
1338
1339        emit_return_marshalling(&mut out, &method.return_type, enum_names, true_opaque_types);
1340        emit_named_param_teardown(&mut out, &visible_params, true_opaque_types);
1341        emit_return_statement(&mut out, &method.return_type);
1342    }
1343
1344    out.push_str("    }\n\n");
1345
1346    out
1347}
1348
1349/// Emit the return-value marshalling code shared by both function and method wrappers.
1350///
1351/// This function emits the code to convert the raw P/Invoke `result` into the managed return
1352/// type and store it in a local variable `returnValue`.  It intentionally does **not** emit
1353/// the `return` statement so that callers can interpose cleanup (param handle teardown) between
1354/// the value computation and the return.
1355///
1356/// `enum_names`: the set of C# type names that are enums (not opaque handles).
1357/// `true_opaque_types`: types with `is_opaque = true` — wrapped in `new CsType(result)`.
1358///
1359/// Callers must invoke `emit_return_statement` after their cleanup to complete the method body.
1360fn emit_return_marshalling(
1361    out: &mut String,
1362    return_type: &TypeRef,
1363    enum_names: &HashSet<String>,
1364    true_opaque_types: &HashSet<String>,
1365) {
1366    if *return_type == TypeRef::Unit {
1367        // void — nothing to return
1368        return;
1369    }
1370
1371    if returns_string(return_type) {
1372        // IntPtr → string, then free the native buffer.
1373        out.push_str("        var returnValue = Marshal.PtrToStringUTF8(result) ?? string.Empty;\n");
1374        out.push_str("        NativeMethods.FreeString(result);\n");
1375    } else if returns_bool_via_int(return_type) {
1376        // C int → bool
1377        out.push_str("        var returnValue = result != 0;\n");
1378    } else if let TypeRef::Named(type_name) = return_type {
1379        let pascal = type_name.to_pascal_case();
1380        if true_opaque_types.contains(type_name) {
1381            // Truly opaque handle: wrap the IntPtr in the C# handle class.
1382            out.push_str(&format!("        var returnValue = new {pascal}(result);\n"));
1383        } else if !enum_names.contains(&pascal) {
1384            // Data struct with to_json: call to_json, deserialise, then free both.
1385            let to_json_method = format!("{pascal}ToJson");
1386            let free_method = format!("{pascal}Free");
1387            let cs_ty = csharp_type(return_type);
1388            out.push_str(&format!(
1389                "        var jsonPtr = NativeMethods.{to_json_method}(result);\n"
1390            ));
1391            out.push_str("        var json = Marshal.PtrToStringUTF8(jsonPtr);\n");
1392            out.push_str("        NativeMethods.FreeString(jsonPtr);\n");
1393            out.push_str(&format!("        NativeMethods.{free_method}(result);\n"));
1394            out.push_str(&format!(
1395                "        var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1396                cs_ty
1397            ));
1398        } else {
1399            // Enum returned as JSON string IntPtr.
1400            let cs_ty = csharp_type(return_type);
1401            out.push_str("        var json = Marshal.PtrToStringUTF8(result);\n");
1402            out.push_str("        NativeMethods.FreeString(result);\n");
1403            out.push_str(&format!(
1404                "        var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1405                cs_ty
1406            ));
1407        }
1408    } else if returns_json_object(return_type) {
1409        // IntPtr → JSON string → deserialized object, then free the native buffer.
1410        let cs_ty = csharp_type(return_type);
1411        out.push_str("        var json = Marshal.PtrToStringUTF8(result);\n");
1412        out.push_str("        NativeMethods.FreeString(result);\n");
1413        out.push_str(&format!(
1414            "        var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1415            cs_ty
1416        ));
1417    } else {
1418        // Numeric primitives — direct return.
1419        out.push_str("        var returnValue = result;\n");
1420    }
1421}
1422
1423/// Emit the final `return returnValue;` statement after cleanup.
1424fn emit_return_statement(out: &mut String, return_type: &TypeRef) {
1425    if *return_type != TypeRef::Unit {
1426        out.push_str("        return returnValue;\n");
1427    }
1428}
1429
1430/// Emit the return-value marshalling code with configurable indentation.
1431///
1432/// Like `emit_return_marshalling` this stores the value in `returnValue` without emitting
1433/// the final `return` statement.  Callers must call `emit_return_statement_indented` after.
1434fn emit_return_marshalling_indented(
1435    out: &mut String,
1436    return_type: &TypeRef,
1437    indent: &str,
1438    enum_names: &HashSet<String>,
1439    true_opaque_types: &HashSet<String>,
1440) {
1441    if *return_type == TypeRef::Unit {
1442        return;
1443    }
1444
1445    if returns_string(return_type) {
1446        out.push_str(&format!(
1447            "{indent}var returnValue = Marshal.PtrToStringUTF8(result) ?? string.Empty;\n"
1448        ));
1449        out.push_str(&format!("{indent}NativeMethods.FreeString(result);\n"));
1450    } else if returns_bool_via_int(return_type) {
1451        out.push_str(&format!("{indent}var returnValue = result != 0;\n"));
1452    } else if let TypeRef::Named(type_name) = return_type {
1453        let pascal = type_name.to_pascal_case();
1454        if true_opaque_types.contains(type_name) {
1455            // Truly opaque handle: wrap the IntPtr in the C# handle class.
1456            out.push_str(&format!("{indent}var returnValue = new {pascal}(result);\n"));
1457        } else if !enum_names.contains(&pascal) {
1458            // Data struct with to_json: call to_json, deserialise, then free both.
1459            let to_json_method = format!("{pascal}ToJson");
1460            let free_method = format!("{pascal}Free");
1461            let cs_ty = csharp_type(return_type);
1462            out.push_str(&format!(
1463                "{indent}var jsonPtr = NativeMethods.{to_json_method}(result);\n"
1464            ));
1465            out.push_str(&format!("{indent}var json = Marshal.PtrToStringUTF8(jsonPtr);\n"));
1466            out.push_str(&format!("{indent}NativeMethods.FreeString(jsonPtr);\n"));
1467            out.push_str(&format!("{indent}NativeMethods.{free_method}(result);\n"));
1468            out.push_str(&format!(
1469                "{indent}var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1470                cs_ty
1471            ));
1472        } else {
1473            // Enum returned as JSON string IntPtr.
1474            let cs_ty = csharp_type(return_type);
1475            out.push_str(&format!("{indent}var json = Marshal.PtrToStringUTF8(result);\n"));
1476            out.push_str(&format!("{indent}NativeMethods.FreeString(result);\n"));
1477            out.push_str(&format!(
1478                "{indent}var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1479                cs_ty
1480            ));
1481        }
1482    } else if returns_json_object(return_type) {
1483        let cs_ty = csharp_type(return_type);
1484        out.push_str(&format!("{indent}var json = Marshal.PtrToStringUTF8(result);\n"));
1485        out.push_str(&format!("{indent}NativeMethods.FreeString(result);\n"));
1486        out.push_str(&format!(
1487            "{indent}var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1488            cs_ty
1489        ));
1490    } else {
1491        out.push_str(&format!("{indent}var returnValue = result;\n"));
1492    }
1493}
1494
1495/// Emit the final `return returnValue;` with configurable indentation.
1496fn emit_return_statement_indented(out: &mut String, return_type: &TypeRef, indent: &str) {
1497    if *return_type != TypeRef::Unit {
1498        out.push_str(&format!("{indent}return returnValue;\n"));
1499    }
1500}
1501
1502fn gen_opaque_handle(typ: &TypeDef, namespace: &str) -> String {
1503    let mut out = hash::header(CommentStyle::DoubleSlash);
1504    out.push_str("using System;\n\n");
1505
1506    out.push_str(&format!("namespace {};\n\n", namespace));
1507
1508    // Generate doc comment if available
1509    if !typ.doc.is_empty() {
1510        out.push_str("/// <summary>\n");
1511        for line in typ.doc.lines() {
1512            out.push_str(&format!("/// {}\n", line));
1513        }
1514        out.push_str("/// </summary>\n");
1515    }
1516
1517    let class_name = typ.name.to_pascal_case();
1518    out.push_str(&format!("public sealed class {} : IDisposable\n", class_name));
1519    out.push_str("{\n");
1520    out.push_str("    internal IntPtr Handle { get; }\n\n");
1521    out.push_str(&format!("    internal {}(IntPtr handle)\n", class_name));
1522    out.push_str("    {\n");
1523    out.push_str("        Handle = handle;\n");
1524    out.push_str("    }\n\n");
1525    out.push_str("    public void Dispose()\n");
1526    out.push_str("    {\n");
1527    out.push_str("        // Native free will be called by the runtime\n");
1528    out.push_str("    }\n");
1529    out.push_str("}\n");
1530
1531    out
1532}
1533
1534fn gen_record_type(
1535    typ: &TypeDef,
1536    namespace: &str,
1537    enum_names: &HashSet<String>,
1538    complex_enums: &HashSet<String>,
1539    custom_converter_enums: &HashSet<String>,
1540    _lang_rename_all: &str,
1541) -> String {
1542    let mut out = hash::header(CommentStyle::DoubleSlash);
1543    out.push_str("using System;\n");
1544    out.push_str("using System.Collections.Generic;\n");
1545    out.push_str("using System.Text.Json;\n");
1546    out.push_str("using System.Text.Json.Serialization;\n\n");
1547
1548    out.push_str(&format!("namespace {};\n\n", namespace));
1549
1550    // Generate doc comment if available
1551    if !typ.doc.is_empty() {
1552        out.push_str("/// <summary>\n");
1553        for line in typ.doc.lines() {
1554            out.push_str(&format!("/// {}\n", line));
1555        }
1556        out.push_str("/// </summary>\n");
1557    }
1558
1559    out.push_str(&format!("public sealed class {}\n", typ.name.to_pascal_case()));
1560    out.push_str("{\n");
1561
1562    for field in &typ.fields {
1563        // Skip unnamed tuple struct fields (e.g., _0, _1, 0, 1, etc.)
1564        if is_tuple_field(field) {
1565            continue;
1566        }
1567
1568        // Doc comment for field
1569        if !field.doc.is_empty() {
1570            out.push_str("    /// <summary>\n");
1571            for line in field.doc.lines() {
1572                out.push_str(&format!("    /// {}\n", line));
1573            }
1574            out.push_str("    /// </summary>\n");
1575        }
1576
1577        // If the field's type is an enum with a custom converter, emit a property-level
1578        // [JsonConverter] attribute. This ensures the custom converter takes precedence
1579        // over the global JsonStringEnumConverter registered in JsonSerializerOptions.
1580        let field_base_type = match &field.ty {
1581            TypeRef::Named(n) => Some(n.to_pascal_case()),
1582            TypeRef::Optional(inner) => match inner.as_ref() {
1583                TypeRef::Named(n) => Some(n.to_pascal_case()),
1584                _ => None,
1585            },
1586            _ => None,
1587        };
1588        if let Some(ref base) = field_base_type {
1589            if custom_converter_enums.contains(base) {
1590                out.push_str(&format!("    [JsonConverter(typeof({base}JsonConverter))]\n"));
1591            }
1592        }
1593
1594        // [JsonPropertyName("json_name")]
1595        // FFI-based languages serialize to JSON that Rust serde deserializes.
1596        // Since Rust uses default snake_case, JSON property names must be snake_case.
1597        let json_name = field.name.clone();
1598        out.push_str(&format!("    [JsonPropertyName(\"{}\")]\n", json_name));
1599
1600        let cs_name = to_csharp_name(&field.name);
1601
1602        // Check if field type is a complex enum (tagged enum with data variants).
1603        // These can't be simple C# enums — use JsonElement for flexible deserialization.
1604        let is_complex = matches!(&field.ty, TypeRef::Named(n) if complex_enums.contains(&n.to_pascal_case()));
1605
1606        if field.optional {
1607            // Optional fields: nullable type, no `required`, default = null
1608            let mapped = if is_complex {
1609                "JsonElement".to_string()
1610            } else {
1611                csharp_type(&field.ty).to_string()
1612            };
1613            let field_type = if mapped.ends_with('?') {
1614                mapped
1615            } else {
1616                format!("{mapped}?")
1617            };
1618            out.push_str(&format!("    public {} {} {{ get; set; }}", field_type, cs_name));
1619            out.push_str(" = null;\n");
1620        } else if typ.has_default || field.default.is_some() {
1621            // Field with an explicit default value or part of a type with defaults.
1622            // Use typed_default from IR to get Rust-compatible defaults.
1623            let field_type = if is_complex {
1624                "JsonElement".to_string()
1625            } else {
1626                csharp_type(&field.ty).to_string()
1627            };
1628            out.push_str(&format!("    public {} {} {{ get; set; }}", field_type, cs_name));
1629            use alef_core::ir::DefaultValue;
1630            // Duration fields are mapped to ulong? so that 0 is distinguishable from
1631            // "not set". Always default to null here; Rust has its own default.
1632            if matches!(&field.ty, TypeRef::Duration) {
1633                out.push_str(" = null;\n");
1634                out.push('\n');
1635                continue;
1636            }
1637            let default_val = match &field.typed_default {
1638                Some(DefaultValue::BoolLiteral(b)) => b.to_string(),
1639                Some(DefaultValue::IntLiteral(n)) => n.to_string(),
1640                Some(DefaultValue::FloatLiteral(f)) => {
1641                    let s = f.to_string();
1642                    if s.contains('.') { s } else { format!("{s}.0") }
1643                }
1644                Some(DefaultValue::StringLiteral(s)) => {
1645                    let escaped = s
1646                        .replace('\\', "\\\\")
1647                        .replace('"', "\\\"")
1648                        .replace('\n', "\\n")
1649                        .replace('\r', "\\r")
1650                        .replace('\t', "\\t");
1651                    format!("\"{}\"", escaped)
1652                }
1653                Some(DefaultValue::EnumVariant(v)) => format!("{}.{}", field_type, v.to_pascal_case()),
1654                Some(DefaultValue::None) => "null".to_string(),
1655                Some(DefaultValue::Empty) | None => match &field.ty {
1656                    TypeRef::Vec(_) => "[]".to_string(),
1657                    TypeRef::Map(k, v) => format!("new Dictionary<{}, {}>()", csharp_type(k), csharp_type(v)),
1658                    TypeRef::String | TypeRef::Char | TypeRef::Path => "\"\"".to_string(),
1659                    TypeRef::Json => "null".to_string(),
1660                    TypeRef::Bytes => "Array.Empty<byte>()".to_string(),
1661                    TypeRef::Primitive(p) => match p {
1662                        PrimitiveType::Bool => "false".to_string(),
1663                        PrimitiveType::F32 | PrimitiveType::F64 => "0.0".to_string(),
1664                        _ => "0".to_string(),
1665                    },
1666                    TypeRef::Named(name) => {
1667                        let pascal = name.to_pascal_case();
1668                        if enum_names.contains(&pascal) {
1669                            "default".to_string()
1670                        } else {
1671                            "default!".to_string()
1672                        }
1673                    }
1674                    _ => "default!".to_string(),
1675                },
1676            };
1677            out.push_str(&format!(" = {};\n", default_val));
1678        } else {
1679            // Non-optional field without explicit default.
1680            // Use type-appropriate zero values instead of `required` to avoid
1681            // JSON deserialization failures when fields are omitted via serde skip_serializing_if.
1682            let field_type = if is_complex {
1683                "JsonElement".to_string()
1684            } else {
1685                csharp_type(&field.ty).to_string()
1686            };
1687            // Duration is mapped to ulong? so null is the correct "not set" default.
1688            if matches!(&field.ty, TypeRef::Duration) {
1689                out.push_str(&format!(
1690                    "    public {} {} {{ get; set; }} = null;\n",
1691                    field_type, cs_name
1692                ));
1693            } else {
1694                let default_val = match &field.ty {
1695                    TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json => "\"\"",
1696                    TypeRef::Vec(_) => "[]",
1697                    TypeRef::Bytes => "Array.Empty<byte>()",
1698                    TypeRef::Primitive(PrimitiveType::Bool) => "false",
1699                    TypeRef::Primitive(PrimitiveType::F32 | PrimitiveType::F64) => "0.0",
1700                    TypeRef::Primitive(_) => "0",
1701                    _ => "default!",
1702                };
1703                out.push_str(&format!(
1704                    "    public {} {} {{ get; set; }} = {};\n",
1705                    field_type, cs_name, default_val
1706                ));
1707            }
1708        }
1709
1710        out.push('\n');
1711    }
1712
1713    out.push_str("}\n");
1714
1715    out
1716}
1717
1718/// Apply a serde `rename_all` strategy to a variant name.
1719fn apply_rename_all(name: &str, rename_all: Option<&str>) -> String {
1720    match rename_all {
1721        Some("snake_case") => name.to_snake_case(),
1722        Some("camelCase") => name.to_lower_camel_case(),
1723        Some("PascalCase") => name.to_pascal_case(),
1724        Some("SCREAMING_SNAKE_CASE") => name.to_snake_case().to_uppercase(),
1725        Some("lowercase") => name.to_lowercase(),
1726        Some("UPPERCASE") => name.to_uppercase(),
1727        _ => name.to_lowercase(),
1728    }
1729}
1730
1731fn gen_enum(enum_def: &EnumDef, namespace: &str) -> String {
1732    let has_data_variants = enum_def.variants.iter().any(|v| !v.fields.is_empty());
1733
1734    // Tagged union: enum has a serde tag AND data variants → generate abstract record hierarchy
1735    if enum_def.serde_tag.is_some() && has_data_variants {
1736        return gen_tagged_union(enum_def, namespace);
1737    }
1738
1739    // If any variant has an explicit serde_rename whose value differs from what
1740    // SnakeCaseLower would produce (e.g. "og:image" vs "og_image"), the global
1741    // JsonStringEnumConverter(SnakeCaseLower) in KreuzcrawlLib.JsonOptions would
1742    // ignore [JsonPropertyName] and use the naming policy instead.
1743    // Also, the non-generic JsonStringEnumConverter does NOT support [JsonPropertyName]
1744    // on enum members at all. For these cases we generate a custom JsonConverter<T>
1745    // that explicitly maps each variant name.
1746    let needs_custom_converter = enum_def.variants.iter().any(|v| {
1747        if let Some(ref rename) = v.serde_rename {
1748            let snake = apply_rename_all(&v.name, enum_def.serde_rename_all.as_deref());
1749            rename != &snake
1750        } else {
1751            false
1752        }
1753    });
1754
1755    let enum_pascal = enum_def.name.to_pascal_case();
1756
1757    // Collect (json_name, pascal_name) pairs
1758    let variants: Vec<(String, String)> = enum_def
1759        .variants
1760        .iter()
1761        .map(|v| {
1762            let json_name = v
1763                .serde_rename
1764                .clone()
1765                .unwrap_or_else(|| apply_rename_all(&v.name, enum_def.serde_rename_all.as_deref()));
1766            let pascal_name = v.name.to_pascal_case();
1767            (json_name, pascal_name)
1768        })
1769        .collect();
1770
1771    let mut out = hash::header(CommentStyle::DoubleSlash);
1772    out.push_str("using System;\n");
1773    out.push_str("using System.Text.Json;\n");
1774    out.push_str("using System.Text.Json.Serialization;\n\n");
1775
1776    out.push_str(&format!("namespace {};\n\n", namespace));
1777
1778    // Generate doc comment if available
1779    if !enum_def.doc.is_empty() {
1780        out.push_str("/// <summary>\n");
1781        for line in enum_def.doc.lines() {
1782            out.push_str(&format!("/// {}\n", line));
1783        }
1784        out.push_str("/// </summary>\n");
1785    }
1786
1787    if needs_custom_converter {
1788        out.push_str(&format!("[JsonConverter(typeof({enum_pascal}JsonConverter))]\n"));
1789    }
1790    out.push_str(&format!("public enum {enum_pascal}\n"));
1791    out.push_str("{\n");
1792
1793    for (json_name, pascal_name) in &variants {
1794        // Find doc for this variant
1795        if let Some(v) = enum_def
1796            .variants
1797            .iter()
1798            .find(|v| v.name.to_pascal_case() == *pascal_name)
1799        {
1800            if !v.doc.is_empty() {
1801                out.push_str("    /// <summary>\n");
1802                for line in v.doc.lines() {
1803                    out.push_str(&format!("    /// {}\n", line));
1804                }
1805                out.push_str("    /// </summary>\n");
1806            }
1807        }
1808        out.push_str(&format!("    [JsonPropertyName(\"{json_name}\")]\n"));
1809        out.push_str(&format!("    {pascal_name},\n"));
1810    }
1811
1812    out.push_str("}\n");
1813
1814    // Generate custom converter class after the enum when needed
1815    if needs_custom_converter {
1816        out.push('\n');
1817        out.push_str(&format!(
1818            "/// <summary>Custom JSON converter for <see cref=\"{enum_pascal}\"/> that respects explicit variant names.</summary>\n"
1819        ));
1820        out.push_str(&format!(
1821            "internal sealed class {enum_pascal}JsonConverter : JsonConverter<{enum_pascal}>\n"
1822        ));
1823        out.push_str("{\n");
1824
1825        // Read
1826        out.push_str(&format!(
1827            "    public override {enum_pascal} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\n"
1828        ));
1829        out.push_str("    {\n");
1830        out.push_str("        var value = reader.GetString();\n");
1831        out.push_str("        return value switch\n");
1832        out.push_str("        {\n");
1833        for (json_name, pascal_name) in &variants {
1834            out.push_str(&format!(
1835                "            \"{json_name}\" => {enum_pascal}.{pascal_name},\n"
1836            ));
1837        }
1838        out.push_str(&format!(
1839            "            _ => throw new JsonException($\"Unknown {enum_pascal} value: {{value}}\")\n"
1840        ));
1841        out.push_str("        };\n");
1842        out.push_str("    }\n\n");
1843
1844        // Write
1845        out.push_str(&format!(
1846            "    public override void Write(Utf8JsonWriter writer, {enum_pascal} value, JsonSerializerOptions options)\n"
1847        ));
1848        out.push_str("    {\n");
1849        out.push_str("        var str = value switch\n");
1850        out.push_str("        {\n");
1851        for (json_name, pascal_name) in &variants {
1852            out.push_str(&format!(
1853                "            {enum_pascal}.{pascal_name} => \"{json_name}\",\n"
1854            ));
1855        }
1856        out.push_str(&format!(
1857            "            _ => throw new JsonException($\"Unknown {enum_pascal} value: {{value}}\")\n"
1858        ));
1859        out.push_str("        };\n");
1860        out.push_str("        writer.WriteStringValue(str);\n");
1861        out.push_str("    }\n");
1862        out.push_str("}\n");
1863    }
1864
1865    out
1866}
1867
1868/// Generate a C# abstract record hierarchy for internally tagged enums.
1869///
1870/// Maps `#[serde(tag = "type_field", rename_all = "snake_case")]` Rust enums to
1871/// a custom `JsonConverter<T>` that buffers all JSON properties before resolving
1872/// the discriminator. This is more robust than `[JsonPolymorphic]` which requires
1873/// the discriminator to be the first property in the JSON object.
1874fn gen_tagged_union(enum_def: &EnumDef, namespace: &str) -> String {
1875    let tag_field = enum_def.serde_tag.as_deref().unwrap_or("type");
1876    let enum_pascal = enum_def.name.to_pascal_case();
1877    let converter_name = format!("{enum_pascal}JsonConverter");
1878    // Namespace prefix used to fully-qualify inner types when their short name is shadowed
1879    // by a nested record of the same name (e.g. ContentPart.ImageUrl shadows ImageUrl).
1880    let ns = namespace;
1881
1882    let mut out = hash::header(CommentStyle::DoubleSlash);
1883    out.push_str("using System;\n");
1884    out.push_str("using System.Collections.Generic;\n");
1885    out.push_str("using System.Text.Json;\n");
1886    out.push_str("using System.Text.Json.Serialization;\n\n");
1887    out.push_str(&format!("namespace {};\n\n", namespace));
1888
1889    // Doc comment
1890    if !enum_def.doc.is_empty() {
1891        out.push_str("/// <summary>\n");
1892        for line in enum_def.doc.lines() {
1893            out.push_str(&format!("/// {}\n", line));
1894        }
1895        out.push_str("/// </summary>\n");
1896    }
1897
1898    // Use custom converter instead of [JsonPolymorphic] to handle discriminator in any position
1899    out.push_str(&format!("[JsonConverter(typeof({converter_name}))]\n"));
1900    out.push_str(&format!("public abstract record {enum_pascal}\n"));
1901    out.push_str("{\n");
1902
1903    // Nested sealed records for each variant
1904    for variant in &enum_def.variants {
1905        let pascal = variant.name.to_pascal_case();
1906
1907        if !variant.doc.is_empty() {
1908            out.push_str("    /// <summary>\n");
1909            for line in variant.doc.lines() {
1910                out.push_str(&format!("    /// {}\n", line));
1911            }
1912            out.push_str("    /// </summary>\n");
1913        }
1914
1915        if variant.fields.is_empty() {
1916            // Unit variant → sealed record with no fields
1917            out.push_str(&format!("    public sealed record {pascal}() : {enum_pascal};\n\n"));
1918        } else {
1919            // CS8910: when a single-field variant has a parameter whose TYPE equals the record name
1920            // (e.g., record ImageUrl(ImageUrl Value)), the primary constructor conflicts with the
1921            // synthesized copy constructor. Use a property-based record body instead.
1922            // This applies to both tuple fields and named fields that get renamed to "Value".
1923            let is_copy_ctor_clash = variant.fields.len() == 1 && {
1924                let field_cs_type = csharp_type(&variant.fields[0].ty);
1925                field_cs_type.as_ref() == pascal
1926            };
1927
1928            if is_copy_ctor_clash {
1929                let cs_type = csharp_type(&variant.fields[0].ty);
1930                // Fully qualify the inner type to avoid the nested record shadowing the
1931                // standalone type of the same name (e.g. `ContentPart.ImageUrl` would shadow
1932                // `LiterLlm.ImageUrl` within the `ContentPart` abstract record body).
1933                let qualified_cs_type = format!("global::{ns}.{cs_type}");
1934                out.push_str(&format!("    public sealed record {pascal} : {enum_pascal}\n"));
1935                out.push_str("    {\n");
1936                out.push_str(&format!(
1937                    "        public required {qualified_cs_type} Value {{ get; init; }}\n"
1938                ));
1939                out.push_str("    }\n\n");
1940            } else {
1941                // Data variant → sealed record with fields as constructor params
1942                out.push_str(&format!("    public sealed record {pascal}(\n"));
1943                for (i, field) in variant.fields.iter().enumerate() {
1944                    let cs_type = csharp_type(&field.ty);
1945                    let cs_type = if field.optional && !cs_type.ends_with('?') {
1946                        format!("{cs_type}?")
1947                    } else {
1948                        cs_type.to_string()
1949                    };
1950                    let comma = if i < variant.fields.len() - 1 { "," } else { "" };
1951                    if is_tuple_field(field) {
1952                        out.push_str(&format!("        {cs_type} Value{comma}\n"));
1953                    } else {
1954                        let json_name = field.name.trim_start_matches('_');
1955                        let cs_name = to_csharp_name(json_name);
1956                        let clashes = cs_name == pascal || cs_name == cs_type;
1957                        if clashes {
1958                            out.push_str(&format!("        {cs_type} Value{comma}\n"));
1959                        } else {
1960                            out.push_str(&format!(
1961                                "        [property: JsonPropertyName(\"{json_name}\")] {cs_type} {cs_name}{comma}\n"
1962                            ));
1963                        }
1964                    }
1965                }
1966                out.push_str(&format!("    ) : {enum_pascal};\n\n"));
1967            }
1968        }
1969    }
1970
1971    out.push_str("}\n\n");
1972
1973    // Generate custom converter that buffers the JSON document before dispatching
1974    out.push_str(&format!(
1975        "/// <summary>Custom JSON converter for <see cref=\"{enum_pascal}\"/> that reads the \"{tag_field}\" discriminator from any position.</summary>\n"
1976    ));
1977    out.push_str(&format!(
1978        "internal sealed class {converter_name} : JsonConverter<{enum_pascal}>\n"
1979    ));
1980    out.push_str("{\n");
1981
1982    // Read method
1983    out.push_str(&format!(
1984        "    public override {enum_pascal} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\n"
1985    ));
1986    out.push_str("    {\n");
1987    out.push_str("        using var doc = JsonDocument.ParseValue(ref reader);\n");
1988    out.push_str("        var root = doc.RootElement;\n");
1989    out.push_str(&format!(
1990        "        if (!root.TryGetProperty(\"{tag_field}\", out var tagEl))\n"
1991    ));
1992    out.push_str(&format!(
1993        "            throw new JsonException(\"{enum_pascal}: missing \\\"{tag_field}\\\" discriminator\");\n"
1994    ));
1995    out.push_str("        var tag = tagEl.GetString();\n");
1996    out.push_str("        var json = root.GetRawText();\n");
1997    out.push_str("        return tag switch\n");
1998    out.push_str("        {\n");
1999
2000    for variant in &enum_def.variants {
2001        let discriminator = variant
2002            .serde_rename
2003            .clone()
2004            .unwrap_or_else(|| apply_rename_all(&variant.name, enum_def.serde_rename_all.as_deref()));
2005        let pascal = variant.name.to_pascal_case();
2006        // Newtype/tuple variants have their inner type's fields inlined alongside the tag in JSON.
2007        // Deserialize the inner type from the full JSON object and wrap it in the record constructor.
2008        // Also treat single named-field variants whose parameter was renamed to "Value" (clash with
2009        // the variant name or the field's own type name) the same way.
2010        let is_tuple_newtype = variant.fields.len() == 1 && is_tuple_field(&variant.fields[0]);
2011        let is_named_clash_newtype = variant.fields.len() == 1 && !is_tuple_field(&variant.fields[0]) && {
2012            let f = &variant.fields[0];
2013            let cs_type = csharp_type(&f.ty);
2014            let cs_name = to_csharp_name(f.name.trim_start_matches('_'));
2015            cs_name == pascal || cs_name == cs_type
2016        };
2017        let is_newtype = is_tuple_newtype || is_named_clash_newtype;
2018        if is_newtype {
2019            let inner_cs_type = csharp_type(&variant.fields[0].ty);
2020            // CS8910: when inner type name equals variant name, use object initializer
2021            // (no primary constructor exists — property-based record was emitted)
2022            if inner_cs_type == pascal {
2023                out.push_str(&format!(
2024                    "            \"{discriminator}\" => new {enum_pascal}.{pascal} {{ Value = JsonSerializer.Deserialize<{inner_cs_type}>(json, options)!\n"
2025                ));
2026                out.push_str(&format!(
2027                    "                ?? throw new JsonException(\"Failed to deserialize {enum_pascal}.{pascal}.Value\") }},\n"
2028                ));
2029            } else {
2030                out.push_str(&format!(
2031                    "            \"{discriminator}\" => new {enum_pascal}.{pascal}(\n"
2032                ));
2033                out.push_str(&format!(
2034                    "                JsonSerializer.Deserialize<{inner_cs_type}>(json, options)!\n"
2035                ));
2036                out.push_str(&format!(
2037                    "                    ?? throw new JsonException(\"Failed to deserialize {enum_pascal}.{pascal}.Value\")),\n"
2038                ));
2039            }
2040        } else {
2041            out.push_str(&format!(
2042                "            \"{discriminator}\" => JsonSerializer.Deserialize<{enum_pascal}.{pascal}>(json, options)!\n"
2043            ));
2044            out.push_str(&format!(
2045                "                ?? throw new JsonException(\"Failed to deserialize {enum_pascal}.{pascal}\"),\n"
2046            ));
2047        }
2048    }
2049
2050    out.push_str(&format!(
2051        "            _ => throw new JsonException($\"Unknown {enum_pascal} discriminator: {{tag}}\")\n"
2052    ));
2053    out.push_str("        };\n");
2054    out.push_str("    }\n\n");
2055
2056    // Write method
2057    out.push_str(&format!(
2058        "    public override void Write(Utf8JsonWriter writer, {enum_pascal} value, JsonSerializerOptions options)\n"
2059    ));
2060    out.push_str("    {\n");
2061
2062    // Build options without this converter to avoid infinite recursion
2063    out.push_str("        // Serialize the concrete type, then inject the discriminator\n");
2064    out.push_str("        switch (value)\n");
2065    out.push_str("        {\n");
2066
2067    for variant in &enum_def.variants {
2068        let discriminator = variant
2069            .serde_rename
2070            .clone()
2071            .unwrap_or_else(|| apply_rename_all(&variant.name, enum_def.serde_rename_all.as_deref()));
2072        let pascal = variant.name.to_pascal_case();
2073        // Newtype/tuple variants: serialize the inner Value's fields inline alongside the tag.
2074        // Also applies to single named-field variants whose parameter was renamed to "Value" due
2075        // to a clash with the variant name or the field's own type name.
2076        let is_tuple_newtype = variant.fields.len() == 1 && is_tuple_field(&variant.fields[0]);
2077        let is_named_clash_newtype = variant.fields.len() == 1 && !is_tuple_field(&variant.fields[0]) && {
2078            let f = &variant.fields[0];
2079            let cs_type = csharp_type(&f.ty);
2080            let cs_name = to_csharp_name(f.name.trim_start_matches('_'));
2081            cs_name == pascal || cs_name == cs_type
2082        };
2083        let is_newtype = is_tuple_newtype || is_named_clash_newtype;
2084        out.push_str(&format!("            case {enum_pascal}.{pascal} v:\n"));
2085        out.push_str("            {\n");
2086        if is_newtype {
2087            out.push_str("                var doc = JsonSerializer.SerializeToDocument(v.Value, options);\n");
2088        } else {
2089            out.push_str("                var doc = JsonSerializer.SerializeToDocument(v, options);\n");
2090        }
2091        out.push_str("                writer.WriteStartObject();\n");
2092        out.push_str(&format!(
2093            "                writer.WriteString(\"{tag_field}\", \"{discriminator}\");\n"
2094        ));
2095        out.push_str("                foreach (var prop in doc.RootElement.EnumerateObject())\n");
2096        out.push_str(&format!(
2097            "                    if (prop.Name != \"{tag_field}\") prop.WriteTo(writer);\n"
2098        ));
2099        out.push_str("                writer.WriteEndObject();\n");
2100        out.push_str("                break;\n");
2101        out.push_str("            }\n");
2102    }
2103
2104    out.push_str(&format!(
2105        "            default: throw new JsonException($\"Unknown {enum_pascal} subtype: {{value.GetType().Name}}\");\n"
2106    ));
2107    out.push_str("        }\n");
2108    out.push_str("    }\n");
2109    out.push_str("}\n");
2110
2111    out
2112}