Skip to main content

alef_backend_csharp/
gen_bindings.rs

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