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