Skip to main content

alef_backend_csharp/
gen_bindings.rs

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