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