Skip to main content

alef_backend_csharp/
gen_bindings.rs

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