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